Adam Roses Wight <awight@wikimedia.org>
Adam Roses Wight <awight@wikimedia.org> <spam@ludd.net>
addshore <addshorewiki@gmail.com>
+addshore <addshorewiki@gmail.com> <adamshorland@gmail.com>
Aditya Sastry <ganeshaditya1@gmail.com>
Adrian Heine <adrian.heine@wikimedia.de>
Alex Z. <mrzmanwiki@gmail.com> <mrzman@users.mediawiki.org>
Daniel Friesen <mediawiki@danielfriesen.name> <daniel@nadir-seen-fire.com>
Daniel Friesen <mediawiki@danielfriesen.name> <dantman@users.mediawiki.org>
Daniel Friesen <mediawiki@danielfriesen.name> <pub-github@nadir-seen-fire.com>
-Daniel Kinzler <daniel.kinzler@wikimedia.de>
-Daniel Kinzler <daniel.kinzler@wikimedia.de> <daniel@users.mediawiki.org>
+Daniel Kinzler <dkinzler@wikimedia.org>
+Daniel Kinzler <dkinzler@wikimedia.org> <daniel.kinzler@wikimedia.de>
+Daniel Kinzler <dkinzler@wikimedia.org> <daniel@users.mediawiki.org>
Daniel Renfro <bluecurio@gmail.com> <drenfro@vistaprint.com>
Danny B. <Wikipedia.Danny.B@email.cz>
Danny B. <Wikipedia.Danny.B@email.cz> <danny.b@email.cz>
Darkdragon09 <ubuntu@ip-172-31-39-38.us-west-2.compute.internal>
David Causse <dcausse@wikimedia.org>
David Chan <david@sheetmusic.org.uk>
+Dayllan Maza <dmaza@wikimedia.org>
+Dayllan Maza <dmaza@wikimedia.org> <dayllan.maza@gmail.com>
Dereckson <dereckson@espace-win.org>
Derk-Jan Hartman <hartman@videolan.org>
Derk-Jan Hartman <hartman@videolan.org> <hartman.wiki@gmail.com>
Katie Filbert <aude.wiki@gmail.com>
Katie Filbert <aude.wiki@gmail.com> <aude@users.mediawiki.org>
Kevin Israel <pleasestand@live.com>
+Kosta Harlan <kharlan@wikimedia.org>
+Kosta Harlan <kharlan@wikimedia.org> <kosta@fastmail.com>
Kunal Grover <kunalgrover05@gmail.com>
Kunal Mehta <legoktm@member.fsf.org>
Kunal Mehta <legoktm@member.fsf.org> <legoktm.wikipedia@gmail.com>
Patrick Reilly <preilly@wikimedia.org> <preilly@users.mediawiki.org>
Patrick Westerhoff <PatrickWesterhoff@gmail.com>
Paul Copperman <paul.copperman@gmail.com> <pcopp@users.mediawiki.org>
+Petar Petković <ppetkovic@wikimedia.org>
Peter Coombe <pcoombe@wikimedia.org>
Peter Coti <petercoti@gmail.com>
Peter Potrowl <peter017@gmail.com> <peter17@users.mediawiki.org>
old and the new schema, but reading the new schema, so Multi-Content Revisions
(MCR) are now functional per default. The new default value of the setting is
SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW.
+* $wgActorTableSchemaMigrationStage no longer accepts MIGRATION_WRITE_BOTH or
+ MIGRATION_WRITE_NEW. It instead uses SCHEMA_COMPAT_WRITE_BOTH |
+ SCHEMA_COMPAT_READ_OLD and SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW
+ for intermediate stages of migration.
==== Removed configuration ====
* $wgEnableAPI and $wgEnableWriteAPI – These settings, deprecated in 1.31,
=== External library changes in 1.32 ===
==== New external libraries ====
-* Added wikimedia/xmp-reader 0.6.0
+* Added wikimedia/xmp-reader 0.6.0.
+* Added Add pear/Net_SMTP 1.8.0.
* …
==== Changed external libraries ====
* Updated jquery from v3.2.1 to v3.3.1.
==== Removed external libraries ====
+* pear/mail_mime-decode was removed.
* …
=== Bug fixes in 1.32 ===
* 'uppercase-se' (NorthernSamiUppercaseCollation) - use 'uca-se' instead
* 'xx-uca-et' (CollationEt) - use 'uca-et' instead
* 'xx-uca-fa' (CollationFa) - use 'uca-fa' instead
+* LanguageCode::bcp47() now always returns a valid BCP 47 code. This means
+ that some MediaWiki-specific language codes, such as `simple`, are mapped
+ into valid BCP 47 codes (eg `en-simple`).
* The hooks 'SpecialRecentChangesFilters' & 'SpecialWatchlistFilters' deprecated
in 1.23 were removed. Instead, use 'ChangesListSpecialPageStructuredFilters'.
The ChangesListSpecialPage code for these legacy hooks, and their use in
phase.
* The global function wfErrorLog, deprecated since 1.25, has now been removed.
Use MWLoggerLegacyLogger::emit or UDPTransport.
- The hooks 'SpecialRecentChangesQuery' & 'SpecialWatchlistQuery', deprecated in
+* The hooks 'SpecialRecentChangesQuery' & 'SpecialWatchlistQuery', deprecated in
1.23, were removed. Instead, use ChangesListSpecialPageStructuredFilters or
ChangesListSpecialPageQuery.
+* The global function wfUsePHP, deprecated since 1.30, has now been removed. To
+ assert a newer version of PHP than MediaWiki does, use extension registration.
+* The hook 'ChangesListSpecialPageFilters', deprecated in 1.29, has now been
+ removed. Use the 'ChangesListSpecialPageStructuredFilters' hook instead.
+* DeferredUpdates::setImmediateMode(), deprecated since 1.29, has been removed.
+* File / MediaHandler::getStreamHeaders(), deprecated since 1.30, was removed.
+* The hook 'DoEditSectionLink', deprecated since 1.25, has been removed. Use
+ the hook 'SkinEditSectionLinks' instead.
+* The hook 'UserGetImplicitGroups', deprecated since 1.25, has been removed.
+* The global function wfRunHooks, deprecated since 1.25, has now been removed.
+ Use Hooks::run().
+* The hook 'UnknownAction', deprecated since 1.19, has now been removed.
+* The hook 'ParserLimitReport', deprecated since 1.22, has been removed. Use
+ the hooks 'ParserLimitReportPrepare' and 'ParserLimitReportFormat' instead.
=== Deprecations in 1.32 ===
* HTMLForm::setSubmitProgressive() is deprecated. No need to call it. Submit
$wgTidyConfig instead.
* All Tidy configurations other than Remex have been hard deprecated;
future parsers will not emit compatible output for these configurations.
+* (T198214) OutputPage::addWikiText(), OutputPage::addWikiTextWithTitle(),
+ and OutputPage::addWikiTextTitle() have been deprecated, since they
+ can result in untidy output. In addition OutputPage::addWikiTextTidy()
+ and OutputPage::addWikiTextTitleTidy() was deprecated to make naming new
+ methods consistent. Use OutputPage::addWikiTextAsInterface() or
+ OutputPage::addWikiTextAsContent() instead, which ensures the output is
+ tidy and clarifies whether content-language specific postprocessing should
+ be done on the text.
* QuickTemplate::msgHtml() and BaseTemplate::msgHtml() have been deprecated
as they promote bad practises. I18n messages should always be properly
escaped.
deprecated. Use CommentStore::insert() instead.
* Language::setCode is deprecated as public function. Use Language::factory
to create a new Language object with a different language code.
+* Several classes have been moved from the MediaWiki\Storage\ namespace to the
+ MediaWiki\Revision\ namespace. The old class names are aliased for
+ compatibility, but are deprecated. Classes are IncompleteRevisionException,
+ MutableRevisionRecord, MutableRevisionSlots, RevisionAccessException,
+ RevisionArchiveRecord, RevisionFactory, RevisionLookup, RevisionRecord,
+ RevisionSlots, RevisionStore, RevisionStoreRecord, SlotRecord, and
+ SuppressedDataException.
+* When using OOUI HTMLForm containing an 'info' field which uses the 'rawrow'
+ option, it is now deprecated to give its contents (the 'default' option)
+ as a string. They should be given as a OOUI\FieldLayout object instead.
+ Notably, this affects fields defined in the 'GetPreferences' hook, because
+ Special:Preferences uses an OOUI form now. (If possible, don't use 'rawrow'.)
+* In Skin::doEditSectionLink omitting the parameters $tooltip and $lang is
+ deprecated. For the $lang parameter, types other than Language are
+ deprecated.
=== Other changes in 1.32 ===
* (T198811) The following tables have had their UNIQUE indexes turned into
return;
}
-// Pathinfo can be used for stupid things. We don't support it for api.php at
+// PATH_INFO can be used for stupid things. We don't support it for api.php at
// all, so error out if it's present.
if ( isset( $_SERVER['PATH_INFO'] ) && $_SERVER['PATH_INFO'] != '' ) {
$correctUrl = wfAppendQuery( wfScript( 'api' ), $wgRequest->getQueryValues() );
'DeleteLogFormatter' => __DIR__ . '/includes/logging/DeleteLogFormatter.php',
'DeleteOldRevisions' => __DIR__ . '/maintenance/deleteOldRevisions.php',
'DeleteOrphanedRevisions' => __DIR__ . '/maintenance/deleteOrphanedRevisions.php',
+ 'DeletePageJob' => __DIR__ . '/includes/jobqueue/jobs/DeletePageJob.php',
'DeleteSelfExternals' => __DIR__ . '/maintenance/deleteSelfExternals.php',
'DeletedContribsPager' => __DIR__ . '/includes/specials/pagers/DeletedContribsPager.php',
'DeletedContributionsPage' => __DIR__ . '/includes/specials/SpecialDeletedContributions.php',
'MediaWiki\\MediaWikiServices' => __DIR__ . '/includes/MediaWikiServices.php',
'MediaWiki\\OutputHandler' => __DIR__ . '/includes/OutputHandler.php',
'MediaWiki\\ProcOpenError' => __DIR__ . '/includes/exception/ProcOpenError.php',
- 'MediaWiki\\Revision\\RenderedRevision' => __DIR__ . '/includes/Revision/RenderedRevision.php',
- 'MediaWiki\\Revision\\RevisionRenderer' => __DIR__ . '/includes/Revision/RevisionRenderer.php',
- 'MediaWiki\\Revision\\SlotRenderingProvider' => __DIR__ . '/includes/Revision/SlotRenderingProvider.php',
'MediaWiki\\Search\\ParserOutputSearchDataExtractor' => __DIR__ . '/includes/search/ParserOutputSearchDataExtractor.php',
'MediaWiki\\ShellDisabledError' => __DIR__ . '/includes/exception/ShellDisabledError.php',
'MediaWiki\\Site\\MediaWikiPageNameNormalizer' => __DIR__ . '/includes/site/MediaWikiPageNameNormalizer.php',
'MediaWiki\\Special\\SpecialPageFactory' => __DIR__ . '/includes/specialpage/SpecialPageFactory.php',
+ 'MediaWiki\\Storage\\IncompleteRevisionException' => __DIR__ . '/includes/Revision/IncompleteRevisionException.php',
+ 'MediaWiki\\Storage\\MutableRevisionRecord' => __DIR__ . '/includes/Revision/MutableRevisionRecord.php',
+ 'MediaWiki\\Storage\\MutableRevisionSlots' => __DIR__ . '/includes/Revision/MutableRevisionSlots.php',
+ 'MediaWiki\\Storage\\RevisionAccessException' => __DIR__ . '/includes/Revision/RevisionAccessException.php',
+ 'MediaWiki\\Storage\\RevisionArchiveRecord' => __DIR__ . '/includes/Revision/RevisionArchiveRecord.php',
+ 'MediaWiki\\Storage\\RevisionFactory' => __DIR__ . '/includes/Revision/RevisionFactory.php',
+ 'MediaWiki\\Storage\\RevisionLookup' => __DIR__ . '/includes/Revision/RevisionLookup.php',
+ 'MediaWiki\\Storage\\RevisionRecord' => __DIR__ . '/includes/Revision/RevisionRecord.php',
+ 'MediaWiki\\Storage\\RevisionSlots' => __DIR__ . '/includes/Revision/RevisionSlots.php',
+ 'MediaWiki\\Storage\\RevisionStore' => __DIR__ . '/includes/Revision/RevisionStore.php',
+ 'MediaWiki\\Storage\\RevisionStoreRecord' => __DIR__ . '/includes/Revision/RevisionStoreRecord.php',
+ 'MediaWiki\\Storage\\SlotRecord' => __DIR__ . '/includes/Revision/SlotRecord.php',
+ 'MediaWiki\\Storage\\SuppressedDataException' => __DIR__ . '/includes/Revision/SuppressedDataException.php',
'MediaWiki\\User\\UserIdentity' => __DIR__ . '/includes/user/UserIdentity.php',
'MediaWiki\\User\\UserIdentityValue' => __DIR__ . '/includes/user/UserIdentityValue.php',
'MediaWiki\\Widget\\CheckMatrixWidget' => __DIR__ . '/includes/widget/CheckMatrixWidget.php',
"oyejorge/less.php": "1.7.0.14",
"pear/mail": "1.4.1",
"pear/mail_mime": "1.10.2",
- "pear/mail_mime-decode": "1.5.5.2",
+ "pear/net_smtp": "1.8.0",
"php": ">=5.6.99",
"psr/log": "1.0.2",
"wikimedia/assert": "0.2.2",
$unpatrolled: Whether or not we are showing unpatrolled changes.
$watched: Whether or not the change is watched by the user.
-'ChangesListSpecialPageFilters': DEPRECATED since 1.29! Use
-'ChangesListSpecialPageStructuredFilters' instead.
-Called after building form options on pages
-inheriting from ChangesListSpecialPage (in core: RecentChanges,
-RecentChangesLinked and Watchlist).
-$special: ChangesListSpecialPage instance
-&$filters: associative array of filter definitions. The keys are the HTML
- name/URL parameters. Each key maps to an associative array with a 'msg'
- (message key) and a 'default' value.
-
'ChangesListSpecialPageQuery': Called when building SQL query on pages
inheriting from ChangesListSpecialPage (in core: RecentChanges,
RecentChangesLinked and Watchlist).
&$article: article (object) being viewed
&$oldid: oldid (int) being viewed
-'DoEditSectionLink': DEPRECATED since 1.25! Use SkinEditSectionLinks instead.
-Override the HTML generated for section edit links
-$skin: Skin object rendering the UI
-$title: Title object for the title being linked to (may not be the same as
- the page title, if the section is included from a template)
-$section: The designation of the section being pointed to, to be included in
- the link, like "§ion=$section"
-$tooltip: The default tooltip. Escape before using.
- By default, this is wrapped in the 'editsectionhint' message.
-&$result: The HTML to return, prefilled with the default plus whatever other
- changes earlier hooks have made
-$lang: The language code to use for the link in the wfMessage function
-
'EditFilter': Perform checks on an edit
$editor: EditPage instance (object). The edit form (see includes/EditPage.php)
$text: Contents of the edit box
&$parser: Parser object
&$varCache: variable cache (array)
-'ParserLimitReport': DEPRECATED since 1.22! Use ParserLimitReportPrepare and
-ParserLimitReportFormat instead.
-Called at the end of Parser:parse() when the parser will
-include comments about size of the text parsed.
-$parser: Parser object
-&$limitReport: text that will be included (without comment tags)
-
'ParserLimitReportFormat': Called for each row in the parser limit report that
needs formatting. If nothing handles this hook, the default is to use "$key" to
get the label, and "$key-value" or "$key-value-text"/"$key-value-html" to
test case files matching the suffix "Test.php".
&$paths: list of test cases and directories to search.
-'UnknownAction': DEPRECATED since 1.19! To add an action in an extension,
-create a subclass of Action, and add a new key to $wgActions.
-An unknown "action" has occurred (useful for defining your own actions).
-$action: action name
-$article: article "acted on"
-
'UnwatchArticle': Before a watch is removed from an article.
&$user: user watching
&$page: WikiPage object to be removed
&$timestamp: timestamp, change this to override local email authentication
timestamp
-'UserGetImplicitGroups': DEPRECATED since 1.25!
-Called in User::getImplicitGroups().
-&$groups: List of implicit (automatically-assigned) groups
-
'UserGetLanguageObject': Called when getting user's interface language object.
$user: User object
&$code: Language code that will be used to create the object
*/
class ActorMigration {
+ /**
+ * Constant for extensions to feature-test whether $wgActorTableSchemaMigrationStage
+ * expects MIGRATION_* or SCHEMA_COMPAT_*
+ */
+ const MIGRATION_STAGE_SCHEMA_COMPAT = 1;
+
/**
* Define fields that use temporary tables for transitional purposes
* @var array Keys are '$key', values are arrays with four fields:
/** @var array|null Cache for `self::getJoin()` */
private $joinCache = null;
- /** @var int One of the MIGRATION_* constants */
+ /** @var int Combination of SCHEMA_COMPAT_* constants */
private $stage;
/** @private */
public function __construct( $stage ) {
+ if ( ( $stage & SCHEMA_COMPAT_WRITE_BOTH ) === 0 ) {
+ throw new InvalidArgumentException( '$stage must include a write mode' );
+ }
+ if ( ( $stage & SCHEMA_COMPAT_READ_BOTH ) === 0 ) {
+ throw new InvalidArgumentException( '$stage must include a read mode' );
+ }
+ if ( ( $stage & SCHEMA_COMPAT_READ_BOTH ) === SCHEMA_COMPAT_READ_BOTH ) {
+ throw new InvalidArgumentException( 'Cannot read both schemas' );
+ }
+ if ( ( $stage & SCHEMA_COMPAT_READ_OLD ) && !( $stage & SCHEMA_COMPAT_WRITE_OLD ) ) {
+ throw new InvalidArgumentException( 'Cannot read the old schema without also writing it' );
+ }
+ if ( ( $stage & SCHEMA_COMPAT_READ_NEW ) && !( $stage & SCHEMA_COMPAT_WRITE_NEW ) ) {
+ throw new InvalidArgumentException( 'Cannot read the new schema without also writing it' );
+ }
+
$this->stage = $stage;
}
* @return string
*/
public function isAnon( $field ) {
- return $this->stage === MIGRATION_NEW ? "$field IS NULL" : "$field = 0";
+ return ( $this->stage & SCHEMA_COMPAT_READ_NEW ) ? "$field IS NULL" : "$field = 0";
}
/**
* @return string
*/
public function isNotAnon( $field ) {
- return $this->stage === MIGRATION_NEW ? "$field IS NOT NULL" : "$field != 0";
+ return ( $this->stage & SCHEMA_COMPAT_READ_NEW ) ? "$field IS NOT NULL" : "$field != 0";
}
/**
list( $text, $actor ) = self::getFieldNames( $key );
- if ( $this->stage === MIGRATION_OLD ) {
+ if ( $this->stage & SCHEMA_COMPAT_READ_OLD ) {
$fields[$key] = $key;
$fields[$text] = $text;
$fields[$actor] = 'NULL';
} else {
- $join = $this->stage === MIGRATION_NEW ? 'JOIN' : 'LEFT JOIN';
-
if ( isset( self::$tempTables[$key] ) ) {
$t = self::$tempTables[$key];
$alias = "temp_$key";
$tables[$alias] = $t['table'];
- $joins[$alias] = [ $join, "{$alias}.{$t['pk']} = {$t['joinPK']}" ];
+ $joins[$alias] = [ 'JOIN', "{$alias}.{$t['pk']} = {$t['joinPK']}" ];
$joinField = "{$alias}.{$t['field']}";
} else {
$joinField = $actor;
$alias = "actor_$key";
$tables[$alias] = 'actor';
- $joins[$alias] = [ $join, "{$alias}.actor_id = {$joinField}" ];
+ $joins[$alias] = [ 'JOIN', "{$alias}.actor_id = {$joinField}" ];
- if ( $this->stage === MIGRATION_NEW ) {
- $fields[$key] = "{$alias}.actor_user";
- $fields[$text] = "{$alias}.actor_name";
- } else {
- $fields[$key] = "COALESCE( {$alias}.actor_user, $key )";
- $fields[$text] = "COALESCE( {$alias}.actor_name, $text )";
- }
+ $fields[$key] = "{$alias}.actor_user";
+ $fields[$text] = "{$alias}.actor_name";
$fields[$actor] = $joinField;
}
list( $text, $actor ) = self::getFieldNames( $key );
$ret = [];
- if ( $this->stage <= MIGRATION_WRITE_BOTH ) {
+ if ( $this->stage & SCHEMA_COMPAT_WRITE_OLD ) {
$ret[$key] = $user->getId();
$ret[$text] = $user->getName();
}
- if ( $this->stage >= MIGRATION_WRITE_BOTH ) {
+ if ( $this->stage & SCHEMA_COMPAT_WRITE_NEW ) {
// We need to be able to assign an actor ID if none exists
if ( !$user instanceof User && !$user->getActorId() ) {
$user = User::newFromAnyId( $user->getId(), $user->getName(), null );
list( $text, $actor ) = self::getFieldNames( $key );
$ret = [];
$callback = null;
- if ( $this->stage <= MIGRATION_WRITE_BOTH ) {
+ if ( $this->stage & SCHEMA_COMPAT_WRITE_OLD ) {
$ret[$key] = $user->getId();
$ret[$text] = $user->getName();
}
- if ( $this->stage >= MIGRATION_WRITE_BOTH ) {
+ if ( $this->stage & SCHEMA_COMPAT_WRITE_NEW ) {
// We need to be able to assign an actor ID if none exists
if ( !$user instanceof User && !$user->getActorId() ) {
$user = User::newFromAnyId( $user->getId(), $user->getName(), null );
* - orconds: (array[]) array of alternatives in case a union of multiple
* queries would be more efficient than a query with OR. May have keys
* 'actor', 'userid', 'username'.
+ * Since 1.32, this is guaranteed to contain just one alternative if
+ * $users contains a single user.
* - joins: (array) to include in the `$join_conds` to `IDatabase->select()`
* All tables and joins are aliased, so `+` is safe to use.
*/
list( $text, $actor ) = self::getFieldNames( $key );
// Combine data into conditions to be ORed together
- $actorNotEmpty = [];
- if ( $this->stage === MIGRATION_OLD ) {
- $actors = [];
- $actorEmpty = [];
- } elseif ( isset( self::$tempTables[$key] ) ) {
- $t = self::$tempTables[$key];
- $alias = "temp_$key";
- $tables[$alias] = $t['table'];
- $joins[$alias] = [
- $this->stage === MIGRATION_NEW ? 'JOIN' : 'LEFT JOIN',
- "{$alias}.{$t['pk']} = {$t['joinPK']}"
- ];
- $joinField = "{$alias}.{$t['field']}";
- $actorEmpty = [ $joinField => null ];
- if ( $this->stage !== MIGRATION_NEW ) {
- // Otherwise the resulting test can evaluate to NULL, and
- // NOT(NULL) is NULL rather than true.
- $actorNotEmpty = [ "$joinField IS NOT NULL" ];
+ if ( $this->stage & SCHEMA_COMPAT_READ_NEW ) {
+ if ( $actors ) {
+ if ( isset( self::$tempTables[$key] ) ) {
+ $t = self::$tempTables[$key];
+ $alias = "temp_$key";
+ $tables[$alias] = $t['table'];
+ $joins[$alias] = [ 'JOIN', "{$alias}.{$t['pk']} = {$t['joinPK']}" ];
+ $joinField = "{$alias}.{$t['field']}";
+ } else {
+ $joinField = $actor;
+ }
+ $conds['actor'] = $db->makeList( [ $joinField => $actors ], IDatabase::LIST_AND );
}
} else {
- $joinField = $actor;
- $actorEmpty = [ $joinField => 0 ];
- }
-
- if ( $actors ) {
- $conds['actor'] = $db->makeList(
- $actorNotEmpty + [ $joinField => $actors ], IDatabase::LIST_AND
- );
- }
- if ( $this->stage < MIGRATION_NEW && $ids ) {
- $conds['userid'] = $db->makeList(
- $actorEmpty + [ $key => $ids ], IDatabase::LIST_AND
- );
- }
- if ( $this->stage < MIGRATION_NEW && $names ) {
- $conds['username'] = $db->makeList(
- $actorEmpty + [ $text => $names ], IDatabase::LIST_AND
- );
+ if ( $ids ) {
+ $conds['userid'] = $db->makeList( [ $key => $ids ], IDatabase::LIST_AND );
+ }
+ if ( $names ) {
+ $conds['username'] = $db->makeList( [ $text => $names ], IDatabase::LIST_AND );
+ }
}
return [
'MediaWiki\\EditPage\\' => __DIR__ . '/editpage/',
'MediaWiki\\Linker\\' => __DIR__ . '/linker/',
'MediaWiki\\Preferences\\' => __DIR__ . '/preferences/',
+ 'MediaWiki\\Revision\\' => __DIR__ . '/Revision/',
'MediaWiki\\Services\\' => __DIR__ . '/services/',
'MediaWiki\\Session\\' => __DIR__ . '/session/',
'MediaWiki\\Shell\\' => __DIR__ . '/shell/',
public static function selectFields() {
global $wgActorTableSchemaMigrationStage;
- if ( $wgActorTableSchemaMigrationStage > MIGRATION_WRITE_BOTH ) {
+ if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_READ_NEW ) {
// If code is using this instead of self::getQueryInfo(), there's a
// decent chance it's going to try to directly access
// $row->ipb_by or $row->ipb_by_text and we can't give it
- // useful values here once those aren't being written anymore.
+ // useful values here once those aren't being used anymore.
throw new BadMethodCallException(
- 'Cannot use ' . __METHOD__ . ' when $wgActorTableSchemaMigrationStage > MIGRATION_WRITE_BOTH'
+ 'Cannot use ' . __METHOD__
+ . ' when $wgActorTableSchemaMigrationStage has SCHEMA_COMPAT_READ_NEW'
);
}
'ipb_address',
'ipb_by',
'ipb_by_text',
- 'ipb_by_actor' => $wgActorTableSchemaMigrationStage > MIGRATION_OLD ? 'ipb_by_actor' : 'NULL',
+ 'ipb_by_actor' => 'NULL',
'ipb_timestamp',
'ipb_auto',
'ipb_anon_only',
*/
$wgDeleteRevisionsLimit = 0;
+/**
+ * Page deletions with > this number of revisions will use the job queue.
+ * Revisions will be archived in batches of (at most) this size, one batch per job.
+ */
+$wgDeleteRevisionsBatchSize = 1000;
+
/**
* The maximum number of edits a user can have and
* can still be hidden by users with the hideuser permission.
* or (since 1.30) a callback to use for creating the job object.
*/
$wgJobClasses = [
+ 'deletePage' => DeletePageJob::class,
'refreshLinks' => RefreshLinksJob::class,
'deleteLinks' => DeleteLinksJob::class,
'htmlCacheUpdate' => HTMLCacheUpdateJob::class,
/**
* Actor table schema migration stage.
+ *
+ * Use the SCHEMA_COMPAT_XXX flags. Supported values:
+ * - SCHEMA_COMPAT_OLD
+ * - SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD
+ * - SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW
+ * - SCHEMA_COMPAT_NEW
+ *
+ * Note that reading the old and new schema at the same time is not supported
+ * in 1.32, but was (with significant query performance issues) in 1.31.
+ *
* @since 1.31
- * @var int One of the MIGRATION_* constants
+ * @since 1.32 changed allowed flags
+ * @var int An appropriate combination of SCHEMA_COMPAT_XXX flags.
*/
-$wgActorTableSchemaMigrationStage = MIGRATION_OLD;
+$wgActorTableSchemaMigrationStage = SCHEMA_COMPAT_OLD;
/**
* Temporary option to disable the date picker from the Expiry Widget.
$title = Title::newFromText( $this->editintro );
if ( $title instanceof Title && $title->exists() && $title->userCan( 'read' ) ) {
// Added using template syntax, to take <noinclude>'s into account.
- $this->context->getOutput()->addWikiTextTitleTidy(
+ $this->context->getOutput()->addWikiTextAsContent(
'<div class="mw-editintro">{{:' . $title->getFullText() . '}}</div>',
+ /*linestart*/true,
$this->mTitle
);
return true;
$this->contentFormat,
$ex->getMessage()
);
- $out->addWikiText( '<div class="error">' . $msg->text() . '</div>' );
+ $out->addWikiText( '<div class="error">' . $msg->plain() . '</div>' );
}
}
$this->contentFormat,
$ex->getMessage()
);
- $out->addWikiText( '<div class="error">' . $msg->text() . '</div>' );
+ $out->addWikiText( '<div class="error">' . $msg->plain() . '</div>' );
}
}
}
return $diff;
}
-/**
- * This function works like "use VERSION" in Perl, the program will die with a
- * backtrace if the current version of PHP is less than the version provided
- *
- * This is useful for extensions which due to their nature are not kept in sync
- * with releases, and might depend on other versions of PHP than the main code
- *
- * Note: PHP might die due to parsing errors in some cases before it ever
- * manages to call this function, such is life
- *
- * @see perldoc -f use
- *
- * @param string|int|float $req_ver The version to check, can be a string, an integer, or a float
- *
- * @deprecated since 1.30
- *
- * @throws MWException
- */
-function wfUsePHP( $req_ver ) {
- wfDeprecated( __FUNCTION__, '1.30' );
- $php_ver = PHP_VERSION;
-
- if ( version_compare( $php_ver, (string)$req_ver, '<' ) ) {
- throw new MWException( "PHP $req_ver required--this is only $php_ver" );
- }
-}
-
/**
* Return the final portion of a pathname.
* Reimplemented because PHP5's "basename()" is buggy with multibyte text.
return ObjectCache::getInstance( $wgMessageCacheType );
}
-/**
- * Call hook functions defined in $wgHooks
- *
- * @param string $event Event name
- * @param array $args Parameters passed to hook functions
- * @param string|null $deprecatedVersion Optionally mark hook as deprecated with version number
- *
- * @return bool True if no handler aborted the hook
- * @deprecated since 1.25 - use Hooks::run
- */
-function wfRunHooks( $event, array $args = [], $deprecatedVersion = null ) {
- wfDeprecated( __METHOD__, '1.25' );
- return Hooks::run( $event, $args, $deprecatedVersion );
-}
-
/**
* Wrapper around php's unpack.
*
}
throw new HttpError( 500, $message );
}
- // Protect against redirects to NS_MEDIA namespace
- // when the user probably wants NS_FILE
- if ( $title->inNamespace( NS_MEDIA ) ) {
- $title->mNamespace = NS_FILE;
- }
$output->setCdnMaxage( 1200 );
$output->redirect( $targetUrl, '301' );
return true;
$action->show();
return;
}
- // NOTE: deprecated hook. Add to $wgActions instead
- if ( Hooks::run(
- 'UnknownAction',
- [
- $request->getVal( 'action', 'view' ),
- $page
- ],
- '1.19'
- ) ) {
- $output->setStatusCode( 404 );
- $output->showErrorPage( 'nosuchaction', 'nosuchactiontext' );
- }
+
+ // If we've not found out which action it is by now, it's unknown
+ $output->setStatusCode( 404 );
+ $output->showErrorPage( 'nosuchaction', 'nosuchactiontext' );
}
/**
use MediaWiki\Storage\BlobStoreFactory;
use MediaWiki\Storage\NameTableStore;
use MediaWiki\Storage\NameTableStoreFactory;
-use MediaWiki\Storage\RevisionFactory;
-use MediaWiki\Storage\RevisionLookup;
-use MediaWiki\Storage\RevisionStore;
+use MediaWiki\Revision\RevisionFactory;
+use MediaWiki\Revision\RevisionLookup;
+use MediaWiki\Revision\RevisionStore;
use OldRevisionImporter;
-use MediaWiki\Storage\RevisionStoreFactory;
+use MediaWiki\Revision\RevisionStoreFactory;
use UploadRevisionImporter;
use Wikimedia\Rdbms\LBFactory;
use LinkCache;
protected function consumeNoFork() {
while ( !feof( $this->input ) ) {
$data = fgets( $this->input );
- if ( $data[ strlen( $data ) - 1 ] == "\n" ) {
+ if ( substr( $data, -1 ) === "\n" ) {
+ // Strip any final new line used to delimit lines of input.
+ // The last line of input might not have it, though.
$data = substr( $data, 0, -1 );
}
- if ( strlen( $data ) !== 0 ) {
- $result = call_user_func( $this->workCallback, $data );
- fwrite( $this->output, "$result\n" );
+ if ( $data === '' ) {
+ continue;
}
+ $result = call_user_func( $this->workCallback, $data );
+ fwrite( $this->output, "$result\n" );
}
}
$this->updateAvailableSockets( $sockets, $used, $sockets ? 0 : 5 );
} while ( !$sockets );
}
- // Strip the trailing \n. The last line of a file might not have a trailing
- // \n though
- if ( $data[ strlen( $data ) - 1 ] == "\n" ) {
+ if ( substr( $data, - 1 ) === "\n" ) {
+ // Strip any final new line used to delimit lines of input.
+ // The last line of input might not have it, though.
$data = substr( $data, 0, -1 );
}
- if ( strlen( $data ) === 0 ) {
+ if ( $data === '' ) {
continue;
}
$socket = array_pop( $sockets );
* @param bool $linestart Is this the start of a line?
* @param bool $interface Is this text in the user interface language?
* @throws MWException
+ * @deprecated since 1.32 due to untidy output; use
+ * addWikiTextAsInterface() if $interface is default value or true,
+ * or else addWikiTextAsContent() if $interface is false.
*/
public function addWikiText( $text, $linestart = true, $interface = true ) {
- $title = $this->getTitle(); // Work around E_STRICT
+ $title = $this->getTitle();
+ if ( !$title ) {
+ throw new MWException( 'Title is null' );
+ }
+ $this->addWikiTextTitleInternal( $text, $title, $linestart, /*tidy*/false, $interface );
+ }
+
+ /**
+ * Convert wikitext *in the user interface language* to HTML and
+ * add it to the buffer. The result will not be
+ * language-converted, as user interface messages are already
+ * localized into a specific variant. Assumes that the current
+ * page title will be used if optional $title is not
+ * provided. Output will be tidy.
+ *
+ * @param string $text Wikitext in the user interface language
+ * @param bool $linestart Is this the start of a line? (Defaults to true)
+ * @param Title|null $title Optional title to use; default of `null`
+ * means use current page title.
+ * @throws MWException if $title is not provided and OutputPage::getTitle()
+ * is null
+ * @since 1.32
+ */
+ public function addWikiTextAsInterface(
+ $text, $linestart = true, Title $title = null
+ ) {
+ if ( $title === null ) {
+ $title = $this->getTitle();
+ }
+ if ( !$title ) {
+ throw new MWException( 'Title is null' );
+ }
+ $this->addWikiTextTitleInternal( $text, $title, $linestart, /*tidy*/true, /*interface*/true );
+ }
+
+ /**
+ * Convert wikitext *in the page content language* to HTML and add
+ * it to the buffer. The result with be language-converted to the
+ * user's preferred variant. Assumes that the current page title
+ * will be used if optional $title is not provided. Output will be
+ * tidy.
+ *
+ * @param string $text Wikitext in the page content language
+ * @param bool $linestart Is this the start of a line? (Defaults to true)
+ * @param Title|null $title Optional title to use; default of `null`
+ * means use current page title.
+ * @throws MWException if $title is not provided and OutputPage::getTitle()
+ * is null
+ * @since 1.32
+ */
+ public function addWikiTextAsContent(
+ $text, $linestart = true, Title $title = null
+ ) {
+ if ( $title === null ) {
+ $title = $this->getTitle();
+ }
if ( !$title ) {
throw new MWException( 'Title is null' );
}
- $this->addWikiTextTitle( $text, $title, $linestart, /*tidy*/false, $interface );
+ $this->addWikiTextTitleInternal( $text, $title, $linestart, /*tidy*/true, /*interface*/false );
}
/**
* @param string $text Wikitext
* @param Title $title
* @param bool $linestart Is this the start of a line?
+ * @deprecated since 1.32 due to untidy output; use
+ * addWikiTextAsInterface()
*/
public function addWikiTextWithTitle( $text, Title $title, $linestart = true ) {
- $this->addWikiTextTitle( $text, $title, $linestart );
+ wfDeprecated( __METHOD__, '1.32' );
+ $this->addWikiTextTitleInternal( $text, $title, $linestart, /*tidy*/false, /*interface*/false );
}
/**
- * Add wikitext with a custom Title object and tidy enabled.
+ * Add wikitext *in content language* with a custom Title object.
+ * Output will be tidy.
*
- * @param string $text Wikitext
+ * @param string $text Wikitext in content language
* @param Title $title
* @param bool $linestart Is this the start of a line?
+ * @deprecated since 1.32 to rename methods consistently; use
+ * addWikiTextAsContent()
*/
function addWikiTextTitleTidy( $text, Title $title, $linestart = true ) {
- $this->addWikiTextTitle( $text, $title, $linestart, true );
+ wfDeprecated( __METHOD__, '1.32' );
+ $this->addWikiTextTitleInternal( $text, $title, $linestart, /*tidy*/true, /*interface*/false );
}
/**
- * Add wikitext with tidy enabled
+ * Add wikitext *in content language*. Output will be tidy.
*
- * @param string $text Wikitext
+ * @param string $text Wikitext in content language
* @param bool $linestart Is this the start of a line?
+ * @deprecated since 1.32 to rename methods consistently; use
+ * addWikiTextAsContent()
*/
public function addWikiTextTidy( $text, $linestart = true ) {
+ wfDeprecated( __METHOD__, '1.32' );
$title = $this->getTitle();
- $this->addWikiTextTitleTidy( $text, $title, $linestart );
+ if ( !$title ) {
+ throw new MWException( 'Title is null' );
+ }
+ $this->addWikiTextTitleInternal( $text, $title, $linestart, /*tidy*/true, /*interface*/false );
}
/**
* @param string $text Wikitext
* @param Title $title
* @param bool $linestart Is this the start of a line?
- * @param bool $tidy Whether to use tidy
+ * @param bool $tidy Whether to use tidy.
+ * Setting this to false (or omitting it) is deprecated
+ * since 1.32; all wikitext should be tidied.
+ * For backwards-compatibility with prior MW releases,
+ * you may wish to invoke this method but set $tidy=true;
+ * this will result in equivalent output to the non-deprecated
+ * addWikiTextAsContent()/addWikiTextAsInterface() methods.
* @param bool $interface Whether it is an interface message
* (for example disables conversion)
+ * @deprecated since 1.32, use addWikiTextAsContent() or
+ * addWikiTextAsInterface() (depending on $interface)
*/
public function addWikiTextTitle( $text, Title $title, $linestart,
$tidy = false, $interface = false
+ ) {
+ wfDeprecated( __METHOD__, '1.32' );
+ return $this->addWikiTextTitleInternal( $text, $title, $linestart, $tidy, $interface );
+ }
+
+ /**
+ * Add wikitext with a custom Title object.
+ * Output is unwrapped.
+ *
+ * @param string $text Wikitext
+ * @param Title $title
+ * @param bool $linestart Is this the start of a line?
+ * @param bool $tidy Whether to use tidy.
+ * Setting this to false (or omitting it) is deprecated
+ * since 1.32; all wikitext should be tidied.
+ * @param bool $interface Whether it is an interface message
+ * (for example disables conversion)
+ * @private
+ */
+ private function addWikiTextTitleInternal(
+ $text, Title $title, $linestart, $tidy, $interface
) {
global $wgParser;
* @file
*/
-use MediaWiki\Storage\MutableRevisionRecord;
-use MediaWiki\Storage\RevisionAccessException;
-use MediaWiki\Storage\RevisionFactory;
-use MediaWiki\Storage\RevisionLookup;
-use MediaWiki\Storage\RevisionRecord;
-use MediaWiki\Storage\RevisionStore;
-use MediaWiki\Storage\RevisionStoreRecord;
-use MediaWiki\Storage\SlotRecord;
+use MediaWiki\Revision\MutableRevisionRecord;
+use MediaWiki\Revision\RevisionAccessException;
+use MediaWiki\Revision\RevisionFactory;
+use MediaWiki\Revision\RevisionLookup;
+use MediaWiki\Revision\RevisionRecord;
+use MediaWiki\Revision\RevisionStore;
+use MediaWiki\Revision\RevisionStoreRecord;
+use MediaWiki\Revision\SlotRecord;
use MediaWiki\Storage\SqlBlobStore;
use Wikimedia\Rdbms\IDatabase;
use MediaWiki\Linker\LinkTarget;
global $wgActorTableSchemaMigrationStage;
wfDeprecated( __METHOD__, '1.31' );
- if ( $wgActorTableSchemaMigrationStage > MIGRATION_WRITE_BOTH ) {
+ if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_READ_NEW ) {
// If code is using this instead of self::getQueryInfo(), there's
// no way the join it's trying to do can work once the old fields
- // aren't being written anymore.
+ // aren't being used anymore.
throw new BadMethodCallException(
- 'Cannot use ' . __METHOD__ . ' when $wgActorTableSchemaMigrationStage > MIGRATION_WRITE_BOTH'
+ 'Cannot use ' . __METHOD__
+ . ' when $wgActorTableSchemaMigrationStage has SCHEMA_COMPAT_READ_NEW'
);
}
global $wgContentHandlerUseDB, $wgActorTableSchemaMigrationStage;
global $wgMultiContentRevisionSchemaMigrationStage;
- if ( $wgActorTableSchemaMigrationStage > MIGRATION_WRITE_BOTH ) {
+ if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_READ_NEW ) {
// If code is using this instead of self::getQueryInfo(), there's a
// decent chance it's going to try to directly access
// $row->rev_user or $row->rev_user_text and we can't give it
- // useful values here once those aren't being written anymore.
+ // useful values here once those aren't being used anymore.
throw new BadMethodCallException(
- 'Cannot use ' . __METHOD__ . ' when $wgActorTableSchemaMigrationStage > MIGRATION_WRITE_BOTH'
+ 'Cannot use ' . __METHOD__
+ . ' when $wgActorTableSchemaMigrationStage has SCHEMA_COMPAT_READ_NEW'
);
}
global $wgContentHandlerUseDB, $wgActorTableSchemaMigrationStage;
global $wgMultiContentRevisionSchemaMigrationStage;
- if ( $wgActorTableSchemaMigrationStage > MIGRATION_WRITE_BOTH ) {
+ if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_READ_NEW ) {
// If code is using this instead of self::getQueryInfo(), there's a
// decent chance it's going to try to directly access
// $row->ar_user or $row->ar_user_text and we can't give it
- // useful values here once those aren't being written anymore.
+ // useful values here once those aren't being used anymore.
throw new BadMethodCallException(
- 'Cannot use ' . __METHOD__ . ' when $wgActorTableSchemaMigrationStage > MIGRATION_WRITE_BOTH'
+ 'Cannot use ' . __METHOD__
+ . ' when $wgActorTableSchemaMigrationStage has SCHEMA_COMPAT_READ_NEW'
);
}
--- /dev/null
+<?php
+/**
+ * Exception representing a failure to look up a revision.
+ *
+ * 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
+ */
+
+namespace MediaWiki\Revision;
+
+/**
+ * Exception throw when trying to access undefined fields on an incomplete RevisionRecord.
+ *
+ * @since 1.31
+ * @since 1.32 Renamed from MediaWiki\Storage\IncompleteRevisionException
+ */
+class IncompleteRevisionException extends RevisionAccessException {
+
+}
+
+/**
+ * Retain the old class name for backwards compatibility.
+ * @deprecated since 1.32
+ */
+class_alias( IncompleteRevisionException::class, 'MediaWiki\Storage\IncompleteRevisionException' );
--- /dev/null
+<?php
+/**
+ * Mutable RevisionRecord implementation, for building new revision entries programmatically.
+ *
+ * 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
+ */
+
+namespace MediaWiki\Revision;
+
+use CommentStoreComment;
+use Content;
+use InvalidArgumentException;
+use MediaWiki\Storage\RevisionSlotsUpdate;
+use MediaWiki\User\UserIdentity;
+use MWException;
+use Title;
+use Wikimedia\Assert\Assert;
+
+/**
+ * Mutable RevisionRecord implementation, for building new revision entries programmatically.
+ * Provides setters for all fields.
+ *
+ * @since 1.31
+ * @since 1.32 Renamed from MediaWiki\Storage\MutableRevisionRecord
+ */
+class MutableRevisionRecord extends RevisionRecord {
+
+ /**
+ * Returns an incomplete MutableRevisionRecord which uses $parent as its
+ * parent revision, and inherits all slots form it. If saved unchanged,
+ * the new revision will act as a null-revision.
+ *
+ * @param RevisionRecord $parent
+ *
+ * @return MutableRevisionRecord
+ */
+ public static function newFromParentRevision( RevisionRecord $parent ) {
+ // TODO: ideally, we wouldn't need a Title here
+ $title = Title::newFromLinkTarget( $parent->getPageAsLinkTarget() );
+ $rev = new MutableRevisionRecord( $title, $parent->getWikiId() );
+
+ foreach ( $parent->getSlotRoles() as $role ) {
+ $slot = $parent->getSlot( $role, self::RAW );
+ $rev->inheritSlot( $slot );
+ }
+
+ $rev->setPageId( $parent->getPageId() );
+ $rev->setParentId( $parent->getId() );
+
+ return $rev;
+ }
+
+ /**
+ * @note Avoid calling this constructor directly. Use the appropriate methods
+ * in RevisionStore instead.
+ *
+ * @param Title $title The title of the page this Revision is associated with.
+ * @param bool|string $wikiId the wiki ID of the site this Revision belongs to,
+ * or false for the local site.
+ *
+ * @throws MWException
+ */
+ function __construct( Title $title, $wikiId = false ) {
+ $slots = new MutableRevisionSlots();
+
+ parent::__construct( $title, $slots, $wikiId );
+
+ $this->mSlots = $slots; // redundant, but nice for static analysis
+ }
+
+ /**
+ * @param int $parentId
+ */
+ public function setParentId( $parentId ) {
+ Assert::parameterType( 'integer', $parentId, '$parentId' );
+
+ $this->mParentId = $parentId;
+ }
+
+ /**
+ * Sets the given slot. If a slot with the same role is already present in the revision,
+ * it is replaced.
+ *
+ * @note This can only be used with a fresh "unattached" SlotRecord. Calling code that has a
+ * SlotRecord from another revision should use inheritSlot(). Calling code that has access to
+ * a Content object can use setContent().
+ *
+ * @note This may cause the slot meta-data for the revision to be lazy-loaded.
+ *
+ * @note Calling this method will cause the revision size and hash to be re-calculated upon
+ * the next call to getSize() and getSha1(), respectively.
+ *
+ * @param SlotRecord $slot
+ */
+ public function setSlot( SlotRecord $slot ) {
+ if ( $slot->hasRevision() && $slot->getRevision() !== $this->getId() ) {
+ throw new InvalidArgumentException(
+ 'The given slot must be an unsaved, unattached one. '
+ . 'This slot is already attached to revision ' . $slot->getRevision() . '. '
+ . 'Use inheritSlot() instead to preserve a slot from a previous revision.'
+ );
+ }
+
+ $this->mSlots->setSlot( $slot );
+ $this->resetAggregateValues();
+ }
+
+ /**
+ * "Inherits" the given slot's content.
+ *
+ * If a slot with the same role is already present in the revision, it is replaced.
+ *
+ * @note This may cause the slot meta-data for the revision to be lazy-loaded.
+ *
+ * @param SlotRecord $parentSlot
+ */
+ public function inheritSlot( SlotRecord $parentSlot ) {
+ $this->mSlots->inheritSlot( $parentSlot );
+ $this->resetAggregateValues();
+ }
+
+ /**
+ * Sets the content for the slot with the given role.
+ *
+ * If a slot with the same role is already present in the revision, it is replaced.
+ * Calling code that has access to a SlotRecord can use inheritSlot() instead.
+ *
+ * @note This may cause the slot meta-data for the revision to be lazy-loaded.
+ *
+ * @note Calling this method will cause the revision size and hash to be re-calculated upon
+ * the next call to getSize() and getSha1(), respectively.
+ *
+ * @param string $role
+ * @param Content $content
+ */
+ public function setContent( $role, Content $content ) {
+ $this->mSlots->setContent( $role, $content );
+ $this->resetAggregateValues();
+ }
+
+ /**
+ * Removes the slot with the given role from this revision.
+ * This effectively ends the "stream" with that role on the revision's page.
+ * Future revisions will no longer inherit this slot, unless it is added back explicitly.
+ *
+ * @note This may cause the slot meta-data for the revision to be lazy-loaded.
+ *
+ * @note Calling this method will cause the revision size and hash to be re-calculated upon
+ * the next call to getSize() and getSha1(), respectively.
+ *
+ * @param string $role
+ */
+ public function removeSlot( $role ) {
+ $this->mSlots->removeSlot( $role );
+ $this->resetAggregateValues();
+ }
+
+ /**
+ * Applies the given update to the slots of this revision.
+ *
+ * @param RevisionSlotsUpdate $update
+ */
+ public function applyUpdate( RevisionSlotsUpdate $update ) {
+ $update->apply( $this->mSlots );
+ }
+
+ /**
+ * @param CommentStoreComment $comment
+ */
+ public function setComment( CommentStoreComment $comment ) {
+ $this->mComment = $comment;
+ }
+
+ /**
+ * Set revision hash, for optimization. Prevents getSha1() from re-calculating the hash.
+ *
+ * @note This should only be used if the calling code is sure that the given hash is correct
+ * for the revision's content, and there is no chance of the content being manipulated
+ * later. When in doubt, this method should not be called.
+ *
+ * @param string $sha1 SHA1 hash as a base36 string.
+ */
+ public function setSha1( $sha1 ) {
+ Assert::parameterType( 'string', $sha1, '$sha1' );
+
+ $this->mSha1 = $sha1;
+ }
+
+ /**
+ * Set nominal revision size, for optimization. Prevents getSize() from re-calculating the size.
+ *
+ * @note This should only be used if the calling code is sure that the given size is correct
+ * for the revision's content, and there is no chance of the content being manipulated
+ * later. When in doubt, this method should not be called.
+ *
+ * @param int $size nominal size in bogo-bytes
+ */
+ public function setSize( $size ) {
+ Assert::parameterType( 'integer', $size, '$size' );
+
+ $this->mSize = $size;
+ }
+
+ /**
+ * @param int $visibility
+ */
+ public function setVisibility( $visibility ) {
+ Assert::parameterType( 'integer', $visibility, '$visibility' );
+
+ $this->mDeleted = $visibility;
+ }
+
+ /**
+ * @param string $timestamp A timestamp understood by wfTimestamp
+ */
+ public function setTimestamp( $timestamp ) {
+ Assert::parameterType( 'string', $timestamp, '$timestamp' );
+
+ $this->mTimestamp = wfTimestamp( TS_MW, $timestamp );
+ }
+
+ /**
+ * @param bool $minorEdit
+ */
+ public function setMinorEdit( $minorEdit ) {
+ Assert::parameterType( 'boolean', $minorEdit, '$minorEdit' );
+
+ $this->mMinorEdit = $minorEdit;
+ }
+
+ /**
+ * Set the revision ID.
+ *
+ * MCR migration note: this replaces Revision::setId()
+ *
+ * @warning Use this with care, especially when preparing a revision for insertion
+ * into the database! The revision ID should only be fixed in special cases
+ * like preserving the original ID when restoring a revision.
+ *
+ * @param int $id
+ */
+ public function setId( $id ) {
+ Assert::parameterType( 'integer', $id, '$id' );
+
+ $this->mId = $id;
+ }
+
+ /**
+ * Sets the user identity associated with the revision
+ *
+ * @param UserIdentity $user
+ */
+ public function setUser( UserIdentity $user ) {
+ $this->mUser = $user;
+ }
+
+ /**
+ * @param int $pageId
+ */
+ public function setPageId( $pageId ) {
+ Assert::parameterType( 'integer', $pageId, '$pageId' );
+
+ if ( $this->mTitle->exists() && $pageId !== $this->mTitle->getArticleID() ) {
+ throw new InvalidArgumentException(
+ 'The given Title does not belong to page ID ' . $this->mPageId
+ );
+ }
+
+ $this->mPageId = $pageId;
+ }
+
+ /**
+ * Returns the nominal size of this revision.
+ *
+ * MCR migration note: this replaces Revision::getSize
+ *
+ * @return int The nominal size, may be computed on the fly if not yet known.
+ */
+ public function getSize() {
+ // If not known, re-calculate and remember. Will be reset when slots change.
+ if ( $this->mSize === null ) {
+ $this->mSize = $this->mSlots->computeSize();
+ }
+
+ return $this->mSize;
+ }
+
+ /**
+ * Returns the base36 sha1 of this revision.
+ *
+ * MCR migration note: this replaces Revision::getSha1
+ *
+ * @return string The revision hash, may be computed on the fly if not yet known.
+ */
+ public function getSha1() {
+ // If not known, re-calculate and remember. Will be reset when slots change.
+ if ( $this->mSha1 === null ) {
+ $this->mSha1 = $this->mSlots->computeSha1();
+ }
+
+ return $this->mSha1;
+ }
+
+ /**
+ * Returns the slots defined for this revision as a MutableRevisionSlots instance,
+ * which can be modified to defined the slots for this revision.
+ *
+ * @return MutableRevisionSlots
+ */
+ public function getSlots() {
+ // Overwritten just guarantee the more narrow return type.
+ return parent::getSlots();
+ }
+
+ /**
+ * Invalidate cached aggregate values such as hash and size.
+ */
+ private function resetAggregateValues() {
+ $this->mSize = null;
+ $this->mSha1 = null;
+ }
+
+}
+
+/**
+ * Retain the old class name for backwards compatibility.
+ * @deprecated since 1.32
+ */
+class_alias( MutableRevisionRecord::class, 'MediaWiki\Storage\MutableRevisionRecord' );
--- /dev/null
+<?php
+/**
+ * Mutable version of RevisionSlots, for constructing a new revision.
+ *
+ * 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
+ */
+
+namespace MediaWiki\Revision;
+
+use Content;
+
+/**
+ * Mutable version of RevisionSlots, for constructing a new revision.
+ *
+ * @since 1.31
+ * @since 1.32 Renamed from MediaWiki\Storage\MutableRevisionSlots
+ */
+class MutableRevisionSlots extends RevisionSlots {
+
+ /**
+ * Constructs a MutableRevisionSlots that inherits from the given
+ * list of slots.
+ *
+ * @param SlotRecord[] $slots
+ *
+ * @return MutableRevisionSlots
+ */
+ public static function newFromParentRevisionSlots( array $slots ) {
+ $inherited = [];
+ foreach ( $slots as $slot ) {
+ $role = $slot->getRole();
+ $inherited[$role] = SlotRecord::newInherited( $slot );
+ }
+
+ return new MutableRevisionSlots( $inherited );
+ }
+
+ /**
+ * @param SlotRecord[] $slots An array of SlotRecords.
+ */
+ public function __construct( array $slots = [] ) {
+ parent::__construct( $slots );
+ }
+
+ /**
+ * Sets the given slot.
+ * If a slot with the same role is already present, it is replaced.
+ *
+ * @param SlotRecord $slot
+ */
+ public function setSlot( SlotRecord $slot ) {
+ if ( !is_array( $this->slots ) ) {
+ $this->getSlots(); // initialize $this->slots
+ }
+
+ $role = $slot->getRole();
+ $this->slots[$role] = $slot;
+ }
+
+ /**
+ * Sets the given slot to an inherited version of $slot.
+ * If a slot with the same role is already present, it is replaced.
+ *
+ * @param SlotRecord $slot
+ */
+ public function inheritSlot( SlotRecord $slot ) {
+ $this->setSlot( SlotRecord::newInherited( $slot ) );
+ }
+
+ /**
+ * Sets the content for the slot with the given role.
+ * If a slot with the same role is already present, it is replaced.
+ *
+ * @param string $role
+ * @param Content $content
+ */
+ public function setContent( $role, Content $content ) {
+ $slot = SlotRecord::newUnsaved( $role, $content );
+ $this->setSlot( $slot );
+ }
+
+ /**
+ * Remove the slot for the given role, discontinue the corresponding stream.
+ *
+ * @param string $role
+ */
+ public function removeSlot( $role ) {
+ if ( !is_array( $this->slots ) ) {
+ $this->getSlots(); // initialize $this->slots
+ }
+
+ unset( $this->slots[$role] );
+ }
+
+}
+
+/**
+ * Retain the old class name for backwards compatibility.
+ * @deprecated since 1.32
+ */
+class_alias( MutableRevisionSlots::class, 'MediaWiki\Storage\MutableRevisionSlots' );
use InvalidArgumentException;
use LogicException;
-use MediaWiki\Storage\RevisionRecord;
-use MediaWiki\Storage\SuppressedDataException;
use ParserOptions;
use ParserOutput;
use Psr\Log\LoggerInterface;
use Revision;
use Title;
use User;
+use Content;
use Wikimedia\Assert\Assert;
/**
'Access to the content has been suppressed for this audience'
);
} else {
- $output = $content->getParserOutput(
- $this->title,
- $this->revision->getId(),
- $this->options,
- $withHtml
- );
+ $output = $this->getSlotParserOutputUncached( $content, $withHtml );
if ( $withHtml && !$output->hasText() ) {
throw new LogicException(
return $this->slotsOutput[$role];
}
+ /**
+ * @note This method exist to make duplicate parses easier to see during profiling
+ * @param Content $content
+ * @param bool $withHtml
+ * @return ParserOutput
+ */
+ private function getSlotParserOutputUncached( Content $content, $withHtml ) {
+ return $content->getParserOutput(
+ $this->title,
+ $this->revision->getId(),
+ $this->options,
+ $withHtml
+ );
+ }
+
/**
* Updates the RevisionRecord after the revision has been saved. This can be used to discard
* and cached ParserOutput so parser functions like {{REVISIONTIMESTAMP}} or {{REVISIONID}}
--- /dev/null
+<?php
+/**
+ * Exception representing a failure to look up a revision.
+ *
+ * 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
+ */
+
+namespace MediaWiki\Revision;
+
+use RuntimeException;
+
+/**
+ * Exception representing a failure to look up a revision.
+ *
+ * @since 1.31
+ * @since 1.32 Renamed from MediaWiki\Storage\RevisionAccessException
+ */
+class RevisionAccessException extends RuntimeException {
+
+}
+
+/**
+ * Retain the old class name for backwards compatibility.
+ * @deprecated since 1.32
+ */
+class_alias( RevisionAccessException::class, 'MediaWiki\Storage\RevisionAccessException' );
--- /dev/null
+<?php
+/**
+ * A RevisionRecord representing a revision of a deleted page persisted in the archive table.
+ *
+ * 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
+ */
+
+namespace MediaWiki\Revision;
+
+use CommentStoreComment;
+use MediaWiki\User\UserIdentity;
+use Title;
+use User;
+use Wikimedia\Assert\Assert;
+
+/**
+ * A RevisionRecord representing a revision of a deleted page persisted in the archive table.
+ * Most getters on RevisionArchiveRecord will never return null. However, getId() and
+ * getParentId() may indeed return null if this information was not stored when the archive entry
+ * was created.
+ *
+ * @since 1.31
+ * @since 1.32 Renamed from MediaWiki\Storage\RevisionArchiveRecord
+ */
+class RevisionArchiveRecord extends RevisionRecord {
+
+ /**
+ * @var int
+ */
+ protected $mArchiveId;
+
+ /**
+ * @note Avoid calling this constructor directly. Use the appropriate methods
+ * in RevisionStore instead.
+ *
+ * @param Title $title The title of the page this Revision is associated with.
+ * @param UserIdentity $user
+ * @param CommentStoreComment $comment
+ * @param object $row An archive table row. Use RevisionStore::getArchiveQueryInfo() to build
+ * a query that yields the required fields.
+ * @param RevisionSlots $slots The slots of this revision.
+ * @param bool|string $wikiId the wiki ID of the site this Revision belongs to,
+ * or false for the local site.
+ */
+ function __construct(
+ Title $title,
+ UserIdentity $user,
+ CommentStoreComment $comment,
+ $row,
+ RevisionSlots $slots,
+ $wikiId = false
+ ) {
+ parent::__construct( $title, $slots, $wikiId );
+ Assert::parameterType( 'object', $row, '$row' );
+
+ $timestamp = wfTimestamp( TS_MW, $row->ar_timestamp );
+ Assert::parameter( is_string( $timestamp ), '$row->rev_timestamp', 'must be a valid timestamp' );
+
+ $this->mArchiveId = intval( $row->ar_id );
+
+ // NOTE: ar_page_id may be different from $this->mTitle->getArticleID() in some cases,
+ // notably when a partially restored page has been moved, and a new page has been created
+ // with the same title. Archive rows for that title will then have the wrong page id.
+ $this->mPageId = isset( $row->ar_page_id ) ? intval( $row->ar_page_id ) : $title->getArticleID();
+
+ // NOTE: ar_parent_id = 0 indicates that there is no parent revision, while null
+ // indicates that the parent revision is unknown. As per MW 1.31, the database schema
+ // allows ar_parent_id to be NULL.
+ $this->mParentId = isset( $row->ar_parent_id ) ? intval( $row->ar_parent_id ) : null;
+ $this->mId = isset( $row->ar_rev_id ) ? intval( $row->ar_rev_id ) : null;
+ $this->mComment = $comment;
+ $this->mUser = $user;
+ $this->mTimestamp = $timestamp;
+ $this->mMinorEdit = boolval( $row->ar_minor_edit );
+ $this->mDeleted = intval( $row->ar_deleted );
+ $this->mSize = isset( $row->ar_len ) ? intval( $row->ar_len ) : null;
+ $this->mSha1 = !empty( $row->ar_sha1 ) ? $row->ar_sha1 : null;
+ }
+
+ /**
+ * Get archive row ID
+ *
+ * @return int
+ */
+ public function getArchiveId() {
+ return $this->mArchiveId;
+ }
+
+ /**
+ * @return int|null The revision id, or null if the original revision ID
+ * was not recorded in the archive table.
+ */
+ public function getId() {
+ // overwritten just to refine the contract specification.
+ return parent::getId();
+ }
+
+ /**
+ * @throws RevisionAccessException if the size was unknown and could not be calculated.
+ * @return int The nominal revision size, never null. May be computed on the fly.
+ */
+ public function getSize() {
+ // If length is null, calculate and remember it (potentially SLOW!).
+ // This is for compatibility with old database rows that don't have the field set.
+ if ( $this->mSize === null ) {
+ $this->mSize = $this->mSlots->computeSize();
+ }
+
+ return $this->mSize;
+ }
+
+ /**
+ * @throws RevisionAccessException if the hash was unknown and could not be calculated.
+ * @return string The revision hash, never null. May be computed on the fly.
+ */
+ public function getSha1() {
+ // If hash is null, calculate it and remember (potentially SLOW!)
+ // This is for compatibility with old database rows that don't have the field set.
+ if ( $this->mSha1 === null ) {
+ $this->mSha1 = $this->mSlots->computeSha1();
+ }
+
+ return $this->mSha1;
+ }
+
+ /**
+ * @param int $audience
+ * @param User|null $user
+ *
+ * @return UserIdentity The identity of the revision author, null if access is forbidden.
+ */
+ public function getUser( $audience = self::FOR_PUBLIC, User $user = null ) {
+ // overwritten just to add a guarantee to the contract
+ return parent::getUser( $audience, $user );
+ }
+
+ /**
+ * @param int $audience
+ * @param User|null $user
+ *
+ * @return CommentStoreComment The revision comment, null if access is forbidden.
+ */
+ public function getComment( $audience = self::FOR_PUBLIC, User $user = null ) {
+ // overwritten just to add a guarantee to the contract
+ return parent::getComment( $audience, $user );
+ }
+
+ /**
+ * @return string never null
+ */
+ public function getTimestamp() {
+ // overwritten just to add a guarantee to the contract
+ return parent::getTimestamp();
+ }
+
+ /**
+ * @see RevisionStore::isComplete
+ *
+ * @return bool always true.
+ */
+ public function isReadyForInsertion() {
+ return true;
+ }
+
+}
+
+/**
+ * Retain the old class name for backwards compatibility.
+ * @deprecated since 1.32
+ */
+class_alias( RevisionArchiveRecord::class, 'MediaWiki\Storage\RevisionArchiveRecord' );
--- /dev/null
+<?php
+/**
+ * Service for constructing revision objects.
+ *
+ * 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
+ */
+
+namespace MediaWiki\Revision;
+
+use MWException;
+use Title;
+
+/**
+ * Service for constructing revision objects.
+ *
+ * @since 1.31
+ * @since 1.32 Renamed from MediaWiki\Storage\RevisionFactory
+ *
+ * @note This was written to act as a drop-in replacement for the corresponding
+ * static methods in Revision.
+ */
+interface RevisionFactory {
+
+ /**
+ * Constructs a new RevisionRecord based on the given associative array following the MW1.29
+ * database convention for the Revision constructor.
+ *
+ * MCR migration note: this replaces Revision::newFromRow
+ *
+ * @deprecated since 1.31. Use a MutableRevisionRecord instead.
+ *
+ * @param array $fields
+ * @param int $queryFlags Flags for lazy loading behavior, see IDBAccessObject::READ_XXX.
+ * @param Title|null $title
+ *
+ * @return MutableRevisionRecord
+ * @throws MWException
+ */
+ public function newMutableRevisionFromArray( array $fields, $queryFlags = 0, Title $title = null );
+
+ /**
+ * Constructs a RevisionRecord given a database row and content slots.
+ *
+ * MCR migration note: this replaces Revision::newFromRow for rows based on the
+ * revision, slot, and content tables defined for MCR since MW1.31.
+ *
+ * @param object $row A query result row as a raw object.
+ * Use RevisionStore::getQueryInfo() to build a query that yields the required fields.
+ * @param int $queryFlags Flags for lazy loading behavior, see IDBAccessObject::READ_XXX.
+ * @param Title|null $title
+ *
+ * @return RevisionRecord
+ */
+ public function newRevisionFromRow( $row, $queryFlags = 0, Title $title = null );
+
+ /**
+ * Make a fake revision object from an archive table row. This is queried
+ * for permissions or even inserted (as in Special:Undelete)
+ *
+ * MCR migration note: this replaces Revision::newFromArchiveRow
+ *
+ * @param object $row A query result row as a raw object.
+ * Use RevisionStore::getArchiveQueryInfo() to build a query that yields the
+ * required fields.
+ * @param int $queryFlags Flags for lazy loading behavior, see IDBAccessObject::READ_XXX.
+ * @param Title|null $title
+ * @param array $overrides An associative array that allows fields in $row to be overwritten.
+ * Keys in this array correspond to field names in $row without the "ar_" prefix, so
+ * $overrides['user'] will override $row->ar_user, etc.
+ *
+ * @return RevisionRecord
+ */
+ public function newRevisionFromArchiveRow(
+ $row,
+ $queryFlags = 0,
+ Title $title = null,
+ array $overrides = []
+ );
+
+}
+
+/**
+ * Retain the old class name for backwards compatibility.
+ * @deprecated since 1.32
+ */
+class_alias( RevisionFactory::class, 'MediaWiki\Storage\RevisionFactory' );
--- /dev/null
+<?php
+/**
+ * Service for looking up page revisions.
+ *
+ * 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
+ */
+
+namespace MediaWiki\Revision;
+
+use IDBAccessObject;
+use MediaWiki\Linker\LinkTarget;
+use Title;
+
+/**
+ * Service for looking up page revisions.
+ *
+ * @note This was written to act as a drop-in replacement for the corresponding
+ * static methods in Revision.
+ *
+ * @since 1.31
+ * @since 1.32 Renamed from MediaWiki\Storage\RevisionLookup
+ */
+interface RevisionLookup extends IDBAccessObject {
+
+ /**
+ * Load a page revision from a given revision ID number.
+ * Returns null if no such revision can be found.
+ *
+ * MCR migration note: this replaces Revision::newFromId
+ *
+ * $flags include:
+ *
+ * @param int $id
+ * @param int $flags bit field, see IDBAccessObject::READ_XXX
+ * @return RevisionRecord|null
+ */
+ public function getRevisionById( $id, $flags = 0 );
+
+ /**
+ * Load either the current, or a specified, revision
+ * that's attached to a given link target. If not attached
+ * to that link target, will return null.
+ *
+ * MCR migration note: this replaces Revision::newFromTitle
+ *
+ * @param LinkTarget $linkTarget
+ * @param int $revId (optional)
+ * @param int $flags bit field, see IDBAccessObject::READ_XXX
+ * @return RevisionRecord|null
+ */
+ public function getRevisionByTitle( LinkTarget $linkTarget, $revId = 0, $flags = 0 );
+
+ /**
+ * Load either the current, or a specified, revision
+ * that's attached to a given page ID.
+ * Returns null if no such revision can be found.
+ *
+ * MCR migration note: this replaces Revision::newFromPageId
+ *
+ * @param int $pageId
+ * @param int $revId (optional)
+ * @param int $flags bit field, see IDBAccessObject::READ_XXX
+ * @return RevisionRecord|null
+ */
+ public function getRevisionByPageId( $pageId, $revId = 0, $flags = 0 );
+
+ /**
+ * Get previous revision for this title
+ *
+ * MCR migration note: this replaces Revision::getPrevious
+ *
+ * @param RevisionRecord $rev
+ * @param Title|null $title if known (optional)
+ *
+ * @return RevisionRecord|null
+ */
+ public function getPreviousRevision( RevisionRecord $rev, Title $title = null );
+
+ /**
+ * Get next revision for this title
+ *
+ * MCR migration note: this replaces Revision::getNext
+ *
+ * @param RevisionRecord $rev
+ * @param Title|null $title if known (optional)
+ *
+ * @return RevisionRecord|null
+ */
+ public function getNextRevision( RevisionRecord $rev, Title $title = null );
+
+ /**
+ * Load a revision based on a known page ID and current revision ID from the DB
+ *
+ * This method allows for the use of caching, though accessing anything that normally
+ * requires permission checks (aside from the text) will trigger a small DB lookup.
+ *
+ * MCR migration note: this replaces Revision::newKnownCurrent
+ *
+ * @param Title $title the associated page title
+ * @param int $revId current revision of this page
+ *
+ * @return RevisionRecord|bool Returns false if missing
+ */
+ public function getKnownCurrentRevision( Title $title, $revId );
+
+}
+
+/**
+ * Retain the old class name for backwards compatibility.
+ * @deprecated since 1.32
+ */
+class_alias( RevisionLookup::class, 'MediaWiki\Storage\RevisionLookup' );
--- /dev/null
+<?php
+/**
+ * Page revision base class.
+ *
+ * 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
+ */
+
+namespace MediaWiki\Revision;
+
+use CommentStoreComment;
+use Content;
+use InvalidArgumentException;
+use LogicException;
+use MediaWiki\Linker\LinkTarget;
+use MediaWiki\User\UserIdentity;
+use MWException;
+use Title;
+use User;
+use Wikimedia\Assert\Assert;
+
+/**
+ * Page revision base class.
+ *
+ * RevisionRecords are considered value objects, but they may use callbacks for lazy loading.
+ * Note that while the base class has no setters, subclasses may offer a mutable interface.
+ *
+ * @since 1.31
+ * @since 1.32 Renamed from MediaWiki\Storage\RevisionRecord
+ */
+abstract class RevisionRecord {
+
+ // RevisionRecord deletion constants
+ const DELETED_TEXT = 1;
+ const DELETED_COMMENT = 2;
+ const DELETED_USER = 4;
+ const DELETED_RESTRICTED = 8;
+ const SUPPRESSED_USER = self::DELETED_USER | self::DELETED_RESTRICTED; // convenience
+ const SUPPRESSED_ALL = self::DELETED_TEXT | self::DELETED_COMMENT | self::DELETED_USER |
+ self::DELETED_RESTRICTED; // convenience
+
+ // Audience options for accessors
+ const FOR_PUBLIC = 1;
+ const FOR_THIS_USER = 2;
+ const RAW = 3;
+
+ /** @var string Wiki ID; false means the current wiki */
+ protected $mWiki = false;
+ /** @var int|null */
+ protected $mId;
+ /** @var int|null */
+ protected $mPageId;
+ /** @var UserIdentity|null */
+ protected $mUser;
+ /** @var bool */
+ protected $mMinorEdit = false;
+ /** @var string|null */
+ protected $mTimestamp;
+ /** @var int using the DELETED_XXX and SUPPRESSED_XXX flags */
+ protected $mDeleted = 0;
+ /** @var int|null */
+ protected $mSize;
+ /** @var string|null */
+ protected $mSha1;
+ /** @var int|null */
+ protected $mParentId;
+ /** @var CommentStoreComment|null */
+ protected $mComment;
+
+ /** @var Title */
+ protected $mTitle; // TODO: we only need the title for permission checks!
+
+ /** @var RevisionSlots */
+ protected $mSlots;
+
+ /**
+ * @note Avoid calling this constructor directly. Use the appropriate methods
+ * in RevisionStore instead.
+ *
+ * @param Title $title The title of the page this Revision is associated with.
+ * @param RevisionSlots $slots The slots of this revision.
+ * @param bool|string $wikiId the wiki ID of the site this Revision belongs to,
+ * or false for the local site.
+ *
+ * @throws MWException
+ */
+ function __construct( Title $title, RevisionSlots $slots, $wikiId = false ) {
+ Assert::parameterType( 'string|boolean', $wikiId, '$wikiId' );
+
+ $this->mTitle = $title;
+ $this->mSlots = $slots;
+ $this->mWiki = $wikiId;
+
+ // XXX: this is a sensible default, but we may not have a Title object here in the future.
+ $this->mPageId = $title->getArticleID();
+ }
+
+ /**
+ * Implemented to defy serialization.
+ *
+ * @throws LogicException always
+ */
+ public function __sleep() {
+ throw new LogicException( __CLASS__ . ' is not serializable.' );
+ }
+
+ /**
+ * @param RevisionRecord $rec
+ *
+ * @return bool True if this RevisionRecord is known to have same content as $rec.
+ * False if the content is different (or not known to be the same).
+ */
+ public function hasSameContent( RevisionRecord $rec ) {
+ if ( $rec === $this ) {
+ return true;
+ }
+
+ if ( $this->getId() !== null && $this->getId() === $rec->getId() ) {
+ return true;
+ }
+
+ // check size before hash, since size is quicker to compute
+ if ( $this->getSize() !== $rec->getSize() ) {
+ return false;
+ }
+
+ // instead of checking the hash, we could also check the content addresses of all slots.
+
+ if ( $this->getSha1() === $rec->getSha1() ) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Returns the Content of the given slot of this revision.
+ * Call getSlotNames() to get a list of available slots.
+ *
+ * Note that for mutable Content objects, each call to this method will return a
+ * fresh clone.
+ *
+ * MCR migration note: this replaces Revision::getContent
+ *
+ * @param string $role The role name of the desired slot
+ * @param int $audience
+ * @param User|null $user
+ *
+ * @throws RevisionAccessException if the slot does not exist or slot data
+ * could not be lazy-loaded.
+ * @return Content|null The content of the given slot, or null if access is forbidden.
+ */
+ public function getContent( $role, $audience = self::FOR_PUBLIC, User $user = null ) {
+ // XXX: throwing an exception would be nicer, but would a further
+ // departure from the signature of Revision::getContent(), and thus
+ // more complex and error prone refactoring.
+ if ( !$this->audienceCan( self::DELETED_TEXT, $audience, $user ) ) {
+ return null;
+ }
+
+ $content = $this->getSlot( $role, $audience, $user )->getContent();
+ return $content->copy();
+ }
+
+ /**
+ * Returns meta-data for the given slot.
+ *
+ * @param string $role The role name of the desired slot
+ * @param int $audience
+ * @param User|null $user
+ *
+ * @throws RevisionAccessException if the slot does not exist or slot data
+ * could not be lazy-loaded.
+ * @return SlotRecord The slot meta-data. If access to the slot content is forbidden,
+ * calling getContent() on the SlotRecord will throw an exception.
+ */
+ public function getSlot( $role, $audience = self::FOR_PUBLIC, User $user = null ) {
+ $slot = $this->mSlots->getSlot( $role );
+
+ if ( !$this->audienceCan( self::DELETED_TEXT, $audience, $user ) ) {
+ return SlotRecord::newWithSuppressedContent( $slot );
+ }
+
+ return $slot;
+ }
+
+ /**
+ * Returns whether the given slot is defined in this revision.
+ *
+ * @param string $role The role name of the desired slot
+ *
+ * @return bool
+ */
+ public function hasSlot( $role ) {
+ return $this->mSlots->hasSlot( $role );
+ }
+
+ /**
+ * Returns the slot names (roles) of all slots present in this revision.
+ * getContent() will succeed only for the names returned by this method.
+ *
+ * @return string[]
+ */
+ public function getSlotRoles() {
+ return $this->mSlots->getSlotRoles();
+ }
+
+ /**
+ * Returns the slots defined for this revision.
+ *
+ * @return RevisionSlots
+ */
+ public function getSlots() {
+ return $this->mSlots;
+ }
+
+ /**
+ * Returns the slots that originate in this revision.
+ *
+ * Note that this does not include any slots inherited from some earlier revision,
+ * even if they are different from the slots in the immediate parent revision.
+ * This is the case for rollbacks: slots of a rollback revision are inherited from
+ * the rollback target, and are different from the slots in the parent revision,
+ * which was rolled back.
+ *
+ * To find all slots modified by this revision against its immediate parent
+ * revision, use RevisionSlotsUpdate::newFromRevisionSlots().
+ *
+ * @return RevisionSlots
+ */
+ public function getOriginalSlots() {
+ return new RevisionSlots( $this->mSlots->getOriginalSlots() );
+ }
+
+ /**
+ * Returns slots inherited from some previous revision.
+ *
+ * "Inherited" slots are all slots that do not originate in this revision.
+ * Note that these slots may still differ from the one in the parent revision.
+ * This is the case for rollbacks: slots of a rollback revision are inherited from
+ * the rollback target, and are different from the slots in the parent revision,
+ * which was rolled back.
+ *
+ * @return RevisionSlots
+ */
+ public function getInheritedSlots() {
+ return new RevisionSlots( $this->mSlots->getInheritedSlots() );
+ }
+
+ /**
+ * Get revision ID. Depending on the concrete subclass, this may return null if
+ * the revision ID is not known (e.g. because the revision does not yet exist
+ * in the database).
+ *
+ * MCR migration note: this replaces Revision::getId
+ *
+ * @return int|null
+ */
+ public function getId() {
+ return $this->mId;
+ }
+
+ /**
+ * Get parent revision ID (the original previous page revision).
+ * If there is no parent revision, this returns 0.
+ * If the parent revision is undefined or unknown, this returns null.
+ *
+ * @note As of MW 1.31, the database schema allows the parent ID to be
+ * NULL to indicate that it is unknown.
+ *
+ * MCR migration note: this replaces Revision::getParentId
+ *
+ * @return int|null
+ */
+ public function getParentId() {
+ return $this->mParentId;
+ }
+
+ /**
+ * Returns the nominal size of this revision, in bogo-bytes.
+ * May be calculated on the fly if not known, which may in the worst
+ * case may involve loading all content.
+ *
+ * MCR migration note: this replaces Revision::getSize
+ *
+ * @throws RevisionAccessException if the size was unknown and could not be calculated.
+ * @return int
+ */
+ abstract public function getSize();
+
+ /**
+ * Returns the base36 sha1 of this revision. This hash is derived from the
+ * hashes of all slots associated with the revision.
+ * May be calculated on the fly if not known, which may in the worst
+ * case may involve loading all content.
+ *
+ * MCR migration note: this replaces Revision::getSha1
+ *
+ * @throws RevisionAccessException if the hash was unknown and could not be calculated.
+ * @return string
+ */
+ abstract public function getSha1();
+
+ /**
+ * Get the page ID. If the page does not yet exist, the page ID is 0.
+ *
+ * MCR migration note: this replaces Revision::getPage
+ *
+ * @return int
+ */
+ public function getPageId() {
+ return $this->mPageId;
+ }
+
+ /**
+ * Get the ID of the wiki this revision belongs to.
+ *
+ * @return string|false The wiki's logical name, of false to indicate the local wiki.
+ */
+ public function getWikiId() {
+ return $this->mWiki;
+ }
+
+ /**
+ * Returns the title of the page this revision is associated with as a LinkTarget object.
+ *
+ * MCR migration note: this replaces Revision::getTitle
+ *
+ * @return LinkTarget
+ */
+ public function getPageAsLinkTarget() {
+ return $this->mTitle;
+ }
+
+ /**
+ * Fetch revision's author's user identity, if it's available to the specified audience.
+ * If the specified audience does not have access to it, null will be
+ * returned. Depending on the concrete subclass, null may also be returned if the user is
+ * not yet specified.
+ *
+ * MCR migration note: this replaces Revision::getUser
+ *
+ * @param int $audience One of:
+ * RevisionRecord::FOR_PUBLIC to be displayed to all users
+ * RevisionRecord::FOR_THIS_USER to be displayed to the given user
+ * RevisionRecord::RAW get the ID regardless of permissions
+ * @param User|null $user User object to check for, only if FOR_THIS_USER is passed
+ * to the $audience parameter
+ * @return UserIdentity|null
+ */
+ public function getUser( $audience = self::FOR_PUBLIC, User $user = null ) {
+ if ( !$this->audienceCan( self::DELETED_USER, $audience, $user ) ) {
+ return null;
+ } else {
+ return $this->mUser;
+ }
+ }
+
+ /**
+ * Fetch revision comment, if it's available to the specified audience.
+ * If the specified audience does not have access to the comment,
+ * this will return null. Depending on the concrete subclass, null may also be returned
+ * if the comment is not yet specified.
+ *
+ * MCR migration note: this replaces Revision::getComment
+ *
+ * @param int $audience One of:
+ * RevisionRecord::FOR_PUBLIC to be displayed to all users
+ * RevisionRecord::FOR_THIS_USER to be displayed to the given user
+ * RevisionRecord::RAW get the text regardless of permissions
+ * @param User|null $user User object to check for, only if FOR_THIS_USER is passed
+ * to the $audience parameter
+ *
+ * @return CommentStoreComment|null
+ */
+ public function getComment( $audience = self::FOR_PUBLIC, User $user = null ) {
+ if ( !$this->audienceCan( self::DELETED_COMMENT, $audience, $user ) ) {
+ return null;
+ } else {
+ return $this->mComment;
+ }
+ }
+
+ /**
+ * MCR migration note: this replaces Revision::isMinor
+ *
+ * @return bool
+ */
+ public function isMinor() {
+ return (bool)$this->mMinorEdit;
+ }
+
+ /**
+ * MCR migration note: this replaces Revision::isDeleted
+ *
+ * @param int $field One of DELETED_* bitfield constants
+ *
+ * @return bool
+ */
+ public function isDeleted( $field ) {
+ return ( $this->getVisibility() & $field ) == $field;
+ }
+
+ /**
+ * Get the deletion bitfield of the revision
+ *
+ * MCR migration note: this replaces Revision::getVisibility
+ *
+ * @return int
+ */
+ public function getVisibility() {
+ return (int)$this->mDeleted;
+ }
+
+ /**
+ * MCR migration note: this replaces Revision::getTimestamp.
+ *
+ * May return null if the timestamp was not specified.
+ *
+ * @return string|null
+ */
+ public function getTimestamp() {
+ return $this->mTimestamp;
+ }
+
+ /**
+ * Check that the given audience has access to the given field.
+ *
+ * MCR migration note: this corresponds to Revision::userCan
+ *
+ * @param int $field One of self::DELETED_TEXT,
+ * self::DELETED_COMMENT,
+ * self::DELETED_USER
+ * @param int $audience One of:
+ * RevisionRecord::FOR_PUBLIC to be displayed to all users
+ * RevisionRecord::FOR_THIS_USER to be displayed to the given user
+ * RevisionRecord::RAW get the text regardless of permissions
+ * @param User|null $user User object to check. Required if $audience is FOR_THIS_USER,
+ * ignored otherwise.
+ *
+ * @return bool
+ */
+ public function audienceCan( $field, $audience, User $user = null ) {
+ if ( $audience == self::FOR_PUBLIC && $this->isDeleted( $field ) ) {
+ return false;
+ } elseif ( $audience == self::FOR_THIS_USER ) {
+ if ( !$user ) {
+ throw new InvalidArgumentException(
+ 'A User object must be given when checking FOR_THIS_USER audience.'
+ );
+ }
+
+ if ( !$this->userCan( $field, $user ) ) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Determine if the current user is allowed to view a particular
+ * field of this revision, if it's marked as deleted.
+ *
+ * MCR migration note: this corresponds to Revision::userCan
+ *
+ * @param int $field One of self::DELETED_TEXT,
+ * self::DELETED_COMMENT,
+ * self::DELETED_USER
+ * @param User $user User object to check
+ * @return bool
+ */
+ protected function userCan( $field, User $user ) {
+ // TODO: use callback for permission checks, so we don't need to know a Title object!
+ return self::userCanBitfield( $this->getVisibility(), $field, $user, $this->mTitle );
+ }
+
+ /**
+ * Determine if the current user is allowed to view a particular
+ * field of this revision, if it's marked as deleted. This is used
+ * by various classes to avoid duplication.
+ *
+ * MCR migration note: this replaces Revision::userCanBitfield
+ *
+ * @param int $bitfield Current field
+ * @param int $field One of self::DELETED_TEXT = File::DELETED_FILE,
+ * self::DELETED_COMMENT = File::DELETED_COMMENT,
+ * self::DELETED_USER = File::DELETED_USER
+ * @param User $user User object to check
+ * @param Title|null $title A Title object to check for per-page restrictions on,
+ * instead of just plain userrights
+ * @return bool
+ */
+ public static function userCanBitfield( $bitfield, $field, User $user, Title $title = null ) {
+ if ( $bitfield & $field ) { // aspect is deleted
+ if ( $bitfield & self::DELETED_RESTRICTED ) {
+ $permissions = [ 'suppressrevision', 'viewsuppressed' ];
+ } elseif ( $field & self::DELETED_TEXT ) {
+ $permissions = [ 'deletedtext' ];
+ } else {
+ $permissions = [ 'deletedhistory' ];
+ }
+ $permissionlist = implode( ', ', $permissions );
+ if ( $title === null ) {
+ wfDebug( "Checking for $permissionlist due to $field match on $bitfield\n" );
+ return $user->isAllowedAny( ...$permissions );
+ } else {
+ $text = $title->getPrefixedText();
+ wfDebug( "Checking for $permissionlist on $text due to $field match on $bitfield\n" );
+ foreach ( $permissions as $perm ) {
+ if ( $title->userCan( $perm, $user ) ) {
+ return true;
+ }
+ }
+ return false;
+ }
+ } else {
+ return true;
+ }
+ }
+
+ /**
+ * Returns whether this RevisionRecord is ready for insertion, that is, whether it contains all
+ * information needed to save it to the database. This should trivially be true for
+ * RevisionRecords loaded from the database.
+ *
+ * Note that this may return true even if getId() or getPage() return null or 0, since these
+ * are generally assigned while the revision is saved to the database, and may not be available
+ * before.
+ *
+ * @return bool
+ */
+ public function isReadyForInsertion() {
+ // NOTE: don't check getSize() and getSha1(), since that may cause the full content to
+ // be loaded in order to calculate the values. Just assume these methods will not return
+ // null if mSlots is not empty.
+
+ // NOTE: getId() and getPageId() may return null before a revision is saved, so don't
+ //check them.
+
+ return $this->getTimestamp() !== null
+ && $this->getComment( self::RAW ) !== null
+ && $this->getUser( self::RAW ) !== null
+ && $this->mSlots->getSlotRoles() !== [];
+ }
+
+}
+
+/**
+ * Retain the old class name for backwards compatibility.
+ * @deprecated since 1.32
+ */
+class_alias( RevisionRecord::class, 'MediaWiki\Storage\RevisionRecord' );
use Html;
use InvalidArgumentException;
-use MediaWiki\Storage\RevisionRecord;
-use MediaWiki\Storage\SlotRecord;
use ParserOptions;
use ParserOutput;
use Psr\Log\LoggerInterface;
--- /dev/null
+<?php
+/**
+ * Value object representing the set of slots belonging to a revision.
+ *
+ * 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
+ */
+
+namespace MediaWiki\Revision;
+
+use Content;
+use LogicException;
+use Wikimedia\Assert\Assert;
+
+/**
+ * Value object representing the set of slots belonging to a revision.
+ *
+ * @since 1.31
+ * @since 1.32 Renamed from MediaWiki\Storage\RevisionSlots
+ */
+class RevisionSlots {
+
+ /** @var SlotRecord[]|callable */
+ protected $slots;
+
+ /**
+ * @param SlotRecord[]|callable $slots SlotRecords,
+ * or a callback that returns such a structure.
+ */
+ public function __construct( $slots ) {
+ Assert::parameterType( 'array|callable', $slots, '$slots' );
+
+ if ( is_callable( $slots ) ) {
+ $this->slots = $slots;
+ } else {
+ $this->setSlotsInternal( $slots );
+ }
+ }
+
+ /**
+ * @param SlotRecord[] $slots
+ */
+ private function setSlotsInternal( array $slots ) {
+ Assert::parameterElementType( SlotRecord::class, $slots, '$slots' );
+
+ $this->slots = [];
+
+ // re-key the slot array
+ foreach ( $slots as $slot ) {
+ $role = $slot->getRole();
+ $this->slots[$role] = $slot;
+ }
+ }
+
+ /**
+ * Implemented to defy serialization.
+ *
+ * @throws LogicException always
+ */
+ public function __sleep() {
+ throw new LogicException( __CLASS__ . ' is not serializable.' );
+ }
+
+ /**
+ * Returns the Content of the given slot.
+ * Call getSlotNames() to get a list of available slots.
+ *
+ * Note that for mutable Content objects, each call to this method will return a
+ * fresh clone.
+ *
+ * @param string $role The role name of the desired slot
+ *
+ * @throws RevisionAccessException if the slot does not exist or slot data
+ * could not be lazy-loaded.
+ * @return Content
+ */
+ public function getContent( $role ) {
+ // Return a copy to be safe. Immutable content objects return $this from copy().
+ return $this->getSlot( $role )->getContent()->copy();
+ }
+
+ /**
+ * Returns the SlotRecord of the given slot.
+ * Call getSlotNames() to get a list of available slots.
+ *
+ * @param string $role The role name of the desired slot
+ *
+ * @throws RevisionAccessException if the slot does not exist or slot data
+ * could not be lazy-loaded.
+ * @return SlotRecord
+ */
+ public function getSlot( $role ) {
+ $slots = $this->getSlots();
+
+ if ( isset( $slots[$role] ) ) {
+ return $slots[$role];
+ } else {
+ throw new RevisionAccessException( 'No such slot: ' . $role );
+ }
+ }
+
+ /**
+ * Returns whether the given slot is set.
+ *
+ * @param string $role The role name of the desired slot
+ *
+ * @return bool
+ */
+ public function hasSlot( $role ) {
+ $slots = $this->getSlots();
+
+ return isset( $slots[$role] );
+ }
+
+ /**
+ * Returns the slot names (roles) of all slots present in this revision.
+ * getContent() will succeed only for the names returned by this method.
+ *
+ * @return string[]
+ */
+ public function getSlotRoles() {
+ $slots = $this->getSlots();
+ return array_keys( $slots );
+ }
+
+ /**
+ * Computes the total nominal size of the revision's slots, in bogo-bytes.
+ *
+ * @warning This is potentially expensive! It may cause all slot's content to be loaded
+ * and deserialized.
+ *
+ * @return int
+ */
+ public function computeSize() {
+ return array_reduce( $this->getSlots(), function ( $accu, SlotRecord $slot ) {
+ return $accu + $slot->getSize();
+ }, 0 );
+ }
+
+ /**
+ * Returns an associative array that maps role names to SlotRecords. Each SlotRecord
+ * represents the content meta-data of a slot, together they define the content of
+ * a revision.
+ *
+ * @note This may cause the content meta-data for the revision to be lazy-loaded.
+ *
+ * @return SlotRecord[] revision slot/content rows, keyed by slot role name.
+ */
+ public function getSlots() {
+ if ( is_callable( $this->slots ) ) {
+ $slots = call_user_func( $this->slots );
+
+ Assert::postcondition(
+ is_array( $slots ),
+ 'Slots info callback should return an array of objects'
+ );
+
+ $this->setSlotsInternal( $slots );
+ }
+
+ return $this->slots;
+ }
+
+ /**
+ * Computes the combined hash of the revisions's slots.
+ *
+ * @note For backwards compatibility, the combined hash of a single slot
+ * is that slot's hash. For consistency, the combined hash of an empty set of slots
+ * is the hash of the empty string.
+ *
+ * @warning This is potentially expensive! It may cause all slot's content to be loaded
+ * and deserialized, then re-serialized and hashed.
+ *
+ * @return string
+ */
+ public function computeSha1() {
+ $slots = $this->getSlots();
+ ksort( $slots );
+
+ if ( empty( $slots ) ) {
+ return SlotRecord::base36Sha1( '' );
+ }
+
+ return array_reduce( $slots, function ( $accu, SlotRecord $slot ) {
+ return $accu === null
+ ? $slot->getSha1()
+ : SlotRecord::base36Sha1( $accu . $slot->getSha1() );
+ }, null );
+ }
+
+ /**
+ * Return all slots that belong to the revision they originate from (that is,
+ * they are not inherited from some other revision).
+ *
+ * @note This may cause the slot meta-data for the revision to be lazy-loaded.
+ *
+ * @return SlotRecord[]
+ */
+ public function getOriginalSlots() {
+ return array_filter(
+ $this->getSlots(),
+ function ( SlotRecord $slot ) {
+ return !$slot->isInherited();
+ }
+ );
+ }
+
+ /**
+ * Return all slots that are not not originate in the revision they belong to (that is,
+ * they are inherited from some other revision).
+ *
+ * @note This may cause the slot meta-data for the revision to be lazy-loaded.
+ *
+ * @return SlotRecord[]
+ */
+ public function getInheritedSlots() {
+ return array_filter(
+ $this->getSlots(),
+ function ( SlotRecord $slot ) {
+ return $slot->isInherited();
+ }
+ );
+ }
+
+ /**
+ * Checks whether the other RevisionSlots instance has the same content
+ * as this instance. Note that this does not mean that the slots have to be the same:
+ * they could for instance belong to different revisions.
+ *
+ * @param RevisionSlots $other
+ *
+ * @return bool
+ */
+ public function hasSameContent( RevisionSlots $other ) {
+ if ( $other === $this ) {
+ return true;
+ }
+
+ $aSlots = $this->getSlots();
+ $bSlots = $other->getSlots();
+
+ ksort( $aSlots );
+ ksort( $bSlots );
+
+ if ( array_keys( $aSlots ) !== array_keys( $bSlots ) ) {
+ return false;
+ }
+
+ foreach ( $aSlots as $role => $s ) {
+ $t = $bSlots[$role];
+
+ if ( !$s->hasSameContent( $t ) ) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Find roles for which the $other RevisionSlots object has different content
+ * as this RevisionSlots object, including any roles that are present in one
+ * but not the other.
+ *
+ * @param RevisionSlots $other
+ *
+ * @return string[] a list of slot roles that are different.
+ */
+ public function getRolesWithDifferentContent( RevisionSlots $other ) {
+ if ( $other === $this ) {
+ return [];
+ }
+
+ $aSlots = $this->getSlots();
+ $bSlots = $other->getSlots();
+
+ ksort( $aSlots );
+ ksort( $bSlots );
+
+ $different = array_keys( array_merge(
+ array_diff_key( $aSlots, $bSlots ),
+ array_diff_key( $bSlots, $aSlots )
+ ) );
+
+ /** @var SlotRecord[] $common */
+ $common = array_intersect_key( $aSlots, $bSlots );
+
+ foreach ( $common as $role => $s ) {
+ $t = $bSlots[$role];
+
+ if ( !$s->hasSameContent( $t ) ) {
+ $different[] = $role;
+ }
+ }
+
+ return $different;
+ }
+
+}
+
+/**
+ * Retain the old class name for backwards compatibility.
+ * @deprecated since 1.32
+ */
+class_alias( RevisionSlots::class, 'MediaWiki\Storage\RevisionSlots' );
--- /dev/null
+<?php
+/**
+ * Service for looking up page revisions.
+ *
+ * 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
+ *
+ * Attribution notice: when this file was created, much of its content was taken
+ * from the Revision.php file as present in release 1.30. Refer to the history
+ * of that file for original authorship.
+ *
+ * @file
+ */
+
+namespace MediaWiki\Revision;
+
+use ActorMigration;
+use CommentStore;
+use CommentStoreComment;
+use Content;
+use ContentHandler;
+use DBAccessObjectUtils;
+use Hooks;
+use IDBAccessObject;
+use InvalidArgumentException;
+use IP;
+use LogicException;
+use MediaWiki\Linker\LinkTarget;
+use MediaWiki\Storage\BlobAccessException;
+use MediaWiki\Storage\BlobStore;
+use MediaWiki\Storage\NameTableAccessException;
+use MediaWiki\Storage\NameTableStore;
+use MediaWiki\Storage\SqlBlobStore;
+use MediaWiki\User\UserIdentity;
+use MediaWiki\User\UserIdentityValue;
+use Message;
+use MWException;
+use MWUnknownContentModelException;
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+use RecentChange;
+use Revision;
+use RuntimeException;
+use stdClass;
+use Title;
+use User;
+use WANObjectCache;
+use Wikimedia\Assert\Assert;
+use Wikimedia\Rdbms\Database;
+use Wikimedia\Rdbms\DBConnRef;
+use Wikimedia\Rdbms\IDatabase;
+use Wikimedia\Rdbms\ILoadBalancer;
+
+/**
+ * Service for looking up page revisions.
+ *
+ * @since 1.31
+ * @since 1.32 Renamed from MediaWiki\Storage\RevisionStore
+ *
+ * @note This was written to act as a drop-in replacement for the corresponding
+ * static methods in Revision.
+ */
+class RevisionStore
+ implements IDBAccessObject, RevisionFactory, RevisionLookup, LoggerAwareInterface {
+
+ const ROW_CACHE_KEY = 'revision-row-1.29';
+
+ /**
+ * @var SqlBlobStore
+ */
+ private $blobStore;
+
+ /**
+ * @var bool|string
+ */
+ private $wikiId;
+
+ /**
+ * @var boolean
+ * @see $wgContentHandlerUseDB
+ */
+ private $contentHandlerUseDB = true;
+
+ /**
+ * @var ILoadBalancer
+ */
+ private $loadBalancer;
+
+ /**
+ * @var WANObjectCache
+ */
+ private $cache;
+
+ /**
+ * @var CommentStore
+ */
+ private $commentStore;
+
+ /**
+ * @var ActorMigration
+ */
+ private $actorMigration;
+
+ /**
+ * @var LoggerInterface
+ */
+ private $logger;
+
+ /**
+ * @var NameTableStore
+ */
+ private $contentModelStore;
+
+ /**
+ * @var NameTableStore
+ */
+ private $slotRoleStore;
+
+ /** @var int An appropriate combination of SCHEMA_COMPAT_XXX flags. */
+ private $mcrMigrationStage;
+
+ /**
+ * @todo $blobStore should be allowed to be any BlobStore!
+ *
+ * @param ILoadBalancer $loadBalancer
+ * @param SqlBlobStore $blobStore
+ * @param WANObjectCache $cache A cache for caching revision rows. This can be the local
+ * wiki's default instance even if $wikiId refers to a different wiki, since
+ * makeGlobalKey() is used to constructed a key that allows cached revision rows from
+ * the same database to be re-used between wikis. For example, enwiki and frwiki will
+ * use the same cache keys for revision rows from the wikidatawiki database, regardless
+ * of the cache's default key space.
+ * @param CommentStore $commentStore
+ * @param NameTableStore $contentModelStore
+ * @param NameTableStore $slotRoleStore
+ * @param int $mcrMigrationStage An appropriate combination of SCHEMA_COMPAT_XXX flags
+ * @param ActorMigration $actorMigration
+ * @param bool|string $wikiId
+ *
+ * @throws MWException if $mcrMigrationStage or $wikiId is invalid.
+ */
+ public function __construct(
+ ILoadBalancer $loadBalancer,
+ SqlBlobStore $blobStore,
+ WANObjectCache $cache,
+ CommentStore $commentStore,
+ NameTableStore $contentModelStore,
+ NameTableStore $slotRoleStore,
+ $mcrMigrationStage,
+ ActorMigration $actorMigration,
+ $wikiId = false
+ ) {
+ Assert::parameterType( 'string|boolean', $wikiId, '$wikiId' );
+ Assert::parameterType( 'integer', $mcrMigrationStage, '$mcrMigrationStage' );
+ Assert::parameter(
+ ( $mcrMigrationStage & SCHEMA_COMPAT_READ_BOTH ) !== SCHEMA_COMPAT_READ_BOTH,
+ '$mcrMigrationStage',
+ 'Reading from the old and the new schema at the same time is not supported.'
+ );
+ Assert::parameter(
+ ( $mcrMigrationStage & SCHEMA_COMPAT_READ_BOTH ) !== 0,
+ '$mcrMigrationStage',
+ 'Reading needs to be enabled for the old or the new schema.'
+ );
+ Assert::parameter(
+ ( $mcrMigrationStage & SCHEMA_COMPAT_WRITE_BOTH ) !== 0,
+ '$mcrMigrationStage',
+ 'Writing needs to be enabled for the old or the new schema.'
+ );
+ Assert::parameter(
+ ( $mcrMigrationStage & SCHEMA_COMPAT_READ_OLD ) === 0
+ || ( $mcrMigrationStage & SCHEMA_COMPAT_WRITE_OLD ) !== 0,
+ '$mcrMigrationStage',
+ 'Cannot read the old schema when not also writing it.'
+ );
+ Assert::parameter(
+ ( $mcrMigrationStage & SCHEMA_COMPAT_READ_NEW ) === 0
+ || ( $mcrMigrationStage & SCHEMA_COMPAT_WRITE_NEW ) !== 0,
+ '$mcrMigrationStage',
+ 'Cannot read the new schema when not also writing it.'
+ );
+
+ $this->loadBalancer = $loadBalancer;
+ $this->blobStore = $blobStore;
+ $this->cache = $cache;
+ $this->commentStore = $commentStore;
+ $this->contentModelStore = $contentModelStore;
+ $this->slotRoleStore = $slotRoleStore;
+ $this->mcrMigrationStage = $mcrMigrationStage;
+ $this->actorMigration = $actorMigration;
+ $this->wikiId = $wikiId;
+ $this->logger = new NullLogger();
+ }
+
+ /**
+ * @param int $flags A combination of SCHEMA_COMPAT_XXX flags.
+ * @return bool True if all the given flags were set in the $mcrMigrationStage
+ * parameter passed to the constructor.
+ */
+ private function hasMcrSchemaFlags( $flags ) {
+ return ( $this->mcrMigrationStage & $flags ) === $flags;
+ }
+
+ /**
+ * Throws a RevisionAccessException if this RevisionStore is configured for cross-wiki loading
+ * and still reading from the old DB schema.
+ *
+ * @throws RevisionAccessException
+ */
+ private function assertCrossWikiContentLoadingIsSafe() {
+ if ( $this->wikiId !== false && $this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_OLD ) ) {
+ throw new RevisionAccessException(
+ "Cross-wiki content loading is not supported by the pre-MCR schema"
+ );
+ }
+ }
+
+ public function setLogger( LoggerInterface $logger ) {
+ $this->logger = $logger;
+ }
+
+ /**
+ * @return bool Whether the store is read-only
+ */
+ public function isReadOnly() {
+ return $this->blobStore->isReadOnly();
+ }
+
+ /**
+ * @return bool
+ */
+ public function getContentHandlerUseDB() {
+ return $this->contentHandlerUseDB;
+ }
+
+ /**
+ * @see $wgContentHandlerUseDB
+ * @param bool $contentHandlerUseDB
+ * @throws MWException
+ */
+ public function setContentHandlerUseDB( $contentHandlerUseDB ) {
+ if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_NEW )
+ || $this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_NEW )
+ ) {
+ if ( !$contentHandlerUseDB ) {
+ throw new MWException(
+ 'Content model must be stored in the database for multi content revision migration.'
+ );
+ }
+ }
+ $this->contentHandlerUseDB = $contentHandlerUseDB;
+ }
+
+ /**
+ * @return ILoadBalancer
+ */
+ private function getDBLoadBalancer() {
+ return $this->loadBalancer;
+ }
+
+ /**
+ * @param int $mode DB_MASTER or DB_REPLICA
+ *
+ * @return IDatabase
+ */
+ private function getDBConnection( $mode ) {
+ $lb = $this->getDBLoadBalancer();
+ return $lb->getConnection( $mode, [], $this->wikiId );
+ }
+
+ /**
+ * @param int $queryFlags a bit field composed of READ_XXX flags
+ *
+ * @return DBConnRef
+ */
+ private function getDBConnectionRefForQueryFlags( $queryFlags ) {
+ list( $mode, ) = DBAccessObjectUtils::getDBOptions( $queryFlags );
+ return $this->getDBConnectionRef( $mode );
+ }
+
+ /**
+ * @param IDatabase $connection
+ */
+ private function releaseDBConnection( IDatabase $connection ) {
+ $lb = $this->getDBLoadBalancer();
+ $lb->reuseConnection( $connection );
+ }
+
+ /**
+ * @param int $mode DB_MASTER or DB_REPLICA
+ *
+ * @return DBConnRef
+ */
+ private function getDBConnectionRef( $mode ) {
+ $lb = $this->getDBLoadBalancer();
+ return $lb->getConnectionRef( $mode, [], $this->wikiId );
+ }
+
+ /**
+ * Determines the page Title based on the available information.
+ *
+ * MCR migration note: this corresponds to Revision::getTitle
+ *
+ * @note this method should be private, external use should be avoided!
+ *
+ * @param int|null $pageId
+ * @param int|null $revId
+ * @param int $queryFlags
+ *
+ * @return Title
+ * @throws RevisionAccessException
+ */
+ public function getTitle( $pageId, $revId, $queryFlags = self::READ_NORMAL ) {
+ if ( !$pageId && !$revId ) {
+ throw new InvalidArgumentException( '$pageId and $revId cannot both be 0 or null' );
+ }
+
+ // This method recalls itself with READ_LATEST if READ_NORMAL doesn't get us a Title
+ // So ignore READ_LATEST_IMMUTABLE flags and handle the fallback logic in this method
+ if ( DBAccessObjectUtils::hasFlags( $queryFlags, self::READ_LATEST_IMMUTABLE ) ) {
+ $queryFlags = self::READ_NORMAL;
+ }
+
+ $canUseTitleNewFromId = ( $pageId !== null && $pageId > 0 && $this->wikiId === false );
+ list( $dbMode, $dbOptions ) = DBAccessObjectUtils::getDBOptions( $queryFlags );
+ $titleFlags = ( $dbMode == DB_MASTER ? Title::GAID_FOR_UPDATE : 0 );
+
+ // Loading by ID is best, but Title::newFromID does not support that for foreign IDs.
+ if ( $canUseTitleNewFromId ) {
+ // TODO: better foreign title handling (introduce TitleFactory)
+ $title = Title::newFromID( $pageId, $titleFlags );
+ if ( $title ) {
+ return $title;
+ }
+ }
+
+ // rev_id is defined as NOT NULL, but this revision may not yet have been inserted.
+ $canUseRevId = ( $revId !== null && $revId > 0 );
+
+ if ( $canUseRevId ) {
+ $dbr = $this->getDBConnectionRef( $dbMode );
+ // @todo: Title::getSelectFields(), or Title::getQueryInfo(), or something like that
+ $row = $dbr->selectRow(
+ [ 'revision', 'page' ],
+ [
+ 'page_namespace',
+ 'page_title',
+ 'page_id',
+ 'page_latest',
+ 'page_is_redirect',
+ 'page_len',
+ ],
+ [ 'rev_id' => $revId ],
+ __METHOD__,
+ $dbOptions,
+ [ 'page' => [ 'JOIN', 'page_id=rev_page' ] ]
+ );
+ if ( $row ) {
+ // TODO: better foreign title handling (introduce TitleFactory)
+ return Title::newFromRow( $row );
+ }
+ }
+
+ // If we still don't have a title, fallback to master if that wasn't already happening.
+ if ( $dbMode !== DB_MASTER ) {
+ $title = $this->getTitle( $pageId, $revId, self::READ_LATEST );
+ if ( $title ) {
+ $this->logger->info(
+ __METHOD__ . ' fell back to READ_LATEST and got a Title.',
+ [ 'trace' => wfBacktrace() ]
+ );
+ return $title;
+ }
+ }
+
+ throw new RevisionAccessException(
+ "Could not determine title for page ID $pageId and revision ID $revId"
+ );
+ }
+
+ /**
+ * @param mixed $value
+ * @param string $name
+ *
+ * @throws IncompleteRevisionException if $value is null
+ * @return mixed $value, if $value is not null
+ */
+ private function failOnNull( $value, $name ) {
+ if ( $value === null ) {
+ throw new IncompleteRevisionException(
+ "$name must not be " . var_export( $value, true ) . "!"
+ );
+ }
+
+ return $value;
+ }
+
+ /**
+ * @param mixed $value
+ * @param string $name
+ *
+ * @throws IncompleteRevisionException if $value is empty
+ * @return mixed $value, if $value is not null
+ */
+ private function failOnEmpty( $value, $name ) {
+ if ( $value === null || $value === 0 || $value === '' ) {
+ throw new IncompleteRevisionException(
+ "$name must not be " . var_export( $value, true ) . "!"
+ );
+ }
+
+ return $value;
+ }
+
+ /**
+ * Insert a new revision into the database, returning the new revision record
+ * on success and dies horribly on failure.
+ *
+ * MCR migration note: this replaces Revision::insertOn
+ *
+ * @param RevisionRecord $rev
+ * @param IDatabase $dbw (master connection)
+ *
+ * @throws InvalidArgumentException
+ * @return RevisionRecord the new revision record.
+ */
+ public function insertRevisionOn( RevisionRecord $rev, IDatabase $dbw ) {
+ // TODO: pass in a DBTransactionContext instead of a database connection.
+ $this->checkDatabaseWikiId( $dbw );
+
+ $slotRoles = $rev->getSlotRoles();
+
+ // Make sure the main slot is always provided throughout migration
+ if ( !in_array( SlotRecord::MAIN, $slotRoles ) ) {
+ throw new InvalidArgumentException(
+ 'main slot must be provided'
+ );
+ }
+
+ // If we are not writing into the new schema, we can't support extra slots.
+ if ( !$this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_NEW )
+ && $slotRoles !== [ SlotRecord::MAIN ]
+ ) {
+ throw new InvalidArgumentException(
+ 'Only the main slot is supported when not writing to the MCR enabled schema!'
+ );
+ }
+
+ // As long as we are not reading from the new schema, we don't want to write extra slots.
+ if ( !$this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_NEW )
+ && $slotRoles !== [ SlotRecord::MAIN ]
+ ) {
+ throw new InvalidArgumentException(
+ 'Only the main slot is supported when not reading from the MCR enabled schema!'
+ );
+ }
+
+ // Checks
+ $this->failOnNull( $rev->getSize(), 'size field' );
+ $this->failOnEmpty( $rev->getSha1(), 'sha1 field' );
+ $this->failOnEmpty( $rev->getTimestamp(), 'timestamp field' );
+ $comment = $this->failOnNull( $rev->getComment( RevisionRecord::RAW ), 'comment' );
+ $user = $this->failOnNull( $rev->getUser( RevisionRecord::RAW ), 'user' );
+ $this->failOnNull( $user->getId(), 'user field' );
+ $this->failOnEmpty( $user->getName(), 'user_text field' );
+
+ if ( !$rev->isReadyForInsertion() ) {
+ // This is here for future-proofing. At the time this check being added, it
+ // was redundant to the individual checks above.
+ throw new IncompleteRevisionException( 'Revision is incomplete' );
+ }
+
+ // TODO: we shouldn't need an actual Title here.
+ $title = Title::newFromLinkTarget( $rev->getPageAsLinkTarget() );
+ $pageId = $this->failOnEmpty( $rev->getPageId(), 'rev_page field' ); // check this early
+
+ $parentId = $rev->getParentId() === null
+ ? $this->getPreviousRevisionId( $dbw, $rev )
+ : $rev->getParentId();
+
+ /** @var RevisionRecord $rev */
+ $rev = $dbw->doAtomicSection(
+ __METHOD__,
+ function ( IDatabase $dbw, $fname ) use (
+ $rev,
+ $user,
+ $comment,
+ $title,
+ $pageId,
+ $parentId
+ ) {
+ return $this->insertRevisionInternal(
+ $rev,
+ $dbw,
+ $user,
+ $comment,
+ $title,
+ $pageId,
+ $parentId
+ );
+ }
+ );
+
+ // sanity checks
+ Assert::postcondition( $rev->getId() > 0, 'revision must have an ID' );
+ Assert::postcondition( $rev->getPageId() > 0, 'revision must have a page ID' );
+ Assert::postcondition(
+ $rev->getComment( RevisionRecord::RAW ) !== null,
+ 'revision must have a comment'
+ );
+ Assert::postcondition(
+ $rev->getUser( RevisionRecord::RAW ) !== null,
+ 'revision must have a user'
+ );
+
+ // Trigger exception if the main slot is missing.
+ // Technically, this could go away after MCR migration: while
+ // calling code may require a main slot to exist, RevisionStore
+ // really should not know or care about that requirement.
+ $rev->getSlot( SlotRecord::MAIN, RevisionRecord::RAW );
+
+ foreach ( $slotRoles as $role ) {
+ $slot = $rev->getSlot( $role, RevisionRecord::RAW );
+ Assert::postcondition(
+ $slot->getContent() !== null,
+ $role . ' slot must have content'
+ );
+ Assert::postcondition(
+ $slot->hasRevision(),
+ $role . ' slot must have a revision associated'
+ );
+ }
+
+ Hooks::run( 'RevisionRecordInserted', [ $rev ] );
+
+ // TODO: deprecate in 1.32!
+ $legacyRevision = new Revision( $rev );
+ Hooks::run( 'RevisionInsertComplete', [ &$legacyRevision, null, null ] );
+
+ return $rev;
+ }
+
+ private function insertRevisionInternal(
+ RevisionRecord $rev,
+ IDatabase $dbw,
+ User $user,
+ CommentStoreComment $comment,
+ Title $title,
+ $pageId,
+ $parentId
+ ) {
+ $slotRoles = $rev->getSlotRoles();
+
+ $revisionRow = $this->insertRevisionRowOn(
+ $dbw,
+ $rev,
+ $title,
+ $parentId
+ );
+
+ $revisionId = $revisionRow['rev_id'];
+
+ $blobHints = [
+ BlobStore::PAGE_HINT => $pageId,
+ BlobStore::REVISION_HINT => $revisionId,
+ BlobStore::PARENT_HINT => $parentId,
+ ];
+
+ $newSlots = [];
+ foreach ( $slotRoles as $role ) {
+ $slot = $rev->getSlot( $role, RevisionRecord::RAW );
+
+ // If the SlotRecord already has a revision ID set, this means it already exists
+ // in the database, and should already belong to the current revision.
+ // However, a slot may already have a revision, but no content ID, if the slot
+ // is emulated based on the archive table, because we are in SCHEMA_COMPAT_READ_OLD
+ // mode, and the respective archive row was not yet migrated to the new schema.
+ // In that case, a new slot row (and content row) must be inserted even during
+ // undeletion.
+ if ( $slot->hasRevision() && $slot->hasContentId() ) {
+ // TODO: properly abort transaction if the assertion fails!
+ Assert::parameter(
+ $slot->getRevision() === $revisionId,
+ 'slot role ' . $slot->getRole(),
+ 'Existing slot should belong to revision '
+ . $revisionId . ', but belongs to revision ' . $slot->getRevision() . '!'
+ );
+
+ // Slot exists, nothing to do, move along.
+ // This happens when restoring archived revisions.
+
+ $newSlots[$role] = $slot;
+
+ // Write the main slot's text ID to the revision table for backwards compatibility
+ if ( $slot->getRole() === SlotRecord::MAIN
+ && $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_OLD )
+ ) {
+ $blobAddress = $slot->getAddress();
+ $this->updateRevisionTextId( $dbw, $revisionId, $blobAddress );
+ }
+ } else {
+ $newSlots[$role] = $this->insertSlotOn( $dbw, $revisionId, $slot, $title, $blobHints );
+ }
+ }
+
+ $this->insertIpChangesRow( $dbw, $user, $rev, $revisionId );
+
+ $rev = new RevisionStoreRecord(
+ $title,
+ $user,
+ $comment,
+ (object)$revisionRow,
+ new RevisionSlots( $newSlots ),
+ $this->wikiId
+ );
+
+ return $rev;
+ }
+
+ /**
+ * @param IDatabase $dbw
+ * @param int $revisionId
+ * @param string &$blobAddress (may change!)
+ *
+ * @return int the text row id
+ */
+ private function updateRevisionTextId( IDatabase $dbw, $revisionId, &$blobAddress ) {
+ $textId = $this->blobStore->getTextIdFromAddress( $blobAddress );
+ if ( !$textId ) {
+ throw new LogicException(
+ 'Blob address not supported in 1.29 database schema: ' . $blobAddress
+ );
+ }
+
+ // getTextIdFromAddress() is free to insert something into the text table, so $textId
+ // may be a new value, not anything already contained in $blobAddress.
+ $blobAddress = SqlBlobStore::makeAddressFromTextId( $textId );
+
+ $dbw->update(
+ 'revision',
+ [ 'rev_text_id' => $textId ],
+ [ 'rev_id' => $revisionId ],
+ __METHOD__
+ );
+
+ return $textId;
+ }
+
+ /**
+ * @param IDatabase $dbw
+ * @param int $revisionId
+ * @param SlotRecord $protoSlot
+ * @param Title $title
+ * @param array $blobHints See the BlobStore::XXX_HINT constants
+ * @return SlotRecord
+ */
+ private function insertSlotOn(
+ IDatabase $dbw,
+ $revisionId,
+ SlotRecord $protoSlot,
+ Title $title,
+ array $blobHints = []
+ ) {
+ if ( $protoSlot->hasAddress() ) {
+ $blobAddress = $protoSlot->getAddress();
+ } else {
+ $blobAddress = $this->storeContentBlob( $protoSlot, $title, $blobHints );
+ }
+
+ $contentId = null;
+
+ // Write the main slot's text ID to the revision table for backwards compatibility
+ if ( $protoSlot->getRole() === SlotRecord::MAIN
+ && $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_OLD )
+ ) {
+ // If SCHEMA_COMPAT_WRITE_NEW is also set, the fake content ID is overwritten
+ // with the real content ID below.
+ $textId = $this->updateRevisionTextId( $dbw, $revisionId, $blobAddress );
+ $contentId = $this->emulateContentId( $textId );
+ }
+
+ if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_NEW ) ) {
+ if ( $protoSlot->hasContentId() ) {
+ $contentId = $protoSlot->getContentId();
+ } else {
+ $contentId = $this->insertContentRowOn( $protoSlot, $dbw, $blobAddress );
+ }
+
+ $this->insertSlotRowOn( $protoSlot, $dbw, $revisionId, $contentId );
+ }
+
+ $savedSlot = SlotRecord::newSaved(
+ $revisionId,
+ $contentId,
+ $blobAddress,
+ $protoSlot
+ );
+
+ return $savedSlot;
+ }
+
+ /**
+ * Insert IP revision into ip_changes for use when querying for a range.
+ * @param IDatabase $dbw
+ * @param User $user
+ * @param RevisionRecord $rev
+ * @param int $revisionId
+ */
+ private function insertIpChangesRow(
+ IDatabase $dbw,
+ User $user,
+ RevisionRecord $rev,
+ $revisionId
+ ) {
+ if ( $user->getId() === 0 && IP::isValid( $user->getName() ) ) {
+ $ipcRow = [
+ 'ipc_rev_id' => $revisionId,
+ 'ipc_rev_timestamp' => $dbw->timestamp( $rev->getTimestamp() ),
+ 'ipc_hex' => IP::toHex( $user->getName() ),
+ ];
+ $dbw->insert( 'ip_changes', $ipcRow, __METHOD__ );
+ }
+ }
+
+ /**
+ * @param IDatabase $dbw
+ * @param RevisionRecord $rev
+ * @param Title $title
+ * @param int $parentId
+ *
+ * @return array a revision table row
+ *
+ * @throws MWException
+ * @throws MWUnknownContentModelException
+ */
+ private function insertRevisionRowOn(
+ IDatabase $dbw,
+ RevisionRecord $rev,
+ Title $title,
+ $parentId
+ ) {
+ $revisionRow = $this->getBaseRevisionRow( $dbw, $rev, $title, $parentId );
+
+ list( $commentFields, $commentCallback ) =
+ $this->commentStore->insertWithTempTable(
+ $dbw,
+ 'rev_comment',
+ $rev->getComment( RevisionRecord::RAW )
+ );
+ $revisionRow += $commentFields;
+
+ list( $actorFields, $actorCallback ) =
+ $this->actorMigration->getInsertValuesWithTempTable(
+ $dbw,
+ 'rev_user',
+ $rev->getUser( RevisionRecord::RAW )
+ );
+ $revisionRow += $actorFields;
+
+ $dbw->insert( 'revision', $revisionRow, __METHOD__ );
+
+ if ( !isset( $revisionRow['rev_id'] ) ) {
+ // only if auto-increment was used
+ $revisionRow['rev_id'] = intval( $dbw->insertId() );
+
+ if ( $dbw->getType() === 'mysql' ) {
+ // (T202032) MySQL until 8.0 and MariaDB until some version after 10.1.34 don't save the
+ // auto-increment value to disk, so on server restart it might reuse IDs from deleted
+ // revisions. We can fix that with an insert with an explicit rev_id value, if necessary.
+
+ $maxRevId = intval( $dbw->selectField( 'archive', 'MAX(ar_rev_id)', '', __METHOD__ ) );
+ $table = 'archive';
+ if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_NEW ) ) {
+ $maxRevId2 = intval( $dbw->selectField( 'slots', 'MAX(slot_revision_id)', '', __METHOD__ ) );
+ if ( $maxRevId2 >= $maxRevId ) {
+ $maxRevId = $maxRevId2;
+ $table = 'slots';
+ }
+ }
+
+ if ( $maxRevId >= $revisionRow['rev_id'] ) {
+ $this->logger->debug(
+ '__METHOD__: Inserted revision {revid} but {table} has revisions up to {maxrevid}.'
+ . ' Trying to fix it.',
+ [
+ 'revid' => $revisionRow['rev_id'],
+ 'table' => $table,
+ 'maxrevid' => $maxRevId,
+ ]
+ );
+
+ if ( !$dbw->lock( 'fix-for-T202032', __METHOD__ ) ) {
+ throw new MWException( 'Failed to get database lock for T202032' );
+ }
+ $fname = __METHOD__;
+ $dbw->onTransactionResolution( function ( $trigger, $dbw ) use ( $fname ) {
+ $dbw->unlock( 'fix-for-T202032', $fname );
+ } );
+
+ $dbw->delete( 'revision', [ 'rev_id' => $revisionRow['rev_id'] ], __METHOD__ );
+
+ // The locking here is mostly to make MySQL bypass the REPEATABLE-READ transaction
+ // isolation (weird MySQL "feature"). It does seem to block concurrent auto-incrementing
+ // inserts too, though, at least on MariaDB 10.1.29.
+ //
+ // Don't try to lock `revision` in this way, it'll deadlock if there are concurrent
+ // transactions in this code path thanks to the row lock from the original ->insert() above.
+ //
+ // And we have to use raw SQL to bypass the "aggregation used with a locking SELECT" warning
+ // that's for non-MySQL DBs.
+ $row1 = $dbw->query(
+ $dbw->selectSqlText( 'archive', [ 'v' => "MAX(ar_rev_id)" ], '', __METHOD__ ) . ' FOR UPDATE'
+ )->fetchObject();
+ if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_NEW ) ) {
+ $row2 = $dbw->query(
+ $dbw->selectSqlText( 'slots', [ 'v' => "MAX(slot_revision_id)" ], '', __METHOD__ )
+ . ' FOR UPDATE'
+ )->fetchObject();
+ } else {
+ $row2 = null;
+ }
+ $maxRevId = max(
+ $maxRevId,
+ $row1 ? intval( $row1->v ) : 0,
+ $row2 ? intval( $row2->v ) : 0
+ );
+
+ // If we don't have SCHEMA_COMPAT_WRITE_NEW, all except the first of any concurrent
+ // transactions will throw a duplicate key error here. It doesn't seem worth trying
+ // to avoid that.
+ $revisionRow['rev_id'] = $maxRevId + 1;
+ $dbw->insert( 'revision', $revisionRow, __METHOD__ );
+ }
+ }
+ }
+
+ $commentCallback( $revisionRow['rev_id'] );
+ $actorCallback( $revisionRow['rev_id'], $revisionRow );
+
+ return $revisionRow;
+ }
+
+ /**
+ * @param IDatabase $dbw
+ * @param RevisionRecord $rev
+ * @param Title $title
+ * @param int $parentId
+ *
+ * @return array [ 0 => array $revisionRow, 1 => callable ]
+ * @throws MWException
+ * @throws MWUnknownContentModelException
+ */
+ private function getBaseRevisionRow(
+ IDatabase $dbw,
+ RevisionRecord $rev,
+ Title $title,
+ $parentId
+ ) {
+ // Record the edit in revisions
+ $revisionRow = [
+ 'rev_page' => $rev->getPageId(),
+ 'rev_parent_id' => $parentId,
+ 'rev_minor_edit' => $rev->isMinor() ? 1 : 0,
+ 'rev_timestamp' => $dbw->timestamp( $rev->getTimestamp() ),
+ 'rev_deleted' => $rev->getVisibility(),
+ 'rev_len' => $rev->getSize(),
+ 'rev_sha1' => $rev->getSha1(),
+ ];
+
+ if ( $rev->getId() !== null ) {
+ // Needed to restore revisions with their original ID
+ $revisionRow['rev_id'] = $rev->getId();
+ }
+
+ if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_OLD ) ) {
+ // In non MCR mode this IF section will relate to the main slot
+ $mainSlot = $rev->getSlot( SlotRecord::MAIN );
+ $model = $mainSlot->getModel();
+ $format = $mainSlot->getFormat();
+
+ // MCR migration note: rev_content_model and rev_content_format will go away
+ if ( $this->contentHandlerUseDB ) {
+ $this->assertCrossWikiContentLoadingIsSafe();
+
+ $defaultModel = ContentHandler::getDefaultModelFor( $title );
+ $defaultFormat = ContentHandler::getForModelID( $defaultModel )->getDefaultFormat();
+
+ $revisionRow['rev_content_model'] = ( $model === $defaultModel ) ? null : $model;
+ $revisionRow['rev_content_format'] = ( $format === $defaultFormat ) ? null : $format;
+ }
+ }
+
+ return $revisionRow;
+ }
+
+ /**
+ * @param SlotRecord $slot
+ * @param Title $title
+ * @param array $blobHints See the BlobStore::XXX_HINT constants
+ *
+ * @throws MWException
+ * @return string the blob address
+ */
+ private function storeContentBlob(
+ SlotRecord $slot,
+ Title $title,
+ array $blobHints = []
+ ) {
+ $content = $slot->getContent();
+ $format = $content->getDefaultFormat();
+ $model = $content->getModel();
+
+ $this->checkContent( $content, $title );
+
+ return $this->blobStore->storeBlob(
+ $content->serialize( $format ),
+ // These hints "leak" some information from the higher abstraction layer to
+ // low level storage to allow for optimization.
+ array_merge(
+ $blobHints,
+ [
+ BlobStore::DESIGNATION_HINT => 'page-content',
+ BlobStore::ROLE_HINT => $slot->getRole(),
+ BlobStore::SHA1_HINT => $slot->getSha1(),
+ BlobStore::MODEL_HINT => $model,
+ BlobStore::FORMAT_HINT => $format,
+ ]
+ )
+ );
+ }
+
+ /**
+ * @param SlotRecord $slot
+ * @param IDatabase $dbw
+ * @param int $revisionId
+ * @param int $contentId
+ */
+ private function insertSlotRowOn( SlotRecord $slot, IDatabase $dbw, $revisionId, $contentId ) {
+ $slotRow = [
+ 'slot_revision_id' => $revisionId,
+ 'slot_role_id' => $this->slotRoleStore->acquireId( $slot->getRole() ),
+ 'slot_content_id' => $contentId,
+ // If the slot has a specific origin use that ID, otherwise use the ID of the revision
+ // that we just inserted.
+ 'slot_origin' => $slot->hasOrigin() ? $slot->getOrigin() : $revisionId,
+ ];
+ $dbw->insert( 'slots', $slotRow, __METHOD__ );
+ }
+
+ /**
+ * @param SlotRecord $slot
+ * @param IDatabase $dbw
+ * @param string $blobAddress
+ * @return int content row ID
+ */
+ private function insertContentRowOn( SlotRecord $slot, IDatabase $dbw, $blobAddress ) {
+ $contentRow = [
+ 'content_size' => $slot->getSize(),
+ 'content_sha1' => $slot->getSha1(),
+ 'content_model' => $this->contentModelStore->acquireId( $slot->getModel() ),
+ 'content_address' => $blobAddress,
+ ];
+ $dbw->insert( 'content', $contentRow, __METHOD__ );
+ return intval( $dbw->insertId() );
+ }
+
+ /**
+ * MCR migration note: this corresponds to Revision::checkContentModel
+ *
+ * @param Content $content
+ * @param Title $title
+ *
+ * @throws MWException
+ * @throws MWUnknownContentModelException
+ */
+ private function checkContent( Content $content, Title $title ) {
+ // Note: may return null for revisions that have not yet been inserted
+
+ $model = $content->getModel();
+ $format = $content->getDefaultFormat();
+ $handler = $content->getContentHandler();
+
+ $name = "$title";
+
+ if ( !$handler->isSupportedFormat( $format ) ) {
+ throw new MWException( "Can't use format $format with content model $model on $name" );
+ }
+
+ if ( !$this->contentHandlerUseDB ) {
+ // if $wgContentHandlerUseDB is not set,
+ // all revisions must use the default content model and format.
+
+ $this->assertCrossWikiContentLoadingIsSafe();
+
+ $defaultModel = ContentHandler::getDefaultModelFor( $title );
+ $defaultHandler = ContentHandler::getForModelID( $defaultModel );
+ $defaultFormat = $defaultHandler->getDefaultFormat();
+
+ if ( $model != $defaultModel ) {
+ throw new MWException( "Can't save non-default content model with "
+ . "\$wgContentHandlerUseDB disabled: model is $model, "
+ . "default for $name is $defaultModel"
+ );
+ }
+
+ if ( $format != $defaultFormat ) {
+ throw new MWException( "Can't use non-default content format with "
+ . "\$wgContentHandlerUseDB disabled: format is $format, "
+ . "default for $name is $defaultFormat"
+ );
+ }
+ }
+
+ if ( !$content->isValid() ) {
+ throw new MWException(
+ "New content for $name is not valid! Content model is $model"
+ );
+ }
+ }
+
+ /**
+ * Create a new null-revision for insertion into a page's
+ * history. This will not re-save the text, but simply refer
+ * to the text from the previous version.
+ *
+ * Such revisions can for instance identify page rename
+ * operations and other such meta-modifications.
+ *
+ * @note This method grabs a FOR UPDATE lock on the relevant row of the page table,
+ * to prevent a new revision from being inserted before the null revision has been written
+ * to the database.
+ *
+ * MCR migration note: this replaces Revision::newNullRevision
+ *
+ * @todo Introduce newFromParentRevision(). newNullRevision can then be based on that
+ * (or go away).
+ *
+ * @param IDatabase $dbw used for obtaining the lock on the page table row
+ * @param Title $title Title of the page to read from
+ * @param CommentStoreComment $comment RevisionRecord's summary
+ * @param bool $minor Whether the revision should be considered as minor
+ * @param User $user The user to attribute the revision to
+ *
+ * @return RevisionRecord|null RevisionRecord or null on error
+ */
+ public function newNullRevision(
+ IDatabase $dbw,
+ Title $title,
+ CommentStoreComment $comment,
+ $minor,
+ User $user
+ ) {
+ $this->checkDatabaseWikiId( $dbw );
+
+ $pageId = $title->getArticleID();
+
+ // T51581: Lock the page table row to ensure no other process
+ // is adding a revision to the page at the same time.
+ // Avoid locking extra tables, compare T191892.
+ $pageLatest = $dbw->selectField(
+ 'page',
+ 'page_latest',
+ [ 'page_id' => $pageId ],
+ __METHOD__,
+ [ 'FOR UPDATE' ]
+ );
+
+ if ( !$pageLatest ) {
+ return null;
+ }
+
+ // Fetch the actual revision row from master, without locking all extra tables.
+ $oldRevision = $this->loadRevisionFromConds(
+ $dbw,
+ [ 'rev_id' => intval( $pageLatest ) ],
+ self::READ_LATEST,
+ $title
+ );
+
+ if ( !$oldRevision ) {
+ $msg = "Failed to load latest revision ID $pageLatest of page ID $pageId.";
+ $this->logger->error(
+ $msg,
+ [ 'exception' => new RuntimeException( $msg ) ]
+ );
+ return null;
+ }
+
+ // Construct the new revision
+ $timestamp = wfTimestampNow(); // TODO: use a callback, so we can override it for testing.
+ $newRevision = MutableRevisionRecord::newFromParentRevision( $oldRevision );
+
+ $newRevision->setComment( $comment );
+ $newRevision->setUser( $user );
+ $newRevision->setTimestamp( $timestamp );
+ $newRevision->setMinorEdit( $minor );
+
+ return $newRevision;
+ }
+
+ /**
+ * MCR migration note: this replaces Revision::isUnpatrolled
+ *
+ * @todo This is overly specific, so move or kill this method.
+ *
+ * @param RevisionRecord $rev
+ *
+ * @return int Rcid of the unpatrolled row, zero if there isn't one
+ */
+ public function getRcIdIfUnpatrolled( RevisionRecord $rev ) {
+ $rc = $this->getRecentChange( $rev );
+ if ( $rc && $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_UNPATROLLED ) {
+ return $rc->getAttribute( 'rc_id' );
+ } else {
+ return 0;
+ }
+ }
+
+ /**
+ * Get the RC object belonging to the current revision, if there's one
+ *
+ * MCR migration note: this replaces Revision::getRecentChange
+ *
+ * @todo move this somewhere else?
+ *
+ * @param RevisionRecord $rev
+ * @param int $flags (optional) $flags include:
+ * IDBAccessObject::READ_LATEST: Select the data from the master
+ *
+ * @return null|RecentChange
+ */
+ public function getRecentChange( RevisionRecord $rev, $flags = 0 ) {
+ list( $dbType, ) = DBAccessObjectUtils::getDBOptions( $flags );
+ $db = $this->getDBConnection( $dbType );
+
+ $userIdentity = $rev->getUser( RevisionRecord::RAW );
+
+ if ( !$userIdentity ) {
+ // If the revision has no user identity, chances are it never went
+ // into the database, and doesn't have an RC entry.
+ return null;
+ }
+
+ // TODO: Select by rc_this_oldid alone - but as of Nov 2017, there is no index on that!
+ $actorWhere = $this->actorMigration->getWhere( $db, 'rc_user', $rev->getUser(), false );
+ $rc = RecentChange::newFromConds(
+ [
+ $actorWhere['conds'],
+ 'rc_timestamp' => $db->timestamp( $rev->getTimestamp() ),
+ 'rc_this_oldid' => $rev->getId()
+ ],
+ __METHOD__,
+ $dbType
+ );
+
+ $this->releaseDBConnection( $db );
+
+ // XXX: cache this locally? Glue it to the RevisionRecord?
+ return $rc;
+ }
+
+ /**
+ * Maps fields of the archive row to corresponding revision rows.
+ *
+ * @param object $archiveRow
+ *
+ * @return object a revision row object, corresponding to $archiveRow.
+ */
+ private static function mapArchiveFields( $archiveRow ) {
+ $fieldMap = [
+ // keep with ar prefix:
+ 'ar_id' => 'ar_id',
+
+ // not the same suffix:
+ 'ar_page_id' => 'rev_page',
+ 'ar_rev_id' => 'rev_id',
+
+ // same suffix:
+ 'ar_text_id' => 'rev_text_id',
+ 'ar_timestamp' => 'rev_timestamp',
+ 'ar_user_text' => 'rev_user_text',
+ 'ar_user' => 'rev_user',
+ 'ar_actor' => 'rev_actor',
+ 'ar_minor_edit' => 'rev_minor_edit',
+ 'ar_deleted' => 'rev_deleted',
+ 'ar_len' => 'rev_len',
+ 'ar_parent_id' => 'rev_parent_id',
+ 'ar_sha1' => 'rev_sha1',
+ 'ar_comment' => 'rev_comment',
+ 'ar_comment_cid' => 'rev_comment_cid',
+ 'ar_comment_id' => 'rev_comment_id',
+ 'ar_comment_text' => 'rev_comment_text',
+ 'ar_comment_data' => 'rev_comment_data',
+ 'ar_comment_old' => 'rev_comment_old',
+ 'ar_content_format' => 'rev_content_format',
+ 'ar_content_model' => 'rev_content_model',
+ ];
+
+ $revRow = new stdClass();
+ foreach ( $fieldMap as $arKey => $revKey ) {
+ if ( property_exists( $archiveRow, $arKey ) ) {
+ $revRow->$revKey = $archiveRow->$arKey;
+ }
+ }
+
+ return $revRow;
+ }
+
+ /**
+ * Constructs a RevisionRecord for the revisions main slot, based on the MW1.29 schema.
+ *
+ * @param object|array $row Either a database row or an array
+ * @param int $queryFlags for callbacks
+ * @param Title $title
+ *
+ * @return SlotRecord The main slot, extracted from the MW 1.29 style row.
+ * @throws MWException
+ */
+ private function emulateMainSlot_1_29( $row, $queryFlags, Title $title ) {
+ $mainSlotRow = new stdClass();
+ $mainSlotRow->role_name = SlotRecord::MAIN;
+ $mainSlotRow->model_name = null;
+ $mainSlotRow->slot_revision_id = null;
+ $mainSlotRow->slot_content_id = null;
+ $mainSlotRow->content_address = null;
+
+ $content = null;
+ $blobData = null;
+ $blobFlags = null;
+
+ if ( is_object( $row ) ) {
+ if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_NEW ) ) {
+ // Don't emulate from a row when using the new schema.
+ // Emulating from an array is still OK.
+ throw new LogicException( 'Can\'t emulate the main slot when using MCR schema.' );
+ }
+
+ // archive row
+ if ( !isset( $row->rev_id ) && ( isset( $row->ar_user ) || isset( $row->ar_actor ) ) ) {
+ $row = $this->mapArchiveFields( $row );
+ }
+
+ if ( isset( $row->rev_text_id ) && $row->rev_text_id > 0 ) {
+ $mainSlotRow->content_address = SqlBlobStore::makeAddressFromTextId(
+ $row->rev_text_id
+ );
+ }
+
+ // This is used by null-revisions
+ $mainSlotRow->slot_origin = isset( $row->slot_origin )
+ ? intval( $row->slot_origin )
+ : null;
+
+ if ( isset( $row->old_text ) ) {
+ // this happens when the text-table gets joined directly, in the pre-1.30 schema
+ $blobData = isset( $row->old_text ) ? strval( $row->old_text ) : null;
+ // Check against selects that might have not included old_flags
+ if ( !property_exists( $row, 'old_flags' ) ) {
+ throw new InvalidArgumentException( 'old_flags was not set in $row' );
+ }
+ $blobFlags = $row->old_flags ?? '';
+ }
+
+ $mainSlotRow->slot_revision_id = intval( $row->rev_id );
+
+ $mainSlotRow->content_size = isset( $row->rev_len ) ? intval( $row->rev_len ) : null;
+ $mainSlotRow->content_sha1 = isset( $row->rev_sha1 ) ? strval( $row->rev_sha1 ) : null;
+ $mainSlotRow->model_name = isset( $row->rev_content_model )
+ ? strval( $row->rev_content_model )
+ : null;
+ // XXX: in the future, we'll probably always use the default format, and drop content_format
+ $mainSlotRow->format_name = isset( $row->rev_content_format )
+ ? strval( $row->rev_content_format )
+ : null;
+
+ if ( isset( $row->rev_text_id ) && intval( $row->rev_text_id ) > 0 ) {
+ // Overwritten below for SCHEMA_COMPAT_WRITE_NEW
+ $mainSlotRow->slot_content_id
+ = $this->emulateContentId( intval( $row->rev_text_id ) );
+ }
+ } elseif ( is_array( $row ) ) {
+ $mainSlotRow->slot_revision_id = isset( $row['id'] ) ? intval( $row['id'] ) : null;
+
+ $mainSlotRow->slot_origin = isset( $row['slot_origin'] )
+ ? intval( $row['slot_origin'] )
+ : null;
+ $mainSlotRow->content_address = isset( $row['text_id'] )
+ ? SqlBlobStore::makeAddressFromTextId( intval( $row['text_id'] ) )
+ : null;
+ $mainSlotRow->content_size = isset( $row['len'] ) ? intval( $row['len'] ) : null;
+ $mainSlotRow->content_sha1 = isset( $row['sha1'] ) ? strval( $row['sha1'] ) : null;
+
+ $mainSlotRow->model_name = isset( $row['content_model'] )
+ ? strval( $row['content_model'] ) : null; // XXX: must be a string!
+ // XXX: in the future, we'll probably always use the default format, and drop content_format
+ $mainSlotRow->format_name = isset( $row['content_format'] )
+ ? strval( $row['content_format'] ) : null;
+ $blobData = isset( $row['text'] ) ? rtrim( strval( $row['text'] ) ) : null;
+ // XXX: If the flags field is not set then $blobFlags should be null so that no
+ // decoding will happen. An empty string will result in default decodings.
+ $blobFlags = isset( $row['flags'] ) ? trim( strval( $row['flags'] ) ) : null;
+
+ // if we have a Content object, override mText and mContentModel
+ if ( !empty( $row['content'] ) ) {
+ if ( !( $row['content'] instanceof Content ) ) {
+ throw new MWException( 'content field must contain a Content object.' );
+ }
+
+ /** @var Content $content */
+ $content = $row['content'];
+ $handler = $content->getContentHandler();
+
+ $mainSlotRow->model_name = $content->getModel();
+
+ // XXX: in the future, we'll probably always use the default format.
+ if ( $mainSlotRow->format_name === null ) {
+ $mainSlotRow->format_name = $handler->getDefaultFormat();
+ }
+ }
+
+ if ( isset( $row['text_id'] ) && intval( $row['text_id'] ) > 0 ) {
+ // Overwritten below for SCHEMA_COMPAT_WRITE_NEW
+ $mainSlotRow->slot_content_id
+ = $this->emulateContentId( intval( $row['text_id'] ) );
+ }
+ } else {
+ throw new MWException( 'Revision constructor passed invalid row format.' );
+ }
+
+ // With the old schema, the content changes with every revision,
+ // except for null-revisions.
+ if ( !isset( $mainSlotRow->slot_origin ) ) {
+ $mainSlotRow->slot_origin = $mainSlotRow->slot_revision_id;
+ }
+
+ if ( $mainSlotRow->model_name === null ) {
+ $mainSlotRow->model_name = function ( SlotRecord $slot ) use ( $title ) {
+ $this->assertCrossWikiContentLoadingIsSafe();
+
+ // TODO: MCR: consider slot role in getDefaultModelFor()! Use LinkTarget!
+ // TODO: MCR: deprecate $title->getModel().
+ return ContentHandler::getDefaultModelFor( $title );
+ };
+ }
+
+ if ( !$content ) {
+ // XXX: We should perhaps fail if $blobData is null and $mainSlotRow->content_address
+ // is missing, but "empty revisions" with no content are used in some edge cases.
+
+ $content = function ( SlotRecord $slot )
+ use ( $blobData, $blobFlags, $queryFlags, $mainSlotRow )
+ {
+ return $this->loadSlotContent(
+ $slot,
+ $blobData,
+ $blobFlags,
+ $mainSlotRow->format_name,
+ $queryFlags
+ );
+ };
+ }
+
+ if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_NEW ) ) {
+ // NOTE: this callback will be looped through RevisionSlot::newInherited(), allowing
+ // the inherited slot to have the same content_id as the original slot. In that case,
+ // $slot will be the inherited slot, while $mainSlotRow still refers to the original slot.
+ $mainSlotRow->slot_content_id =
+ function ( SlotRecord $slot ) use ( $queryFlags, $mainSlotRow ) {
+ $db = $this->getDBConnectionRefForQueryFlags( $queryFlags );
+ return $this->findSlotContentId( $db, $mainSlotRow->slot_revision_id, SlotRecord::MAIN );
+ };
+ }
+
+ return new SlotRecord( $mainSlotRow, $content );
+ }
+
+ /**
+ * Provides a content ID to use with emulated SlotRecords in SCHEMA_COMPAT_OLD mode,
+ * based on the revision's text ID (rev_text_id or ar_text_id, respectively).
+ * Note that in SCHEMA_COMPAT_WRITE_BOTH, a callback to findSlotContentId() should be used
+ * instead, since in that mode, some revision rows may already have a real content ID,
+ * while other's don't - and for the ones that don't, we should indicate that it
+ * is missing and cause SlotRecords::hasContentId() to return false.
+ *
+ * @param int $textId
+ * @return int The emulated content ID
+ */
+ private function emulateContentId( $textId ) {
+ // Return a negative number to ensure the ID is distinct from any real content IDs
+ // that will be assigned in SCHEMA_COMPAT_WRITE_NEW mode and read in SCHEMA_COMPAT_READ_NEW
+ // mode.
+ return -$textId;
+ }
+
+ /**
+ * Loads a Content object based on a slot row.
+ *
+ * This method does not call $slot->getContent(), and may be used as a callback
+ * called by $slot->getContent().
+ *
+ * MCR migration note: this roughly corresponds to Revision::getContentInternal
+ *
+ * @param SlotRecord $slot The SlotRecord to load content for
+ * @param string|null $blobData The content blob, in the form indicated by $blobFlags
+ * @param string|null $blobFlags Flags indicating how $blobData needs to be processed.
+ * Use null if no processing should happen. That is in constrast to the empty string,
+ * which causes the blob to be decoded according to the configured legacy encoding.
+ * @param string|null $blobFormat MIME type indicating how $dataBlob is encoded
+ * @param int $queryFlags
+ *
+ * @throws RevisionAccessException
+ * @return Content
+ */
+ private function loadSlotContent(
+ SlotRecord $slot,
+ $blobData = null,
+ $blobFlags = null,
+ $blobFormat = null,
+ $queryFlags = 0
+ ) {
+ if ( $blobData !== null ) {
+ Assert::parameterType( 'string', $blobData, '$blobData' );
+ Assert::parameterType( 'string|null', $blobFlags, '$blobFlags' );
+
+ $cacheKey = $slot->hasAddress() ? $slot->getAddress() : null;
+
+ if ( $blobFlags === null ) {
+ // No blob flags, so use the blob verbatim.
+ $data = $blobData;
+ } else {
+ $data = $this->blobStore->expandBlob( $blobData, $blobFlags, $cacheKey );
+ if ( $data === false ) {
+ throw new RevisionAccessException(
+ "Failed to expand blob data using flags $blobFlags (key: $cacheKey)"
+ );
+ }
+ }
+
+ } else {
+ $address = $slot->getAddress();
+ try {
+ $data = $this->blobStore->getBlob( $address, $queryFlags );
+ } catch ( BlobAccessException $e ) {
+ throw new RevisionAccessException(
+ "Failed to load data blob from $address: " . $e->getMessage(), 0, $e
+ );
+ }
+ }
+
+ // Unserialize content
+ $handler = ContentHandler::getForModelID( $slot->getModel() );
+
+ $content = $handler->unserializeContent( $data, $blobFormat );
+ return $content;
+ }
+
+ /**
+ * Load a page revision from a given revision ID number.
+ * Returns null if no such revision can be found.
+ *
+ * MCR migration note: this replaces Revision::newFromId
+ *
+ * $flags include:
+ * IDBAccessObject::READ_LATEST: Select the data from the master
+ * IDBAccessObject::READ_LOCKING : Select & lock the data from the master
+ *
+ * @param int $id
+ * @param int $flags (optional)
+ * @return RevisionRecord|null
+ */
+ public function getRevisionById( $id, $flags = 0 ) {
+ return $this->newRevisionFromConds( [ 'rev_id' => intval( $id ) ], $flags );
+ }
+
+ /**
+ * Load either the current, or a specified, revision
+ * that's attached to a given link target. If not attached
+ * to that link target, will return null.
+ *
+ * MCR migration note: this replaces Revision::newFromTitle
+ *
+ * $flags include:
+ * IDBAccessObject::READ_LATEST: Select the data from the master
+ * IDBAccessObject::READ_LOCKING : Select & lock the data from the master
+ *
+ * @param LinkTarget $linkTarget
+ * @param int $revId (optional)
+ * @param int $flags Bitfield (optional)
+ * @return RevisionRecord|null
+ */
+ public function getRevisionByTitle( LinkTarget $linkTarget, $revId = 0, $flags = 0 ) {
+ $conds = [
+ 'page_namespace' => $linkTarget->getNamespace(),
+ 'page_title' => $linkTarget->getDBkey()
+ ];
+ if ( $revId ) {
+ // Use the specified revision ID.
+ // Note that we use newRevisionFromConds here because we want to retry
+ // and fall back to master if the page is not found on a replica.
+ // Since the caller supplied a revision ID, we are pretty sure the revision is
+ // supposed to exist, so we should try hard to find it.
+ $conds['rev_id'] = $revId;
+ return $this->newRevisionFromConds( $conds, $flags );
+ } else {
+ // Use a join to get the latest revision.
+ // Note that we don't use newRevisionFromConds here because we don't want to retry
+ // and fall back to master. The assumption is that we only want to force the fallback
+ // if we are quite sure the revision exists because the caller supplied a revision ID.
+ // If the page isn't found at all on a replica, it probably simply does not exist.
+ $db = $this->getDBConnectionRefForQueryFlags( $flags );
+
+ $conds[] = 'rev_id=page_latest';
+ $rev = $this->loadRevisionFromConds( $db, $conds, $flags );
+
+ return $rev;
+ }
+ }
+
+ /**
+ * Load either the current, or a specified, revision
+ * that's attached to a given page ID.
+ * Returns null if no such revision can be found.
+ *
+ * MCR migration note: this replaces Revision::newFromPageId
+ *
+ * $flags include:
+ * IDBAccessObject::READ_LATEST: Select the data from the master (since 1.20)
+ * IDBAccessObject::READ_LOCKING : Select & lock the data from the master
+ *
+ * @param int $pageId
+ * @param int $revId (optional)
+ * @param int $flags Bitfield (optional)
+ * @return RevisionRecord|null
+ */
+ public function getRevisionByPageId( $pageId, $revId = 0, $flags = 0 ) {
+ $conds = [ 'page_id' => $pageId ];
+ if ( $revId ) {
+ // Use the specified revision ID.
+ // Note that we use newRevisionFromConds here because we want to retry
+ // and fall back to master if the page is not found on a replica.
+ // Since the caller supplied a revision ID, we are pretty sure the revision is
+ // supposed to exist, so we should try hard to find it.
+ $conds['rev_id'] = $revId;
+ return $this->newRevisionFromConds( $conds, $flags );
+ } else {
+ // Use a join to get the latest revision.
+ // Note that we don't use newRevisionFromConds here because we don't want to retry
+ // and fall back to master. The assumption is that we only want to force the fallback
+ // if we are quite sure the revision exists because the caller supplied a revision ID.
+ // If the page isn't found at all on a replica, it probably simply does not exist.
+ $db = $this->getDBConnectionRefForQueryFlags( $flags );
+
+ $conds[] = 'rev_id=page_latest';
+ $rev = $this->loadRevisionFromConds( $db, $conds, $flags );
+
+ return $rev;
+ }
+ }
+
+ /**
+ * Load the revision for the given title with the given timestamp.
+ * WARNING: Timestamps may in some circumstances not be unique,
+ * so this isn't the best key to use.
+ *
+ * MCR migration note: this replaces Revision::loadFromTimestamp
+ *
+ * @param Title $title
+ * @param string $timestamp
+ * @return RevisionRecord|null
+ */
+ public function getRevisionByTimestamp( $title, $timestamp ) {
+ $db = $this->getDBConnection( DB_REPLICA );
+ return $this->newRevisionFromConds(
+ [
+ 'rev_timestamp' => $db->timestamp( $timestamp ),
+ 'page_namespace' => $title->getNamespace(),
+ 'page_title' => $title->getDBkey()
+ ],
+ 0,
+ $title
+ );
+ }
+
+ /**
+ * @param int $revId The revision to load slots for.
+ * @param int $queryFlags
+ *
+ * @return SlotRecord[]
+ */
+ private function loadSlotRecords( $revId, $queryFlags ) {
+ $revQuery = self::getSlotsQueryInfo( [ 'content' ] );
+
+ list( $dbMode, $dbOptions ) = DBAccessObjectUtils::getDBOptions( $queryFlags );
+ $db = $this->getDBConnectionRef( $dbMode );
+
+ $res = $db->select(
+ $revQuery['tables'],
+ $revQuery['fields'],
+ [
+ 'slot_revision_id' => $revId,
+ ],
+ __METHOD__,
+ $dbOptions,
+ $revQuery['joins']
+ );
+
+ $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 );
+
+ $contentCallback = function ( SlotRecord $slot ) use ( $queryFlags, $row ) {
+ return $this->loadSlotContent( $slot, null, null, null, $queryFlags );
+ };
+
+ $slots[$row->role_name] = new SlotRecord( $row, $contentCallback );
+ }
+
+ if ( !isset( $slots[SlotRecord::MAIN] ) ) {
+ throw new RevisionAccessException(
+ 'Main slot of revision ' . $revId . ' not found in database!'
+ );
+ };
+
+ return $slots;
+ }
+
+ /**
+ * Factory method for RevisionSlots.
+ *
+ * @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 int $queryFlags
+ * @param Title $title
+ *
+ * @return RevisionSlots
+ * @throws MWException
+ */
+ private function newRevisionSlots(
+ $revId,
+ $revisionRow,
+ $queryFlags,
+ Title $title
+ ) {
+ if ( !$this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_NEW ) ) {
+ $mainSlot = $this->emulateMainSlot_1_29( $revisionRow, $queryFlags, $title );
+ $slots = new RevisionSlots( [ SlotRecord::MAIN => $mainSlot ] );
+ } else {
+ // 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 );
+ } );
+ }
+
+ return $slots;
+ }
+
+ /**
+ * Make a fake revision object from an archive table row. This is queried
+ * for permissions or even inserted (as in Special:Undelete)
+ *
+ * MCR migration note: this replaces Revision::newFromArchiveRow
+ *
+ * @param object $row
+ * @param int $queryFlags
+ * @param Title|null $title
+ * @param array $overrides associative array with fields of $row to override. This may be
+ * used e.g. to force the parent revision ID or page ID. Keys in the array are fields
+ * names from the archive table without the 'ar_' prefix, i.e. use 'parent_id' to
+ * override ar_parent_id.
+ *
+ * @return RevisionRecord
+ * @throws MWException
+ */
+ public function newRevisionFromArchiveRow(
+ $row,
+ $queryFlags = 0,
+ Title $title = null,
+ array $overrides = []
+ ) {
+ Assert::parameterType( 'object', $row, '$row' );
+
+ // check second argument, since Revision::newFromArchiveRow had $overrides in that spot.
+ Assert::parameterType( 'integer', $queryFlags, '$queryFlags' );
+
+ if ( !$title && isset( $overrides['title'] ) ) {
+ if ( !( $overrides['title'] instanceof Title ) ) {
+ throw new MWException( 'title field override must contain a Title object.' );
+ }
+
+ $title = $overrides['title'];
+ }
+
+ if ( !isset( $title ) ) {
+ if ( isset( $row->ar_namespace ) && isset( $row->ar_title ) ) {
+ $title = Title::makeTitle( $row->ar_namespace, $row->ar_title );
+ } else {
+ throw new InvalidArgumentException(
+ 'A Title or ar_namespace and ar_title must be given'
+ );
+ }
+ }
+
+ foreach ( $overrides as $key => $value ) {
+ $field = "ar_$key";
+ $row->$field = $value;
+ }
+
+ try {
+ $user = User::newFromAnyId(
+ $row->ar_user ?? null,
+ $row->ar_user_text ?? null,
+ $row->ar_actor ?? null
+ );
+ } catch ( InvalidArgumentException $ex ) {
+ wfWarn( __METHOD__ . ': ' . $title->getPrefixedDBkey() . ': ' . $ex->getMessage() );
+ $user = new UserIdentityValue( 0, 'Unknown user', 0 );
+ }
+
+ $db = $this->getDBConnectionRefForQueryFlags( $queryFlags );
+ // 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 );
+
+ return new RevisionArchiveRecord( $title, $user, $comment, $row, $slots, $this->wikiId );
+ }
+
+ /**
+ * @see RevisionFactory::newRevisionFromRow
+ *
+ * MCR migration note: this replaces Revision::newFromRow
+ *
+ * @param object $row
+ * @param int $queryFlags
+ * @param Title|null $title
+ *
+ * @return RevisionRecord
+ */
+ public function newRevisionFromRow( $row, $queryFlags = 0, Title $title = null ) {
+ Assert::parameterType( 'object', $row, '$row' );
+
+ if ( !$title ) {
+ $pageId = $row->rev_page ?? 0; // XXX: also check page_id?
+ $revId = $row->rev_id ?? 0;
+
+ $title = $this->getTitle( $pageId, $revId, $queryFlags );
+ }
+
+ if ( !isset( $row->page_latest ) ) {
+ $row->page_latest = $title->getLatestRevID();
+ if ( $row->page_latest === 0 && $title->exists() ) {
+ wfWarn( 'Encountered title object in limbo: ID ' . $title->getArticleID() );
+ }
+ }
+
+ try {
+ $user = User::newFromAnyId(
+ $row->rev_user ?? null,
+ $row->rev_user_text ?? null,
+ $row->rev_actor ?? null
+ );
+ } catch ( InvalidArgumentException $ex ) {
+ wfWarn( __METHOD__ . ': ' . $title->getPrefixedDBkey() . ': ' . $ex->getMessage() );
+ $user = new UserIdentityValue( 0, 'Unknown user', 0 );
+ }
+
+ $db = $this->getDBConnectionRefForQueryFlags( $queryFlags );
+ // 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 );
+
+ return new RevisionStoreRecord( $title, $user, $comment, $row, $slots, $this->wikiId );
+ }
+
+ /**
+ * Constructs a new MutableRevisionRecord based on the given associative array following
+ * the MW1.29 convention for the Revision constructor.
+ *
+ * MCR migration note: this replaces Revision::newFromRow
+ *
+ * @param array $fields
+ * @param int $queryFlags
+ * @param Title|null $title
+ *
+ * @return MutableRevisionRecord
+ * @throws MWException
+ * @throws RevisionAccessException
+ */
+ public function newMutableRevisionFromArray(
+ array $fields,
+ $queryFlags = 0,
+ Title $title = null
+ ) {
+ if ( !$title && isset( $fields['title'] ) ) {
+ if ( !( $fields['title'] instanceof Title ) ) {
+ throw new MWException( 'title field must contain a Title object.' );
+ }
+
+ $title = $fields['title'];
+ }
+
+ if ( !$title ) {
+ $pageId = $fields['page'] ?? 0;
+ $revId = $fields['id'] ?? 0;
+
+ $title = $this->getTitle( $pageId, $revId, $queryFlags );
+ }
+
+ if ( !isset( $fields['page'] ) ) {
+ $fields['page'] = $title->getArticleID( $queryFlags );
+ }
+
+ // if we have a content object, use it to set the model and type
+ if ( !empty( $fields['content'] ) ) {
+ if ( !( $fields['content'] instanceof Content ) && !is_array( $fields['content'] ) ) {
+ throw new MWException(
+ 'content field must contain a Content object or an array of Content objects.'
+ );
+ }
+ }
+
+ if ( !empty( $fields['text_id'] ) ) {
+ if ( !$this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_OLD ) ) {
+ throw new MWException( "The text_id field is only available in the pre-MCR schema" );
+ }
+
+ if ( !empty( $fields['content'] ) ) {
+ throw new MWException(
+ "Text already stored in external store (id {$fields['text_id']}), " .
+ "can't specify content object"
+ );
+ }
+ }
+
+ if (
+ isset( $fields['comment'] )
+ && !( $fields['comment'] instanceof CommentStoreComment )
+ ) {
+ $commentData = $fields['comment_data'] ?? null;
+
+ if ( $fields['comment'] instanceof Message ) {
+ $fields['comment'] = CommentStoreComment::newUnsavedComment(
+ $fields['comment'],
+ $commentData
+ );
+ } else {
+ $commentText = trim( strval( $fields['comment'] ) );
+ $fields['comment'] = CommentStoreComment::newUnsavedComment(
+ $commentText,
+ $commentData
+ );
+ }
+ }
+
+ $revision = new MutableRevisionRecord( $title, $this->wikiId );
+ $this->initializeMutableRevisionFromArray( $revision, $fields );
+
+ if ( isset( $fields['content'] ) && is_array( $fields['content'] ) ) {
+ foreach ( $fields['content'] as $role => $content ) {
+ $revision->setContent( $role, $content );
+ }
+ } else {
+ $mainSlot = $this->emulateMainSlot_1_29( $fields, $queryFlags, $title );
+ $revision->setSlot( $mainSlot );
+ }
+
+ return $revision;
+ }
+
+ /**
+ * @param MutableRevisionRecord $record
+ * @param array $fields
+ */
+ private function initializeMutableRevisionFromArray(
+ MutableRevisionRecord $record,
+ array $fields
+ ) {
+ /** @var UserIdentity $user */
+ $user = null;
+
+ if ( isset( $fields['user'] ) && ( $fields['user'] instanceof UserIdentity ) ) {
+ $user = $fields['user'];
+ } else {
+ try {
+ $user = User::newFromAnyId(
+ $fields['user'] ?? null,
+ $fields['user_text'] ?? null,
+ $fields['actor'] ?? null
+ );
+ } catch ( InvalidArgumentException $ex ) {
+ $user = null;
+ }
+ }
+
+ if ( $user ) {
+ $record->setUser( $user );
+ }
+
+ $timestamp = isset( $fields['timestamp'] )
+ ? strval( $fields['timestamp'] )
+ : wfTimestampNow(); // TODO: use a callback, so we can override it for testing.
+
+ $record->setTimestamp( $timestamp );
+
+ if ( isset( $fields['page'] ) ) {
+ $record->setPageId( intval( $fields['page'] ) );
+ }
+
+ if ( isset( $fields['id'] ) ) {
+ $record->setId( intval( $fields['id'] ) );
+ }
+ if ( isset( $fields['parent_id'] ) ) {
+ $record->setParentId( intval( $fields['parent_id'] ) );
+ }
+
+ if ( isset( $fields['sha1'] ) ) {
+ $record->setSha1( $fields['sha1'] );
+ }
+ if ( isset( $fields['size'] ) ) {
+ $record->setSize( intval( $fields['size'] ) );
+ }
+
+ if ( isset( $fields['minor_edit'] ) ) {
+ $record->setMinorEdit( intval( $fields['minor_edit'] ) !== 0 );
+ }
+ if ( isset( $fields['deleted'] ) ) {
+ $record->setVisibility( intval( $fields['deleted'] ) );
+ }
+
+ if ( isset( $fields['comment'] ) ) {
+ Assert::parameterType(
+ CommentStoreComment::class,
+ $fields['comment'],
+ '$row[\'comment\']'
+ );
+ $record->setComment( $fields['comment'] );
+ }
+ }
+
+ /**
+ * Load a page revision from a given revision ID number.
+ * Returns null if no such revision can be found.
+ *
+ * MCR migration note: this corresponds to Revision::loadFromId
+ *
+ * @note direct use is deprecated!
+ * @todo remove when unused! there seem to be no callers of Revision::loadFromId
+ *
+ * @param IDatabase $db
+ * @param int $id
+ *
+ * @return RevisionRecord|null
+ */
+ public function loadRevisionFromId( IDatabase $db, $id ) {
+ return $this->loadRevisionFromConds( $db, [ 'rev_id' => intval( $id ) ] );
+ }
+
+ /**
+ * Load either the current, or a specified, revision
+ * that's attached to a given page. If not attached
+ * to that page, will return null.
+ *
+ * MCR migration note: this replaces Revision::loadFromPageId
+ *
+ * @note direct use is deprecated!
+ * @todo remove when unused!
+ *
+ * @param IDatabase $db
+ * @param int $pageid
+ * @param int $id
+ * @return RevisionRecord|null
+ */
+ public function loadRevisionFromPageId( IDatabase $db, $pageid, $id = 0 ) {
+ $conds = [ 'rev_page' => intval( $pageid ), 'page_id' => intval( $pageid ) ];
+ if ( $id ) {
+ $conds['rev_id'] = intval( $id );
+ } else {
+ $conds[] = 'rev_id=page_latest';
+ }
+ return $this->loadRevisionFromConds( $db, $conds );
+ }
+
+ /**
+ * Load either the current, or a specified, revision
+ * that's attached to a given page. If not attached
+ * to that page, will return null.
+ *
+ * MCR migration note: this replaces Revision::loadFromTitle
+ *
+ * @note direct use is deprecated!
+ * @todo remove when unused!
+ *
+ * @param IDatabase $db
+ * @param Title $title
+ * @param int $id
+ *
+ * @return RevisionRecord|null
+ */
+ public function loadRevisionFromTitle( IDatabase $db, $title, $id = 0 ) {
+ if ( $id ) {
+ $matchId = intval( $id );
+ } else {
+ $matchId = 'page_latest';
+ }
+
+ return $this->loadRevisionFromConds(
+ $db,
+ [
+ "rev_id=$matchId",
+ 'page_namespace' => $title->getNamespace(),
+ 'page_title' => $title->getDBkey()
+ ],
+ 0,
+ $title
+ );
+ }
+
+ /**
+ * Load the revision for the given title with the given timestamp.
+ * WARNING: Timestamps may in some circumstances not be unique,
+ * so this isn't the best key to use.
+ *
+ * MCR migration note: this replaces Revision::loadFromTimestamp
+ *
+ * @note direct use is deprecated! Use getRevisionFromTimestamp instead!
+ * @todo remove when unused!
+ *
+ * @param IDatabase $db
+ * @param Title $title
+ * @param string $timestamp
+ * @return RevisionRecord|null
+ */
+ public function loadRevisionFromTimestamp( IDatabase $db, $title, $timestamp ) {
+ return $this->loadRevisionFromConds( $db,
+ [
+ 'rev_timestamp' => $db->timestamp( $timestamp ),
+ 'page_namespace' => $title->getNamespace(),
+ 'page_title' => $title->getDBkey()
+ ],
+ 0,
+ $title
+ );
+ }
+
+ /**
+ * Given a set of conditions, fetch a revision
+ *
+ * This method should be used if we are pretty sure the revision exists.
+ * Unless $flags has READ_LATEST set, this method will first try to find the revision
+ * on a replica before hitting the master database.
+ *
+ * MCR migration note: this corresponds to Revision::newFromConds
+ *
+ * @param array $conditions
+ * @param int $flags (optional)
+ * @param Title|null $title
+ *
+ * @return RevisionRecord|null
+ */
+ private function newRevisionFromConds( $conditions, $flags = 0, Title $title = null ) {
+ $db = $this->getDBConnectionRefForQueryFlags( $flags );
+ $rev = $this->loadRevisionFromConds( $db, $conditions, $flags, $title );
+
+ $lb = $this->getDBLoadBalancer();
+
+ // Make sure new pending/committed revision are visibile later on
+ // within web requests to certain avoid bugs like T93866 and T94407.
+ if ( !$rev
+ && !( $flags & self::READ_LATEST )
+ && $lb->getServerCount() > 1
+ && $lb->hasOrMadeRecentMasterChanges()
+ ) {
+ $flags = self::READ_LATEST;
+ $dbw = $this->getDBConnection( DB_MASTER );
+ $rev = $this->loadRevisionFromConds( $dbw, $conditions, $flags, $title );
+ $this->releaseDBConnection( $dbw );
+ }
+
+ return $rev;
+ }
+
+ /**
+ * Given a set of conditions, fetch a revision from
+ * the given database connection.
+ *
+ * MCR migration note: this corresponds to Revision::loadFromConds
+ *
+ * @param IDatabase $db
+ * @param array $conditions
+ * @param int $flags (optional)
+ * @param Title|null $title
+ *
+ * @return RevisionRecord|null
+ */
+ private function loadRevisionFromConds(
+ IDatabase $db,
+ $conditions,
+ $flags = 0,
+ Title $title = null
+ ) {
+ $row = $this->fetchRevisionRowFromConds( $db, $conditions, $flags );
+ if ( $row ) {
+ $rev = $this->newRevisionFromRow( $row, $flags, $title );
+
+ return $rev;
+ }
+
+ return null;
+ }
+
+ /**
+ * Throws an exception if the given database connection does not belong to the wiki this
+ * RevisionStore is bound to.
+ *
+ * @param IDatabase $db
+ * @throws MWException
+ */
+ private function checkDatabaseWikiId( IDatabase $db ) {
+ $storeWiki = $this->wikiId;
+ $dbWiki = $db->getDomainID();
+
+ if ( $dbWiki === $storeWiki ) {
+ return;
+ }
+
+ $storeWiki = $storeWiki ?: $this->loadBalancer->getLocalDomainID();
+ // @FIXME: when would getDomainID() be false here?
+ $dbWiki = $dbWiki ?: wfWikiID();
+
+ if ( $dbWiki === $storeWiki ) {
+ return;
+ }
+
+ // HACK: counteract encoding imposed by DatabaseDomain
+ $storeWiki = str_replace( '?h', '-', $storeWiki );
+ $dbWiki = str_replace( '?h', '-', $dbWiki );
+
+ if ( $dbWiki === $storeWiki ) {
+ return;
+ }
+
+ throw new MWException( "RevisionStore for $storeWiki "
+ . "cannot be used with a DB connection for $dbWiki" );
+ }
+
+ /**
+ * Given a set of conditions, return a row with the
+ * fields necessary to build RevisionRecord objects.
+ *
+ * MCR migration note: this corresponds to Revision::fetchFromConds
+ *
+ * @param IDatabase $db
+ * @param array $conditions
+ * @param int $flags (optional)
+ *
+ * @return object|false data row as a raw object
+ */
+ private function fetchRevisionRowFromConds( IDatabase $db, $conditions, $flags = 0 ) {
+ $this->checkDatabaseWikiId( $db );
+
+ $revQuery = $this->getQueryInfo( [ 'page', 'user' ] );
+ $options = [];
+ if ( ( $flags & self::READ_LOCKING ) == self::READ_LOCKING ) {
+ $options[] = 'FOR UPDATE';
+ }
+ return $db->selectRow(
+ $revQuery['tables'],
+ $revQuery['fields'],
+ $conditions,
+ __METHOD__,
+ $options,
+ $revQuery['joins']
+ );
+ }
+
+ /**
+ * Finds the ID of a content row for a given revision and slot role.
+ * This can be used to re-use content rows even while the content ID
+ * is still missing from SlotRecords, when writing to both the old and
+ * the new schema during MCR schema migration.
+ *
+ * @todo remove after MCR schema migration is complete.
+ *
+ * @param IDatabase $db
+ * @param int $revId
+ * @param string $role
+ *
+ * @return int|null
+ */
+ private function findSlotContentId( IDatabase $db, $revId, $role ) {
+ if ( !$this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_NEW ) ) {
+ return null;
+ }
+
+ try {
+ $roleId = $this->slotRoleStore->getId( $role );
+ $conditions = [
+ 'slot_revision_id' => $revId,
+ 'slot_role_id' => $roleId,
+ ];
+
+ $contentId = $db->selectField( 'slots', 'slot_content_id', $conditions, __METHOD__ );
+
+ return $contentId ?: null;
+ } catch ( NameTableAccessException $ex ) {
+ // If the role is missing from the slot_roles table,
+ // the corresponding row in slots cannot exist.
+ return null;
+ }
+ }
+
+ /**
+ * Return the tables, fields, and join conditions to be selected to create
+ * a new RevisionStoreRecord object.
+ *
+ * MCR migration note: this replaces Revision::getQueryInfo
+ *
+ * If the format of fields returned changes in any way then the cache key provided by
+ * self::getRevisionRowCacheKey should be updated.
+ *
+ * @since 1.31
+ *
+ * @param array $options Any combination of the following strings
+ * - 'page': Join with the page table, and select fields to identify the page
+ * - 'user': Join with the user table, and select the user name
+ * - 'text': Join with the text table, and select fields to load page text. This
+ * option is deprecated in MW 1.32 when the MCR migration flag SCHEMA_COMPAT_WRITE_NEW
+ * is set, and disallowed when SCHEMA_COMPAT_READ_OLD is not set.
+ *
+ * @return array With three keys:
+ * - tables: (string[]) to include in the `$table` to `IDatabase->select()`
+ * - fields: (string[]) to include in the `$vars` to `IDatabase->select()`
+ * - joins: (array) to include in the `$join_conds` to `IDatabase->select()`
+ */
+ public function getQueryInfo( $options = [] ) {
+ $ret = [
+ 'tables' => [],
+ 'fields' => [],
+ 'joins' => [],
+ ];
+
+ $ret['tables'][] = 'revision';
+ $ret['fields'] = array_merge( $ret['fields'], [
+ 'rev_id',
+ 'rev_page',
+ 'rev_timestamp',
+ 'rev_minor_edit',
+ 'rev_deleted',
+ 'rev_len',
+ 'rev_parent_id',
+ 'rev_sha1',
+ ] );
+
+ $commentQuery = $this->commentStore->getJoin( 'rev_comment' );
+ $ret['tables'] = array_merge( $ret['tables'], $commentQuery['tables'] );
+ $ret['fields'] = array_merge( $ret['fields'], $commentQuery['fields'] );
+ $ret['joins'] = array_merge( $ret['joins'], $commentQuery['joins'] );
+
+ $actorQuery = $this->actorMigration->getJoin( 'rev_user' );
+ $ret['tables'] = array_merge( $ret['tables'], $actorQuery['tables'] );
+ $ret['fields'] = array_merge( $ret['fields'], $actorQuery['fields'] );
+ $ret['joins'] = array_merge( $ret['joins'], $actorQuery['joins'] );
+
+ if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_OLD ) ) {
+ $ret['fields'][] = 'rev_text_id';
+
+ if ( $this->contentHandlerUseDB ) {
+ $ret['fields'][] = 'rev_content_format';
+ $ret['fields'][] = 'rev_content_model';
+ }
+ }
+
+ if ( in_array( 'page', $options, true ) ) {
+ $ret['tables'][] = 'page';
+ $ret['fields'] = array_merge( $ret['fields'], [
+ 'page_namespace',
+ 'page_title',
+ 'page_id',
+ 'page_latest',
+ 'page_is_redirect',
+ 'page_len',
+ ] );
+ $ret['joins']['page'] = [ 'INNER JOIN', [ 'page_id = rev_page' ] ];
+ }
+
+ if ( in_array( 'user', $options, true ) ) {
+ $ret['tables'][] = 'user';
+ $ret['fields'] = array_merge( $ret['fields'], [
+ 'user_name',
+ ] );
+ $u = $actorQuery['fields']['rev_user'];
+ $ret['joins']['user'] = [ 'LEFT JOIN', [ "$u != 0", "user_id = $u" ] ];
+ }
+
+ if ( in_array( 'text', $options, true ) ) {
+ if ( !$this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_OLD ) ) {
+ throw new InvalidArgumentException( 'text table can no longer be joined directly' );
+ } elseif ( !$this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_OLD ) ) {
+ // NOTE: even when this class is set to not read from the old schema, callers
+ // should still be able to join against the text table, as long as we are still
+ // writing the old schema for compatibility.
+ // TODO: This should trigger a deprecation warning eventually (T200918), but not
+ // before all known usages are removed (see T198341 and T201164).
+ // wfDeprecated( __METHOD__ . ' with `text` option', '1.32' );
+ }
+
+ $ret['tables'][] = 'text';
+ $ret['fields'] = array_merge( $ret['fields'], [
+ 'old_text',
+ 'old_flags'
+ ] );
+ $ret['joins']['text'] = [ 'INNER JOIN', [ 'rev_text_id=old_id' ] ];
+ }
+
+ return $ret;
+ }
+
+ /**
+ * Return the tables, fields, and join conditions to be selected to create
+ * a new SlotRecord.
+ *
+ * @since 1.32
+ *
+ * @param array $options Any combination of the following strings
+ * - 'content': Join with the content table, and select content meta-data fields
+ * - 'model': Join with the content_models table, and select the model_name field.
+ * Only applicable if 'content' is also set.
+ * - 'role': Join with the slot_roles table, and select the role_name field
+ *
+ * @return array With three keys:
+ * - tables: (string[]) to include in the `$table` to `IDatabase->select()`
+ * - fields: (string[]) to include in the `$vars` to `IDatabase->select()`
+ * - joins: (array) to include in the `$join_conds` to `IDatabase->select()`
+ */
+ public function getSlotsQueryInfo( $options = [] ) {
+ $ret = [
+ 'tables' => [],
+ 'fields' => [],
+ 'joins' => [],
+ ];
+
+ if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_OLD ) ) {
+ $db = $this->getDBConnectionRef( DB_REPLICA );
+ $ret['tables']['slots'] = 'revision';
+
+ $ret['fields']['slot_revision_id'] = 'slots.rev_id';
+ $ret['fields']['slot_content_id'] = 'NULL';
+ $ret['fields']['slot_origin'] = 'slots.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_address']
+ = $db->buildConcat( [ $db->addQuotes( 'tt:' ), 'slots.rev_text_id' ] );
+
+ if ( $this->contentHandlerUseDB ) {
+ $ret['fields']['model_name'] = 'slots.rev_content_model';
+ } else {
+ $ret['fields']['model_name'] = 'NULL';
+ }
+ }
+ } else {
+ $ret['tables'][] = 'slots';
+ $ret['fields'] = array_merge( $ret['fields'], [
+ 'slot_revision_id',
+ 'slot_content_id',
+ 'slot_origin',
+ 'slot_role_id',
+ ] );
+
+ if ( in_array( 'role', $options, true ) ) {
+ // Use left join to attach role name, so we still find the revision row even
+ // if the role name is missing. This triggers a more obvious failure mode.
+ $ret['tables'][] = 'slot_roles';
+ $ret['joins']['slot_roles'] = [ 'LEFT JOIN', [ 'slot_role_id = role_id' ] ];
+ $ret['fields'][] = 'role_name';
+ }
+
+ if ( in_array( 'content', $options, true ) ) {
+ $ret['tables'][] = 'content';
+ $ret['fields'] = array_merge( $ret['fields'], [
+ 'content_size',
+ 'content_sha1',
+ 'content_address',
+ 'content_model',
+ ] );
+ $ret['joins']['content'] = [ 'INNER JOIN', [ 'slot_content_id = content_id' ] ];
+
+ if ( in_array( 'model', $options, true ) ) {
+ // Use left join to attach model name, so we still find the revision row even
+ // if the model name is missing. This triggers a more obvious failure mode.
+ $ret['tables'][] = 'content_models';
+ $ret['joins']['content_models'] = [ 'LEFT JOIN', [ 'content_model = model_id' ] ];
+ $ret['fields'][] = 'model_name';
+ }
+
+ }
+ }
+
+ return $ret;
+ }
+
+ /**
+ * Return the tables, fields, and join conditions to be selected to create
+ * a new RevisionArchiveRecord object.
+ *
+ * MCR migration note: this replaces Revision::getArchiveQueryInfo
+ *
+ * @since 1.31
+ *
+ * @return array With three keys:
+ * - tables: (string[]) to include in the `$table` to `IDatabase->select()`
+ * - fields: (string[]) to include in the `$vars` to `IDatabase->select()`
+ * - joins: (array) to include in the `$join_conds` to `IDatabase->select()`
+ */
+ public function getArchiveQueryInfo() {
+ $commentQuery = $this->commentStore->getJoin( 'ar_comment' );
+ $actorQuery = $this->actorMigration->getJoin( 'ar_user' );
+ $ret = [
+ 'tables' => [ 'archive' ] + $commentQuery['tables'] + $actorQuery['tables'],
+ 'fields' => [
+ 'ar_id',
+ 'ar_page_id',
+ 'ar_namespace',
+ 'ar_title',
+ 'ar_rev_id',
+ 'ar_timestamp',
+ 'ar_minor_edit',
+ 'ar_deleted',
+ 'ar_len',
+ 'ar_parent_id',
+ 'ar_sha1',
+ ] + $commentQuery['fields'] + $actorQuery['fields'],
+ 'joins' => $commentQuery['joins'] + $actorQuery['joins'],
+ ];
+
+ if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_OLD ) ) {
+ $ret['fields'][] = 'ar_text_id';
+
+ if ( $this->contentHandlerUseDB ) {
+ $ret['fields'][] = 'ar_content_format';
+ $ret['fields'][] = 'ar_content_model';
+ }
+ }
+
+ return $ret;
+ }
+
+ /**
+ * Do a batched query for the sizes of a set of revisions.
+ *
+ * MCR migration note: this replaces Revision::getParentLengths
+ *
+ * @param int[] $revIds
+ * @return int[] associative array mapping revision IDs from $revIds to the nominal size
+ * of the corresponding revision.
+ */
+ public function getRevisionSizes( array $revIds ) {
+ return $this->listRevisionSizes( $this->getDBConnection( DB_REPLICA ), $revIds );
+ }
+
+ /**
+ * Do a batched query for the sizes of a set of revisions.
+ *
+ * MCR migration note: this replaces Revision::getParentLengths
+ *
+ * @deprecated use RevisionStore::getRevisionSizes instead.
+ *
+ * @param IDatabase $db
+ * @param int[] $revIds
+ * @return int[] associative array mapping revision IDs from $revIds to the nominal size
+ * of the corresponding revision.
+ */
+ public function listRevisionSizes( IDatabase $db, array $revIds ) {
+ $this->checkDatabaseWikiId( $db );
+
+ $revLens = [];
+ if ( !$revIds ) {
+ return $revLens; // empty
+ }
+
+ $res = $db->select(
+ 'revision',
+ [ 'rev_id', 'rev_len' ],
+ [ 'rev_id' => $revIds ],
+ __METHOD__
+ );
+
+ foreach ( $res as $row ) {
+ $revLens[$row->rev_id] = intval( $row->rev_len );
+ }
+
+ return $revLens;
+ }
+
+ /**
+ * Get previous revision for this title
+ *
+ * MCR migration note: this replaces Revision::getPrevious
+ *
+ * @param RevisionRecord $rev
+ * @param Title|null $title if known (optional)
+ *
+ * @return RevisionRecord|null
+ */
+ public function getPreviousRevision( RevisionRecord $rev, Title $title = null ) {
+ if ( $title === null ) {
+ $title = $this->getTitle( $rev->getPageId(), $rev->getId() );
+ }
+ $prev = $title->getPreviousRevisionID( $rev->getId() );
+ if ( $prev ) {
+ return $this->getRevisionByTitle( $title, $prev );
+ }
+ return null;
+ }
+
+ /**
+ * Get next revision for this title
+ *
+ * MCR migration note: this replaces Revision::getNext
+ *
+ * @param RevisionRecord $rev
+ * @param Title|null $title if known (optional)
+ *
+ * @return RevisionRecord|null
+ */
+ public function getNextRevision( RevisionRecord $rev, Title $title = null ) {
+ if ( $title === null ) {
+ $title = $this->getTitle( $rev->getPageId(), $rev->getId() );
+ }
+ $next = $title->getNextRevisionID( $rev->getId() );
+ if ( $next ) {
+ return $this->getRevisionByTitle( $title, $next );
+ }
+ return null;
+ }
+
+ /**
+ * Get previous revision Id for this page_id
+ * This is used to populate rev_parent_id on save
+ *
+ * MCR migration note: this corresponds to Revision::getPreviousRevisionId
+ *
+ * @param IDatabase $db
+ * @param RevisionRecord $rev
+ *
+ * @return int
+ */
+ private function getPreviousRevisionId( IDatabase $db, RevisionRecord $rev ) {
+ $this->checkDatabaseWikiId( $db );
+
+ if ( $rev->getPageId() === null ) {
+ return 0;
+ }
+ # Use page_latest if ID is not given
+ if ( !$rev->getId() ) {
+ $prevId = $db->selectField(
+ 'page', 'page_latest',
+ [ 'page_id' => $rev->getPageId() ],
+ __METHOD__
+ );
+ } else {
+ $prevId = $db->selectField(
+ 'revision', 'rev_id',
+ [ 'rev_page' => $rev->getPageId(), 'rev_id < ' . $rev->getId() ],
+ __METHOD__,
+ [ 'ORDER BY' => 'rev_id DESC' ]
+ );
+ }
+ return intval( $prevId );
+ }
+
+ /**
+ * Get rev_timestamp from rev_id, without loading the rest of the row
+ *
+ * MCR migration note: this replaces Revision::getTimestampFromId
+ *
+ * @param Title $title
+ * @param int $id
+ * @param int $flags
+ * @return string|bool False if not found
+ */
+ public function getTimestampFromId( $title, $id, $flags = 0 ) {
+ $db = $this->getDBConnectionRefForQueryFlags( $flags );
+
+ $conds = [ 'rev_id' => $id ];
+ $conds['rev_page'] = $title->getArticleID();
+ $timestamp = $db->selectField( 'revision', 'rev_timestamp', $conds, __METHOD__ );
+
+ return ( $timestamp !== false ) ? wfTimestamp( TS_MW, $timestamp ) : false;
+ }
+
+ /**
+ * Get count of revisions per page...not very efficient
+ *
+ * MCR migration note: this replaces Revision::countByPageId
+ *
+ * @param IDatabase $db
+ * @param int $id Page id
+ * @return int
+ */
+ public function countRevisionsByPageId( IDatabase $db, $id ) {
+ $this->checkDatabaseWikiId( $db );
+
+ $row = $db->selectRow( 'revision',
+ [ 'revCount' => 'COUNT(*)' ],
+ [ 'rev_page' => $id ],
+ __METHOD__
+ );
+ if ( $row ) {
+ return intval( $row->revCount );
+ }
+ return 0;
+ }
+
+ /**
+ * Get count of revisions per page...not very efficient
+ *
+ * MCR migration note: this replaces Revision::countByTitle
+ *
+ * @param IDatabase $db
+ * @param Title $title
+ * @return int
+ */
+ public function countRevisionsByTitle( IDatabase $db, $title ) {
+ $id = $title->getArticleID();
+ if ( $id ) {
+ return $this->countRevisionsByPageId( $db, $id );
+ }
+ return 0;
+ }
+
+ /**
+ * Check if no edits were made by other users since
+ * the time a user started editing the page. Limit to
+ * 50 revisions for the sake of performance.
+ *
+ * MCR migration note: this replaces Revision::userWasLastToEdit
+ *
+ * @deprecated since 1.31; Can possibly be removed, since the self-conflict suppression
+ * logic in EditPage that uses this seems conceptually dubious. Revision::userWasLastToEdit
+ * has been deprecated since 1.24.
+ *
+ * @param IDatabase $db The Database to perform the check on.
+ * @param int $pageId The ID of the page in question
+ * @param int $userId The ID of the user in question
+ * @param string $since Look at edits since this time
+ *
+ * @return bool True if the given user was the only one to edit since the given timestamp
+ */
+ public function userWasLastToEdit( IDatabase $db, $pageId, $userId, $since ) {
+ $this->checkDatabaseWikiId( $db );
+
+ if ( !$userId ) {
+ return false;
+ }
+
+ $revQuery = $this->getQueryInfo();
+ $res = $db->select(
+ $revQuery['tables'],
+ [
+ 'rev_user' => $revQuery['fields']['rev_user'],
+ ],
+ [
+ 'rev_page' => $pageId,
+ 'rev_timestamp > ' . $db->addQuotes( $db->timestamp( $since ) )
+ ],
+ __METHOD__,
+ [ 'ORDER BY' => 'rev_timestamp ASC', 'LIMIT' => 50 ],
+ $revQuery['joins']
+ );
+ foreach ( $res as $row ) {
+ if ( $row->rev_user != $userId ) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Load a revision based on a known page ID and current revision ID from the DB
+ *
+ * This method allows for the use of caching, though accessing anything that normally
+ * requires permission checks (aside from the text) will trigger a small DB lookup.
+ *
+ * MCR migration note: this replaces Revision::newKnownCurrent
+ *
+ * @param Title $title the associated page title
+ * @param int $revId current revision of this page. Defaults to $title->getLatestRevID().
+ *
+ * @return RevisionRecord|bool Returns false if missing
+ */
+ public function getKnownCurrentRevision( Title $title, $revId ) {
+ $db = $this->getDBConnectionRef( DB_REPLICA );
+
+ $pageId = $title->getArticleID();
+
+ if ( !$pageId ) {
+ return false;
+ }
+
+ if ( !$revId ) {
+ $revId = $title->getLatestRevID();
+ }
+
+ if ( !$revId ) {
+ wfWarn(
+ 'No latest revision known for page ' . $title->getPrefixedDBkey()
+ . ' even though it exists with page ID ' . $pageId
+ );
+ return false;
+ }
+
+ $row = $this->cache->getWithSetCallback(
+ // Page/rev IDs passed in from DB to reflect history merges
+ $this->getRevisionRowCacheKey( $db, $pageId, $revId ),
+ WANObjectCache::TTL_WEEK,
+ function ( $curValue, &$ttl, array &$setOpts ) use ( $db, $pageId, $revId ) {
+ $setOpts += Database::getCacheSetOptions( $db );
+
+ $conds = [
+ 'rev_page' => intval( $pageId ),
+ 'page_id' => intval( $pageId ),
+ 'rev_id' => intval( $revId ),
+ ];
+
+ $row = $this->fetchRevisionRowFromConds( $db, $conds );
+ return $row ?: false; // don't cache negatives
+ }
+ );
+
+ // Reflect revision deletion and user renames
+ if ( $row ) {
+ return $this->newRevisionFromRow( $row, 0, $title );
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Get a cache key for use with a row as selected with getQueryInfo( [ 'page', 'user' ] )
+ * Caching rows without 'page' or 'user' could lead to issues.
+ * If the format of the rows returned by the query provided by getQueryInfo changes the
+ * cache key should be updated to avoid conflicts.
+ *
+ * @param IDatabase $db
+ * @param int $pageId
+ * @param int $revId
+ * @return string
+ */
+ private function getRevisionRowCacheKey( IDatabase $db, $pageId, $revId ) {
+ return $this->cache->makeGlobalKey(
+ self::ROW_CACHE_KEY,
+ $db->getDomainID(),
+ $pageId,
+ $revId
+ );
+ }
+
+ // TODO: move relevant methods from Title here, e.g. getFirstRevision, isBigDeletion, etc.
+
+}
+
+/**
+ * Retain the old class name for backwards compatibility.
+ * @deprecated since 1.32
+ */
+class_alias( RevisionStore::class, 'MediaWiki\Storage\RevisionStore' );
--- /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
+ *
+ * Attribution notice: when this file was created, much of its content was taken
+ * from the Revision.php file as present in release 1.30. Refer to the history
+ * of that file for original authorship.
+ *
+ * @file
+ */
+
+namespace MediaWiki\Revision;
+
+use ActorMigration;
+use CommentStore;
+use MediaWiki\Logger\Spi as LoggerSpi;
+use MediaWiki\Storage\BlobStoreFactory;
+use MediaWiki\Storage\NameTableStoreFactory;
+use WANObjectCache;
+use Wikimedia\Assert\Assert;
+use Wikimedia\Rdbms\ILBFactory;
+
+/**
+ * Factory service for RevisionStore instances. This allows RevisionStores to be created for
+ * cross-wiki access.
+ *
+ * @warning Beware compatibility issues with schema migration in the context of cross-wiki access!
+ * This class assumes that all wikis are at compatible migration stages for all relevant schemas.
+ * Relevant schemas are: revision storage (MCR), the revision comment table, and the actor table.
+ * Migration stages are compatible as long as a) there are no wikis in the cluster that only write
+ * the old schema or b) there are no wikis that read only the new schema.
+ *
+ * @since 1.32
+ */
+class RevisionStoreFactory {
+
+ /** @var BlobStoreFactory */
+ private $blobStoreFactory;
+ /** @var ILBFactory */
+ private $dbLoadBalancerFactory;
+ /** @var WANObjectCache */
+ private $cache;
+ /** @var LoggerSpi */
+ private $loggerProvider;
+
+ /** @var CommentStore */
+ private $commentStore;
+ /** @var ActorMigration */
+ private $actorMigration;
+ /** @var int One of the MIGRATION_* constants */
+ private $mcrMigrationStage;
+ /**
+ * @var bool
+ * @see $wgContentHandlerUseDB
+ */
+ private $contentHandlerUseDB;
+
+ /** @var NameTableStoreFactory */
+ private $nameTables;
+
+ /**
+ * @param ILBFactory $dbLoadBalancerFactory
+ * @param BlobStoreFactory $blobStoreFactory
+ * @param NameTableStoreFactory $nameTables
+ * @param WANObjectCache $cache
+ * @param CommentStore $commentStore
+ * @param ActorMigration $actorMigration
+ * @param int $migrationStage
+ * @param LoggerSpi $loggerProvider
+ * @param bool $contentHandlerUseDB see {@link $wgContentHandlerUseDB}. Must be the same
+ * for all wikis in the cluster. Will go away after MCR migration.
+ */
+ public function __construct(
+ ILBFactory $dbLoadBalancerFactory,
+ BlobStoreFactory $blobStoreFactory,
+ NameTableStoreFactory $nameTables,
+ WANObjectCache $cache,
+ CommentStore $commentStore,
+ ActorMigration $actorMigration,
+ $migrationStage,
+ LoggerSpi $loggerProvider,
+ $contentHandlerUseDB
+ ) {
+ Assert::parameterType( 'integer', $migrationStage, '$migrationStage' );
+ $this->dbLoadBalancerFactory = $dbLoadBalancerFactory;
+ $this->blobStoreFactory = $blobStoreFactory;
+ $this->nameTables = $nameTables;
+ $this->cache = $cache;
+ $this->commentStore = $commentStore;
+ $this->actorMigration = $actorMigration;
+ $this->mcrMigrationStage = $migrationStage;
+ $this->loggerProvider = $loggerProvider;
+ $this->contentHandlerUseDB = $contentHandlerUseDB;
+ }
+
+ /**
+ * @since 1.32
+ *
+ * @param bool|string $wikiId false for the current domain / wikid
+ *
+ * @return RevisionStore for the given wikiId with all necessary services and a logger
+ */
+ public function getRevisionStore( $wikiId = false ) {
+ Assert::parameterType( 'string|boolean', $wikiId, '$wikiId' );
+
+ $store = new RevisionStore(
+ $this->dbLoadBalancerFactory->getMainLB( $wikiId ),
+ $this->blobStoreFactory->newSqlBlobStore( $wikiId ),
+ $this->cache, // Pass local cache instance; Leave cache sharing to RevisionStore.
+ $this->commentStore,
+ $this->nameTables->getContentModels( $wikiId ),
+ $this->nameTables->getSlotRoles( $wikiId ),
+ $this->mcrMigrationStage,
+ $this->actorMigration,
+ $wikiId
+ );
+
+ $store->setLogger( $this->loggerProvider->getLogger( 'RevisionStore' ) );
+ $store->setContentHandlerUseDB( $this->contentHandlerUseDB );
+
+ return $store;
+ }
+}
--- /dev/null
+<?php
+/**
+ * A RevisionRecord representing an existing revision persisted in the revision table.
+ *
+ * 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
+ */
+
+namespace MediaWiki\Revision;
+
+use CommentStoreComment;
+use InvalidArgumentException;
+use MediaWiki\User\UserIdentity;
+use Title;
+use User;
+use Wikimedia\Assert\Assert;
+
+/**
+ * A RevisionRecord representing an existing revision persisted in the revision table.
+ * RevisionStoreRecord has no optional fields, getters will never return null.
+ *
+ * @since 1.31
+ * @since 1.32 Renamed from MediaWiki\Storage\RevisionStoreRecord
+ */
+class RevisionStoreRecord extends RevisionRecord {
+
+ /** @var bool */
+ protected $mCurrent = false;
+
+ /**
+ * @note Avoid calling this constructor directly. Use the appropriate methods
+ * in RevisionStore instead.
+ *
+ * @param Title $title The title of the page this Revision is associated with.
+ * @param UserIdentity $user
+ * @param CommentStoreComment $comment
+ * @param object $row A row from the revision table. Use RevisionStore::getQueryInfo() to build
+ * a query that yields the required fields.
+ * @param RevisionSlots $slots The slots of this revision.
+ * @param bool|string $wikiId the wiki ID of the site this Revision belongs to,
+ * or false for the local site.
+ */
+ function __construct(
+ Title $title,
+ UserIdentity $user,
+ CommentStoreComment $comment,
+ $row,
+ RevisionSlots $slots,
+ $wikiId = false
+ ) {
+ parent::__construct( $title, $slots, $wikiId );
+ Assert::parameterType( 'object', $row, '$row' );
+
+ $this->mId = intval( $row->rev_id );
+ $this->mPageId = intval( $row->rev_page );
+ $this->mComment = $comment;
+
+ $timestamp = wfTimestamp( TS_MW, $row->rev_timestamp );
+ Assert::parameter( is_string( $timestamp ), '$row->rev_timestamp', 'must be a valid timestamp' );
+
+ $this->mUser = $user;
+ $this->mMinorEdit = boolval( $row->rev_minor_edit );
+ $this->mTimestamp = $timestamp;
+ $this->mDeleted = intval( $row->rev_deleted );
+
+ // NOTE: rev_parent_id = 0 indicates that there is no parent revision, while null
+ // indicates that the parent revision is unknown. As per MW 1.31, the database schema
+ // allows rev_parent_id to be NULL.
+ $this->mParentId = isset( $row->rev_parent_id ) ? intval( $row->rev_parent_id ) : null;
+ $this->mSize = isset( $row->rev_len ) ? intval( $row->rev_len ) : null;
+ $this->mSha1 = !empty( $row->rev_sha1 ) ? $row->rev_sha1 : null;
+
+ // NOTE: we must not call $this->mTitle->getLatestRevID() here, since the state of
+ // page_latest may be in limbo during revision creation. In that case, calling
+ // $this->mTitle->getLatestRevID() would cause a bad value to be cached in the Title
+ // object. During page creation, that bad value would be 0.
+ if ( isset( $row->page_latest ) ) {
+ $this->mCurrent = ( $row->rev_id == $row->page_latest );
+ }
+
+ // sanity check
+ if (
+ $this->mPageId && $this->mTitle->exists()
+ && $this->mPageId !== $this->mTitle->getArticleID()
+ ) {
+ throw new InvalidArgumentException(
+ 'The given Title does not belong to page ID ' . $this->mPageId .
+ ' but actually belongs to ' . $this->mTitle->getArticleID()
+ );
+ }
+ }
+
+ /**
+ * MCR migration note: this replaces Revision::isCurrent
+ *
+ * @return bool
+ */
+ public function isCurrent() {
+ return $this->mCurrent;
+ }
+
+ /**
+ * MCR migration note: this replaces Revision::isDeleted
+ *
+ * @param int $field One of DELETED_* bitfield constants
+ *
+ * @return bool
+ */
+ public function isDeleted( $field ) {
+ if ( $this->isCurrent() && $field === self::DELETED_TEXT ) {
+ // Current revisions of pages cannot have the content hidden. Skipping this
+ // check is very useful for Parser as it fetches templates using newKnownCurrent().
+ // Calling getVisibility() in that case triggers a verification database query.
+ return false; // no need to check
+ }
+
+ return parent::isDeleted( $field );
+ }
+
+ protected function userCan( $field, User $user ) {
+ if ( $this->isCurrent() && $field === self::DELETED_TEXT ) {
+ // Current revisions of pages cannot have the content hidden. Skipping this
+ // check is very useful for Parser as it fetches templates using newKnownCurrent().
+ // Calling getVisibility() in that case triggers a verification database query.
+ return true; // no need to check
+ }
+
+ return parent::userCan( $field, $user );
+ }
+
+ /**
+ * @return int The revision id, never null.
+ */
+ public function getId() {
+ // overwritten just to add a guarantee to the contract
+ return parent::getId();
+ }
+
+ /**
+ * @throws RevisionAccessException if the size was unknown and could not be calculated.
+ * @return string The nominal revision size, never null. May be computed on the fly.
+ */
+ public function getSize() {
+ // If length is null, calculate and remember it (potentially SLOW!).
+ // This is for compatibility with old database rows that don't have the field set.
+ if ( $this->mSize === null ) {
+ $this->mSize = $this->mSlots->computeSize();
+ }
+
+ return $this->mSize;
+ }
+
+ /**
+ * @throws RevisionAccessException if the hash was unknown and could not be calculated.
+ * @return string The revision hash, never null. May be computed on the fly.
+ */
+ public function getSha1() {
+ // If hash is null, calculate it and remember (potentially SLOW!)
+ // This is for compatibility with old database rows that don't have the field set.
+ if ( $this->mSha1 === null ) {
+ $this->mSha1 = $this->mSlots->computeSha1();
+ }
+
+ return $this->mSha1;
+ }
+
+ /**
+ * @param int $audience
+ * @param User|null $user
+ *
+ * @return UserIdentity The identity of the revision author, null if access is forbidden.
+ */
+ public function getUser( $audience = self::FOR_PUBLIC, User $user = null ) {
+ // overwritten just to add a guarantee to the contract
+ return parent::getUser( $audience, $user );
+ }
+
+ /**
+ * @param int $audience
+ * @param User|null $user
+ *
+ * @return CommentStoreComment The revision comment, null if access is forbidden.
+ */
+ public function getComment( $audience = self::FOR_PUBLIC, User $user = null ) {
+ // overwritten just to add a guarantee to the contract
+ return parent::getComment( $audience, $user );
+ }
+
+ /**
+ * @return string timestamp, never null
+ */
+ public function getTimestamp() {
+ // overwritten just to add a guarantee to the contract
+ return parent::getTimestamp();
+ }
+
+ /**
+ * @see RevisionStore::isComplete
+ *
+ * @return bool always true.
+ */
+ public function isReadyForInsertion() {
+ return true;
+ }
+
+}
+
+/**
+ * Retain the old class name for backwards compatibility.
+ * @deprecated since 1.32
+ */
+class_alias( RevisionStoreRecord::class, 'MediaWiki\Storage\RevisionStoreRecord' );
--- /dev/null
+<?php
+/**
+ * Value object representing a content slot associated with a page revision.
+ *
+ * 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
+ */
+
+namespace MediaWiki\Revision;
+
+use Content;
+use InvalidArgumentException;
+use LogicException;
+use OutOfBoundsException;
+use Wikimedia\Assert\Assert;
+
+/**
+ * Value object representing a content slot associated with a page revision.
+ * SlotRecord provides direct access to a Content object.
+ * That access may be implemented through a callback.
+ *
+ * @since 1.31
+ * @since 1.32 Renamed from MediaWiki\Storage\SlotRecord
+ */
+class SlotRecord {
+
+ const MAIN = 'main';
+
+ /**
+ * @var object database result row, as a raw object. Callbacks are supported for field values,
+ * to enable on-demand emulation of these values. This is primarily intended for use
+ * during schema migration.
+ */
+ private $row;
+
+ /**
+ * @var Content|callable
+ */
+ private $content;
+
+ /**
+ * Returns a new SlotRecord just like the given $slot, except that calling getContent()
+ * will fail with an exception.
+ *
+ * @param SlotRecord $slot
+ *
+ * @return SlotRecord
+ */
+ public static function newWithSuppressedContent( SlotRecord $slot ) {
+ $row = $slot->row;
+
+ return new SlotRecord( $row, function () {
+ throw new SuppressedDataException( 'Content suppressed!' );
+ } );
+ }
+
+ /**
+ * Constructs a new SlotRecord from an existing SlotRecord, overriding some fields.
+ * The slot's content cannot be overwritten.
+ *
+ * @param SlotRecord $slot
+ * @param array $overrides
+ *
+ * @return SlotRecord
+ */
+ private static function newDerived( SlotRecord $slot, array $overrides = [] ) {
+ $row = clone $slot->row;
+ $row->slot_id = null; // never copy the row ID!
+
+ foreach ( $overrides as $key => $value ) {
+ $row->$key = $value;
+ }
+
+ return new SlotRecord( $row, $slot->content );
+ }
+
+ /**
+ * Constructs a new SlotRecord for a new revision, inheriting the content of the given SlotRecord
+ * of a previous revision.
+ *
+ * Note that a SlotRecord constructed this way are intended as prototypes,
+ * to be used wit newSaved(). They are incomplete, so some getters such as
+ * getRevision() will fail.
+ *
+ * @param SlotRecord $slot
+ *
+ * @return SlotRecord
+ */
+ public static function newInherited( SlotRecord $slot ) {
+ // Sanity check - we can't inherit from a Slot that's not attached to a revision.
+ $slot->getRevision();
+ $slot->getOrigin();
+ $slot->getAddress();
+
+ // NOTE: slot_origin and content_address are copied from $slot.
+ return self::newDerived( $slot, [
+ 'slot_revision_id' => null,
+ ] );
+ }
+
+ /**
+ * Constructs a new Slot from a Content object for a new revision.
+ * This is the preferred way to construct a slot for storing Content that
+ * resulted from a user edit. The slot is assumed to be not inherited.
+ *
+ * Note that a SlotRecord constructed this way are intended as prototypes,
+ * to be used wit newSaved(). They are incomplete, so some getters such as
+ * getAddress() will fail.
+ *
+ * @param string $role
+ * @param Content $content
+ *
+ * @return SlotRecord An incomplete proto-slot object, to be used with newSaved() later.
+ */
+ public static function newUnsaved( $role, Content $content ) {
+ Assert::parameterType( 'string', $role, '$role' );
+
+ $row = [
+ 'slot_id' => null, // not yet known
+ 'slot_revision_id' => null, // not yet known
+ 'slot_origin' => null, // not yet known, will be set in newSaved()
+ 'content_size' => null, // compute later
+ 'content_sha1' => null, // compute later
+ 'slot_content_id' => null, // not yet known, will be set in newSaved()
+ 'content_address' => null, // not yet known, will be set in newSaved()
+ 'role_name' => $role,
+ 'model_name' => $content->getModel(),
+ ];
+
+ return new SlotRecord( (object)$row, $content );
+ }
+
+ /**
+ * Constructs a complete SlotRecord for a newly saved revision, based on the incomplete
+ * proto-slot. This adds information that has only become available during saving,
+ * particularly the revision ID, content ID and content address.
+ *
+ * @param int $revisionId the revision the slot is to be associated with (field slot_revision_id).
+ * If $protoSlot already has a revision, it must be the same.
+ * @param int|null $contentId the ID of the row in the content table describing the content
+ * referenced by $contentAddress (field slot_content_id).
+ * If $protoSlot already has a content ID, it must be the same.
+ * @param string $contentAddress the slot's content address (field content_address).
+ * If $protoSlot already has an address, it must be the same.
+ * @param SlotRecord $protoSlot The proto-slot that was provided as input for creating a new
+ * revision. $protoSlot must have a content address if inherited.
+ *
+ * @return SlotRecord If the state of $protoSlot is inappropriate for saving a new revision.
+ */
+ public static function newSaved(
+ $revisionId,
+ $contentId,
+ $contentAddress,
+ SlotRecord $protoSlot
+ ) {
+ Assert::parameterType( 'integer', $revisionId, '$revisionId' );
+ // TODO once migration is over $contentId must be an integer
+ Assert::parameterType( 'integer|null', $contentId, '$contentId' );
+ Assert::parameterType( 'string', $contentAddress, '$contentAddress' );
+
+ if ( $protoSlot->hasRevision() && $protoSlot->getRevision() !== $revisionId ) {
+ throw new LogicException(
+ "Mismatching revision ID $revisionId: "
+ . "The slot already belongs to revision {$protoSlot->getRevision()}. "
+ . "Use SlotRecord::newInherited() to re-use content between revisions."
+ );
+ }
+
+ if ( $protoSlot->hasAddress() && $protoSlot->getAddress() !== $contentAddress ) {
+ throw new LogicException(
+ "Mismatching blob address $contentAddress: "
+ . "The slot already has content at {$protoSlot->getAddress()}."
+ );
+ }
+
+ if ( $protoSlot->hasContentId() && $protoSlot->getContentId() !== $contentId ) {
+ throw new LogicException(
+ "Mismatching content ID $contentId: "
+ . "The slot already has content row {$protoSlot->getContentId()} associated."
+ );
+ }
+
+ if ( $protoSlot->isInherited() ) {
+ if ( !$protoSlot->hasAddress() ) {
+ throw new InvalidArgumentException(
+ "An inherited blob should have a content address!"
+ );
+ }
+ if ( !$protoSlot->hasField( 'slot_origin' ) ) {
+ throw new InvalidArgumentException(
+ "A saved inherited slot should have an origin set!"
+ );
+ }
+ $origin = $protoSlot->getOrigin();
+ } else {
+ $origin = $revisionId;
+ }
+
+ return self::newDerived( $protoSlot, [
+ 'slot_revision_id' => $revisionId,
+ 'slot_content_id' => $contentId,
+ 'slot_origin' => $origin,
+ 'content_address' => $contentAddress,
+ ] );
+ }
+
+ /**
+ * SlotRecord constructor.
+ *
+ * The following fields are supported by the $row parameter:
+ *
+ * $row->blob_data
+ * $row->blob_address
+ *
+ * @param object $row A database row composed of fields of the slot and content tables,
+ * as a raw object. Any field value can be a callback that produces the field value
+ * given this SlotRecord as a parameter. However, plain strings cannot be used as
+ * callbacks here, for security reasons.
+ * @param Content|callable $content The content object associated with the slot, or a
+ * callback that will return that Content object, given this SlotRecord as a parameter.
+ */
+ public function __construct( $row, $content ) {
+ Assert::parameterType( 'object', $row, '$row' );
+ Assert::parameterType( 'Content|callable', $content, '$content' );
+
+ Assert::parameter(
+ property_exists( $row, 'slot_revision_id' ),
+ '$row->slot_revision_id',
+ 'must exist'
+ );
+ Assert::parameter(
+ property_exists( $row, 'slot_content_id' ),
+ '$row->slot_content_id',
+ 'must exist'
+ );
+ Assert::parameter(
+ property_exists( $row, 'content_address' ),
+ '$row->content_address',
+ 'must exist'
+ );
+ Assert::parameter(
+ property_exists( $row, 'model_name' ),
+ '$row->model_name',
+ 'must exist'
+ );
+ Assert::parameter(
+ property_exists( $row, 'slot_origin' ),
+ '$row->slot_origin',
+ 'must exist'
+ );
+ Assert::parameter(
+ !property_exists( $row, 'slot_inherited' ),
+ '$row->slot_inherited',
+ 'must not exist'
+ );
+ Assert::parameter(
+ !property_exists( $row, 'slot_revision' ),
+ '$row->slot_revision',
+ 'must not exist'
+ );
+
+ $this->row = $row;
+ $this->content = $content;
+ }
+
+ /**
+ * Implemented to defy serialization.
+ *
+ * @throws LogicException always
+ */
+ public function __sleep() {
+ throw new LogicException( __CLASS__ . ' is not serializable.' );
+ }
+
+ /**
+ * Returns the Content of the given slot.
+ *
+ * @note This is free to load Content from whatever subsystem is necessary,
+ * performing potentially expensive operations and triggering I/O-related
+ * failure modes.
+ *
+ * @note This method does not apply audience filtering.
+ *
+ * @throws SuppressedDataException if access to the content is not allowed according
+ * to the audience check performed by RevisionRecord::getSlot().
+ *
+ * @return Content The slot's content. This is a direct reference to the internal instance,
+ * copy before exposing to application logic!
+ */
+ public function getContent() {
+ if ( $this->content instanceof Content ) {
+ return $this->content;
+ }
+
+ $obj = call_user_func( $this->content, $this );
+
+ Assert::postcondition(
+ $obj instanceof Content,
+ 'Slot content callback should return a Content object'
+ );
+
+ $this->content = $obj;
+
+ return $this->content;
+ }
+
+ /**
+ * Returns the string value of a data field from the database row supplied to the constructor.
+ * If the field was set to a callback, that callback is invoked and the result returned.
+ *
+ * @param string $name
+ *
+ * @throws OutOfBoundsException
+ * @throws IncompleteRevisionException
+ * @return mixed Returns the field's value, never null.
+ */
+ private function getField( $name ) {
+ if ( !isset( $this->row->$name ) ) {
+ // distinguish between unknown and uninitialized fields
+ if ( property_exists( $this->row, $name ) ) {
+ throw new IncompleteRevisionException( 'Uninitialized field: ' . $name );
+ } else {
+ throw new OutOfBoundsException( 'No such field: ' . $name );
+ }
+ }
+
+ $value = $this->row->$name;
+
+ // NOTE: allow callbacks, but don't trust plain string callables from the database!
+ if ( !is_string( $value ) && is_callable( $value ) ) {
+ $value = call_user_func( $value, $this );
+ $this->setField( $name, $value );
+ }
+
+ return $value;
+ }
+
+ /**
+ * Returns the string value of a data field from the database row supplied to the constructor.
+ *
+ * @param string $name
+ *
+ * @throws OutOfBoundsException
+ * @throws IncompleteRevisionException
+ * @return string Returns the string value
+ */
+ private function getStringField( $name ) {
+ return strval( $this->getField( $name ) );
+ }
+
+ /**
+ * Returns the int value of a data field from the database row supplied to the constructor.
+ *
+ * @param string $name
+ *
+ * @throws OutOfBoundsException
+ * @throws IncompleteRevisionException
+ * @return int Returns the int value
+ */
+ private function getIntField( $name ) {
+ return intval( $this->getField( $name ) );
+ }
+
+ /**
+ * @param string $name
+ * @return bool whether this record contains the given field
+ */
+ private function hasField( $name ) {
+ if ( isset( $this->row->$name ) ) {
+ // if the field is a callback, resolve first, then re-check
+ if ( !is_string( $this->row->$name ) && is_callable( $this->row->$name ) ) {
+ $this->getField( $name );
+ }
+ }
+
+ return isset( $this->row->$name );
+ }
+
+ /**
+ * Returns the ID of the revision this slot is associated with.
+ *
+ * @return int
+ */
+ public function getRevision() {
+ return $this->getIntField( 'slot_revision_id' );
+ }
+
+ /**
+ * Returns the revision ID of the revision that originated the slot's content.
+ *
+ * @return int
+ */
+ public function getOrigin() {
+ return $this->getIntField( 'slot_origin' );
+ }
+
+ /**
+ * Whether this slot was inherited from an older revision.
+ *
+ * If this SlotRecord is already attached to a revision, this returns true
+ * if the slot's revision of origin is the same as the revision it belongs to.
+ *
+ * If this SlotRecord is not yet attached to a revision, this returns true
+ * if the slot already has an address.
+ *
+ * @return bool
+ */
+ public function isInherited() {
+ if ( $this->hasRevision() ) {
+ return $this->getRevision() !== $this->getOrigin();
+ } else {
+ return $this->hasAddress();
+ }
+ }
+
+ /**
+ * Whether this slot has an address. Slots will have an address if their
+ * content has been stored. While building a new revision,
+ * SlotRecords will not have an address associated.
+ *
+ * @return bool
+ */
+ public function hasAddress() {
+ return $this->hasField( 'content_address' );
+ }
+
+ /**
+ * Whether this slot has an origin (revision ID that originated the slot's content.
+ *
+ * @since 1.32
+ *
+ * @return bool
+ */
+ public function hasOrigin() {
+ return $this->hasField( 'slot_origin' );
+ }
+
+ /**
+ * Whether this slot has a content ID. Slots will have a content ID if their
+ * content has been stored in the content table. While building a new revision,
+ * SlotRecords will not have an ID associated.
+ *
+ * Also, during schema migration, hasContentId() may return false when encountering an
+ * un-migrated database entry in SCHEMA_COMPAT_WRITE_BOTH mode.
+ * It will however always return true for saved revisions on SCHEMA_COMPAT_READ_NEW mode,
+ * or without SCHEMA_COMPAT_WRITE_NEW mode. In the latter case, an emulated content ID
+ * is used, derived from the revision's text ID.
+ *
+ * Note that hasContentId() returning false while hasRevision() returns true always
+ * indicates an unmigrated row in SCHEMA_COMPAT_WRITE_BOTH mode, as described above.
+ * For an unsaved slot, both these methods would return false.
+ *
+ * @since 1.32
+ *
+ * @return bool
+ */
+ public function hasContentId() {
+ return $this->hasField( 'slot_content_id' );
+ }
+
+ /**
+ * Whether this slot has revision ID associated. Slots will have a revision ID associated
+ * only if they were loaded as part of an existing revision. While building a new revision,
+ * Slotrecords will not have a revision ID associated.
+ *
+ * @return bool
+ */
+ public function hasRevision() {
+ return $this->hasField( 'slot_revision_id' );
+ }
+
+ /**
+ * Returns the role of the slot.
+ *
+ * @return string
+ */
+ public function getRole() {
+ return $this->getStringField( 'role_name' );
+ }
+
+ /**
+ * Returns the address of this slot's content.
+ * This address can be used with BlobStore to load the Content object.
+ *
+ * @return string
+ */
+ public function getAddress() {
+ return $this->getStringField( 'content_address' );
+ }
+
+ /**
+ * Returns the ID of the content meta data row associated with the slot.
+ * This information should be irrelevant to application logic, it is here to allow
+ * the construction of a full row for the revision table.
+ *
+ * Note that this method may return an emulated value during schema migration in
+ * SCHEMA_COMPAT_WRITE_OLD mode. See RevisionStore::emulateContentId for more information.
+ *
+ * @return int
+ */
+ public function getContentId() {
+ return $this->getIntField( 'slot_content_id' );
+ }
+
+ /**
+ * Returns the content size
+ *
+ * @return int size of the content, in bogo-bytes, as reported by Content::getSize.
+ */
+ public function getSize() {
+ try {
+ $size = $this->getIntField( 'content_size' );
+ } catch ( IncompleteRevisionException $ex ) {
+ $size = $this->getContent()->getSize();
+ $this->setField( 'content_size', $size );
+ }
+
+ return $size;
+ }
+
+ /**
+ * Returns the content size
+ *
+ * @return string hash of the content.
+ */
+ public function getSha1() {
+ try {
+ $sha1 = $this->getStringField( 'content_sha1' );
+ } catch ( IncompleteRevisionException $ex ) {
+ $format = $this->hasField( 'format_name' )
+ ? $this->getStringField( 'format_name' )
+ : null;
+
+ $data = $this->getContent()->serialize( $format );
+ $sha1 = self::base36Sha1( $data );
+ $this->setField( 'content_sha1', $sha1 );
+ }
+
+ return $sha1;
+ }
+
+ /**
+ * Returns the content model. This is the model name that decides
+ * which ContentHandler is appropriate for interpreting the
+ * data of the blob referenced by the address returned by getAddress().
+ *
+ * @return string the content model of the content
+ */
+ public function getModel() {
+ try {
+ $model = $this->getStringField( 'model_name' );
+ } catch ( IncompleteRevisionException $ex ) {
+ $model = $this->getContent()->getModel();
+ $this->setField( 'model_name', $model );
+ }
+
+ return $model;
+ }
+
+ /**
+ * Returns the blob serialization format as a MIME type.
+ *
+ * @note When this method returns null, the caller is expected
+ * to auto-detect the serialization format, or to rely on
+ * the default format associated with the content model.
+ *
+ * @return string|null
+ */
+ public function getFormat() {
+ // XXX: we currently do not plan to store the format for each slot!
+
+ if ( $this->hasField( 'format_name' ) ) {
+ return $this->getStringField( 'format_name' );
+ }
+
+ return null;
+ }
+
+ /**
+ * @param string $name
+ * @param string|int|null $value
+ */
+ private function setField( $name, $value ) {
+ $this->row->$name = $value;
+ }
+
+ /**
+ * Get the base 36 SHA-1 value for a string of text
+ *
+ * MCR migration note: this replaces Revision::base36Sha1
+ *
+ * @param string $blob
+ * @return string
+ */
+ public static function base36Sha1( $blob ) {
+ return \Wikimedia\base_convert( sha1( $blob ), 16, 36, 31 );
+ }
+
+ /**
+ * Returns true if $other has the same content as this slot.
+ * The check is performed based on the model, address size, and hash.
+ * Two slots can have the same content if they use different content addresses,
+ * but if they have the same address and the same model, they have the same content.
+ * Two slots can have the same content if they belong to different
+ * revisions or pages.
+ *
+ * Note that hasSameContent() may return false even if Content::equals returns true for
+ * the content of two slots. This may happen if the two slots have different serializations
+ * representing equivalent Content. Such false negatives are considered acceptable. Code
+ * that has to be absolutely sure the Content is really not the same if hasSameContent()
+ * returns false should call getContent() and compare the Content objects directly.
+ *
+ * @since 1.32
+ *
+ * @param SlotRecord $other
+ * @return bool
+ */
+ public function hasSameContent( SlotRecord $other ) {
+ if ( $other === $this ) {
+ return true;
+ }
+
+ if ( $this->getModel() !== $other->getModel() ) {
+ return false;
+ }
+
+ if ( $this->hasAddress()
+ && $other->hasAddress()
+ && $this->getAddress() == $other->getAddress()
+ ) {
+ return true;
+ }
+
+ if ( $this->getSize() !== $other->getSize() ) {
+ return false;
+ }
+
+ if ( $this->getSha1() !== $other->getSha1() ) {
+ return false;
+ }
+
+ return true;
+ }
+
+}
+
+/**
+ * Retain the old class name for backwards compatibility.
+ * @deprecated since 1.32
+ */
+class_alias( SlotRecord::class, 'MediaWiki\Storage\SlotRecord' );
*/
namespace MediaWiki\Revision;
-use MediaWiki\Storage\SuppressedDataException;
use ParserOutput;
/**
--- /dev/null
+<?php
+/**
+ * Exception representing a failure to look up a revision.
+ *
+ * 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
+ */
+
+namespace MediaWiki\Revision;
+
+/**
+ * Exception raised in response to an audience check when attempting to
+ * access suppressed information without permission.
+ *
+ * @since 1.31
+ * @since 1.32 Renamed from MediaWiki\Storage\SuppressedDataException
+ */
+class SuppressedDataException extends RevisionAccessException {
+
+}
+
+/**
+ * Retain the old class name for backwards compatibility.
+ * @deprecated since 1.32
+ */
+class_alias( SuppressedDataException::class, 'MediaWiki\Storage\SuppressedDataException' );
use MediaWiki\MediaWikiServices;
use MediaWiki\Preferences\PreferencesFactory;
use MediaWiki\Preferences\DefaultPreferencesFactory;
+use MediaWiki\Revision\RevisionFactory;
+use MediaWiki\Revision\RevisionLookup;
+use MediaWiki\Revision\RevisionRenderer;
+use MediaWiki\Revision\RevisionStore;
+use MediaWiki\Revision\RevisionStoreFactory;
use MediaWiki\Shell\CommandFactory;
use MediaWiki\Special\SpecialPageFactory;
use MediaWiki\Storage\BlobStore;
-use MediaWiki\Revision\RevisionRenderer;
use MediaWiki\Storage\BlobStoreFactory;
use MediaWiki\Storage\NameTableStoreFactory;
-use MediaWiki\Storage\RevisionFactory;
-use MediaWiki\Storage\RevisionLookup;
-use MediaWiki\Storage\RevisionStore;
-use MediaWiki\Storage\RevisionStoreFactory;
use MediaWiki\Storage\SqlBlobStore;
return [
use LinksUpdate;
use LogicException;
use MediaWiki\Edit\PreparedEdit;
+use MediaWiki\Revision\MutableRevisionRecord;
use MediaWiki\Revision\RenderedRevision;
+use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\RevisionRenderer;
+use MediaWiki\Revision\RevisionSlots;
+use MediaWiki\Revision\RevisionStore;
+use MediaWiki\Revision\SlotRecord;
use MediaWiki\User\UserIdentity;
use MessageCache;
use ParserCache;
+++ /dev/null
-<?php
-/**
- * Exception representing a failure to look up a revision.
- *
- * 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
- */
-
-namespace MediaWiki\Storage;
-
-/**
- * Exception throw when trying to access undefined fields on an incomplete RevisionRecord.
- *
- * @since 1.31
- */
-class IncompleteRevisionException extends RevisionAccessException {
-
-}
+++ /dev/null
-<?php
-/**
- * Mutable RevisionRecord implementation, for building new revision entries programmatically.
- *
- * 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
- */
-
-namespace MediaWiki\Storage;
-
-use CommentStoreComment;
-use Content;
-use InvalidArgumentException;
-use MediaWiki\User\UserIdentity;
-use MWException;
-use Title;
-use Wikimedia\Assert\Assert;
-
-/**
- * Mutable RevisionRecord implementation, for building new revision entries programmatically.
- * Provides setters for all fields.
- *
- * @since 1.31
- */
-class MutableRevisionRecord extends RevisionRecord {
-
- /**
- * Returns an incomplete MutableRevisionRecord which uses $parent as its
- * parent revision, and inherits all slots form it. If saved unchanged,
- * the new revision will act as a null-revision.
- *
- * @param RevisionRecord $parent
- *
- * @return MutableRevisionRecord
- */
- public static function newFromParentRevision( RevisionRecord $parent ) {
- // TODO: ideally, we wouldn't need a Title here
- $title = Title::newFromLinkTarget( $parent->getPageAsLinkTarget() );
- $rev = new MutableRevisionRecord( $title, $parent->getWikiId() );
-
- foreach ( $parent->getSlotRoles() as $role ) {
- $slot = $parent->getSlot( $role, self::RAW );
- $rev->inheritSlot( $slot );
- }
-
- $rev->setPageId( $parent->getPageId() );
- $rev->setParentId( $parent->getId() );
-
- return $rev;
- }
-
- /**
- * @note Avoid calling this constructor directly. Use the appropriate methods
- * in RevisionStore instead.
- *
- * @param Title $title The title of the page this Revision is associated with.
- * @param bool|string $wikiId the wiki ID of the site this Revision belongs to,
- * or false for the local site.
- *
- * @throws MWException
- */
- function __construct( Title $title, $wikiId = false ) {
- $slots = new MutableRevisionSlots();
-
- parent::__construct( $title, $slots, $wikiId );
-
- $this->mSlots = $slots; // redundant, but nice for static analysis
- }
-
- /**
- * @param int $parentId
- */
- public function setParentId( $parentId ) {
- Assert::parameterType( 'integer', $parentId, '$parentId' );
-
- $this->mParentId = $parentId;
- }
-
- /**
- * Sets the given slot. If a slot with the same role is already present in the revision,
- * it is replaced.
- *
- * @note This can only be used with a fresh "unattached" SlotRecord. Calling code that has a
- * SlotRecord from another revision should use inheritSlot(). Calling code that has access to
- * a Content object can use setContent().
- *
- * @note This may cause the slot meta-data for the revision to be lazy-loaded.
- *
- * @note Calling this method will cause the revision size and hash to be re-calculated upon
- * the next call to getSize() and getSha1(), respectively.
- *
- * @param SlotRecord $slot
- */
- public function setSlot( SlotRecord $slot ) {
- if ( $slot->hasRevision() && $slot->getRevision() !== $this->getId() ) {
- throw new InvalidArgumentException(
- 'The given slot must be an unsaved, unattached one. '
- . 'This slot is already attached to revision ' . $slot->getRevision() . '. '
- . 'Use inheritSlot() instead to preserve a slot from a previous revision.'
- );
- }
-
- $this->mSlots->setSlot( $slot );
- $this->resetAggregateValues();
- }
-
- /**
- * "Inherits" the given slot's content.
- *
- * If a slot with the same role is already present in the revision, it is replaced.
- *
- * @note This may cause the slot meta-data for the revision to be lazy-loaded.
- *
- * @param SlotRecord $parentSlot
- */
- public function inheritSlot( SlotRecord $parentSlot ) {
- $this->mSlots->inheritSlot( $parentSlot );
- $this->resetAggregateValues();
- }
-
- /**
- * Sets the content for the slot with the given role.
- *
- * If a slot with the same role is already present in the revision, it is replaced.
- * Calling code that has access to a SlotRecord can use inheritSlot() instead.
- *
- * @note This may cause the slot meta-data for the revision to be lazy-loaded.
- *
- * @note Calling this method will cause the revision size and hash to be re-calculated upon
- * the next call to getSize() and getSha1(), respectively.
- *
- * @param string $role
- * @param Content $content
- */
- public function setContent( $role, Content $content ) {
- $this->mSlots->setContent( $role, $content );
- $this->resetAggregateValues();
- }
-
- /**
- * Removes the slot with the given role from this revision.
- * This effectively ends the "stream" with that role on the revision's page.
- * Future revisions will no longer inherit this slot, unless it is added back explicitly.
- *
- * @note This may cause the slot meta-data for the revision to be lazy-loaded.
- *
- * @note Calling this method will cause the revision size and hash to be re-calculated upon
- * the next call to getSize() and getSha1(), respectively.
- *
- * @param string $role
- */
- public function removeSlot( $role ) {
- $this->mSlots->removeSlot( $role );
- $this->resetAggregateValues();
- }
-
- /**
- * Applies the given update to the slots of this revision.
- *
- * @param RevisionSlotsUpdate $update
- */
- public function applyUpdate( RevisionSlotsUpdate $update ) {
- $update->apply( $this->mSlots );
- }
-
- /**
- * @param CommentStoreComment $comment
- */
- public function setComment( CommentStoreComment $comment ) {
- $this->mComment = $comment;
- }
-
- /**
- * Set revision hash, for optimization. Prevents getSha1() from re-calculating the hash.
- *
- * @note This should only be used if the calling code is sure that the given hash is correct
- * for the revision's content, and there is no chance of the content being manipulated
- * later. When in doubt, this method should not be called.
- *
- * @param string $sha1 SHA1 hash as a base36 string.
- */
- public function setSha1( $sha1 ) {
- Assert::parameterType( 'string', $sha1, '$sha1' );
-
- $this->mSha1 = $sha1;
- }
-
- /**
- * Set nominal revision size, for optimization. Prevents getSize() from re-calculating the size.
- *
- * @note This should only be used if the calling code is sure that the given size is correct
- * for the revision's content, and there is no chance of the content being manipulated
- * later. When in doubt, this method should not be called.
- *
- * @param int $size nominal size in bogo-bytes
- */
- public function setSize( $size ) {
- Assert::parameterType( 'integer', $size, '$size' );
-
- $this->mSize = $size;
- }
-
- /**
- * @param int $visibility
- */
- public function setVisibility( $visibility ) {
- Assert::parameterType( 'integer', $visibility, '$visibility' );
-
- $this->mDeleted = $visibility;
- }
-
- /**
- * @param string $timestamp A timestamp understood by wfTimestamp
- */
- public function setTimestamp( $timestamp ) {
- Assert::parameterType( 'string', $timestamp, '$timestamp' );
-
- $this->mTimestamp = wfTimestamp( TS_MW, $timestamp );
- }
-
- /**
- * @param bool $minorEdit
- */
- public function setMinorEdit( $minorEdit ) {
- Assert::parameterType( 'boolean', $minorEdit, '$minorEdit' );
-
- $this->mMinorEdit = $minorEdit;
- }
-
- /**
- * Set the revision ID.
- *
- * MCR migration note: this replaces Revision::setId()
- *
- * @warning Use this with care, especially when preparing a revision for insertion
- * into the database! The revision ID should only be fixed in special cases
- * like preserving the original ID when restoring a revision.
- *
- * @param int $id
- */
- public function setId( $id ) {
- Assert::parameterType( 'integer', $id, '$id' );
-
- $this->mId = $id;
- }
-
- /**
- * Sets the user identity associated with the revision
- *
- * @param UserIdentity $user
- */
- public function setUser( UserIdentity $user ) {
- $this->mUser = $user;
- }
-
- /**
- * @param int $pageId
- */
- public function setPageId( $pageId ) {
- Assert::parameterType( 'integer', $pageId, '$pageId' );
-
- if ( $this->mTitle->exists() && $pageId !== $this->mTitle->getArticleID() ) {
- throw new InvalidArgumentException(
- 'The given Title does not belong to page ID ' . $this->mPageId
- );
- }
-
- $this->mPageId = $pageId;
- }
-
- /**
- * Returns the nominal size of this revision.
- *
- * MCR migration note: this replaces Revision::getSize
- *
- * @return int The nominal size, may be computed on the fly if not yet known.
- */
- public function getSize() {
- // If not known, re-calculate and remember. Will be reset when slots change.
- if ( $this->mSize === null ) {
- $this->mSize = $this->mSlots->computeSize();
- }
-
- return $this->mSize;
- }
-
- /**
- * Returns the base36 sha1 of this revision.
- *
- * MCR migration note: this replaces Revision::getSha1
- *
- * @return string The revision hash, may be computed on the fly if not yet known.
- */
- public function getSha1() {
- // If not known, re-calculate and remember. Will be reset when slots change.
- if ( $this->mSha1 === null ) {
- $this->mSha1 = $this->mSlots->computeSha1();
- }
-
- return $this->mSha1;
- }
-
- /**
- * Returns the slots defined for this revision as a MutableRevisionSlots instance,
- * which can be modified to defined the slots for this revision.
- *
- * @return MutableRevisionSlots
- */
- public function getSlots() {
- // Overwritten just guarantee the more narrow return type.
- return parent::getSlots();
- }
-
- /**
- * Invalidate cached aggregate values such as hash and size.
- */
- private function resetAggregateValues() {
- $this->mSize = null;
- $this->mSha1 = null;
- }
-
-}
+++ /dev/null
-<?php
-/**
- * Mutable version of RevisionSlots, for constructing a new revision.
- *
- * 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
- */
-
-namespace MediaWiki\Storage;
-
-use Content;
-
-/**
- * Mutable version of RevisionSlots, for constructing a new revision.
- *
- * @since 1.31
- */
-class MutableRevisionSlots extends RevisionSlots {
-
- /**
- * Constructs a MutableRevisionSlots that inherits from the given
- * list of slots.
- *
- * @param SlotRecord[] $slots
- *
- * @return MutableRevisionSlots
- */
- public static function newFromParentRevisionSlots( array $slots ) {
- $inherited = [];
- foreach ( $slots as $slot ) {
- $role = $slot->getRole();
- $inherited[$role] = SlotRecord::newInherited( $slot );
- }
-
- return new MutableRevisionSlots( $inherited );
- }
-
- /**
- * @param SlotRecord[] $slots An array of SlotRecords.
- */
- public function __construct( array $slots = [] ) {
- parent::__construct( $slots );
- }
-
- /**
- * Sets the given slot.
- * If a slot with the same role is already present, it is replaced.
- *
- * @param SlotRecord $slot
- */
- public function setSlot( SlotRecord $slot ) {
- if ( !is_array( $this->slots ) ) {
- $this->getSlots(); // initialize $this->slots
- }
-
- $role = $slot->getRole();
- $this->slots[$role] = $slot;
- }
-
- /**
- * Sets the given slot to an inherited version of $slot.
- * If a slot with the same role is already present, it is replaced.
- *
- * @param SlotRecord $slot
- */
- public function inheritSlot( SlotRecord $slot ) {
- $this->setSlot( SlotRecord::newInherited( $slot ) );
- }
-
- /**
- * Sets the content for the slot with the given role.
- * If a slot with the same role is already present, it is replaced.
- *
- * @param string $role
- * @param Content $content
- */
- public function setContent( $role, Content $content ) {
- $slot = SlotRecord::newUnsaved( $role, $content );
- $this->setSlot( $slot );
- }
-
- /**
- * Remove the slot for the given role, discontinue the corresponding stream.
- *
- * @param string $role
- */
- public function removeSlot( $role ) {
- if ( !is_array( $this->slots ) ) {
- $this->getSlots(); // initialize $this->slots
- }
-
- unset( $this->slots[$role] );
- }
-
-}
if ( !isset( $infos[$tableName] ) ) {
throw new \InvalidArgumentException( "Invalid table name \$tableName" );
}
- if ( $wiki === wfWikiID() ) {
+ if ( $wiki === $this->lbFactory->getLocalDomainID() ) {
$wiki = false;
}
+
if ( isset( $this->stores[$tableName][$wiki] ) ) {
return $this->stores[$tableName][$wiki];
}
use LogicException;
use ManualLogEntry;
use MediaWiki\Linker\LinkTarget;
+use MediaWiki\Revision\MutableRevisionRecord;
+use MediaWiki\Revision\RevisionAccessException;
+use MediaWiki\Revision\RevisionRecord;
+use MediaWiki\Revision\RevisionStore;
+use MediaWiki\Revision\SlotRecord;
use MWException;
use RecentChange;
use Revision;
+++ /dev/null
-<?php
-/**
- * Exception representing a failure to look up a revision.
- *
- * 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
- */
-
-namespace MediaWiki\Storage;
-
-use RuntimeException;
-
-/**
- * Exception representing a failure to look up a revision.
- *
- * @since 1.31
- */
-class RevisionAccessException extends RuntimeException {
-
-}
+++ /dev/null
-<?php
-/**
- * A RevisionRecord representing a revision of a deleted page persisted in the archive table.
- *
- * 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
- */
-
-namespace MediaWiki\Storage;
-
-use CommentStoreComment;
-use MediaWiki\User\UserIdentity;
-use Title;
-use User;
-use Wikimedia\Assert\Assert;
-
-/**
- * A RevisionRecord representing a revision of a deleted page persisted in the archive table.
- * Most getters on RevisionArchiveRecord will never return null. However, getId() and
- * getParentId() may indeed return null if this information was not stored when the archive entry
- * was created.
- *
- * @since 1.31
- */
-class RevisionArchiveRecord extends RevisionRecord {
-
- /**
- * @var int
- */
- protected $mArchiveId;
-
- /**
- * @note Avoid calling this constructor directly. Use the appropriate methods
- * in RevisionStore instead.
- *
- * @param Title $title The title of the page this Revision is associated with.
- * @param UserIdentity $user
- * @param CommentStoreComment $comment
- * @param object $row An archive table row. Use RevisionStore::getArchiveQueryInfo() to build
- * a query that yields the required fields.
- * @param RevisionSlots $slots The slots of this revision.
- * @param bool|string $wikiId the wiki ID of the site this Revision belongs to,
- * or false for the local site.
- */
- function __construct(
- Title $title,
- UserIdentity $user,
- CommentStoreComment $comment,
- $row,
- RevisionSlots $slots,
- $wikiId = false
- ) {
- parent::__construct( $title, $slots, $wikiId );
- Assert::parameterType( 'object', $row, '$row' );
-
- $timestamp = wfTimestamp( TS_MW, $row->ar_timestamp );
- Assert::parameter( is_string( $timestamp ), '$row->rev_timestamp', 'must be a valid timestamp' );
-
- $this->mArchiveId = intval( $row->ar_id );
-
- // NOTE: ar_page_id may be different from $this->mTitle->getArticleID() in some cases,
- // notably when a partially restored page has been moved, and a new page has been created
- // with the same title. Archive rows for that title will then have the wrong page id.
- $this->mPageId = isset( $row->ar_page_id ) ? intval( $row->ar_page_id ) : $title->getArticleID();
-
- // NOTE: ar_parent_id = 0 indicates that there is no parent revision, while null
- // indicates that the parent revision is unknown. As per MW 1.31, the database schema
- // allows ar_parent_id to be NULL.
- $this->mParentId = isset( $row->ar_parent_id ) ? intval( $row->ar_parent_id ) : null;
- $this->mId = isset( $row->ar_rev_id ) ? intval( $row->ar_rev_id ) : null;
- $this->mComment = $comment;
- $this->mUser = $user;
- $this->mTimestamp = $timestamp;
- $this->mMinorEdit = boolval( $row->ar_minor_edit );
- $this->mDeleted = intval( $row->ar_deleted );
- $this->mSize = isset( $row->ar_len ) ? intval( $row->ar_len ) : null;
- $this->mSha1 = !empty( $row->ar_sha1 ) ? $row->ar_sha1 : null;
- }
-
- /**
- * Get archive row ID
- *
- * @return int
- */
- public function getArchiveId() {
- return $this->mArchiveId;
- }
-
- /**
- * @return int|null The revision id, or null if the original revision ID
- * was not recorded in the archive table.
- */
- public function getId() {
- // overwritten just to refine the contract specification.
- return parent::getId();
- }
-
- /**
- * @throws RevisionAccessException if the size was unknown and could not be calculated.
- * @return int The nominal revision size, never null. May be computed on the fly.
- */
- public function getSize() {
- // If length is null, calculate and remember it (potentially SLOW!).
- // This is for compatibility with old database rows that don't have the field set.
- if ( $this->mSize === null ) {
- $this->mSize = $this->mSlots->computeSize();
- }
-
- return $this->mSize;
- }
-
- /**
- * @throws RevisionAccessException if the hash was unknown and could not be calculated.
- * @return string The revision hash, never null. May be computed on the fly.
- */
- public function getSha1() {
- // If hash is null, calculate it and remember (potentially SLOW!)
- // This is for compatibility with old database rows that don't have the field set.
- if ( $this->mSha1 === null ) {
- $this->mSha1 = $this->mSlots->computeSha1();
- }
-
- return $this->mSha1;
- }
-
- /**
- * @param int $audience
- * @param User|null $user
- *
- * @return UserIdentity The identity of the revision author, null if access is forbidden.
- */
- public function getUser( $audience = self::FOR_PUBLIC, User $user = null ) {
- // overwritten just to add a guarantee to the contract
- return parent::getUser( $audience, $user );
- }
-
- /**
- * @param int $audience
- * @param User|null $user
- *
- * @return CommentStoreComment The revision comment, null if access is forbidden.
- */
- public function getComment( $audience = self::FOR_PUBLIC, User $user = null ) {
- // overwritten just to add a guarantee to the contract
- return parent::getComment( $audience, $user );
- }
-
- /**
- * @return string never null
- */
- public function getTimestamp() {
- // overwritten just to add a guarantee to the contract
- return parent::getTimestamp();
- }
-
- /**
- * @see RevisionStore::isComplete
- *
- * @return bool always true.
- */
- public function isReadyForInsertion() {
- return true;
- }
-
-}
+++ /dev/null
-<?php
-/**
- * Service for constructing revision objects.
- *
- * 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
- */
-
-namespace MediaWiki\Storage;
-
-use MWException;
-use Title;
-
-/**
- * Service for constructing revision objects.
- *
- * @since 1.31
- *
- * @note This was written to act as a drop-in replacement for the corresponding
- * static methods in Revision.
- */
-interface RevisionFactory {
-
- /**
- * Constructs a new RevisionRecord based on the given associative array following the MW1.29
- * database convention for the Revision constructor.
- *
- * MCR migration note: this replaces Revision::newFromRow
- *
- * @deprecated since 1.31. Use a MutableRevisionRecord instead.
- *
- * @param array $fields
- * @param int $queryFlags Flags for lazy loading behavior, see IDBAccessObject::READ_XXX.
- * @param Title|null $title
- *
- * @return MutableRevisionRecord
- * @throws MWException
- */
- public function newMutableRevisionFromArray( array $fields, $queryFlags = 0, Title $title = null );
-
- /**
- * Constructs a RevisionRecord given a database row and content slots.
- *
- * MCR migration note: this replaces Revision::newFromRow for rows based on the
- * revision, slot, and content tables defined for MCR since MW1.31.
- *
- * @param object $row A query result row as a raw object.
- * Use RevisionStore::getQueryInfo() to build a query that yields the required fields.
- * @param int $queryFlags Flags for lazy loading behavior, see IDBAccessObject::READ_XXX.
- * @param Title|null $title
- *
- * @return RevisionRecord
- */
- public function newRevisionFromRow( $row, $queryFlags = 0, Title $title = null );
-
- /**
- * Make a fake revision object from an archive table row. This is queried
- * for permissions or even inserted (as in Special:Undelete)
- *
- * MCR migration note: this replaces Revision::newFromArchiveRow
- *
- * @param object $row A query result row as a raw object.
- * Use RevisionStore::getArchiveQueryInfo() to build a query that yields the
- * required fields.
- * @param int $queryFlags Flags for lazy loading behavior, see IDBAccessObject::READ_XXX.
- * @param Title|null $title
- * @param array $overrides An associative array that allows fields in $row to be overwritten.
- * Keys in this array correspond to field names in $row without the "ar_" prefix, so
- * $overrides['user'] will override $row->ar_user, etc.
- *
- * @return RevisionRecord
- */
- public function newRevisionFromArchiveRow(
- $row,
- $queryFlags = 0,
- Title $title = null,
- array $overrides = []
- );
-
-}
+++ /dev/null
-<?php
-/**
- * Service for looking up page revisions.
- *
- * 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
- */
-
-namespace MediaWiki\Storage;
-
-use IDBAccessObject;
-use MediaWiki\Linker\LinkTarget;
-use Title;
-
-/**
- * Service for looking up page revisions.
- *
- * @note This was written to act as a drop-in replacement for the corresponding
- * static methods in Revision.
- *
- * @since 1.31
- */
-interface RevisionLookup extends IDBAccessObject {
-
- /**
- * Load a page revision from a given revision ID number.
- * Returns null if no such revision can be found.
- *
- * MCR migration note: this replaces Revision::newFromId
- *
- * $flags include:
- *
- * @param int $id
- * @param int $flags bit field, see IDBAccessObject::READ_XXX
- * @return RevisionRecord|null
- */
- public function getRevisionById( $id, $flags = 0 );
-
- /**
- * Load either the current, or a specified, revision
- * that's attached to a given link target. If not attached
- * to that link target, will return null.
- *
- * MCR migration note: this replaces Revision::newFromTitle
- *
- * @param LinkTarget $linkTarget
- * @param int $revId (optional)
- * @param int $flags bit field, see IDBAccessObject::READ_XXX
- * @return RevisionRecord|null
- */
- public function getRevisionByTitle( LinkTarget $linkTarget, $revId = 0, $flags = 0 );
-
- /**
- * Load either the current, or a specified, revision
- * that's attached to a given page ID.
- * Returns null if no such revision can be found.
- *
- * MCR migration note: this replaces Revision::newFromPageId
- *
- * @param int $pageId
- * @param int $revId (optional)
- * @param int $flags bit field, see IDBAccessObject::READ_XXX
- * @return RevisionRecord|null
- */
- public function getRevisionByPageId( $pageId, $revId = 0, $flags = 0 );
-
- /**
- * Get previous revision for this title
- *
- * MCR migration note: this replaces Revision::getPrevious
- *
- * @param RevisionRecord $rev
- * @param Title|null $title if known (optional)
- *
- * @return RevisionRecord|null
- */
- public function getPreviousRevision( RevisionRecord $rev, Title $title = null );
-
- /**
- * Get next revision for this title
- *
- * MCR migration note: this replaces Revision::getNext
- *
- * @param RevisionRecord $rev
- * @param Title|null $title if known (optional)
- *
- * @return RevisionRecord|null
- */
- public function getNextRevision( RevisionRecord $rev, Title $title = null );
-
- /**
- * Load a revision based on a known page ID and current revision ID from the DB
- *
- * This method allows for the use of caching, though accessing anything that normally
- * requires permission checks (aside from the text) will trigger a small DB lookup.
- *
- * MCR migration note: this replaces Revision::newKnownCurrent
- *
- * @param Title $title the associated page title
- * @param int $revId current revision of this page
- *
- * @return RevisionRecord|bool Returns false if missing
- */
- public function getKnownCurrentRevision( Title $title, $revId );
-
-}
+++ /dev/null
-<?php
-/**
- * Page revision base class.
- *
- * 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
- */
-
-namespace MediaWiki\Storage;
-
-use CommentStoreComment;
-use Content;
-use InvalidArgumentException;
-use LogicException;
-use MediaWiki\Linker\LinkTarget;
-use MediaWiki\User\UserIdentity;
-use MWException;
-use Title;
-use User;
-use Wikimedia\Assert\Assert;
-
-/**
- * Page revision base class.
- *
- * RevisionRecords are considered value objects, but they may use callbacks for lazy loading.
- * Note that while the base class has no setters, subclasses may offer a mutable interface.
- *
- * @since 1.31
- */
-abstract class RevisionRecord {
-
- // RevisionRecord deletion constants
- const DELETED_TEXT = 1;
- const DELETED_COMMENT = 2;
- const DELETED_USER = 4;
- const DELETED_RESTRICTED = 8;
- const SUPPRESSED_USER = self::DELETED_USER | self::DELETED_RESTRICTED; // convenience
- const SUPPRESSED_ALL = self::DELETED_TEXT | self::DELETED_COMMENT | self::DELETED_USER |
- self::DELETED_RESTRICTED; // convenience
-
- // Audience options for accessors
- const FOR_PUBLIC = 1;
- const FOR_THIS_USER = 2;
- const RAW = 3;
-
- /** @var string Wiki ID; false means the current wiki */
- protected $mWiki = false;
- /** @var int|null */
- protected $mId;
- /** @var int|null */
- protected $mPageId;
- /** @var UserIdentity|null */
- protected $mUser;
- /** @var bool */
- protected $mMinorEdit = false;
- /** @var string|null */
- protected $mTimestamp;
- /** @var int using the DELETED_XXX and SUPPRESSED_XXX flags */
- protected $mDeleted = 0;
- /** @var int|null */
- protected $mSize;
- /** @var string|null */
- protected $mSha1;
- /** @var int|null */
- protected $mParentId;
- /** @var CommentStoreComment|null */
- protected $mComment;
-
- /** @var Title */
- protected $mTitle; // TODO: we only need the title for permission checks!
-
- /** @var RevisionSlots */
- protected $mSlots;
-
- /**
- * @note Avoid calling this constructor directly. Use the appropriate methods
- * in RevisionStore instead.
- *
- * @param Title $title The title of the page this Revision is associated with.
- * @param RevisionSlots $slots The slots of this revision.
- * @param bool|string $wikiId the wiki ID of the site this Revision belongs to,
- * or false for the local site.
- *
- * @throws MWException
- */
- function __construct( Title $title, RevisionSlots $slots, $wikiId = false ) {
- Assert::parameterType( 'string|boolean', $wikiId, '$wikiId' );
-
- $this->mTitle = $title;
- $this->mSlots = $slots;
- $this->mWiki = $wikiId;
-
- // XXX: this is a sensible default, but we may not have a Title object here in the future.
- $this->mPageId = $title->getArticleID();
- }
-
- /**
- * Implemented to defy serialization.
- *
- * @throws LogicException always
- */
- public function __sleep() {
- throw new LogicException( __CLASS__ . ' is not serializable.' );
- }
-
- /**
- * @param RevisionRecord $rec
- *
- * @return bool True if this RevisionRecord is known to have same content as $rec.
- * False if the content is different (or not known to be the same).
- */
- public function hasSameContent( RevisionRecord $rec ) {
- if ( $rec === $this ) {
- return true;
- }
-
- if ( $this->getId() !== null && $this->getId() === $rec->getId() ) {
- return true;
- }
-
- // check size before hash, since size is quicker to compute
- if ( $this->getSize() !== $rec->getSize() ) {
- return false;
- }
-
- // instead of checking the hash, we could also check the content addresses of all slots.
-
- if ( $this->getSha1() === $rec->getSha1() ) {
- return true;
- }
-
- return false;
- }
-
- /**
- * Returns the Content of the given slot of this revision.
- * Call getSlotNames() to get a list of available slots.
- *
- * Note that for mutable Content objects, each call to this method will return a
- * fresh clone.
- *
- * MCR migration note: this replaces Revision::getContent
- *
- * @param string $role The role name of the desired slot
- * @param int $audience
- * @param User|null $user
- *
- * @throws RevisionAccessException if the slot does not exist or slot data
- * could not be lazy-loaded.
- * @return Content|null The content of the given slot, or null if access is forbidden.
- */
- public function getContent( $role, $audience = self::FOR_PUBLIC, User $user = null ) {
- // XXX: throwing an exception would be nicer, but would a further
- // departure from the signature of Revision::getContent(), and thus
- // more complex and error prone refactoring.
- if ( !$this->audienceCan( self::DELETED_TEXT, $audience, $user ) ) {
- return null;
- }
-
- $content = $this->getSlot( $role, $audience, $user )->getContent();
- return $content->copy();
- }
-
- /**
- * Returns meta-data for the given slot.
- *
- * @param string $role The role name of the desired slot
- * @param int $audience
- * @param User|null $user
- *
- * @throws RevisionAccessException if the slot does not exist or slot data
- * could not be lazy-loaded.
- * @return SlotRecord The slot meta-data. If access to the slot content is forbidden,
- * calling getContent() on the SlotRecord will throw an exception.
- */
- public function getSlot( $role, $audience = self::FOR_PUBLIC, User $user = null ) {
- $slot = $this->mSlots->getSlot( $role );
-
- if ( !$this->audienceCan( self::DELETED_TEXT, $audience, $user ) ) {
- return SlotRecord::newWithSuppressedContent( $slot );
- }
-
- return $slot;
- }
-
- /**
- * Returns whether the given slot is defined in this revision.
- *
- * @param string $role The role name of the desired slot
- *
- * @return bool
- */
- public function hasSlot( $role ) {
- return $this->mSlots->hasSlot( $role );
- }
-
- /**
- * Returns the slot names (roles) of all slots present in this revision.
- * getContent() will succeed only for the names returned by this method.
- *
- * @return string[]
- */
- public function getSlotRoles() {
- return $this->mSlots->getSlotRoles();
- }
-
- /**
- * Returns the slots defined for this revision.
- *
- * @return RevisionSlots
- */
- public function getSlots() {
- return $this->mSlots;
- }
-
- /**
- * Returns the slots that originate in this revision.
- *
- * Note that this does not include any slots inherited from some earlier revision,
- * even if they are different from the slots in the immediate parent revision.
- * This is the case for rollbacks: slots of a rollback revision are inherited from
- * the rollback target, and are different from the slots in the parent revision,
- * which was rolled back.
- *
- * To find all slots modified by this revision against its immediate parent
- * revision, use RevisionSlotsUpdate::newFromRevisionSlots().
- *
- * @return RevisionSlots
- */
- public function getOriginalSlots() {
- return new RevisionSlots( $this->mSlots->getOriginalSlots() );
- }
-
- /**
- * Returns slots inherited from some previous revision.
- *
- * "Inherited" slots are all slots that do not originate in this revision.
- * Note that these slots may still differ from the one in the parent revision.
- * This is the case for rollbacks: slots of a rollback revision are inherited from
- * the rollback target, and are different from the slots in the parent revision,
- * which was rolled back.
- *
- * @return RevisionSlots
- */
- public function getInheritedSlots() {
- return new RevisionSlots( $this->mSlots->getInheritedSlots() );
- }
-
- /**
- * Get revision ID. Depending on the concrete subclass, this may return null if
- * the revision ID is not known (e.g. because the revision does not yet exist
- * in the database).
- *
- * MCR migration note: this replaces Revision::getId
- *
- * @return int|null
- */
- public function getId() {
- return $this->mId;
- }
-
- /**
- * Get parent revision ID (the original previous page revision).
- * If there is no parent revision, this returns 0.
- * If the parent revision is undefined or unknown, this returns null.
- *
- * @note As of MW 1.31, the database schema allows the parent ID to be
- * NULL to indicate that it is unknown.
- *
- * MCR migration note: this replaces Revision::getParentId
- *
- * @return int|null
- */
- public function getParentId() {
- return $this->mParentId;
- }
-
- /**
- * Returns the nominal size of this revision, in bogo-bytes.
- * May be calculated on the fly if not known, which may in the worst
- * case may involve loading all content.
- *
- * MCR migration note: this replaces Revision::getSize
- *
- * @throws RevisionAccessException if the size was unknown and could not be calculated.
- * @return int
- */
- abstract public function getSize();
-
- /**
- * Returns the base36 sha1 of this revision. This hash is derived from the
- * hashes of all slots associated with the revision.
- * May be calculated on the fly if not known, which may in the worst
- * case may involve loading all content.
- *
- * MCR migration note: this replaces Revision::getSha1
- *
- * @throws RevisionAccessException if the hash was unknown and could not be calculated.
- * @return string
- */
- abstract public function getSha1();
-
- /**
- * Get the page ID. If the page does not yet exist, the page ID is 0.
- *
- * MCR migration note: this replaces Revision::getPage
- *
- * @return int
- */
- public function getPageId() {
- return $this->mPageId;
- }
-
- /**
- * Get the ID of the wiki this revision belongs to.
- *
- * @return string|false The wiki's logical name, of false to indicate the local wiki.
- */
- public function getWikiId() {
- return $this->mWiki;
- }
-
- /**
- * Returns the title of the page this revision is associated with as a LinkTarget object.
- *
- * MCR migration note: this replaces Revision::getTitle
- *
- * @return LinkTarget
- */
- public function getPageAsLinkTarget() {
- return $this->mTitle;
- }
-
- /**
- * Fetch revision's author's user identity, if it's available to the specified audience.
- * If the specified audience does not have access to it, null will be
- * returned. Depending on the concrete subclass, null may also be returned if the user is
- * not yet specified.
- *
- * MCR migration note: this replaces Revision::getUser
- *
- * @param int $audience One of:
- * RevisionRecord::FOR_PUBLIC to be displayed to all users
- * RevisionRecord::FOR_THIS_USER to be displayed to the given user
- * RevisionRecord::RAW get the ID regardless of permissions
- * @param User|null $user User object to check for, only if FOR_THIS_USER is passed
- * to the $audience parameter
- * @return UserIdentity|null
- */
- public function getUser( $audience = self::FOR_PUBLIC, User $user = null ) {
- if ( !$this->audienceCan( self::DELETED_USER, $audience, $user ) ) {
- return null;
- } else {
- return $this->mUser;
- }
- }
-
- /**
- * Fetch revision comment, if it's available to the specified audience.
- * If the specified audience does not have access to the comment,
- * this will return null. Depending on the concrete subclass, null may also be returned
- * if the comment is not yet specified.
- *
- * MCR migration note: this replaces Revision::getComment
- *
- * @param int $audience One of:
- * RevisionRecord::FOR_PUBLIC to be displayed to all users
- * RevisionRecord::FOR_THIS_USER to be displayed to the given user
- * RevisionRecord::RAW get the text regardless of permissions
- * @param User|null $user User object to check for, only if FOR_THIS_USER is passed
- * to the $audience parameter
- *
- * @return CommentStoreComment|null
- */
- public function getComment( $audience = self::FOR_PUBLIC, User $user = null ) {
- if ( !$this->audienceCan( self::DELETED_COMMENT, $audience, $user ) ) {
- return null;
- } else {
- return $this->mComment;
- }
- }
-
- /**
- * MCR migration note: this replaces Revision::isMinor
- *
- * @return bool
- */
- public function isMinor() {
- return (bool)$this->mMinorEdit;
- }
-
- /**
- * MCR migration note: this replaces Revision::isDeleted
- *
- * @param int $field One of DELETED_* bitfield constants
- *
- * @return bool
- */
- public function isDeleted( $field ) {
- return ( $this->getVisibility() & $field ) == $field;
- }
-
- /**
- * Get the deletion bitfield of the revision
- *
- * MCR migration note: this replaces Revision::getVisibility
- *
- * @return int
- */
- public function getVisibility() {
- return (int)$this->mDeleted;
- }
-
- /**
- * MCR migration note: this replaces Revision::getTimestamp.
- *
- * May return null if the timestamp was not specified.
- *
- * @return string|null
- */
- public function getTimestamp() {
- return $this->mTimestamp;
- }
-
- /**
- * Check that the given audience has access to the given field.
- *
- * MCR migration note: this corresponds to Revision::userCan
- *
- * @param int $field One of self::DELETED_TEXT,
- * self::DELETED_COMMENT,
- * self::DELETED_USER
- * @param int $audience One of:
- * RevisionRecord::FOR_PUBLIC to be displayed to all users
- * RevisionRecord::FOR_THIS_USER to be displayed to the given user
- * RevisionRecord::RAW get the text regardless of permissions
- * @param User|null $user User object to check. Required if $audience is FOR_THIS_USER,
- * ignored otherwise.
- *
- * @return bool
- */
- public function audienceCan( $field, $audience, User $user = null ) {
- if ( $audience == self::FOR_PUBLIC && $this->isDeleted( $field ) ) {
- return false;
- } elseif ( $audience == self::FOR_THIS_USER ) {
- if ( !$user ) {
- throw new InvalidArgumentException(
- 'A User object must be given when checking FOR_THIS_USER audience.'
- );
- }
-
- if ( !$this->userCan( $field, $user ) ) {
- return false;
- }
- }
-
- return true;
- }
-
- /**
- * Determine if the current user is allowed to view a particular
- * field of this revision, if it's marked as deleted.
- *
- * MCR migration note: this corresponds to Revision::userCan
- *
- * @param int $field One of self::DELETED_TEXT,
- * self::DELETED_COMMENT,
- * self::DELETED_USER
- * @param User $user User object to check
- * @return bool
- */
- protected function userCan( $field, User $user ) {
- // TODO: use callback for permission checks, so we don't need to know a Title object!
- return self::userCanBitfield( $this->getVisibility(), $field, $user, $this->mTitle );
- }
-
- /**
- * Determine if the current user is allowed to view a particular
- * field of this revision, if it's marked as deleted. This is used
- * by various classes to avoid duplication.
- *
- * MCR migration note: this replaces Revision::userCanBitfield
- *
- * @param int $bitfield Current field
- * @param int $field One of self::DELETED_TEXT = File::DELETED_FILE,
- * self::DELETED_COMMENT = File::DELETED_COMMENT,
- * self::DELETED_USER = File::DELETED_USER
- * @param User $user User object to check
- * @param Title|null $title A Title object to check for per-page restrictions on,
- * instead of just plain userrights
- * @return bool
- */
- public static function userCanBitfield( $bitfield, $field, User $user, Title $title = null ) {
- if ( $bitfield & $field ) { // aspect is deleted
- if ( $bitfield & self::DELETED_RESTRICTED ) {
- $permissions = [ 'suppressrevision', 'viewsuppressed' ];
- } elseif ( $field & self::DELETED_TEXT ) {
- $permissions = [ 'deletedtext' ];
- } else {
- $permissions = [ 'deletedhistory' ];
- }
- $permissionlist = implode( ', ', $permissions );
- if ( $title === null ) {
- wfDebug( "Checking for $permissionlist due to $field match on $bitfield\n" );
- return $user->isAllowedAny( ...$permissions );
- } else {
- $text = $title->getPrefixedText();
- wfDebug( "Checking for $permissionlist on $text due to $field match on $bitfield\n" );
- foreach ( $permissions as $perm ) {
- if ( $title->userCan( $perm, $user ) ) {
- return true;
- }
- }
- return false;
- }
- } else {
- return true;
- }
- }
-
- /**
- * Returns whether this RevisionRecord is ready for insertion, that is, whether it contains all
- * information needed to save it to the database. This should trivially be true for
- * RevisionRecords loaded from the database.
- *
- * Note that this may return true even if getId() or getPage() return null or 0, since these
- * are generally assigned while the revision is saved to the database, and may not be available
- * before.
- *
- * @return bool
- */
- public function isReadyForInsertion() {
- // NOTE: don't check getSize() and getSha1(), since that may cause the full content to
- // be loaded in order to calculate the values. Just assume these methods will not return
- // null if mSlots is not empty.
-
- // NOTE: getId() and getPageId() may return null before a revision is saved, so don't
- //check them.
-
- return $this->getTimestamp() !== null
- && $this->getComment( self::RAW ) !== null
- && $this->getUser( self::RAW ) !== null
- && $this->mSlots->getSlotRoles() !== [];
- }
-
-}
+++ /dev/null
-<?php
-/**
- * Value object representing the set of slots belonging to a revision.
- *
- * 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
- */
-
-namespace MediaWiki\Storage;
-
-use Content;
-use LogicException;
-use Wikimedia\Assert\Assert;
-
-/**
- * Value object representing the set of slots belonging to a revision.
- *
- * @since 1.31
- */
-class RevisionSlots {
-
- /** @var SlotRecord[]|callable */
- protected $slots;
-
- /**
- * @param SlotRecord[]|callable $slots SlotRecords,
- * or a callback that returns such a structure.
- */
- public function __construct( $slots ) {
- Assert::parameterType( 'array|callable', $slots, '$slots' );
-
- if ( is_callable( $slots ) ) {
- $this->slots = $slots;
- } else {
- $this->setSlotsInternal( $slots );
- }
- }
-
- /**
- * @param SlotRecord[] $slots
- */
- private function setSlotsInternal( array $slots ) {
- Assert::parameterElementType( SlotRecord::class, $slots, '$slots' );
-
- $this->slots = [];
-
- // re-key the slot array
- foreach ( $slots as $slot ) {
- $role = $slot->getRole();
- $this->slots[$role] = $slot;
- }
- }
-
- /**
- * Implemented to defy serialization.
- *
- * @throws LogicException always
- */
- public function __sleep() {
- throw new LogicException( __CLASS__ . ' is not serializable.' );
- }
-
- /**
- * Returns the Content of the given slot.
- * Call getSlotNames() to get a list of available slots.
- *
- * Note that for mutable Content objects, each call to this method will return a
- * fresh clone.
- *
- * @param string $role The role name of the desired slot
- *
- * @throws RevisionAccessException if the slot does not exist or slot data
- * could not be lazy-loaded.
- * @return Content
- */
- public function getContent( $role ) {
- // Return a copy to be safe. Immutable content objects return $this from copy().
- return $this->getSlot( $role )->getContent()->copy();
- }
-
- /**
- * Returns the SlotRecord of the given slot.
- * Call getSlotNames() to get a list of available slots.
- *
- * @param string $role The role name of the desired slot
- *
- * @throws RevisionAccessException if the slot does not exist or slot data
- * could not be lazy-loaded.
- * @return SlotRecord
- */
- public function getSlot( $role ) {
- $slots = $this->getSlots();
-
- if ( isset( $slots[$role] ) ) {
- return $slots[$role];
- } else {
- throw new RevisionAccessException( 'No such slot: ' . $role );
- }
- }
-
- /**
- * Returns whether the given slot is set.
- *
- * @param string $role The role name of the desired slot
- *
- * @return bool
- */
- public function hasSlot( $role ) {
- $slots = $this->getSlots();
-
- return isset( $slots[$role] );
- }
-
- /**
- * Returns the slot names (roles) of all slots present in this revision.
- * getContent() will succeed only for the names returned by this method.
- *
- * @return string[]
- */
- public function getSlotRoles() {
- $slots = $this->getSlots();
- return array_keys( $slots );
- }
-
- /**
- * Computes the total nominal size of the revision's slots, in bogo-bytes.
- *
- * @warning This is potentially expensive! It may cause all slot's content to be loaded
- * and deserialized.
- *
- * @return int
- */
- public function computeSize() {
- return array_reduce( $this->getSlots(), function ( $accu, SlotRecord $slot ) {
- return $accu + $slot->getSize();
- }, 0 );
- }
-
- /**
- * Returns an associative array that maps role names to SlotRecords. Each SlotRecord
- * represents the content meta-data of a slot, together they define the content of
- * a revision.
- *
- * @note This may cause the content meta-data for the revision to be lazy-loaded.
- *
- * @return SlotRecord[] revision slot/content rows, keyed by slot role name.
- */
- public function getSlots() {
- if ( is_callable( $this->slots ) ) {
- $slots = call_user_func( $this->slots );
-
- Assert::postcondition(
- is_array( $slots ),
- 'Slots info callback should return an array of objects'
- );
-
- $this->setSlotsInternal( $slots );
- }
-
- return $this->slots;
- }
-
- /**
- * Computes the combined hash of the revisions's slots.
- *
- * @note For backwards compatibility, the combined hash of a single slot
- * is that slot's hash. For consistency, the combined hash of an empty set of slots
- * is the hash of the empty string.
- *
- * @warning This is potentially expensive! It may cause all slot's content to be loaded
- * and deserialized, then re-serialized and hashed.
- *
- * @return string
- */
- public function computeSha1() {
- $slots = $this->getSlots();
- ksort( $slots );
-
- if ( empty( $slots ) ) {
- return SlotRecord::base36Sha1( '' );
- }
-
- return array_reduce( $slots, function ( $accu, SlotRecord $slot ) {
- return $accu === null
- ? $slot->getSha1()
- : SlotRecord::base36Sha1( $accu . $slot->getSha1() );
- }, null );
- }
-
- /**
- * Return all slots that belong to the revision they originate from (that is,
- * they are not inherited from some other revision).
- *
- * @note This may cause the slot meta-data for the revision to be lazy-loaded.
- *
- * @return SlotRecord[]
- */
- public function getOriginalSlots() {
- return array_filter(
- $this->getSlots(),
- function ( SlotRecord $slot ) {
- return !$slot->isInherited();
- }
- );
- }
-
- /**
- * Return all slots that are not not originate in the revision they belong to (that is,
- * they are inherited from some other revision).
- *
- * @note This may cause the slot meta-data for the revision to be lazy-loaded.
- *
- * @return SlotRecord[]
- */
- public function getInheritedSlots() {
- return array_filter(
- $this->getSlots(),
- function ( SlotRecord $slot ) {
- return $slot->isInherited();
- }
- );
- }
-
- /**
- * Checks whether the other RevisionSlots instance has the same content
- * as this instance. Note that this does not mean that the slots have to be the same:
- * they could for instance belong to different revisions.
- *
- * @param RevisionSlots $other
- *
- * @return bool
- */
- public function hasSameContent( RevisionSlots $other ) {
- if ( $other === $this ) {
- return true;
- }
-
- $aSlots = $this->getSlots();
- $bSlots = $other->getSlots();
-
- ksort( $aSlots );
- ksort( $bSlots );
-
- if ( array_keys( $aSlots ) !== array_keys( $bSlots ) ) {
- return false;
- }
-
- foreach ( $aSlots as $role => $s ) {
- $t = $bSlots[$role];
-
- if ( !$s->hasSameContent( $t ) ) {
- return false;
- }
- }
-
- return true;
- }
-
- /**
- * Find roles for which the $other RevisionSlots object has different content
- * as this RevisionSlots object, including any roles that are present in one
- * but not the other.
- *
- * @param RevisionSlots $other
- *
- * @return string[] a list of slot roles that are different.
- */
- public function getRolesWithDifferentContent( RevisionSlots $other ) {
- if ( $other === $this ) {
- return [];
- }
-
- $aSlots = $this->getSlots();
- $bSlots = $other->getSlots();
-
- ksort( $aSlots );
- ksort( $bSlots );
-
- $different = array_keys( array_merge(
- array_diff_key( $aSlots, $bSlots ),
- array_diff_key( $bSlots, $aSlots )
- ) );
-
- /** @var SlotRecord[] $common */
- $common = array_intersect_key( $aSlots, $bSlots );
-
- foreach ( $common as $role => $s ) {
- $t = $bSlots[$role];
-
- if ( !$s->hasSameContent( $t ) ) {
- $different[] = $role;
- }
- }
-
- return $different;
- }
-
-}
namespace MediaWiki\Storage;
use Content;
+use MediaWiki\Revision\MutableRevisionSlots;
+use MediaWiki\Revision\RevisionAccessException;
+use MediaWiki\Revision\RevisionSlots;
+use MediaWiki\Revision\SlotRecord;
/**
* Value object representing a modification of revision slots.
+++ /dev/null
-<?php
-/**
- * Service for looking up page revisions.
- *
- * 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
- *
- * Attribution notice: when this file was created, much of its content was taken
- * from the Revision.php file as present in release 1.30. Refer to the history
- * of that file for original authorship.
- *
- * @file
- */
-
-namespace MediaWiki\Storage;
-
-use ActorMigration;
-use CommentStore;
-use CommentStoreComment;
-use Content;
-use ContentHandler;
-use DBAccessObjectUtils;
-use Hooks;
-use IDBAccessObject;
-use InvalidArgumentException;
-use IP;
-use LogicException;
-use MediaWiki\Linker\LinkTarget;
-use MediaWiki\User\UserIdentity;
-use MediaWiki\User\UserIdentityValue;
-use Message;
-use MWException;
-use MWUnknownContentModelException;
-use Psr\Log\LoggerAwareInterface;
-use Psr\Log\LoggerInterface;
-use Psr\Log\NullLogger;
-use RecentChange;
-use Revision;
-use RuntimeException;
-use stdClass;
-use Title;
-use User;
-use WANObjectCache;
-use Wikimedia\Assert\Assert;
-use Wikimedia\Rdbms\Database;
-use Wikimedia\Rdbms\DBConnRef;
-use Wikimedia\Rdbms\IDatabase;
-use Wikimedia\Rdbms\ILoadBalancer;
-
-/**
- * Service for looking up page revisions.
- *
- * @since 1.31
- *
- * @note This was written to act as a drop-in replacement for the corresponding
- * static methods in Revision.
- */
-class RevisionStore
- implements IDBAccessObject, RevisionFactory, RevisionLookup, LoggerAwareInterface {
-
- const ROW_CACHE_KEY = 'revision-row-1.29';
-
- /**
- * @var SqlBlobStore
- */
- private $blobStore;
-
- /**
- * @var bool|string
- */
- private $wikiId;
-
- /**
- * @var boolean
- * @see $wgContentHandlerUseDB
- */
- private $contentHandlerUseDB = true;
-
- /**
- * @var ILoadBalancer
- */
- private $loadBalancer;
-
- /**
- * @var WANObjectCache
- */
- private $cache;
-
- /**
- * @var CommentStore
- */
- private $commentStore;
-
- /**
- * @var ActorMigration
- */
- private $actorMigration;
-
- /**
- * @var LoggerInterface
- */
- private $logger;
-
- /**
- * @var NameTableStore
- */
- private $contentModelStore;
-
- /**
- * @var NameTableStore
- */
- private $slotRoleStore;
-
- /** @var int An appropriate combination of SCHEMA_COMPAT_XXX flags. */
- private $mcrMigrationStage;
-
- /**
- * @todo $blobStore should be allowed to be any BlobStore!
- *
- * @param ILoadBalancer $loadBalancer
- * @param SqlBlobStore $blobStore
- * @param WANObjectCache $cache A cache for caching revision rows. This can be the local
- * wiki's default instance even if $wikiId refers to a different wiki, since
- * makeGlobalKey() is used to constructed a key that allows cached revision rows from
- * the same database to be re-used between wikis. For example, enwiki and frwiki will
- * use the same cache keys for revision rows from the wikidatawiki database, regardless
- * of the cache's default key space.
- * @param CommentStore $commentStore
- * @param NameTableStore $contentModelStore
- * @param NameTableStore $slotRoleStore
- * @param int $mcrMigrationStage An appropriate combination of SCHEMA_COMPAT_XXX flags
- * @param ActorMigration $actorMigration
- * @param bool|string $wikiId
- *
- * @throws MWException if $mcrMigrationStage or $wikiId is invalid.
- */
- public function __construct(
- ILoadBalancer $loadBalancer,
- SqlBlobStore $blobStore,
- WANObjectCache $cache,
- CommentStore $commentStore,
- NameTableStore $contentModelStore,
- NameTableStore $slotRoleStore,
- $mcrMigrationStage,
- ActorMigration $actorMigration,
- $wikiId = false
- ) {
- Assert::parameterType( 'string|boolean', $wikiId, '$wikiId' );
- Assert::parameterType( 'integer', $mcrMigrationStage, '$mcrMigrationStage' );
- Assert::parameter(
- ( $mcrMigrationStage & SCHEMA_COMPAT_READ_BOTH ) !== SCHEMA_COMPAT_READ_BOTH,
- '$mcrMigrationStage',
- 'Reading from the old and the new schema at the same time is not supported.'
- );
- Assert::parameter(
- ( $mcrMigrationStage & SCHEMA_COMPAT_READ_BOTH ) !== 0,
- '$mcrMigrationStage',
- 'Reading needs to be enabled for the old or the new schema.'
- );
- Assert::parameter(
- ( $mcrMigrationStage & SCHEMA_COMPAT_WRITE_BOTH ) !== 0,
- '$mcrMigrationStage',
- 'Writing needs to be enabled for the old or the new schema.'
- );
- Assert::parameter(
- ( $mcrMigrationStage & SCHEMA_COMPAT_READ_OLD ) === 0
- || ( $mcrMigrationStage & SCHEMA_COMPAT_WRITE_OLD ) !== 0,
- '$mcrMigrationStage',
- 'Cannot read the old schema when not also writing it.'
- );
- Assert::parameter(
- ( $mcrMigrationStage & SCHEMA_COMPAT_READ_NEW ) === 0
- || ( $mcrMigrationStage & SCHEMA_COMPAT_WRITE_NEW ) !== 0,
- '$mcrMigrationStage',
- 'Cannot read the new schema when not also writing it.'
- );
-
- $this->loadBalancer = $loadBalancer;
- $this->blobStore = $blobStore;
- $this->cache = $cache;
- $this->commentStore = $commentStore;
- $this->contentModelStore = $contentModelStore;
- $this->slotRoleStore = $slotRoleStore;
- $this->mcrMigrationStage = $mcrMigrationStage;
- $this->actorMigration = $actorMigration;
- $this->wikiId = $wikiId;
- $this->logger = new NullLogger();
- }
-
- /**
- * @param int $flags A combination of SCHEMA_COMPAT_XXX flags.
- * @return bool True if all the given flags were set in the $mcrMigrationStage
- * parameter passed to the constructor.
- */
- private function hasMcrSchemaFlags( $flags ) {
- return ( $this->mcrMigrationStage & $flags ) === $flags;
- }
-
- /**
- * Throws a RevisionAccessException if this RevisionStore is configured for cross-wiki loading
- * and still reading from the old DB schema.
- *
- * @throws RevisionAccessException
- */
- private function assertCrossWikiContentLoadingIsSafe() {
- if ( $this->wikiId !== false && $this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_OLD ) ) {
- throw new RevisionAccessException(
- "Cross-wiki content loading is not supported by the pre-MCR schema"
- );
- }
- }
-
- public function setLogger( LoggerInterface $logger ) {
- $this->logger = $logger;
- }
-
- /**
- * @return bool Whether the store is read-only
- */
- public function isReadOnly() {
- return $this->blobStore->isReadOnly();
- }
-
- /**
- * @return bool
- */
- public function getContentHandlerUseDB() {
- return $this->contentHandlerUseDB;
- }
-
- /**
- * @see $wgContentHandlerUseDB
- * @param bool $contentHandlerUseDB
- * @throws MWException
- */
- public function setContentHandlerUseDB( $contentHandlerUseDB ) {
- if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_NEW )
- || $this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_NEW )
- ) {
- if ( !$contentHandlerUseDB ) {
- throw new MWException(
- 'Content model must be stored in the database for multi content revision migration.'
- );
- }
- }
- $this->contentHandlerUseDB = $contentHandlerUseDB;
- }
-
- /**
- * @return ILoadBalancer
- */
- private function getDBLoadBalancer() {
- return $this->loadBalancer;
- }
-
- /**
- * @param int $mode DB_MASTER or DB_REPLICA
- *
- * @return IDatabase
- */
- private function getDBConnection( $mode ) {
- $lb = $this->getDBLoadBalancer();
- return $lb->getConnection( $mode, [], $this->wikiId );
- }
-
- /**
- * @param int $queryFlags a bit field composed of READ_XXX flags
- *
- * @return DBConnRef
- */
- private function getDBConnectionRefForQueryFlags( $queryFlags ) {
- list( $mode, ) = DBAccessObjectUtils::getDBOptions( $queryFlags );
- return $this->getDBConnectionRef( $mode );
- }
-
- /**
- * @param IDatabase $connection
- */
- private function releaseDBConnection( IDatabase $connection ) {
- $lb = $this->getDBLoadBalancer();
- $lb->reuseConnection( $connection );
- }
-
- /**
- * @param int $mode DB_MASTER or DB_REPLICA
- *
- * @return DBConnRef
- */
- private function getDBConnectionRef( $mode ) {
- $lb = $this->getDBLoadBalancer();
- return $lb->getConnectionRef( $mode, [], $this->wikiId );
- }
-
- /**
- * Determines the page Title based on the available information.
- *
- * MCR migration note: this corresponds to Revision::getTitle
- *
- * @note this method should be private, external use should be avoided!
- *
- * @param int|null $pageId
- * @param int|null $revId
- * @param int $queryFlags
- *
- * @return Title
- * @throws RevisionAccessException
- */
- public function getTitle( $pageId, $revId, $queryFlags = self::READ_NORMAL ) {
- if ( !$pageId && !$revId ) {
- throw new InvalidArgumentException( '$pageId and $revId cannot both be 0 or null' );
- }
-
- // This method recalls itself with READ_LATEST if READ_NORMAL doesn't get us a Title
- // So ignore READ_LATEST_IMMUTABLE flags and handle the fallback logic in this method
- if ( DBAccessObjectUtils::hasFlags( $queryFlags, self::READ_LATEST_IMMUTABLE ) ) {
- $queryFlags = self::READ_NORMAL;
- }
-
- $canUseTitleNewFromId = ( $pageId !== null && $pageId > 0 && $this->wikiId === false );
- list( $dbMode, $dbOptions ) = DBAccessObjectUtils::getDBOptions( $queryFlags );
- $titleFlags = ( $dbMode == DB_MASTER ? Title::GAID_FOR_UPDATE : 0 );
-
- // Loading by ID is best, but Title::newFromID does not support that for foreign IDs.
- if ( $canUseTitleNewFromId ) {
- // TODO: better foreign title handling (introduce TitleFactory)
- $title = Title::newFromID( $pageId, $titleFlags );
- if ( $title ) {
- return $title;
- }
- }
-
- // rev_id is defined as NOT NULL, but this revision may not yet have been inserted.
- $canUseRevId = ( $revId !== null && $revId > 0 );
-
- if ( $canUseRevId ) {
- $dbr = $this->getDBConnectionRef( $dbMode );
- // @todo: Title::getSelectFields(), or Title::getQueryInfo(), or something like that
- $row = $dbr->selectRow(
- [ 'revision', 'page' ],
- [
- 'page_namespace',
- 'page_title',
- 'page_id',
- 'page_latest',
- 'page_is_redirect',
- 'page_len',
- ],
- [ 'rev_id' => $revId ],
- __METHOD__,
- $dbOptions,
- [ 'page' => [ 'JOIN', 'page_id=rev_page' ] ]
- );
- if ( $row ) {
- // TODO: better foreign title handling (introduce TitleFactory)
- return Title::newFromRow( $row );
- }
- }
-
- // If we still don't have a title, fallback to master if that wasn't already happening.
- if ( $dbMode !== DB_MASTER ) {
- $title = $this->getTitle( $pageId, $revId, self::READ_LATEST );
- if ( $title ) {
- $this->logger->info(
- __METHOD__ . ' fell back to READ_LATEST and got a Title.',
- [ 'trace' => wfBacktrace() ]
- );
- return $title;
- }
- }
-
- throw new RevisionAccessException(
- "Could not determine title for page ID $pageId and revision ID $revId"
- );
- }
-
- /**
- * @param mixed $value
- * @param string $name
- *
- * @throws IncompleteRevisionException if $value is null
- * @return mixed $value, if $value is not null
- */
- private function failOnNull( $value, $name ) {
- if ( $value === null ) {
- throw new IncompleteRevisionException(
- "$name must not be " . var_export( $value, true ) . "!"
- );
- }
-
- return $value;
- }
-
- /**
- * @param mixed $value
- * @param string $name
- *
- * @throws IncompleteRevisionException if $value is empty
- * @return mixed $value, if $value is not null
- */
- private function failOnEmpty( $value, $name ) {
- if ( $value === null || $value === 0 || $value === '' ) {
- throw new IncompleteRevisionException(
- "$name must not be " . var_export( $value, true ) . "!"
- );
- }
-
- return $value;
- }
-
- /**
- * Insert a new revision into the database, returning the new revision record
- * on success and dies horribly on failure.
- *
- * MCR migration note: this replaces Revision::insertOn
- *
- * @param RevisionRecord $rev
- * @param IDatabase $dbw (master connection)
- *
- * @throws InvalidArgumentException
- * @return RevisionRecord the new revision record.
- */
- public function insertRevisionOn( RevisionRecord $rev, IDatabase $dbw ) {
- // TODO: pass in a DBTransactionContext instead of a database connection.
- $this->checkDatabaseWikiId( $dbw );
-
- $slotRoles = $rev->getSlotRoles();
-
- // Make sure the main slot is always provided throughout migration
- if ( !in_array( SlotRecord::MAIN, $slotRoles ) ) {
- throw new InvalidArgumentException(
- 'main slot must be provided'
- );
- }
-
- // If we are not writing into the new schema, we can't support extra slots.
- if ( !$this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_NEW )
- && $slotRoles !== [ SlotRecord::MAIN ]
- ) {
- throw new InvalidArgumentException(
- 'Only the main slot is supported when not writing to the MCR enabled schema!'
- );
- }
-
- // As long as we are not reading from the new schema, we don't want to write extra slots.
- if ( !$this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_NEW )
- && $slotRoles !== [ SlotRecord::MAIN ]
- ) {
- throw new InvalidArgumentException(
- 'Only the main slot is supported when not reading from the MCR enabled schema!'
- );
- }
-
- // Checks
- $this->failOnNull( $rev->getSize(), 'size field' );
- $this->failOnEmpty( $rev->getSha1(), 'sha1 field' );
- $this->failOnEmpty( $rev->getTimestamp(), 'timestamp field' );
- $comment = $this->failOnNull( $rev->getComment( RevisionRecord::RAW ), 'comment' );
- $user = $this->failOnNull( $rev->getUser( RevisionRecord::RAW ), 'user' );
- $this->failOnNull( $user->getId(), 'user field' );
- $this->failOnEmpty( $user->getName(), 'user_text field' );
-
- if ( !$rev->isReadyForInsertion() ) {
- // This is here for future-proofing. At the time this check being added, it
- // was redundant to the individual checks above.
- throw new IncompleteRevisionException( 'Revision is incomplete' );
- }
-
- // TODO: we shouldn't need an actual Title here.
- $title = Title::newFromLinkTarget( $rev->getPageAsLinkTarget() );
- $pageId = $this->failOnEmpty( $rev->getPageId(), 'rev_page field' ); // check this early
-
- $parentId = $rev->getParentId() === null
- ? $this->getPreviousRevisionId( $dbw, $rev )
- : $rev->getParentId();
-
- /** @var RevisionRecord $rev */
- $rev = $dbw->doAtomicSection(
- __METHOD__,
- function ( IDatabase $dbw, $fname ) use (
- $rev,
- $user,
- $comment,
- $title,
- $pageId,
- $parentId
- ) {
- return $this->insertRevisionInternal(
- $rev,
- $dbw,
- $user,
- $comment,
- $title,
- $pageId,
- $parentId
- );
- }
- );
-
- // sanity checks
- Assert::postcondition( $rev->getId() > 0, 'revision must have an ID' );
- Assert::postcondition( $rev->getPageId() > 0, 'revision must have a page ID' );
- Assert::postcondition(
- $rev->getComment( RevisionRecord::RAW ) !== null,
- 'revision must have a comment'
- );
- Assert::postcondition(
- $rev->getUser( RevisionRecord::RAW ) !== null,
- 'revision must have a user'
- );
-
- // Trigger exception if the main slot is missing.
- // Technically, this could go away after MCR migration: while
- // calling code may require a main slot to exist, RevisionStore
- // really should not know or care about that requirement.
- $rev->getSlot( SlotRecord::MAIN, RevisionRecord::RAW );
-
- foreach ( $slotRoles as $role ) {
- $slot = $rev->getSlot( $role, RevisionRecord::RAW );
- Assert::postcondition(
- $slot->getContent() !== null,
- $role . ' slot must have content'
- );
- Assert::postcondition(
- $slot->hasRevision(),
- $role . ' slot must have a revision associated'
- );
- }
-
- Hooks::run( 'RevisionRecordInserted', [ $rev ] );
-
- // TODO: deprecate in 1.32!
- $legacyRevision = new Revision( $rev );
- Hooks::run( 'RevisionInsertComplete', [ &$legacyRevision, null, null ] );
-
- return $rev;
- }
-
- private function insertRevisionInternal(
- RevisionRecord $rev,
- IDatabase $dbw,
- User $user,
- CommentStoreComment $comment,
- Title $title,
- $pageId,
- $parentId
- ) {
- $slotRoles = $rev->getSlotRoles();
-
- $revisionRow = $this->insertRevisionRowOn(
- $dbw,
- $rev,
- $title,
- $parentId
- );
-
- $revisionId = $revisionRow['rev_id'];
-
- $blobHints = [
- BlobStore::PAGE_HINT => $pageId,
- BlobStore::REVISION_HINT => $revisionId,
- BlobStore::PARENT_HINT => $parentId,
- ];
-
- $newSlots = [];
- foreach ( $slotRoles as $role ) {
- $slot = $rev->getSlot( $role, RevisionRecord::RAW );
-
- // If the SlotRecord already has a revision ID set, this means it already exists
- // in the database, and should already belong to the current revision.
- // However, a slot may already have a revision, but no content ID, if the slot
- // is emulated based on the archive table, because we are in SCHEMA_COMPAT_READ_OLD
- // mode, and the respective archive row was not yet migrated to the new schema.
- // In that case, a new slot row (and content row) must be inserted even during
- // undeletion.
- if ( $slot->hasRevision() && $slot->hasContentId() ) {
- // TODO: properly abort transaction if the assertion fails!
- Assert::parameter(
- $slot->getRevision() === $revisionId,
- 'slot role ' . $slot->getRole(),
- 'Existing slot should belong to revision '
- . $revisionId . ', but belongs to revision ' . $slot->getRevision() . '!'
- );
-
- // Slot exists, nothing to do, move along.
- // This happens when restoring archived revisions.
-
- $newSlots[$role] = $slot;
-
- // Write the main slot's text ID to the revision table for backwards compatibility
- if ( $slot->getRole() === SlotRecord::MAIN
- && $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_OLD )
- ) {
- $blobAddress = $slot->getAddress();
- $this->updateRevisionTextId( $dbw, $revisionId, $blobAddress );
- }
- } else {
- $newSlots[$role] = $this->insertSlotOn( $dbw, $revisionId, $slot, $title, $blobHints );
- }
- }
-
- $this->insertIpChangesRow( $dbw, $user, $rev, $revisionId );
-
- $rev = new RevisionStoreRecord(
- $title,
- $user,
- $comment,
- (object)$revisionRow,
- new RevisionSlots( $newSlots ),
- $this->wikiId
- );
-
- return $rev;
- }
-
- /**
- * @param IDatabase $dbw
- * @param int $revisionId
- * @param string &$blobAddress (may change!)
- *
- * @return int the text row id
- */
- private function updateRevisionTextId( IDatabase $dbw, $revisionId, &$blobAddress ) {
- $textId = $this->blobStore->getTextIdFromAddress( $blobAddress );
- if ( !$textId ) {
- throw new LogicException(
- 'Blob address not supported in 1.29 database schema: ' . $blobAddress
- );
- }
-
- // getTextIdFromAddress() is free to insert something into the text table, so $textId
- // may be a new value, not anything already contained in $blobAddress.
- $blobAddress = SqlBlobStore::makeAddressFromTextId( $textId );
-
- $dbw->update(
- 'revision',
- [ 'rev_text_id' => $textId ],
- [ 'rev_id' => $revisionId ],
- __METHOD__
- );
-
- return $textId;
- }
-
- /**
- * @param IDatabase $dbw
- * @param int $revisionId
- * @param SlotRecord $protoSlot
- * @param Title $title
- * @param array $blobHints See the BlobStore::XXX_HINT constants
- * @return SlotRecord
- */
- private function insertSlotOn(
- IDatabase $dbw,
- $revisionId,
- SlotRecord $protoSlot,
- Title $title,
- array $blobHints = []
- ) {
- if ( $protoSlot->hasAddress() ) {
- $blobAddress = $protoSlot->getAddress();
- } else {
- $blobAddress = $this->storeContentBlob( $protoSlot, $title, $blobHints );
- }
-
- $contentId = null;
-
- // Write the main slot's text ID to the revision table for backwards compatibility
- if ( $protoSlot->getRole() === SlotRecord::MAIN
- && $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_OLD )
- ) {
- // If SCHEMA_COMPAT_WRITE_NEW is also set, the fake content ID is overwritten
- // with the real content ID below.
- $textId = $this->updateRevisionTextId( $dbw, $revisionId, $blobAddress );
- $contentId = $this->emulateContentId( $textId );
- }
-
- if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_NEW ) ) {
- if ( $protoSlot->hasContentId() ) {
- $contentId = $protoSlot->getContentId();
- } else {
- $contentId = $this->insertContentRowOn( $protoSlot, $dbw, $blobAddress );
- }
-
- $this->insertSlotRowOn( $protoSlot, $dbw, $revisionId, $contentId );
- }
-
- $savedSlot = SlotRecord::newSaved(
- $revisionId,
- $contentId,
- $blobAddress,
- $protoSlot
- );
-
- return $savedSlot;
- }
-
- /**
- * Insert IP revision into ip_changes for use when querying for a range.
- * @param IDatabase $dbw
- * @param User $user
- * @param RevisionRecord $rev
- * @param int $revisionId
- */
- private function insertIpChangesRow(
- IDatabase $dbw,
- User $user,
- RevisionRecord $rev,
- $revisionId
- ) {
- if ( $user->getId() === 0 && IP::isValid( $user->getName() ) ) {
- $ipcRow = [
- 'ipc_rev_id' => $revisionId,
- 'ipc_rev_timestamp' => $dbw->timestamp( $rev->getTimestamp() ),
- 'ipc_hex' => IP::toHex( $user->getName() ),
- ];
- $dbw->insert( 'ip_changes', $ipcRow, __METHOD__ );
- }
- }
-
- /**
- * @param IDatabase $dbw
- * @param RevisionRecord $rev
- * @param Title $title
- * @param int $parentId
- *
- * @return array a revision table row
- *
- * @throws MWException
- * @throws MWUnknownContentModelException
- */
- private function insertRevisionRowOn(
- IDatabase $dbw,
- RevisionRecord $rev,
- Title $title,
- $parentId
- ) {
- $revisionRow = $this->getBaseRevisionRow( $dbw, $rev, $title, $parentId );
-
- list( $commentFields, $commentCallback ) =
- $this->commentStore->insertWithTempTable(
- $dbw,
- 'rev_comment',
- $rev->getComment( RevisionRecord::RAW )
- );
- $revisionRow += $commentFields;
-
- list( $actorFields, $actorCallback ) =
- $this->actorMigration->getInsertValuesWithTempTable(
- $dbw,
- 'rev_user',
- $rev->getUser( RevisionRecord::RAW )
- );
- $revisionRow += $actorFields;
-
- $dbw->insert( 'revision', $revisionRow, __METHOD__ );
-
- if ( !isset( $revisionRow['rev_id'] ) ) {
- // only if auto-increment was used
- $revisionRow['rev_id'] = intval( $dbw->insertId() );
-
- if ( $dbw->getType() === 'mysql' ) {
- // (T202032) MySQL until 8.0 and MariaDB until some version after 10.1.34 don't save the
- // auto-increment value to disk, so on server restart it might reuse IDs from deleted
- // revisions. We can fix that with an insert with an explicit rev_id value, if necessary.
-
- $maxRevId = intval( $dbw->selectField( 'archive', 'MAX(ar_rev_id)', '', __METHOD__ ) );
- $table = 'archive';
- if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_NEW ) ) {
- $maxRevId2 = intval( $dbw->selectField( 'slots', 'MAX(slot_revision_id)', '', __METHOD__ ) );
- if ( $maxRevId2 >= $maxRevId ) {
- $maxRevId = $maxRevId2;
- $table = 'slots';
- }
- }
-
- if ( $maxRevId >= $revisionRow['rev_id'] ) {
- $this->logger->debug(
- '__METHOD__: Inserted revision {revid} but {table} has revisions up to {maxrevid}.'
- . ' Trying to fix it.',
- [
- 'revid' => $revisionRow['rev_id'],
- 'table' => $table,
- 'maxrevid' => $maxRevId,
- ]
- );
-
- if ( !$dbw->lock( 'fix-for-T202032', __METHOD__ ) ) {
- throw new MWException( 'Failed to get database lock for T202032' );
- }
- $fname = __METHOD__;
- $dbw->onTransactionResolution( function ( $trigger, $dbw ) use ( $fname ) {
- $dbw->unlock( 'fix-for-T202032', $fname );
- } );
-
- $dbw->delete( 'revision', [ 'rev_id' => $revisionRow['rev_id'] ], __METHOD__ );
-
- // The locking here is mostly to make MySQL bypass the REPEATABLE-READ transaction
- // isolation (weird MySQL "feature"). It does seem to block concurrent auto-incrementing
- // inserts too, though, at least on MariaDB 10.1.29.
- //
- // Don't try to lock `revision` in this way, it'll deadlock if there are concurrent
- // transactions in this code path thanks to the row lock from the original ->insert() above.
- //
- // And we have to use raw SQL to bypass the "aggregation used with a locking SELECT" warning
- // that's for non-MySQL DBs.
- $row1 = $dbw->query(
- $dbw->selectSqlText( 'archive', [ 'v' => "MAX(ar_rev_id)" ], '', __METHOD__ ) . ' FOR UPDATE'
- )->fetchObject();
- if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_NEW ) ) {
- $row2 = $dbw->query(
- $dbw->selectSqlText( 'slots', [ 'v' => "MAX(slot_revision_id)" ], '', __METHOD__ )
- . ' FOR UPDATE'
- )->fetchObject();
- } else {
- $row2 = null;
- }
- $maxRevId = max(
- $maxRevId,
- $row1 ? intval( $row1->v ) : 0,
- $row2 ? intval( $row2->v ) : 0
- );
-
- // If we don't have SCHEMA_COMPAT_WRITE_NEW, all except the first of any concurrent
- // transactions will throw a duplicate key error here. It doesn't seem worth trying
- // to avoid that.
- $revisionRow['rev_id'] = $maxRevId + 1;
- $dbw->insert( 'revision', $revisionRow, __METHOD__ );
- }
- }
- }
-
- $commentCallback( $revisionRow['rev_id'] );
- $actorCallback( $revisionRow['rev_id'], $revisionRow );
-
- return $revisionRow;
- }
-
- /**
- * @param IDatabase $dbw
- * @param RevisionRecord $rev
- * @param Title $title
- * @param int $parentId
- *
- * @return array [ 0 => array $revisionRow, 1 => callable ]
- * @throws MWException
- * @throws MWUnknownContentModelException
- */
- private function getBaseRevisionRow(
- IDatabase $dbw,
- RevisionRecord $rev,
- Title $title,
- $parentId
- ) {
- // Record the edit in revisions
- $revisionRow = [
- 'rev_page' => $rev->getPageId(),
- 'rev_parent_id' => $parentId,
- 'rev_minor_edit' => $rev->isMinor() ? 1 : 0,
- 'rev_timestamp' => $dbw->timestamp( $rev->getTimestamp() ),
- 'rev_deleted' => $rev->getVisibility(),
- 'rev_len' => $rev->getSize(),
- 'rev_sha1' => $rev->getSha1(),
- ];
-
- if ( $rev->getId() !== null ) {
- // Needed to restore revisions with their original ID
- $revisionRow['rev_id'] = $rev->getId();
- }
-
- if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_OLD ) ) {
- // In non MCR mode this IF section will relate to the main slot
- $mainSlot = $rev->getSlot( SlotRecord::MAIN );
- $model = $mainSlot->getModel();
- $format = $mainSlot->getFormat();
-
- // MCR migration note: rev_content_model and rev_content_format will go away
- if ( $this->contentHandlerUseDB ) {
- $this->assertCrossWikiContentLoadingIsSafe();
-
- $defaultModel = ContentHandler::getDefaultModelFor( $title );
- $defaultFormat = ContentHandler::getForModelID( $defaultModel )->getDefaultFormat();
-
- $revisionRow['rev_content_model'] = ( $model === $defaultModel ) ? null : $model;
- $revisionRow['rev_content_format'] = ( $format === $defaultFormat ) ? null : $format;
- }
- }
-
- return $revisionRow;
- }
-
- /**
- * @param SlotRecord $slot
- * @param Title $title
- * @param array $blobHints See the BlobStore::XXX_HINT constants
- *
- * @throws MWException
- * @return string the blob address
- */
- private function storeContentBlob(
- SlotRecord $slot,
- Title $title,
- array $blobHints = []
- ) {
- $content = $slot->getContent();
- $format = $content->getDefaultFormat();
- $model = $content->getModel();
-
- $this->checkContent( $content, $title );
-
- return $this->blobStore->storeBlob(
- $content->serialize( $format ),
- // These hints "leak" some information from the higher abstraction layer to
- // low level storage to allow for optimization.
- array_merge(
- $blobHints,
- [
- BlobStore::DESIGNATION_HINT => 'page-content',
- BlobStore::ROLE_HINT => $slot->getRole(),
- BlobStore::SHA1_HINT => $slot->getSha1(),
- BlobStore::MODEL_HINT => $model,
- BlobStore::FORMAT_HINT => $format,
- ]
- )
- );
- }
-
- /**
- * @param SlotRecord $slot
- * @param IDatabase $dbw
- * @param int $revisionId
- * @param int $contentId
- */
- private function insertSlotRowOn( SlotRecord $slot, IDatabase $dbw, $revisionId, $contentId ) {
- $slotRow = [
- 'slot_revision_id' => $revisionId,
- 'slot_role_id' => $this->slotRoleStore->acquireId( $slot->getRole() ),
- 'slot_content_id' => $contentId,
- // If the slot has a specific origin use that ID, otherwise use the ID of the revision
- // that we just inserted.
- 'slot_origin' => $slot->hasOrigin() ? $slot->getOrigin() : $revisionId,
- ];
- $dbw->insert( 'slots', $slotRow, __METHOD__ );
- }
-
- /**
- * @param SlotRecord $slot
- * @param IDatabase $dbw
- * @param string $blobAddress
- * @return int content row ID
- */
- private function insertContentRowOn( SlotRecord $slot, IDatabase $dbw, $blobAddress ) {
- $contentRow = [
- 'content_size' => $slot->getSize(),
- 'content_sha1' => $slot->getSha1(),
- 'content_model' => $this->contentModelStore->acquireId( $slot->getModel() ),
- 'content_address' => $blobAddress,
- ];
- $dbw->insert( 'content', $contentRow, __METHOD__ );
- return intval( $dbw->insertId() );
- }
-
- /**
- * MCR migration note: this corresponds to Revision::checkContentModel
- *
- * @param Content $content
- * @param Title $title
- *
- * @throws MWException
- * @throws MWUnknownContentModelException
- */
- private function checkContent( Content $content, Title $title ) {
- // Note: may return null for revisions that have not yet been inserted
-
- $model = $content->getModel();
- $format = $content->getDefaultFormat();
- $handler = $content->getContentHandler();
-
- $name = "$title";
-
- if ( !$handler->isSupportedFormat( $format ) ) {
- throw new MWException( "Can't use format $format with content model $model on $name" );
- }
-
- if ( !$this->contentHandlerUseDB ) {
- // if $wgContentHandlerUseDB is not set,
- // all revisions must use the default content model and format.
-
- $this->assertCrossWikiContentLoadingIsSafe();
-
- $defaultModel = ContentHandler::getDefaultModelFor( $title );
- $defaultHandler = ContentHandler::getForModelID( $defaultModel );
- $defaultFormat = $defaultHandler->getDefaultFormat();
-
- if ( $model != $defaultModel ) {
- throw new MWException( "Can't save non-default content model with "
- . "\$wgContentHandlerUseDB disabled: model is $model, "
- . "default for $name is $defaultModel"
- );
- }
-
- if ( $format != $defaultFormat ) {
- throw new MWException( "Can't use non-default content format with "
- . "\$wgContentHandlerUseDB disabled: format is $format, "
- . "default for $name is $defaultFormat"
- );
- }
- }
-
- if ( !$content->isValid() ) {
- throw new MWException(
- "New content for $name is not valid! Content model is $model"
- );
- }
- }
-
- /**
- * Create a new null-revision for insertion into a page's
- * history. This will not re-save the text, but simply refer
- * to the text from the previous version.
- *
- * Such revisions can for instance identify page rename
- * operations and other such meta-modifications.
- *
- * @note This method grabs a FOR UPDATE lock on the relevant row of the page table,
- * to prevent a new revision from being inserted before the null revision has been written
- * to the database.
- *
- * MCR migration note: this replaces Revision::newNullRevision
- *
- * @todo Introduce newFromParentRevision(). newNullRevision can then be based on that
- * (or go away).
- *
- * @param IDatabase $dbw used for obtaining the lock on the page table row
- * @param Title $title Title of the page to read from
- * @param CommentStoreComment $comment RevisionRecord's summary
- * @param bool $minor Whether the revision should be considered as minor
- * @param User $user The user to attribute the revision to
- *
- * @return RevisionRecord|null RevisionRecord or null on error
- */
- public function newNullRevision(
- IDatabase $dbw,
- Title $title,
- CommentStoreComment $comment,
- $minor,
- User $user
- ) {
- $this->checkDatabaseWikiId( $dbw );
-
- $pageId = $title->getArticleID();
-
- // T51581: Lock the page table row to ensure no other process
- // is adding a revision to the page at the same time.
- // Avoid locking extra tables, compare T191892.
- $pageLatest = $dbw->selectField(
- 'page',
- 'page_latest',
- [ 'page_id' => $pageId ],
- __METHOD__,
- [ 'FOR UPDATE' ]
- );
-
- if ( !$pageLatest ) {
- return null;
- }
-
- // Fetch the actual revision row from master, without locking all extra tables.
- $oldRevision = $this->loadRevisionFromConds(
- $dbw,
- [ 'rev_id' => intval( $pageLatest ) ],
- self::READ_LATEST,
- $title
- );
-
- if ( !$oldRevision ) {
- $msg = "Failed to load latest revision ID $pageLatest of page ID $pageId.";
- $this->logger->error(
- $msg,
- [ 'exception' => new RuntimeException( $msg ) ]
- );
- return null;
- }
-
- // Construct the new revision
- $timestamp = wfTimestampNow(); // TODO: use a callback, so we can override it for testing.
- $newRevision = MutableRevisionRecord::newFromParentRevision( $oldRevision );
-
- $newRevision->setComment( $comment );
- $newRevision->setUser( $user );
- $newRevision->setTimestamp( $timestamp );
- $newRevision->setMinorEdit( $minor );
-
- return $newRevision;
- }
-
- /**
- * MCR migration note: this replaces Revision::isUnpatrolled
- *
- * @todo This is overly specific, so move or kill this method.
- *
- * @param RevisionRecord $rev
- *
- * @return int Rcid of the unpatrolled row, zero if there isn't one
- */
- public function getRcIdIfUnpatrolled( RevisionRecord $rev ) {
- $rc = $this->getRecentChange( $rev );
- if ( $rc && $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_UNPATROLLED ) {
- return $rc->getAttribute( 'rc_id' );
- } else {
- return 0;
- }
- }
-
- /**
- * Get the RC object belonging to the current revision, if there's one
- *
- * MCR migration note: this replaces Revision::getRecentChange
- *
- * @todo move this somewhere else?
- *
- * @param RevisionRecord $rev
- * @param int $flags (optional) $flags include:
- * IDBAccessObject::READ_LATEST: Select the data from the master
- *
- * @return null|RecentChange
- */
- public function getRecentChange( RevisionRecord $rev, $flags = 0 ) {
- list( $dbType, ) = DBAccessObjectUtils::getDBOptions( $flags );
- $db = $this->getDBConnection( $dbType );
-
- $userIdentity = $rev->getUser( RevisionRecord::RAW );
-
- if ( !$userIdentity ) {
- // If the revision has no user identity, chances are it never went
- // into the database, and doesn't have an RC entry.
- return null;
- }
-
- // TODO: Select by rc_this_oldid alone - but as of Nov 2017, there is no index on that!
- $actorWhere = $this->actorMigration->getWhere( $db, 'rc_user', $rev->getUser(), false );
- $rc = RecentChange::newFromConds(
- [
- $actorWhere['conds'],
- 'rc_timestamp' => $db->timestamp( $rev->getTimestamp() ),
- 'rc_this_oldid' => $rev->getId()
- ],
- __METHOD__,
- $dbType
- );
-
- $this->releaseDBConnection( $db );
-
- // XXX: cache this locally? Glue it to the RevisionRecord?
- return $rc;
- }
-
- /**
- * Maps fields of the archive row to corresponding revision rows.
- *
- * @param object $archiveRow
- *
- * @return object a revision row object, corresponding to $archiveRow.
- */
- private static function mapArchiveFields( $archiveRow ) {
- $fieldMap = [
- // keep with ar prefix:
- 'ar_id' => 'ar_id',
-
- // not the same suffix:
- 'ar_page_id' => 'rev_page',
- 'ar_rev_id' => 'rev_id',
-
- // same suffix:
- 'ar_text_id' => 'rev_text_id',
- 'ar_timestamp' => 'rev_timestamp',
- 'ar_user_text' => 'rev_user_text',
- 'ar_user' => 'rev_user',
- 'ar_actor' => 'rev_actor',
- 'ar_minor_edit' => 'rev_minor_edit',
- 'ar_deleted' => 'rev_deleted',
- 'ar_len' => 'rev_len',
- 'ar_parent_id' => 'rev_parent_id',
- 'ar_sha1' => 'rev_sha1',
- 'ar_comment' => 'rev_comment',
- 'ar_comment_cid' => 'rev_comment_cid',
- 'ar_comment_id' => 'rev_comment_id',
- 'ar_comment_text' => 'rev_comment_text',
- 'ar_comment_data' => 'rev_comment_data',
- 'ar_comment_old' => 'rev_comment_old',
- 'ar_content_format' => 'rev_content_format',
- 'ar_content_model' => 'rev_content_model',
- ];
-
- $revRow = new stdClass();
- foreach ( $fieldMap as $arKey => $revKey ) {
- if ( property_exists( $archiveRow, $arKey ) ) {
- $revRow->$revKey = $archiveRow->$arKey;
- }
- }
-
- return $revRow;
- }
-
- /**
- * Constructs a RevisionRecord for the revisions main slot, based on the MW1.29 schema.
- *
- * @param object|array $row Either a database row or an array
- * @param int $queryFlags for callbacks
- * @param Title $title
- *
- * @return SlotRecord The main slot, extracted from the MW 1.29 style row.
- * @throws MWException
- */
- private function emulateMainSlot_1_29( $row, $queryFlags, Title $title ) {
- $mainSlotRow = new stdClass();
- $mainSlotRow->role_name = SlotRecord::MAIN;
- $mainSlotRow->model_name = null;
- $mainSlotRow->slot_revision_id = null;
- $mainSlotRow->slot_content_id = null;
- $mainSlotRow->content_address = null;
-
- $content = null;
- $blobData = null;
- $blobFlags = null;
-
- if ( is_object( $row ) ) {
- if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_NEW ) ) {
- // Don't emulate from a row when using the new schema.
- // Emulating from an array is still OK.
- throw new LogicException( 'Can\'t emulate the main slot when using MCR schema.' );
- }
-
- // archive row
- if ( !isset( $row->rev_id ) && ( isset( $row->ar_user ) || isset( $row->ar_actor ) ) ) {
- $row = $this->mapArchiveFields( $row );
- }
-
- if ( isset( $row->rev_text_id ) && $row->rev_text_id > 0 ) {
- $mainSlotRow->content_address = SqlBlobStore::makeAddressFromTextId(
- $row->rev_text_id
- );
- }
-
- // This is used by null-revisions
- $mainSlotRow->slot_origin = isset( $row->slot_origin )
- ? intval( $row->slot_origin )
- : null;
-
- if ( isset( $row->old_text ) ) {
- // this happens when the text-table gets joined directly, in the pre-1.30 schema
- $blobData = isset( $row->old_text ) ? strval( $row->old_text ) : null;
- // Check against selects that might have not included old_flags
- if ( !property_exists( $row, 'old_flags' ) ) {
- throw new InvalidArgumentException( 'old_flags was not set in $row' );
- }
- $blobFlags = $row->old_flags ?? '';
- }
-
- $mainSlotRow->slot_revision_id = intval( $row->rev_id );
-
- $mainSlotRow->content_size = isset( $row->rev_len ) ? intval( $row->rev_len ) : null;
- $mainSlotRow->content_sha1 = isset( $row->rev_sha1 ) ? strval( $row->rev_sha1 ) : null;
- $mainSlotRow->model_name = isset( $row->rev_content_model )
- ? strval( $row->rev_content_model )
- : null;
- // XXX: in the future, we'll probably always use the default format, and drop content_format
- $mainSlotRow->format_name = isset( $row->rev_content_format )
- ? strval( $row->rev_content_format )
- : null;
-
- if ( isset( $row->rev_text_id ) && intval( $row->rev_text_id ) > 0 ) {
- // Overwritten below for SCHEMA_COMPAT_WRITE_NEW
- $mainSlotRow->slot_content_id
- = $this->emulateContentId( intval( $row->rev_text_id ) );
- }
- } elseif ( is_array( $row ) ) {
- $mainSlotRow->slot_revision_id = isset( $row['id'] ) ? intval( $row['id'] ) : null;
-
- $mainSlotRow->slot_origin = isset( $row['slot_origin'] )
- ? intval( $row['slot_origin'] )
- : null;
- $mainSlotRow->content_address = isset( $row['text_id'] )
- ? SqlBlobStore::makeAddressFromTextId( intval( $row['text_id'] ) )
- : null;
- $mainSlotRow->content_size = isset( $row['len'] ) ? intval( $row['len'] ) : null;
- $mainSlotRow->content_sha1 = isset( $row['sha1'] ) ? strval( $row['sha1'] ) : null;
-
- $mainSlotRow->model_name = isset( $row['content_model'] )
- ? strval( $row['content_model'] ) : null; // XXX: must be a string!
- // XXX: in the future, we'll probably always use the default format, and drop content_format
- $mainSlotRow->format_name = isset( $row['content_format'] )
- ? strval( $row['content_format'] ) : null;
- $blobData = isset( $row['text'] ) ? rtrim( strval( $row['text'] ) ) : null;
- // XXX: If the flags field is not set then $blobFlags should be null so that no
- // decoding will happen. An empty string will result in default decodings.
- $blobFlags = isset( $row['flags'] ) ? trim( strval( $row['flags'] ) ) : null;
-
- // if we have a Content object, override mText and mContentModel
- if ( !empty( $row['content'] ) ) {
- if ( !( $row['content'] instanceof Content ) ) {
- throw new MWException( 'content field must contain a Content object.' );
- }
-
- /** @var Content $content */
- $content = $row['content'];
- $handler = $content->getContentHandler();
-
- $mainSlotRow->model_name = $content->getModel();
-
- // XXX: in the future, we'll probably always use the default format.
- if ( $mainSlotRow->format_name === null ) {
- $mainSlotRow->format_name = $handler->getDefaultFormat();
- }
- }
-
- if ( isset( $row['text_id'] ) && intval( $row['text_id'] ) > 0 ) {
- // Overwritten below for SCHEMA_COMPAT_WRITE_NEW
- $mainSlotRow->slot_content_id
- = $this->emulateContentId( intval( $row['text_id'] ) );
- }
- } else {
- throw new MWException( 'Revision constructor passed invalid row format.' );
- }
-
- // With the old schema, the content changes with every revision,
- // except for null-revisions.
- if ( !isset( $mainSlotRow->slot_origin ) ) {
- $mainSlotRow->slot_origin = $mainSlotRow->slot_revision_id;
- }
-
- if ( $mainSlotRow->model_name === null ) {
- $mainSlotRow->model_name = function ( SlotRecord $slot ) use ( $title ) {
- $this->assertCrossWikiContentLoadingIsSafe();
-
- // TODO: MCR: consider slot role in getDefaultModelFor()! Use LinkTarget!
- // TODO: MCR: deprecate $title->getModel().
- return ContentHandler::getDefaultModelFor( $title );
- };
- }
-
- if ( !$content ) {
- // XXX: We should perhaps fail if $blobData is null and $mainSlotRow->content_address
- // is missing, but "empty revisions" with no content are used in some edge cases.
-
- $content = function ( SlotRecord $slot )
- use ( $blobData, $blobFlags, $queryFlags, $mainSlotRow )
- {
- return $this->loadSlotContent(
- $slot,
- $blobData,
- $blobFlags,
- $mainSlotRow->format_name,
- $queryFlags
- );
- };
- }
-
- if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_NEW ) ) {
- // NOTE: this callback will be looped through RevisionSlot::newInherited(), allowing
- // the inherited slot to have the same content_id as the original slot. In that case,
- // $slot will be the inherited slot, while $mainSlotRow still refers to the original slot.
- $mainSlotRow->slot_content_id =
- function ( SlotRecord $slot ) use ( $queryFlags, $mainSlotRow ) {
- $db = $this->getDBConnectionRefForQueryFlags( $queryFlags );
- return $this->findSlotContentId( $db, $mainSlotRow->slot_revision_id, SlotRecord::MAIN );
- };
- }
-
- return new SlotRecord( $mainSlotRow, $content );
- }
-
- /**
- * Provides a content ID to use with emulated SlotRecords in SCHEMA_COMPAT_OLD mode,
- * based on the revision's text ID (rev_text_id or ar_text_id, respectively).
- * Note that in SCHEMA_COMPAT_WRITE_BOTH, a callback to findSlotContentId() should be used
- * instead, since in that mode, some revision rows may already have a real content ID,
- * while other's don't - and for the ones that don't, we should indicate that it
- * is missing and cause SlotRecords::hasContentId() to return false.
- *
- * @param int $textId
- * @return int The emulated content ID
- */
- private function emulateContentId( $textId ) {
- // Return a negative number to ensure the ID is distinct from any real content IDs
- // that will be assigned in SCHEMA_COMPAT_WRITE_NEW mode and read in SCHEMA_COMPAT_READ_NEW
- // mode.
- return -$textId;
- }
-
- /**
- * Loads a Content object based on a slot row.
- *
- * This method does not call $slot->getContent(), and may be used as a callback
- * called by $slot->getContent().
- *
- * MCR migration note: this roughly corresponds to Revision::getContentInternal
- *
- * @param SlotRecord $slot The SlotRecord to load content for
- * @param string|null $blobData The content blob, in the form indicated by $blobFlags
- * @param string|null $blobFlags Flags indicating how $blobData needs to be processed.
- * Use null if no processing should happen. That is in constrast to the empty string,
- * which causes the blob to be decoded according to the configured legacy encoding.
- * @param string|null $blobFormat MIME type indicating how $dataBlob is encoded
- * @param int $queryFlags
- *
- * @throws RevisionAccessException
- * @return Content
- */
- private function loadSlotContent(
- SlotRecord $slot,
- $blobData = null,
- $blobFlags = null,
- $blobFormat = null,
- $queryFlags = 0
- ) {
- if ( $blobData !== null ) {
- Assert::parameterType( 'string', $blobData, '$blobData' );
- Assert::parameterType( 'string|null', $blobFlags, '$blobFlags' );
-
- $cacheKey = $slot->hasAddress() ? $slot->getAddress() : null;
-
- if ( $blobFlags === null ) {
- // No blob flags, so use the blob verbatim.
- $data = $blobData;
- } else {
- $data = $this->blobStore->expandBlob( $blobData, $blobFlags, $cacheKey );
- if ( $data === false ) {
- throw new RevisionAccessException(
- "Failed to expand blob data using flags $blobFlags (key: $cacheKey)"
- );
- }
- }
-
- } else {
- $address = $slot->getAddress();
- try {
- $data = $this->blobStore->getBlob( $address, $queryFlags );
- } catch ( BlobAccessException $e ) {
- throw new RevisionAccessException(
- "Failed to load data blob from $address: " . $e->getMessage(), 0, $e
- );
- }
- }
-
- // Unserialize content
- $handler = ContentHandler::getForModelID( $slot->getModel() );
-
- $content = $handler->unserializeContent( $data, $blobFormat );
- return $content;
- }
-
- /**
- * Load a page revision from a given revision ID number.
- * Returns null if no such revision can be found.
- *
- * MCR migration note: this replaces Revision::newFromId
- *
- * $flags include:
- * IDBAccessObject::READ_LATEST: Select the data from the master
- * IDBAccessObject::READ_LOCKING : Select & lock the data from the master
- *
- * @param int $id
- * @param int $flags (optional)
- * @return RevisionRecord|null
- */
- public function getRevisionById( $id, $flags = 0 ) {
- return $this->newRevisionFromConds( [ 'rev_id' => intval( $id ) ], $flags );
- }
-
- /**
- * Load either the current, or a specified, revision
- * that's attached to a given link target. If not attached
- * to that link target, will return null.
- *
- * MCR migration note: this replaces Revision::newFromTitle
- *
- * $flags include:
- * IDBAccessObject::READ_LATEST: Select the data from the master
- * IDBAccessObject::READ_LOCKING : Select & lock the data from the master
- *
- * @param LinkTarget $linkTarget
- * @param int $revId (optional)
- * @param int $flags Bitfield (optional)
- * @return RevisionRecord|null
- */
- public function getRevisionByTitle( LinkTarget $linkTarget, $revId = 0, $flags = 0 ) {
- $conds = [
- 'page_namespace' => $linkTarget->getNamespace(),
- 'page_title' => $linkTarget->getDBkey()
- ];
- if ( $revId ) {
- // Use the specified revision ID.
- // Note that we use newRevisionFromConds here because we want to retry
- // and fall back to master if the page is not found on a replica.
- // Since the caller supplied a revision ID, we are pretty sure the revision is
- // supposed to exist, so we should try hard to find it.
- $conds['rev_id'] = $revId;
- return $this->newRevisionFromConds( $conds, $flags );
- } else {
- // Use a join to get the latest revision.
- // Note that we don't use newRevisionFromConds here because we don't want to retry
- // and fall back to master. The assumption is that we only want to force the fallback
- // if we are quite sure the revision exists because the caller supplied a revision ID.
- // If the page isn't found at all on a replica, it probably simply does not exist.
- $db = $this->getDBConnectionRefForQueryFlags( $flags );
-
- $conds[] = 'rev_id=page_latest';
- $rev = $this->loadRevisionFromConds( $db, $conds, $flags );
-
- return $rev;
- }
- }
-
- /**
- * Load either the current, or a specified, revision
- * that's attached to a given page ID.
- * Returns null if no such revision can be found.
- *
- * MCR migration note: this replaces Revision::newFromPageId
- *
- * $flags include:
- * IDBAccessObject::READ_LATEST: Select the data from the master (since 1.20)
- * IDBAccessObject::READ_LOCKING : Select & lock the data from the master
- *
- * @param int $pageId
- * @param int $revId (optional)
- * @param int $flags Bitfield (optional)
- * @return RevisionRecord|null
- */
- public function getRevisionByPageId( $pageId, $revId = 0, $flags = 0 ) {
- $conds = [ 'page_id' => $pageId ];
- if ( $revId ) {
- // Use the specified revision ID.
- // Note that we use newRevisionFromConds here because we want to retry
- // and fall back to master if the page is not found on a replica.
- // Since the caller supplied a revision ID, we are pretty sure the revision is
- // supposed to exist, so we should try hard to find it.
- $conds['rev_id'] = $revId;
- return $this->newRevisionFromConds( $conds, $flags );
- } else {
- // Use a join to get the latest revision.
- // Note that we don't use newRevisionFromConds here because we don't want to retry
- // and fall back to master. The assumption is that we only want to force the fallback
- // if we are quite sure the revision exists because the caller supplied a revision ID.
- // If the page isn't found at all on a replica, it probably simply does not exist.
- $db = $this->getDBConnectionRefForQueryFlags( $flags );
-
- $conds[] = 'rev_id=page_latest';
- $rev = $this->loadRevisionFromConds( $db, $conds, $flags );
-
- return $rev;
- }
- }
-
- /**
- * Load the revision for the given title with the given timestamp.
- * WARNING: Timestamps may in some circumstances not be unique,
- * so this isn't the best key to use.
- *
- * MCR migration note: this replaces Revision::loadFromTimestamp
- *
- * @param Title $title
- * @param string $timestamp
- * @return RevisionRecord|null
- */
- public function getRevisionByTimestamp( $title, $timestamp ) {
- $db = $this->getDBConnection( DB_REPLICA );
- return $this->newRevisionFromConds(
- [
- 'rev_timestamp' => $db->timestamp( $timestamp ),
- 'page_namespace' => $title->getNamespace(),
- 'page_title' => $title->getDBkey()
- ],
- 0,
- $title
- );
- }
-
- /**
- * @param int $revId The revision to load slots for.
- * @param int $queryFlags
- *
- * @return SlotRecord[]
- */
- private function loadSlotRecords( $revId, $queryFlags ) {
- $revQuery = self::getSlotsQueryInfo( [ 'content' ] );
-
- list( $dbMode, $dbOptions ) = DBAccessObjectUtils::getDBOptions( $queryFlags );
- $db = $this->getDBConnectionRef( $dbMode );
-
- $res = $db->select(
- $revQuery['tables'],
- $revQuery['fields'],
- [
- 'slot_revision_id' => $revId,
- ],
- __METHOD__,
- $dbOptions,
- $revQuery['joins']
- );
-
- $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 );
-
- $contentCallback = function ( SlotRecord $slot ) use ( $queryFlags, $row ) {
- return $this->loadSlotContent( $slot, null, null, null, $queryFlags );
- };
-
- $slots[$row->role_name] = new SlotRecord( $row, $contentCallback );
- }
-
- if ( !isset( $slots[SlotRecord::MAIN] ) ) {
- throw new RevisionAccessException(
- 'Main slot of revision ' . $revId . ' not found in database!'
- );
- };
-
- return $slots;
- }
-
- /**
- * Factory method for RevisionSlots.
- *
- * @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 int $queryFlags
- * @param Title $title
- *
- * @return RevisionSlots
- * @throws MWException
- */
- private function newRevisionSlots(
- $revId,
- $revisionRow,
- $queryFlags,
- Title $title
- ) {
- if ( !$this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_NEW ) ) {
- $mainSlot = $this->emulateMainSlot_1_29( $revisionRow, $queryFlags, $title );
- $slots = new RevisionSlots( [ SlotRecord::MAIN => $mainSlot ] );
- } else {
- // 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 );
- } );
- }
-
- return $slots;
- }
-
- /**
- * Make a fake revision object from an archive table row. This is queried
- * for permissions or even inserted (as in Special:Undelete)
- *
- * MCR migration note: this replaces Revision::newFromArchiveRow
- *
- * @param object $row
- * @param int $queryFlags
- * @param Title|null $title
- * @param array $overrides associative array with fields of $row to override. This may be
- * used e.g. to force the parent revision ID or page ID. Keys in the array are fields
- * names from the archive table without the 'ar_' prefix, i.e. use 'parent_id' to
- * override ar_parent_id.
- *
- * @return RevisionRecord
- * @throws MWException
- */
- public function newRevisionFromArchiveRow(
- $row,
- $queryFlags = 0,
- Title $title = null,
- array $overrides = []
- ) {
- Assert::parameterType( 'object', $row, '$row' );
-
- // check second argument, since Revision::newFromArchiveRow had $overrides in that spot.
- Assert::parameterType( 'integer', $queryFlags, '$queryFlags' );
-
- if ( !$title && isset( $overrides['title'] ) ) {
- if ( !( $overrides['title'] instanceof Title ) ) {
- throw new MWException( 'title field override must contain a Title object.' );
- }
-
- $title = $overrides['title'];
- }
-
- if ( !isset( $title ) ) {
- if ( isset( $row->ar_namespace ) && isset( $row->ar_title ) ) {
- $title = Title::makeTitle( $row->ar_namespace, $row->ar_title );
- } else {
- throw new InvalidArgumentException(
- 'A Title or ar_namespace and ar_title must be given'
- );
- }
- }
-
- foreach ( $overrides as $key => $value ) {
- $field = "ar_$key";
- $row->$field = $value;
- }
-
- try {
- $user = User::newFromAnyId(
- $row->ar_user ?? null,
- $row->ar_user_text ?? null,
- $row->ar_actor ?? null
- );
- } catch ( InvalidArgumentException $ex ) {
- wfWarn( __METHOD__ . ': ' . $title->getPrefixedDBkey() . ': ' . $ex->getMessage() );
- $user = new UserIdentityValue( 0, 'Unknown user', 0 );
- }
-
- $db = $this->getDBConnectionRefForQueryFlags( $queryFlags );
- // 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 );
-
- return new RevisionArchiveRecord( $title, $user, $comment, $row, $slots, $this->wikiId );
- }
-
- /**
- * @see RevisionFactory::newRevisionFromRow
- *
- * MCR migration note: this replaces Revision::newFromRow
- *
- * @param object $row
- * @param int $queryFlags
- * @param Title|null $title
- *
- * @return RevisionRecord
- */
- public function newRevisionFromRow( $row, $queryFlags = 0, Title $title = null ) {
- Assert::parameterType( 'object', $row, '$row' );
-
- if ( !$title ) {
- $pageId = $row->rev_page ?? 0; // XXX: also check page_id?
- $revId = $row->rev_id ?? 0;
-
- $title = $this->getTitle( $pageId, $revId, $queryFlags );
- }
-
- if ( !isset( $row->page_latest ) ) {
- $row->page_latest = $title->getLatestRevID();
- if ( $row->page_latest === 0 && $title->exists() ) {
- wfWarn( 'Encountered title object in limbo: ID ' . $title->getArticleID() );
- }
- }
-
- try {
- $user = User::newFromAnyId(
- $row->rev_user ?? null,
- $row->rev_user_text ?? null,
- $row->rev_actor ?? null
- );
- } catch ( InvalidArgumentException $ex ) {
- wfWarn( __METHOD__ . ': ' . $title->getPrefixedDBkey() . ': ' . $ex->getMessage() );
- $user = new UserIdentityValue( 0, 'Unknown user', 0 );
- }
-
- $db = $this->getDBConnectionRefForQueryFlags( $queryFlags );
- // 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 );
-
- return new RevisionStoreRecord( $title, $user, $comment, $row, $slots, $this->wikiId );
- }
-
- /**
- * Constructs a new MutableRevisionRecord based on the given associative array following
- * the MW1.29 convention for the Revision constructor.
- *
- * MCR migration note: this replaces Revision::newFromRow
- *
- * @param array $fields
- * @param int $queryFlags
- * @param Title|null $title
- *
- * @return MutableRevisionRecord
- * @throws MWException
- * @throws RevisionAccessException
- */
- public function newMutableRevisionFromArray(
- array $fields,
- $queryFlags = 0,
- Title $title = null
- ) {
- if ( !$title && isset( $fields['title'] ) ) {
- if ( !( $fields['title'] instanceof Title ) ) {
- throw new MWException( 'title field must contain a Title object.' );
- }
-
- $title = $fields['title'];
- }
-
- if ( !$title ) {
- $pageId = $fields['page'] ?? 0;
- $revId = $fields['id'] ?? 0;
-
- $title = $this->getTitle( $pageId, $revId, $queryFlags );
- }
-
- if ( !isset( $fields['page'] ) ) {
- $fields['page'] = $title->getArticleID( $queryFlags );
- }
-
- // if we have a content object, use it to set the model and type
- if ( !empty( $fields['content'] ) ) {
- if ( !( $fields['content'] instanceof Content ) && !is_array( $fields['content'] ) ) {
- throw new MWException(
- 'content field must contain a Content object or an array of Content objects.'
- );
- }
- }
-
- if ( !empty( $fields['text_id'] ) ) {
- if ( !$this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_OLD ) ) {
- throw new MWException( "The text_id field is only available in the pre-MCR schema" );
- }
-
- if ( !empty( $fields['content'] ) ) {
- throw new MWException(
- "Text already stored in external store (id {$fields['text_id']}), " .
- "can't specify content object"
- );
- }
- }
-
- if (
- isset( $fields['comment'] )
- && !( $fields['comment'] instanceof CommentStoreComment )
- ) {
- $commentData = $fields['comment_data'] ?? null;
-
- if ( $fields['comment'] instanceof Message ) {
- $fields['comment'] = CommentStoreComment::newUnsavedComment(
- $fields['comment'],
- $commentData
- );
- } else {
- $commentText = trim( strval( $fields['comment'] ) );
- $fields['comment'] = CommentStoreComment::newUnsavedComment(
- $commentText,
- $commentData
- );
- }
- }
-
- $revision = new MutableRevisionRecord( $title, $this->wikiId );
- $this->initializeMutableRevisionFromArray( $revision, $fields );
-
- if ( isset( $fields['content'] ) && is_array( $fields['content'] ) ) {
- foreach ( $fields['content'] as $role => $content ) {
- $revision->setContent( $role, $content );
- }
- } else {
- $mainSlot = $this->emulateMainSlot_1_29( $fields, $queryFlags, $title );
- $revision->setSlot( $mainSlot );
- }
-
- return $revision;
- }
-
- /**
- * @param MutableRevisionRecord $record
- * @param array $fields
- */
- private function initializeMutableRevisionFromArray(
- MutableRevisionRecord $record,
- array $fields
- ) {
- /** @var UserIdentity $user */
- $user = null;
-
- if ( isset( $fields['user'] ) && ( $fields['user'] instanceof UserIdentity ) ) {
- $user = $fields['user'];
- } else {
- try {
- $user = User::newFromAnyId(
- $fields['user'] ?? null,
- $fields['user_text'] ?? null,
- $fields['actor'] ?? null
- );
- } catch ( InvalidArgumentException $ex ) {
- $user = null;
- }
- }
-
- if ( $user ) {
- $record->setUser( $user );
- }
-
- $timestamp = isset( $fields['timestamp'] )
- ? strval( $fields['timestamp'] )
- : wfTimestampNow(); // TODO: use a callback, so we can override it for testing.
-
- $record->setTimestamp( $timestamp );
-
- if ( isset( $fields['page'] ) ) {
- $record->setPageId( intval( $fields['page'] ) );
- }
-
- if ( isset( $fields['id'] ) ) {
- $record->setId( intval( $fields['id'] ) );
- }
- if ( isset( $fields['parent_id'] ) ) {
- $record->setParentId( intval( $fields['parent_id'] ) );
- }
-
- if ( isset( $fields['sha1'] ) ) {
- $record->setSha1( $fields['sha1'] );
- }
- if ( isset( $fields['size'] ) ) {
- $record->setSize( intval( $fields['size'] ) );
- }
-
- if ( isset( $fields['minor_edit'] ) ) {
- $record->setMinorEdit( intval( $fields['minor_edit'] ) !== 0 );
- }
- if ( isset( $fields['deleted'] ) ) {
- $record->setVisibility( intval( $fields['deleted'] ) );
- }
-
- if ( isset( $fields['comment'] ) ) {
- Assert::parameterType(
- CommentStoreComment::class,
- $fields['comment'],
- '$row[\'comment\']'
- );
- $record->setComment( $fields['comment'] );
- }
- }
-
- /**
- * Load a page revision from a given revision ID number.
- * Returns null if no such revision can be found.
- *
- * MCR migration note: this corresponds to Revision::loadFromId
- *
- * @note direct use is deprecated!
- * @todo remove when unused! there seem to be no callers of Revision::loadFromId
- *
- * @param IDatabase $db
- * @param int $id
- *
- * @return RevisionRecord|null
- */
- public function loadRevisionFromId( IDatabase $db, $id ) {
- return $this->loadRevisionFromConds( $db, [ 'rev_id' => intval( $id ) ] );
- }
-
- /**
- * Load either the current, or a specified, revision
- * that's attached to a given page. If not attached
- * to that page, will return null.
- *
- * MCR migration note: this replaces Revision::loadFromPageId
- *
- * @note direct use is deprecated!
- * @todo remove when unused!
- *
- * @param IDatabase $db
- * @param int $pageid
- * @param int $id
- * @return RevisionRecord|null
- */
- public function loadRevisionFromPageId( IDatabase $db, $pageid, $id = 0 ) {
- $conds = [ 'rev_page' => intval( $pageid ), 'page_id' => intval( $pageid ) ];
- if ( $id ) {
- $conds['rev_id'] = intval( $id );
- } else {
- $conds[] = 'rev_id=page_latest';
- }
- return $this->loadRevisionFromConds( $db, $conds );
- }
-
- /**
- * Load either the current, or a specified, revision
- * that's attached to a given page. If not attached
- * to that page, will return null.
- *
- * MCR migration note: this replaces Revision::loadFromTitle
- *
- * @note direct use is deprecated!
- * @todo remove when unused!
- *
- * @param IDatabase $db
- * @param Title $title
- * @param int $id
- *
- * @return RevisionRecord|null
- */
- public function loadRevisionFromTitle( IDatabase $db, $title, $id = 0 ) {
- if ( $id ) {
- $matchId = intval( $id );
- } else {
- $matchId = 'page_latest';
- }
-
- return $this->loadRevisionFromConds(
- $db,
- [
- "rev_id=$matchId",
- 'page_namespace' => $title->getNamespace(),
- 'page_title' => $title->getDBkey()
- ],
- 0,
- $title
- );
- }
-
- /**
- * Load the revision for the given title with the given timestamp.
- * WARNING: Timestamps may in some circumstances not be unique,
- * so this isn't the best key to use.
- *
- * MCR migration note: this replaces Revision::loadFromTimestamp
- *
- * @note direct use is deprecated! Use getRevisionFromTimestamp instead!
- * @todo remove when unused!
- *
- * @param IDatabase $db
- * @param Title $title
- * @param string $timestamp
- * @return RevisionRecord|null
- */
- public function loadRevisionFromTimestamp( IDatabase $db, $title, $timestamp ) {
- return $this->loadRevisionFromConds( $db,
- [
- 'rev_timestamp' => $db->timestamp( $timestamp ),
- 'page_namespace' => $title->getNamespace(),
- 'page_title' => $title->getDBkey()
- ],
- 0,
- $title
- );
- }
-
- /**
- * Given a set of conditions, fetch a revision
- *
- * This method should be used if we are pretty sure the revision exists.
- * Unless $flags has READ_LATEST set, this method will first try to find the revision
- * on a replica before hitting the master database.
- *
- * MCR migration note: this corresponds to Revision::newFromConds
- *
- * @param array $conditions
- * @param int $flags (optional)
- * @param Title|null $title
- *
- * @return RevisionRecord|null
- */
- private function newRevisionFromConds( $conditions, $flags = 0, Title $title = null ) {
- $db = $this->getDBConnectionRefForQueryFlags( $flags );
- $rev = $this->loadRevisionFromConds( $db, $conditions, $flags, $title );
-
- $lb = $this->getDBLoadBalancer();
-
- // Make sure new pending/committed revision are visibile later on
- // within web requests to certain avoid bugs like T93866 and T94407.
- if ( !$rev
- && !( $flags & self::READ_LATEST )
- && $lb->getServerCount() > 1
- && $lb->hasOrMadeRecentMasterChanges()
- ) {
- $flags = self::READ_LATEST;
- $dbw = $this->getDBConnection( DB_MASTER );
- $rev = $this->loadRevisionFromConds( $dbw, $conditions, $flags, $title );
- $this->releaseDBConnection( $dbw );
- }
-
- return $rev;
- }
-
- /**
- * Given a set of conditions, fetch a revision from
- * the given database connection.
- *
- * MCR migration note: this corresponds to Revision::loadFromConds
- *
- * @param IDatabase $db
- * @param array $conditions
- * @param int $flags (optional)
- * @param Title|null $title
- *
- * @return RevisionRecord|null
- */
- private function loadRevisionFromConds(
- IDatabase $db,
- $conditions,
- $flags = 0,
- Title $title = null
- ) {
- $row = $this->fetchRevisionRowFromConds( $db, $conditions, $flags );
- if ( $row ) {
- $rev = $this->newRevisionFromRow( $row, $flags, $title );
-
- return $rev;
- }
-
- return null;
- }
-
- /**
- * Throws an exception if the given database connection does not belong to the wiki this
- * RevisionStore is bound to.
- *
- * @param IDatabase $db
- * @throws MWException
- */
- private function checkDatabaseWikiId( IDatabase $db ) {
- $storeWiki = $this->wikiId;
- $dbWiki = $db->getDomainID();
-
- if ( $dbWiki === $storeWiki ) {
- return;
- }
-
- // XXX: we really want the default database ID...
- $storeWiki = $storeWiki ?: wfWikiID();
- $dbWiki = $dbWiki ?: wfWikiID();
-
- if ( $dbWiki === $storeWiki ) {
- return;
- }
-
- // HACK: counteract encoding imposed by DatabaseDomain
- $storeWiki = str_replace( '?h', '-', $storeWiki );
- $dbWiki = str_replace( '?h', '-', $dbWiki );
-
- if ( $dbWiki === $storeWiki ) {
- return;
- }
-
- throw new MWException( "RevisionStore for $storeWiki "
- . "cannot be used with a DB connection for $dbWiki" );
- }
-
- /**
- * Given a set of conditions, return a row with the
- * fields necessary to build RevisionRecord objects.
- *
- * MCR migration note: this corresponds to Revision::fetchFromConds
- *
- * @param IDatabase $db
- * @param array $conditions
- * @param int $flags (optional)
- *
- * @return object|false data row as a raw object
- */
- private function fetchRevisionRowFromConds( IDatabase $db, $conditions, $flags = 0 ) {
- $this->checkDatabaseWikiId( $db );
-
- $revQuery = $this->getQueryInfo( [ 'page', 'user' ] );
- $options = [];
- if ( ( $flags & self::READ_LOCKING ) == self::READ_LOCKING ) {
- $options[] = 'FOR UPDATE';
- }
- return $db->selectRow(
- $revQuery['tables'],
- $revQuery['fields'],
- $conditions,
- __METHOD__,
- $options,
- $revQuery['joins']
- );
- }
-
- /**
- * Finds the ID of a content row for a given revision and slot role.
- * This can be used to re-use content rows even while the content ID
- * is still missing from SlotRecords, when writing to both the old and
- * the new schema during MCR schema migration.
- *
- * @todo remove after MCR schema migration is complete.
- *
- * @param IDatabase $db
- * @param int $revId
- * @param string $role
- *
- * @return int|null
- */
- private function findSlotContentId( IDatabase $db, $revId, $role ) {
- if ( !$this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_NEW ) ) {
- return null;
- }
-
- try {
- $roleId = $this->slotRoleStore->getId( $role );
- $conditions = [
- 'slot_revision_id' => $revId,
- 'slot_role_id' => $roleId,
- ];
-
- $contentId = $db->selectField( 'slots', 'slot_content_id', $conditions, __METHOD__ );
-
- return $contentId ?: null;
- } catch ( NameTableAccessException $ex ) {
- // If the role is missing from the slot_roles table,
- // the corresponding row in slots cannot exist.
- return null;
- }
- }
-
- /**
- * Return the tables, fields, and join conditions to be selected to create
- * a new RevisionStoreRecord object.
- *
- * MCR migration note: this replaces Revision::getQueryInfo
- *
- * If the format of fields returned changes in any way then the cache key provided by
- * self::getRevisionRowCacheKey should be updated.
- *
- * @since 1.31
- *
- * @param array $options Any combination of the following strings
- * - 'page': Join with the page table, and select fields to identify the page
- * - 'user': Join with the user table, and select the user name
- * - 'text': Join with the text table, and select fields to load page text. This
- * option is deprecated in MW 1.32 when the MCR migration flag SCHEMA_COMPAT_WRITE_NEW
- * is set, and disallowed when SCHEMA_COMPAT_READ_OLD is not set.
- *
- * @return array With three keys:
- * - tables: (string[]) to include in the `$table` to `IDatabase->select()`
- * - fields: (string[]) to include in the `$vars` to `IDatabase->select()`
- * - joins: (array) to include in the `$join_conds` to `IDatabase->select()`
- */
- public function getQueryInfo( $options = [] ) {
- $ret = [
- 'tables' => [],
- 'fields' => [],
- 'joins' => [],
- ];
-
- $ret['tables'][] = 'revision';
- $ret['fields'] = array_merge( $ret['fields'], [
- 'rev_id',
- 'rev_page',
- 'rev_timestamp',
- 'rev_minor_edit',
- 'rev_deleted',
- 'rev_len',
- 'rev_parent_id',
- 'rev_sha1',
- ] );
-
- $commentQuery = $this->commentStore->getJoin( 'rev_comment' );
- $ret['tables'] = array_merge( $ret['tables'], $commentQuery['tables'] );
- $ret['fields'] = array_merge( $ret['fields'], $commentQuery['fields'] );
- $ret['joins'] = array_merge( $ret['joins'], $commentQuery['joins'] );
-
- $actorQuery = $this->actorMigration->getJoin( 'rev_user' );
- $ret['tables'] = array_merge( $ret['tables'], $actorQuery['tables'] );
- $ret['fields'] = array_merge( $ret['fields'], $actorQuery['fields'] );
- $ret['joins'] = array_merge( $ret['joins'], $actorQuery['joins'] );
-
- if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_OLD ) ) {
- $ret['fields'][] = 'rev_text_id';
-
- if ( $this->contentHandlerUseDB ) {
- $ret['fields'][] = 'rev_content_format';
- $ret['fields'][] = 'rev_content_model';
- }
- }
-
- if ( in_array( 'page', $options, true ) ) {
- $ret['tables'][] = 'page';
- $ret['fields'] = array_merge( $ret['fields'], [
- 'page_namespace',
- 'page_title',
- 'page_id',
- 'page_latest',
- 'page_is_redirect',
- 'page_len',
- ] );
- $ret['joins']['page'] = [ 'INNER JOIN', [ 'page_id = rev_page' ] ];
- }
-
- if ( in_array( 'user', $options, true ) ) {
- $ret['tables'][] = 'user';
- $ret['fields'] = array_merge( $ret['fields'], [
- 'user_name',
- ] );
- $u = $actorQuery['fields']['rev_user'];
- $ret['joins']['user'] = [ 'LEFT JOIN', [ "$u != 0", "user_id = $u" ] ];
- }
-
- if ( in_array( 'text', $options, true ) ) {
- if ( !$this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_OLD ) ) {
- throw new InvalidArgumentException( 'text table can no longer be joined directly' );
- } elseif ( !$this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_OLD ) ) {
- // NOTE: even when this class is set to not read from the old schema, callers
- // should still be able to join against the text table, as long as we are still
- // writing the old schema for compatibility.
- // TODO: This should trigger a deprecation warning eventually (T200918), but not
- // before all known usages are removed (see T198341 and T201164).
- // wfDeprecated( __METHOD__ . ' with `text` option', '1.32' );
- }
-
- $ret['tables'][] = 'text';
- $ret['fields'] = array_merge( $ret['fields'], [
- 'old_text',
- 'old_flags'
- ] );
- $ret['joins']['text'] = [ 'INNER JOIN', [ 'rev_text_id=old_id' ] ];
- }
-
- return $ret;
- }
-
- /**
- * Return the tables, fields, and join conditions to be selected to create
- * a new SlotRecord.
- *
- * @since 1.32
- *
- * @param array $options Any combination of the following strings
- * - 'content': Join with the content table, and select content meta-data fields
- * - 'model': Join with the content_models table, and select the model_name field.
- * Only applicable if 'content' is also set.
- * - 'role': Join with the slot_roles table, and select the role_name field
- *
- * @return array With three keys:
- * - tables: (string[]) to include in the `$table` to `IDatabase->select()`
- * - fields: (string[]) to include in the `$vars` to `IDatabase->select()`
- * - joins: (array) to include in the `$join_conds` to `IDatabase->select()`
- */
- public function getSlotsQueryInfo( $options = [] ) {
- $ret = [
- 'tables' => [],
- 'fields' => [],
- 'joins' => [],
- ];
-
- if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_OLD ) ) {
- $db = $this->getDBConnectionRef( DB_REPLICA );
- $ret['tables']['slots'] = 'revision';
-
- $ret['fields']['slot_revision_id'] = 'slots.rev_id';
- $ret['fields']['slot_content_id'] = 'NULL';
- $ret['fields']['slot_origin'] = 'slots.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_address']
- = $db->buildConcat( [ $db->addQuotes( 'tt:' ), 'slots.rev_text_id' ] );
-
- if ( $this->contentHandlerUseDB ) {
- $ret['fields']['model_name'] = 'slots.rev_content_model';
- } else {
- $ret['fields']['model_name'] = 'NULL';
- }
- }
- } else {
- $ret['tables'][] = 'slots';
- $ret['fields'] = array_merge( $ret['fields'], [
- 'slot_revision_id',
- 'slot_content_id',
- 'slot_origin',
- 'slot_role_id',
- ] );
-
- if ( in_array( 'role', $options, true ) ) {
- // Use left join to attach role name, so we still find the revision row even
- // if the role name is missing. This triggers a more obvious failure mode.
- $ret['tables'][] = 'slot_roles';
- $ret['joins']['slot_roles'] = [ 'LEFT JOIN', [ 'slot_role_id = role_id' ] ];
- $ret['fields'][] = 'role_name';
- }
-
- if ( in_array( 'content', $options, true ) ) {
- $ret['tables'][] = 'content';
- $ret['fields'] = array_merge( $ret['fields'], [
- 'content_size',
- 'content_sha1',
- 'content_address',
- 'content_model',
- ] );
- $ret['joins']['content'] = [ 'INNER JOIN', [ 'slot_content_id = content_id' ] ];
-
- if ( in_array( 'model', $options, true ) ) {
- // Use left join to attach model name, so we still find the revision row even
- // if the model name is missing. This triggers a more obvious failure mode.
- $ret['tables'][] = 'content_models';
- $ret['joins']['content_models'] = [ 'LEFT JOIN', [ 'content_model = model_id' ] ];
- $ret['fields'][] = 'model_name';
- }
-
- }
- }
-
- return $ret;
- }
-
- /**
- * Return the tables, fields, and join conditions to be selected to create
- * a new RevisionArchiveRecord object.
- *
- * MCR migration note: this replaces Revision::getArchiveQueryInfo
- *
- * @since 1.31
- *
- * @return array With three keys:
- * - tables: (string[]) to include in the `$table` to `IDatabase->select()`
- * - fields: (string[]) to include in the `$vars` to `IDatabase->select()`
- * - joins: (array) to include in the `$join_conds` to `IDatabase->select()`
- */
- public function getArchiveQueryInfo() {
- $commentQuery = $this->commentStore->getJoin( 'ar_comment' );
- $actorQuery = $this->actorMigration->getJoin( 'ar_user' );
- $ret = [
- 'tables' => [ 'archive' ] + $commentQuery['tables'] + $actorQuery['tables'],
- 'fields' => [
- 'ar_id',
- 'ar_page_id',
- 'ar_namespace',
- 'ar_title',
- 'ar_rev_id',
- 'ar_timestamp',
- 'ar_minor_edit',
- 'ar_deleted',
- 'ar_len',
- 'ar_parent_id',
- 'ar_sha1',
- ] + $commentQuery['fields'] + $actorQuery['fields'],
- 'joins' => $commentQuery['joins'] + $actorQuery['joins'],
- ];
-
- if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_OLD ) ) {
- $ret['fields'][] = 'ar_text_id';
-
- if ( $this->contentHandlerUseDB ) {
- $ret['fields'][] = 'ar_content_format';
- $ret['fields'][] = 'ar_content_model';
- }
- }
-
- return $ret;
- }
-
- /**
- * Do a batched query for the sizes of a set of revisions.
- *
- * MCR migration note: this replaces Revision::getParentLengths
- *
- * @param int[] $revIds
- * @return int[] associative array mapping revision IDs from $revIds to the nominal size
- * of the corresponding revision.
- */
- public function getRevisionSizes( array $revIds ) {
- return $this->listRevisionSizes( $this->getDBConnection( DB_REPLICA ), $revIds );
- }
-
- /**
- * Do a batched query for the sizes of a set of revisions.
- *
- * MCR migration note: this replaces Revision::getParentLengths
- *
- * @deprecated use RevisionStore::getRevisionSizes instead.
- *
- * @param IDatabase $db
- * @param int[] $revIds
- * @return int[] associative array mapping revision IDs from $revIds to the nominal size
- * of the corresponding revision.
- */
- public function listRevisionSizes( IDatabase $db, array $revIds ) {
- $this->checkDatabaseWikiId( $db );
-
- $revLens = [];
- if ( !$revIds ) {
- return $revLens; // empty
- }
-
- $res = $db->select(
- 'revision',
- [ 'rev_id', 'rev_len' ],
- [ 'rev_id' => $revIds ],
- __METHOD__
- );
-
- foreach ( $res as $row ) {
- $revLens[$row->rev_id] = intval( $row->rev_len );
- }
-
- return $revLens;
- }
-
- /**
- * Get previous revision for this title
- *
- * MCR migration note: this replaces Revision::getPrevious
- *
- * @param RevisionRecord $rev
- * @param Title|null $title if known (optional)
- *
- * @return RevisionRecord|null
- */
- public function getPreviousRevision( RevisionRecord $rev, Title $title = null ) {
- if ( $title === null ) {
- $title = $this->getTitle( $rev->getPageId(), $rev->getId() );
- }
- $prev = $title->getPreviousRevisionID( $rev->getId() );
- if ( $prev ) {
- return $this->getRevisionByTitle( $title, $prev );
- }
- return null;
- }
-
- /**
- * Get next revision for this title
- *
- * MCR migration note: this replaces Revision::getNext
- *
- * @param RevisionRecord $rev
- * @param Title|null $title if known (optional)
- *
- * @return RevisionRecord|null
- */
- public function getNextRevision( RevisionRecord $rev, Title $title = null ) {
- if ( $title === null ) {
- $title = $this->getTitle( $rev->getPageId(), $rev->getId() );
- }
- $next = $title->getNextRevisionID( $rev->getId() );
- if ( $next ) {
- return $this->getRevisionByTitle( $title, $next );
- }
- return null;
- }
-
- /**
- * Get previous revision Id for this page_id
- * This is used to populate rev_parent_id on save
- *
- * MCR migration note: this corresponds to Revision::getPreviousRevisionId
- *
- * @param IDatabase $db
- * @param RevisionRecord $rev
- *
- * @return int
- */
- private function getPreviousRevisionId( IDatabase $db, RevisionRecord $rev ) {
- $this->checkDatabaseWikiId( $db );
-
- if ( $rev->getPageId() === null ) {
- return 0;
- }
- # Use page_latest if ID is not given
- if ( !$rev->getId() ) {
- $prevId = $db->selectField(
- 'page', 'page_latest',
- [ 'page_id' => $rev->getPageId() ],
- __METHOD__
- );
- } else {
- $prevId = $db->selectField(
- 'revision', 'rev_id',
- [ 'rev_page' => $rev->getPageId(), 'rev_id < ' . $rev->getId() ],
- __METHOD__,
- [ 'ORDER BY' => 'rev_id DESC' ]
- );
- }
- return intval( $prevId );
- }
-
- /**
- * Get rev_timestamp from rev_id, without loading the rest of the row
- *
- * MCR migration note: this replaces Revision::getTimestampFromId
- *
- * @param Title $title
- * @param int $id
- * @param int $flags
- * @return string|bool False if not found
- */
- public function getTimestampFromId( $title, $id, $flags = 0 ) {
- $db = $this->getDBConnectionRefForQueryFlags( $flags );
-
- $conds = [ 'rev_id' => $id ];
- $conds['rev_page'] = $title->getArticleID();
- $timestamp = $db->selectField( 'revision', 'rev_timestamp', $conds, __METHOD__ );
-
- return ( $timestamp !== false ) ? wfTimestamp( TS_MW, $timestamp ) : false;
- }
-
- /**
- * Get count of revisions per page...not very efficient
- *
- * MCR migration note: this replaces Revision::countByPageId
- *
- * @param IDatabase $db
- * @param int $id Page id
- * @return int
- */
- public function countRevisionsByPageId( IDatabase $db, $id ) {
- $this->checkDatabaseWikiId( $db );
-
- $row = $db->selectRow( 'revision',
- [ 'revCount' => 'COUNT(*)' ],
- [ 'rev_page' => $id ],
- __METHOD__
- );
- if ( $row ) {
- return intval( $row->revCount );
- }
- return 0;
- }
-
- /**
- * Get count of revisions per page...not very efficient
- *
- * MCR migration note: this replaces Revision::countByTitle
- *
- * @param IDatabase $db
- * @param Title $title
- * @return int
- */
- public function countRevisionsByTitle( IDatabase $db, $title ) {
- $id = $title->getArticleID();
- if ( $id ) {
- return $this->countRevisionsByPageId( $db, $id );
- }
- return 0;
- }
-
- /**
- * Check if no edits were made by other users since
- * the time a user started editing the page. Limit to
- * 50 revisions for the sake of performance.
- *
- * MCR migration note: this replaces Revision::userWasLastToEdit
- *
- * @deprecated since 1.31; Can possibly be removed, since the self-conflict suppression
- * logic in EditPage that uses this seems conceptually dubious. Revision::userWasLastToEdit
- * has been deprecated since 1.24.
- *
- * @param IDatabase $db The Database to perform the check on.
- * @param int $pageId The ID of the page in question
- * @param int $userId The ID of the user in question
- * @param string $since Look at edits since this time
- *
- * @return bool True if the given user was the only one to edit since the given timestamp
- */
- public function userWasLastToEdit( IDatabase $db, $pageId, $userId, $since ) {
- $this->checkDatabaseWikiId( $db );
-
- if ( !$userId ) {
- return false;
- }
-
- $revQuery = $this->getQueryInfo();
- $res = $db->select(
- $revQuery['tables'],
- [
- 'rev_user' => $revQuery['fields']['rev_user'],
- ],
- [
- 'rev_page' => $pageId,
- 'rev_timestamp > ' . $db->addQuotes( $db->timestamp( $since ) )
- ],
- __METHOD__,
- [ 'ORDER BY' => 'rev_timestamp ASC', 'LIMIT' => 50 ],
- $revQuery['joins']
- );
- foreach ( $res as $row ) {
- if ( $row->rev_user != $userId ) {
- return false;
- }
- }
- return true;
- }
-
- /**
- * Load a revision based on a known page ID and current revision ID from the DB
- *
- * This method allows for the use of caching, though accessing anything that normally
- * requires permission checks (aside from the text) will trigger a small DB lookup.
- *
- * MCR migration note: this replaces Revision::newKnownCurrent
- *
- * @param Title $title the associated page title
- * @param int $revId current revision of this page. Defaults to $title->getLatestRevID().
- *
- * @return RevisionRecord|bool Returns false if missing
- */
- public function getKnownCurrentRevision( Title $title, $revId ) {
- $db = $this->getDBConnectionRef( DB_REPLICA );
-
- $pageId = $title->getArticleID();
-
- if ( !$pageId ) {
- return false;
- }
-
- if ( !$revId ) {
- $revId = $title->getLatestRevID();
- }
-
- if ( !$revId ) {
- wfWarn(
- 'No latest revision known for page ' . $title->getPrefixedDBkey()
- . ' even though it exists with page ID ' . $pageId
- );
- return false;
- }
-
- $row = $this->cache->getWithSetCallback(
- // Page/rev IDs passed in from DB to reflect history merges
- $this->getRevisionRowCacheKey( $db, $pageId, $revId ),
- WANObjectCache::TTL_WEEK,
- function ( $curValue, &$ttl, array &$setOpts ) use ( $db, $pageId, $revId ) {
- $setOpts += Database::getCacheSetOptions( $db );
-
- $conds = [
- 'rev_page' => intval( $pageId ),
- 'page_id' => intval( $pageId ),
- 'rev_id' => intval( $revId ),
- ];
-
- $row = $this->fetchRevisionRowFromConds( $db, $conds );
- return $row ?: false; // don't cache negatives
- }
- );
-
- // Reflect revision deletion and user renames
- if ( $row ) {
- return $this->newRevisionFromRow( $row, 0, $title );
- } else {
- return false;
- }
- }
-
- /**
- * Get a cache key for use with a row as selected with getQueryInfo( [ 'page', 'user' ] )
- * Caching rows without 'page' or 'user' could lead to issues.
- * If the format of the rows returned by the query provided by getQueryInfo changes the
- * cache key should be updated to avoid conflicts.
- *
- * @param IDatabase $db
- * @param int $pageId
- * @param int $revId
- * @return string
- */
- private function getRevisionRowCacheKey( IDatabase $db, $pageId, $revId ) {
- return $this->cache->makeGlobalKey(
- self::ROW_CACHE_KEY,
- $db->getDomainID(),
- $pageId,
- $revId
- );
- }
-
- // TODO: move relevant methods from Title here, e.g. getFirstRevision, isBigDeletion, etc.
-
-}
+++ /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
- *
- * Attribution notice: when this file was created, much of its content was taken
- * from the Revision.php file as present in release 1.30. Refer to the history
- * of that file for original authorship.
- *
- * @file
- */
-
-namespace MediaWiki\Storage;
-
-use ActorMigration;
-use CommentStore;
-use MediaWiki\Logger\Spi as LoggerSpi;
-use WANObjectCache;
-use Wikimedia\Assert\Assert;
-use Wikimedia\Rdbms\ILBFactory;
-
-/**
- * Factory service for RevisionStore instances. This allows RevisionStores to be created for
- * cross-wiki access.
- *
- * @warning Beware compatibility issues with schema migration in the context of cross-wiki access!
- * This class assumes that all wikis are at compatible migration stages for all relevant schemas.
- * Relevant schemas are: revision storage (MCR), the revision comment table, and the actor table.
- * Migration stages are compatible as long as a) there are no wikis in the cluster that only write
- * the old schema or b) there are no wikis that read only the new schema.
- *
- * @since 1.32
- */
-class RevisionStoreFactory {
-
- /** @var BlobStoreFactory */
- private $blobStoreFactory;
- /** @var ILBFactory */
- private $dbLoadBalancerFactory;
- /** @var WANObjectCache */
- private $cache;
- /** @var LoggerSpi */
- private $loggerProvider;
-
- /** @var CommentStore */
- private $commentStore;
- /** @var ActorMigration */
- private $actorMigration;
- /** @var int One of the MIGRATION_* constants */
- private $mcrMigrationStage;
- /**
- * @var bool
- * @see $wgContentHandlerUseDB
- */
- private $contentHandlerUseDB;
-
- /** @var NameTableStoreFactory */
- private $nameTables;
-
- /**
- * @param ILBFactory $dbLoadBalancerFactory
- * @param BlobStoreFactory $blobStoreFactory
- * @param NameTableStoreFactory $nameTables
- * @param WANObjectCache $cache
- * @param CommentStore $commentStore
- * @param ActorMigration $actorMigration
- * @param int $migrationStage
- * @param LoggerSpi $loggerProvider
- * @param bool $contentHandlerUseDB see {@link $wgContentHandlerUseDB}. Must be the same
- * for all wikis in the cluster. Will go away after MCR migration.
- */
- public function __construct(
- ILBFactory $dbLoadBalancerFactory,
- BlobStoreFactory $blobStoreFactory,
- NameTableStoreFactory $nameTables,
- WANObjectCache $cache,
- CommentStore $commentStore,
- ActorMigration $actorMigration,
- $migrationStage,
- LoggerSpi $loggerProvider,
- $contentHandlerUseDB
- ) {
- Assert::parameterType( 'integer', $migrationStage, '$migrationStage' );
- $this->dbLoadBalancerFactory = $dbLoadBalancerFactory;
- $this->blobStoreFactory = $blobStoreFactory;
- $this->nameTables = $nameTables;
- $this->cache = $cache;
- $this->commentStore = $commentStore;
- $this->actorMigration = $actorMigration;
- $this->mcrMigrationStage = $migrationStage;
- $this->loggerProvider = $loggerProvider;
- $this->contentHandlerUseDB = $contentHandlerUseDB;
- }
-
- /**
- * @since 1.32
- *
- * @param bool|string $wikiId false for the current domain / wikid
- *
- * @return RevisionStore for the given wikiId with all necessary services and a logger
- */
- public function getRevisionStore( $wikiId = false ) {
- Assert::parameterType( 'string|boolean', $wikiId, '$wikiId' );
-
- $store = new RevisionStore(
- $this->dbLoadBalancerFactory->getMainLB( $wikiId ),
- $this->blobStoreFactory->newSqlBlobStore( $wikiId ),
- $this->cache, // Pass local cache instance; Leave cache sharing to RevisionStore.
- $this->commentStore,
- $this->nameTables->getContentModels( $wikiId ),
- $this->nameTables->getSlotRoles( $wikiId ),
- $this->mcrMigrationStage,
- $this->actorMigration,
- $wikiId
- );
-
- $store->setLogger( $this->loggerProvider->getLogger( 'RevisionStore' ) );
- $store->setContentHandlerUseDB( $this->contentHandlerUseDB );
-
- return $store;
- }
-}
+++ /dev/null
-<?php
-/**
- * A RevisionRecord representing an existing revision persisted in the revision table.
- *
- * 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
- */
-
-namespace MediaWiki\Storage;
-
-use CommentStoreComment;
-use InvalidArgumentException;
-use MediaWiki\User\UserIdentity;
-use Title;
-use User;
-use Wikimedia\Assert\Assert;
-
-/**
- * A RevisionRecord representing an existing revision persisted in the revision table.
- * RevisionStoreRecord has no optional fields, getters will never return null.
- *
- * @since 1.31
- */
-class RevisionStoreRecord extends RevisionRecord {
-
- /** @var bool */
- protected $mCurrent = false;
-
- /**
- * @note Avoid calling this constructor directly. Use the appropriate methods
- * in RevisionStore instead.
- *
- * @param Title $title The title of the page this Revision is associated with.
- * @param UserIdentity $user
- * @param CommentStoreComment $comment
- * @param object $row A row from the revision table. Use RevisionStore::getQueryInfo() to build
- * a query that yields the required fields.
- * @param RevisionSlots $slots The slots of this revision.
- * @param bool|string $wikiId the wiki ID of the site this Revision belongs to,
- * or false for the local site.
- */
- function __construct(
- Title $title,
- UserIdentity $user,
- CommentStoreComment $comment,
- $row,
- RevisionSlots $slots,
- $wikiId = false
- ) {
- parent::__construct( $title, $slots, $wikiId );
- Assert::parameterType( 'object', $row, '$row' );
-
- $this->mId = intval( $row->rev_id );
- $this->mPageId = intval( $row->rev_page );
- $this->mComment = $comment;
-
- $timestamp = wfTimestamp( TS_MW, $row->rev_timestamp );
- Assert::parameter( is_string( $timestamp ), '$row->rev_timestamp', 'must be a valid timestamp' );
-
- $this->mUser = $user;
- $this->mMinorEdit = boolval( $row->rev_minor_edit );
- $this->mTimestamp = $timestamp;
- $this->mDeleted = intval( $row->rev_deleted );
-
- // NOTE: rev_parent_id = 0 indicates that there is no parent revision, while null
- // indicates that the parent revision is unknown. As per MW 1.31, the database schema
- // allows rev_parent_id to be NULL.
- $this->mParentId = isset( $row->rev_parent_id ) ? intval( $row->rev_parent_id ) : null;
- $this->mSize = isset( $row->rev_len ) ? intval( $row->rev_len ) : null;
- $this->mSha1 = !empty( $row->rev_sha1 ) ? $row->rev_sha1 : null;
-
- // NOTE: we must not call $this->mTitle->getLatestRevID() here, since the state of
- // page_latest may be in limbo during revision creation. In that case, calling
- // $this->mTitle->getLatestRevID() would cause a bad value to be cached in the Title
- // object. During page creation, that bad value would be 0.
- if ( isset( $row->page_latest ) ) {
- $this->mCurrent = ( $row->rev_id == $row->page_latest );
- }
-
- // sanity check
- if (
- $this->mPageId && $this->mTitle->exists()
- && $this->mPageId !== $this->mTitle->getArticleID()
- ) {
- throw new InvalidArgumentException(
- 'The given Title does not belong to page ID ' . $this->mPageId .
- ' but actually belongs to ' . $this->mTitle->getArticleID()
- );
- }
- }
-
- /**
- * MCR migration note: this replaces Revision::isCurrent
- *
- * @return bool
- */
- public function isCurrent() {
- return $this->mCurrent;
- }
-
- /**
- * MCR migration note: this replaces Revision::isDeleted
- *
- * @param int $field One of DELETED_* bitfield constants
- *
- * @return bool
- */
- public function isDeleted( $field ) {
- if ( $this->isCurrent() && $field === self::DELETED_TEXT ) {
- // Current revisions of pages cannot have the content hidden. Skipping this
- // check is very useful for Parser as it fetches templates using newKnownCurrent().
- // Calling getVisibility() in that case triggers a verification database query.
- return false; // no need to check
- }
-
- return parent::isDeleted( $field );
- }
-
- protected function userCan( $field, User $user ) {
- if ( $this->isCurrent() && $field === self::DELETED_TEXT ) {
- // Current revisions of pages cannot have the content hidden. Skipping this
- // check is very useful for Parser as it fetches templates using newKnownCurrent().
- // Calling getVisibility() in that case triggers a verification database query.
- return true; // no need to check
- }
-
- return parent::userCan( $field, $user );
- }
-
- /**
- * @return int The revision id, never null.
- */
- public function getId() {
- // overwritten just to add a guarantee to the contract
- return parent::getId();
- }
-
- /**
- * @throws RevisionAccessException if the size was unknown and could not be calculated.
- * @return string The nominal revision size, never null. May be computed on the fly.
- */
- public function getSize() {
- // If length is null, calculate and remember it (potentially SLOW!).
- // This is for compatibility with old database rows that don't have the field set.
- if ( $this->mSize === null ) {
- $this->mSize = $this->mSlots->computeSize();
- }
-
- return $this->mSize;
- }
-
- /**
- * @throws RevisionAccessException if the hash was unknown and could not be calculated.
- * @return string The revision hash, never null. May be computed on the fly.
- */
- public function getSha1() {
- // If hash is null, calculate it and remember (potentially SLOW!)
- // This is for compatibility with old database rows that don't have the field set.
- if ( $this->mSha1 === null ) {
- $this->mSha1 = $this->mSlots->computeSha1();
- }
-
- return $this->mSha1;
- }
-
- /**
- * @param int $audience
- * @param User|null $user
- *
- * @return UserIdentity The identity of the revision author, null if access is forbidden.
- */
- public function getUser( $audience = self::FOR_PUBLIC, User $user = null ) {
- // overwritten just to add a guarantee to the contract
- return parent::getUser( $audience, $user );
- }
-
- /**
- * @param int $audience
- * @param User|null $user
- *
- * @return CommentStoreComment The revision comment, null if access is forbidden.
- */
- public function getComment( $audience = self::FOR_PUBLIC, User $user = null ) {
- // overwritten just to add a guarantee to the contract
- return parent::getComment( $audience, $user );
- }
-
- /**
- * @return string timestamp, never null
- */
- public function getTimestamp() {
- // overwritten just to add a guarantee to the contract
- return parent::getTimestamp();
- }
-
- /**
- * @see RevisionStore::isComplete
- *
- * @return bool always true.
- */
- public function isReadyForInsertion() {
- return true;
- }
-
-}
+++ /dev/null
-<?php
-/**
- * Value object representing a content slot associated with a page revision.
- *
- * 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
- */
-
-namespace MediaWiki\Storage;
-
-use Content;
-use InvalidArgumentException;
-use LogicException;
-use OutOfBoundsException;
-use Wikimedia\Assert\Assert;
-
-/**
- * Value object representing a content slot associated with a page revision.
- * SlotRecord provides direct access to a Content object.
- * That access may be implemented through a callback.
- *
- * @since 1.31
- */
-class SlotRecord {
-
- const MAIN = 'main';
-
- /**
- * @var object database result row, as a raw object. Callbacks are supported for field values,
- * to enable on-demand emulation of these values. This is primarily intended for use
- * during schema migration.
- */
- private $row;
-
- /**
- * @var Content|callable
- */
- private $content;
-
- /**
- * Returns a new SlotRecord just like the given $slot, except that calling getContent()
- * will fail with an exception.
- *
- * @param SlotRecord $slot
- *
- * @return SlotRecord
- */
- public static function newWithSuppressedContent( SlotRecord $slot ) {
- $row = $slot->row;
-
- return new SlotRecord( $row, function () {
- throw new SuppressedDataException( 'Content suppressed!' );
- } );
- }
-
- /**
- * Constructs a new SlotRecord from an existing SlotRecord, overriding some fields.
- * The slot's content cannot be overwritten.
- *
- * @param SlotRecord $slot
- * @param array $overrides
- *
- * @return SlotRecord
- */
- private static function newDerived( SlotRecord $slot, array $overrides = [] ) {
- $row = clone $slot->row;
- $row->slot_id = null; // never copy the row ID!
-
- foreach ( $overrides as $key => $value ) {
- $row->$key = $value;
- }
-
- return new SlotRecord( $row, $slot->content );
- }
-
- /**
- * Constructs a new SlotRecord for a new revision, inheriting the content of the given SlotRecord
- * of a previous revision.
- *
- * Note that a SlotRecord constructed this way are intended as prototypes,
- * to be used wit newSaved(). They are incomplete, so some getters such as
- * getRevision() will fail.
- *
- * @param SlotRecord $slot
- *
- * @return SlotRecord
- */
- public static function newInherited( SlotRecord $slot ) {
- // Sanity check - we can't inherit from a Slot that's not attached to a revision.
- $slot->getRevision();
- $slot->getOrigin();
- $slot->getAddress();
-
- // NOTE: slot_origin and content_address are copied from $slot.
- return self::newDerived( $slot, [
- 'slot_revision_id' => null,
- ] );
- }
-
- /**
- * Constructs a new Slot from a Content object for a new revision.
- * This is the preferred way to construct a slot for storing Content that
- * resulted from a user edit. The slot is assumed to be not inherited.
- *
- * Note that a SlotRecord constructed this way are intended as prototypes,
- * to be used wit newSaved(). They are incomplete, so some getters such as
- * getAddress() will fail.
- *
- * @param string $role
- * @param Content $content
- *
- * @return SlotRecord An incomplete proto-slot object, to be used with newSaved() later.
- */
- public static function newUnsaved( $role, Content $content ) {
- Assert::parameterType( 'string', $role, '$role' );
-
- $row = [
- 'slot_id' => null, // not yet known
- 'slot_revision_id' => null, // not yet known
- 'slot_origin' => null, // not yet known, will be set in newSaved()
- 'content_size' => null, // compute later
- 'content_sha1' => null, // compute later
- 'slot_content_id' => null, // not yet known, will be set in newSaved()
- 'content_address' => null, // not yet known, will be set in newSaved()
- 'role_name' => $role,
- 'model_name' => $content->getModel(),
- ];
-
- return new SlotRecord( (object)$row, $content );
- }
-
- /**
- * Constructs a complete SlotRecord for a newly saved revision, based on the incomplete
- * proto-slot. This adds information that has only become available during saving,
- * particularly the revision ID, content ID and content address.
- *
- * @param int $revisionId the revision the slot is to be associated with (field slot_revision_id).
- * If $protoSlot already has a revision, it must be the same.
- * @param int|null $contentId the ID of the row in the content table describing the content
- * referenced by $contentAddress (field slot_content_id).
- * If $protoSlot already has a content ID, it must be the same.
- * @param string $contentAddress the slot's content address (field content_address).
- * If $protoSlot already has an address, it must be the same.
- * @param SlotRecord $protoSlot The proto-slot that was provided as input for creating a new
- * revision. $protoSlot must have a content address if inherited.
- *
- * @return SlotRecord If the state of $protoSlot is inappropriate for saving a new revision.
- */
- public static function newSaved(
- $revisionId,
- $contentId,
- $contentAddress,
- SlotRecord $protoSlot
- ) {
- Assert::parameterType( 'integer', $revisionId, '$revisionId' );
- // TODO once migration is over $contentId must be an integer
- Assert::parameterType( 'integer|null', $contentId, '$contentId' );
- Assert::parameterType( 'string', $contentAddress, '$contentAddress' );
-
- if ( $protoSlot->hasRevision() && $protoSlot->getRevision() !== $revisionId ) {
- throw new LogicException(
- "Mismatching revision ID $revisionId: "
- . "The slot already belongs to revision {$protoSlot->getRevision()}. "
- . "Use SlotRecord::newInherited() to re-use content between revisions."
- );
- }
-
- if ( $protoSlot->hasAddress() && $protoSlot->getAddress() !== $contentAddress ) {
- throw new LogicException(
- "Mismatching blob address $contentAddress: "
- . "The slot already has content at {$protoSlot->getAddress()}."
- );
- }
-
- if ( $protoSlot->hasContentId() && $protoSlot->getContentId() !== $contentId ) {
- throw new LogicException(
- "Mismatching content ID $contentId: "
- . "The slot already has content row {$protoSlot->getContentId()} associated."
- );
- }
-
- if ( $protoSlot->isInherited() ) {
- if ( !$protoSlot->hasAddress() ) {
- throw new InvalidArgumentException(
- "An inherited blob should have a content address!"
- );
- }
- if ( !$protoSlot->hasField( 'slot_origin' ) ) {
- throw new InvalidArgumentException(
- "A saved inherited slot should have an origin set!"
- );
- }
- $origin = $protoSlot->getOrigin();
- } else {
- $origin = $revisionId;
- }
-
- return self::newDerived( $protoSlot, [
- 'slot_revision_id' => $revisionId,
- 'slot_content_id' => $contentId,
- 'slot_origin' => $origin,
- 'content_address' => $contentAddress,
- ] );
- }
-
- /**
- * SlotRecord constructor.
- *
- * The following fields are supported by the $row parameter:
- *
- * $row->blob_data
- * $row->blob_address
- *
- * @param object $row A database row composed of fields of the slot and content tables,
- * as a raw object. Any field value can be a callback that produces the field value
- * given this SlotRecord as a parameter. However, plain strings cannot be used as
- * callbacks here, for security reasons.
- * @param Content|callable $content The content object associated with the slot, or a
- * callback that will return that Content object, given this SlotRecord as a parameter.
- */
- public function __construct( $row, $content ) {
- Assert::parameterType( 'object', $row, '$row' );
- Assert::parameterType( 'Content|callable', $content, '$content' );
-
- Assert::parameter(
- property_exists( $row, 'slot_revision_id' ),
- '$row->slot_revision_id',
- 'must exist'
- );
- Assert::parameter(
- property_exists( $row, 'slot_content_id' ),
- '$row->slot_content_id',
- 'must exist'
- );
- Assert::parameter(
- property_exists( $row, 'content_address' ),
- '$row->content_address',
- 'must exist'
- );
- Assert::parameter(
- property_exists( $row, 'model_name' ),
- '$row->model_name',
- 'must exist'
- );
- Assert::parameter(
- property_exists( $row, 'slot_origin' ),
- '$row->slot_origin',
- 'must exist'
- );
- Assert::parameter(
- !property_exists( $row, 'slot_inherited' ),
- '$row->slot_inherited',
- 'must not exist'
- );
- Assert::parameter(
- !property_exists( $row, 'slot_revision' ),
- '$row->slot_revision',
- 'must not exist'
- );
-
- $this->row = $row;
- $this->content = $content;
- }
-
- /**
- * Implemented to defy serialization.
- *
- * @throws LogicException always
- */
- public function __sleep() {
- throw new LogicException( __CLASS__ . ' is not serializable.' );
- }
-
- /**
- * Returns the Content of the given slot.
- *
- * @note This is free to load Content from whatever subsystem is necessary,
- * performing potentially expensive operations and triggering I/O-related
- * failure modes.
- *
- * @note This method does not apply audience filtering.
- *
- * @throws SuppressedDataException if access to the content is not allowed according
- * to the audience check performed by RevisionRecord::getSlot().
- *
- * @return Content The slot's content. This is a direct reference to the internal instance,
- * copy before exposing to application logic!
- */
- public function getContent() {
- if ( $this->content instanceof Content ) {
- return $this->content;
- }
-
- $obj = call_user_func( $this->content, $this );
-
- Assert::postcondition(
- $obj instanceof Content,
- 'Slot content callback should return a Content object'
- );
-
- $this->content = $obj;
-
- return $this->content;
- }
-
- /**
- * Returns the string value of a data field from the database row supplied to the constructor.
- * If the field was set to a callback, that callback is invoked and the result returned.
- *
- * @param string $name
- *
- * @throws OutOfBoundsException
- * @throws IncompleteRevisionException
- * @return mixed Returns the field's value, never null.
- */
- private function getField( $name ) {
- if ( !isset( $this->row->$name ) ) {
- // distinguish between unknown and uninitialized fields
- if ( property_exists( $this->row, $name ) ) {
- throw new IncompleteRevisionException( 'Uninitialized field: ' . $name );
- } else {
- throw new OutOfBoundsException( 'No such field: ' . $name );
- }
- }
-
- $value = $this->row->$name;
-
- // NOTE: allow callbacks, but don't trust plain string callables from the database!
- if ( !is_string( $value ) && is_callable( $value ) ) {
- $value = call_user_func( $value, $this );
- $this->setField( $name, $value );
- }
-
- return $value;
- }
-
- /**
- * Returns the string value of a data field from the database row supplied to the constructor.
- *
- * @param string $name
- *
- * @throws OutOfBoundsException
- * @throws IncompleteRevisionException
- * @return string Returns the string value
- */
- private function getStringField( $name ) {
- return strval( $this->getField( $name ) );
- }
-
- /**
- * Returns the int value of a data field from the database row supplied to the constructor.
- *
- * @param string $name
- *
- * @throws OutOfBoundsException
- * @throws IncompleteRevisionException
- * @return int Returns the int value
- */
- private function getIntField( $name ) {
- return intval( $this->getField( $name ) );
- }
-
- /**
- * @param string $name
- * @return bool whether this record contains the given field
- */
- private function hasField( $name ) {
- if ( isset( $this->row->$name ) ) {
- // if the field is a callback, resolve first, then re-check
- if ( !is_string( $this->row->$name ) && is_callable( $this->row->$name ) ) {
- $this->getField( $name );
- }
- }
-
- return isset( $this->row->$name );
- }
-
- /**
- * Returns the ID of the revision this slot is associated with.
- *
- * @return int
- */
- public function getRevision() {
- return $this->getIntField( 'slot_revision_id' );
- }
-
- /**
- * Returns the revision ID of the revision that originated the slot's content.
- *
- * @return int
- */
- public function getOrigin() {
- return $this->getIntField( 'slot_origin' );
- }
-
- /**
- * Whether this slot was inherited from an older revision.
- *
- * If this SlotRecord is already attached to a revision, this returns true
- * if the slot's revision of origin is the same as the revision it belongs to.
- *
- * If this SlotRecord is not yet attached to a revision, this returns true
- * if the slot already has an address.
- *
- * @return bool
- */
- public function isInherited() {
- if ( $this->hasRevision() ) {
- return $this->getRevision() !== $this->getOrigin();
- } else {
- return $this->hasAddress();
- }
- }
-
- /**
- * Whether this slot has an address. Slots will have an address if their
- * content has been stored. While building a new revision,
- * SlotRecords will not have an address associated.
- *
- * @return bool
- */
- public function hasAddress() {
- return $this->hasField( 'content_address' );
- }
-
- /**
- * Whether this slot has an origin (revision ID that originated the slot's content.
- *
- * @since 1.32
- *
- * @return bool
- */
- public function hasOrigin() {
- return $this->hasField( 'slot_origin' );
- }
-
- /**
- * Whether this slot has a content ID. Slots will have a content ID if their
- * content has been stored in the content table. While building a new revision,
- * SlotRecords will not have an ID associated.
- *
- * Also, during schema migration, hasContentId() may return false when encountering an
- * un-migrated database entry in SCHEMA_COMPAT_WRITE_BOTH mode.
- * It will however always return true for saved revisions on SCHEMA_COMPAT_READ_NEW mode,
- * or without SCHEMA_COMPAT_WRITE_NEW mode. In the latter case, an emulated content ID
- * is used, derived from the revision's text ID.
- *
- * Note that hasContentId() returning false while hasRevision() returns true always
- * indicates an unmigrated row in SCHEMA_COMPAT_WRITE_BOTH mode, as described above.
- * For an unsaved slot, both these methods would return false.
- *
- * @since 1.32
- *
- * @return bool
- */
- public function hasContentId() {
- return $this->hasField( 'slot_content_id' );
- }
-
- /**
- * Whether this slot has revision ID associated. Slots will have a revision ID associated
- * only if they were loaded as part of an existing revision. While building a new revision,
- * Slotrecords will not have a revision ID associated.
- *
- * @return bool
- */
- public function hasRevision() {
- return $this->hasField( 'slot_revision_id' );
- }
-
- /**
- * Returns the role of the slot.
- *
- * @return string
- */
- public function getRole() {
- return $this->getStringField( 'role_name' );
- }
-
- /**
- * Returns the address of this slot's content.
- * This address can be used with BlobStore to load the Content object.
- *
- * @return string
- */
- public function getAddress() {
- return $this->getStringField( 'content_address' );
- }
-
- /**
- * Returns the ID of the content meta data row associated with the slot.
- * This information should be irrelevant to application logic, it is here to allow
- * the construction of a full row for the revision table.
- *
- * Note that this method may return an emulated value during schema migration in
- * SCHEMA_COMPAT_WRITE_OLD mode. See RevisionStore::emulateContentId for more information.
- *
- * @return int
- */
- public function getContentId() {
- return $this->getIntField( 'slot_content_id' );
- }
-
- /**
- * Returns the content size
- *
- * @return int size of the content, in bogo-bytes, as reported by Content::getSize.
- */
- public function getSize() {
- try {
- $size = $this->getIntField( 'content_size' );
- } catch ( IncompleteRevisionException $ex ) {
- $size = $this->getContent()->getSize();
- $this->setField( 'content_size', $size );
- }
-
- return $size;
- }
-
- /**
- * Returns the content size
- *
- * @return string hash of the content.
- */
- public function getSha1() {
- try {
- $sha1 = $this->getStringField( 'content_sha1' );
- } catch ( IncompleteRevisionException $ex ) {
- $format = $this->hasField( 'format_name' )
- ? $this->getStringField( 'format_name' )
- : null;
-
- $data = $this->getContent()->serialize( $format );
- $sha1 = self::base36Sha1( $data );
- $this->setField( 'content_sha1', $sha1 );
- }
-
- return $sha1;
- }
-
- /**
- * Returns the content model. This is the model name that decides
- * which ContentHandler is appropriate for interpreting the
- * data of the blob referenced by the address returned by getAddress().
- *
- * @return string the content model of the content
- */
- public function getModel() {
- try {
- $model = $this->getStringField( 'model_name' );
- } catch ( IncompleteRevisionException $ex ) {
- $model = $this->getContent()->getModel();
- $this->setField( 'model_name', $model );
- }
-
- return $model;
- }
-
- /**
- * Returns the blob serialization format as a MIME type.
- *
- * @note When this method returns null, the caller is expected
- * to auto-detect the serialization format, or to rely on
- * the default format associated with the content model.
- *
- * @return string|null
- */
- public function getFormat() {
- // XXX: we currently do not plan to store the format for each slot!
-
- if ( $this->hasField( 'format_name' ) ) {
- return $this->getStringField( 'format_name' );
- }
-
- return null;
- }
-
- /**
- * @param string $name
- * @param string|int|null $value
- */
- private function setField( $name, $value ) {
- $this->row->$name = $value;
- }
-
- /**
- * Get the base 36 SHA-1 value for a string of text
- *
- * MCR migration note: this replaces Revision::base36Sha1
- *
- * @param string $blob
- * @return string
- */
- public static function base36Sha1( $blob ) {
- return \Wikimedia\base_convert( sha1( $blob ), 16, 36, 31 );
- }
-
- /**
- * Returns true if $other has the same content as this slot.
- * The check is performed based on the model, address size, and hash.
- * Two slots can have the same content if they use different content addresses,
- * but if they have the same address and the same model, they have the same content.
- * Two slots can have the same content if they belong to different
- * revisions or pages.
- *
- * Note that hasSameContent() may return false even if Content::equals returns true for
- * the content of two slots. This may happen if the two slots have different serializations
- * representing equivalent Content. Such false negatives are considered acceptable. Code
- * that has to be absolutely sure the Content is really not the same if hasSameContent()
- * returns false should call getContent() and compare the Content objects directly.
- *
- * @since 1.32
- *
- * @param SlotRecord $other
- * @return bool
- */
- public function hasSameContent( SlotRecord $other ) {
- if ( $other === $this ) {
- return true;
- }
-
- if ( $this->getModel() !== $other->getModel() ) {
- return false;
- }
-
- if ( $this->hasAddress()
- && $other->hasAddress()
- && $this->getAddress() == $other->getAddress()
- ) {
- return true;
- }
-
- if ( $this->getSize() !== $other->getSize() ) {
- return false;
- }
-
- if ( $this->getSha1() !== $other->getSha1() ) {
- return false;
- }
-
- return true;
- }
-
-}
+++ /dev/null
-<?php
-/**
- * Exception representing a failure to look up a revision.
- *
- * 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
- */
-
-namespace MediaWiki\Storage;
-
-/**
- * Exception raised in response to an audience check when attempting to
- * access suppressed information without permission.
- *
- * @since 1.31
- */
-class SuppressedDataException extends RevisionAccessException {
-
-}
* are distinct from Special Pages because an action must apply to exactly one page.
*
* To add an action in an extension, create a subclass of Action, and add the key to
- * $wgActions. There is also the deprecated UnknownAction hook
+ * $wgActions.
*
* Actions generally fall into two groups: the show-a-form-then-do-something-with-the-input
* format (protect, delete, move, etc), and the just-do-something format (watch, rollback,
$dbrWatchlist = wfGetDB( DB_REPLICA, 'watchlist' );
$setOpts += Database::getCacheSetOptions( $dbr, $dbrWatchlist );
- if ( $wgActorTableSchemaMigrationStage === MIGRATION_NEW ) {
+ if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_READ_NEW ) {
$tables = [ 'revision_actor_temp' ];
$field = 'revactor_actor';
$pageField = 'revactor_page';
$tsField = 'revactor_timestamp';
$joins = [];
- } elseif ( $wgActorTableSchemaMigrationStage === MIGRATION_OLD ) {
+ } else {
$tables = [ 'revision' ];
$field = 'rev_user_text';
$pageField = 'rev_page';
$tsField = 'rev_timestamp';
$joins = [];
- } else {
- $tables = [ 'revision', 'revision_actor_temp', 'actor' ];
- $field = 'COALESCE( actor_name, rev_user_text)';
- $pageField = 'rev_page';
- $tsField = 'rev_timestamp';
- $joins = [
- 'revision_actor_temp' => [ 'LEFT JOIN', 'revactor_rev = rev_id' ],
- 'actor' => [ 'LEFT JOIN', 'revactor_actor = actor_id' ],
- ];
}
$watchedItemStore = MediaWikiServices::getInstance()->getWatchedItemStore();
*/
use MediaWiki\MediaWikiServices;
-use MediaWiki\Storage\MutableRevisionRecord;
-use MediaWiki\Storage\RevisionRecord;
-use MediaWiki\Storage\SlotRecord;
+use MediaWiki\Revision\MutableRevisionRecord;
+use MediaWiki\Revision\RevisionRecord;
+use MediaWiki\Revision\SlotRecord;
/**
* Temporary action for MCR undos
// Avoid PHP 7.1 warning of passing $this by reference
$apiModule = $this;
- Hooks::run( 'APIGetDescription', [ &$apiModule, &$desc ] );
+ Hooks::run( 'APIGetDescription', [ &$apiModule, &$desc ], '1.25' );
$desc = self::escapeWikiText( $desc );
if ( is_array( $desc ) ) {
$desc = implode( "\n", $desc );
// Avoid PHP 7.1 warning of passing $this by reference
$apiModule = $this;
- Hooks::run( 'APIGetParamDescription', [ &$apiModule, &$desc ] );
+ Hooks::run( 'APIGetParamDescription', [ &$apiModule, &$desc ], '1.25' );
if ( !$desc ) {
$desc = [];
*/
use MediaWiki\MediaWikiServices;
-use MediaWiki\Storage\MutableRevisionRecord;
-use MediaWiki\Storage\RevisionRecord;
-use MediaWiki\Storage\RevisionStore;
-use MediaWiki\Storage\SlotRecord;
+use MediaWiki\Revision\MutableRevisionRecord;
+use MediaWiki\Revision\RevisionRecord;
+use MediaWiki\Revision\RevisionStore;
+use MediaWiki\Revision\SlotRecord;
class ApiComparePages extends ApiBase {
*/
use MediaWiki\MediaWikiServices;
-use MediaWiki\Storage\RevisionAccessException;
-use MediaWiki\Storage\RevisionRecord;
-use MediaWiki\Storage\RevisionStore;
-use MediaWiki\Storage\SlotRecord;
+use MediaWiki\Revision\RevisionAccessException;
+use MediaWiki\Revision\RevisionRecord;
+use MediaWiki\Revision\RevisionStore;
+use MediaWiki\Revision\SlotRecord;
/**
* @ingroup API
$session = $status->getValue();
$authRes = 'Success';
$loginType = 'BotPassword';
- } elseif ( !$botLoginData[2] ||
+ } elseif (
$status->hasMessage( 'login-throttled' ) ||
$status->hasMessage( 'botpasswords-needs-reset' ) ||
$status->hasMessage( 'botpasswords-locked' )
'BotPassword login failed: ' . $status->getWikiText( false, false, 'en' )
);
}
+ // For other errors, let's see if it's a valid non-bot login
}
if ( $authRes === false ) {
);
break;
+ // @codeCoverageIgnoreStart
+ // Unreachable
default:
ApiBase::dieDebug( __METHOD__, "Unhandled case value: {$authRes}" );
+ // @codeCoverageIgnoreEnd
}
$this->getResult()->addValue( null, 'login', $result );
- if ( $loginType === 'LoginForm' && isset( LoginForm::$statusCodes[$authRes] ) ) {
- $authRes = LoginForm::$statusCodes[$authRes];
- }
LoggerFactory::getInstance( 'authevents' )->info( 'Login attempt', [
'event' => 'login',
'successful' => $authRes === 'Success',
*/
use MediaWiki\MediaWikiServices;
+use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Storage\NameTableAccessException;
-use MediaWiki\Storage\RevisionRecord;
/**
* Query module to enumerate all deleted revisions.
*/
use MediaWiki\MediaWikiServices;
-use MediaWiki\Storage\RevisionRecord;
+use MediaWiki\Revision\RevisionRecord;
/**
* Query module to enumerate all revisions.
// Actually count the actions using a subquery (T66505 and T66507)
$tables = [ 'recentchanges' ];
$joins = [];
- if ( $wgActorTableSchemaMigrationStage === MIGRATION_OLD ) {
+ if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_READ_OLD ) {
$userCond = 'rc_user_text = user_name';
} else {
$tables[] = 'actor';
- $joins['actor'] = [
- $wgActorTableSchemaMigrationStage === MIGRATION_NEW ? 'JOIN' : 'LEFT JOIN',
- 'rc_actor = actor_id'
- ];
- if ( $wgActorTableSchemaMigrationStage === MIGRATION_NEW ) {
- $userCond = 'actor_user = user_id';
- } else {
- $userCond = 'actor_user = user_id OR (rc_actor = 0 AND rc_user_text = user_name)';
- }
+ $joins['actor'] = [ 'JOIN', 'rc_actor = actor_id' ];
+ $userCond = 'actor_user = user_id';
}
$timestamp = $db->timestamp( wfTimestamp( TS_UNIX ) - $activeUserSeconds );
$this->addFields( [
*/
use MediaWiki\MediaWikiServices;
-use MediaWiki\Storage\RevisionRecord;
+use MediaWiki\Revision\RevisionRecord;
/**
* A query module to show contributors to a page
$result = $this->getResult();
$revQuery = MediaWikiServices::getInstance()->getRevisionStore()->getQueryInfo();
- // For MIGRATION_NEW, target indexes on the revision_actor_temp table.
- // Otherwise, revision is fine because it'll have to check all revision rows anyway.
- $pageField = $wgActorTableSchemaMigrationStage === MIGRATION_NEW ? 'revactor_page' : 'rev_page';
- $idField = $wgActorTableSchemaMigrationStage === MIGRATION_NEW
+ // For SCHEMA_COMPAT_READ_NEW, target indexes on the
+ // revision_actor_temp table, otherwise on the revision table.
+ $pageField = ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_READ_NEW )
+ ? 'revactor_page' : 'rev_page';
+ $idField = ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_READ_NEW )
? 'revactor_actor' : $revQuery['fields']['rev_user'];
- $countField = $wgActorTableSchemaMigrationStage === MIGRATION_NEW
+ $countField = ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_READ_NEW )
? 'revactor_actor' : $revQuery['fields']['rev_user_text'];
// First, count anons
*/
use MediaWiki\MediaWikiServices;
+use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Storage\NameTableAccessException;
-use MediaWiki\Storage\RevisionRecord;
/**
* Query module to enumerate deleted revisions for pages.
* @file
*/
-use MediaWiki\Storage\RevisionRecord;
+use MediaWiki\Revision\RevisionRecord;
/**
* Query module to enumerate all deleted files.
*/
use MediaWiki\MediaWikiServices;
+use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Storage\NameTableAccessException;
-use MediaWiki\Storage\RevisionRecord;
/**
* A query action to enumerate the recent changes that were done to the wiki.
*/
use MediaWiki\MediaWikiServices;
+use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Storage\NameTableAccessException;
-use MediaWiki\Storage\RevisionRecord;
/**
* A query action to enumerate revisions of a given page, or show top revisions
* @file
*/
-use MediaWiki\Storage\RevisionAccessException;
-use MediaWiki\Storage\RevisionRecord;
-use MediaWiki\Storage\SlotRecord;
+use MediaWiki\Revision\RevisionAccessException;
+use MediaWiki\Revision\RevisionRecord;
+use MediaWiki\Revision\SlotRecord;
use MediaWiki\MediaWikiServices;
/**
$data = [];
foreach ( $langNames as $code => $name ) {
- $lang = [ 'code' => $code ];
+ $lang = [
+ 'code' => $code,
+ 'bcp47' => LanguageCode::bcp47( $code ),
+ ];
ApiResult::setContentValue( $lang, 'name', $name );
$data[] = $lang;
}
*/
use MediaWiki\MediaWikiServices;
+use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Storage\NameTableAccessException;
-use MediaWiki\Storage\RevisionRecord;
/**
* This query action adds a list of a specified user's contributions to the output.
$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
- // that so we have to UNION instead.
- if ( $wgActorTableSchemaMigrationStage === MIGRATION_NEW ) {
+ // old, pull from rev_user.
+ if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_READ_NEW ) {
$res = $dbSecondary->select(
'actor',
[ 'actor_id', 'user_id' => 'COALESCE(actor_user,0)', 'user_name' => 'actor_name' ],
$fname,
[ 'ORDER BY' => [ "user_name $sort" ], 'LIMIT' => $limit ]
);
- } elseif ( $wgActorTableSchemaMigrationStage === MIGRATION_OLD ) {
+ } else {
$res = $dbSecondary->select(
'revision',
[ 'actor_id' => 'NULL', 'user_id' => 'rev_user', 'user_name' => 'rev_user_text' ],
$fname,
[ 'DISTINCT', 'ORDER BY' => [ "rev_user_text $sort" ], 'LIMIT' => $limit ]
);
- } else {
- // There are three queries we have to combine to be sure of getting all results:
- // - actor table (any rows that have been migrated will have empty rev_user_text)
- // - revision+actor by user id
- // - revision+actor by name for anons
- $options = $dbSecondary->unionSupportsOrderAndLimit()
- ? [ 'ORDER BY' => [ "user_name $sort" ], 'LIMIT' => $limit ] : [];
- $subsql = [];
- $subsql[] = $dbSecondary->selectSQLText(
- 'actor',
- [ 'actor_id', 'user_id' => 'COALESCE(actor_user,0)', 'user_name' => 'actor_name' ],
- array_merge( [ "actor_name$like" ], $from ? [ "actor_name $from" ] : [] ),
- $fname,
- $options
- );
- $subsql[] = $dbSecondary->selectSQLText(
- [ 'revision', 'actor' ],
- [ 'actor_id', 'user_id' => 'rev_user', 'user_name' => 'rev_user_text' ],
- array_merge(
- [ "rev_user_text$like", 'rev_user != 0' ],
- $from ? [ "rev_user_text $from" ] : []
- ),
- $fname,
- array_merge( [ 'DISTINCT' ], $options ),
- [ 'actor' => [ 'LEFT JOIN', 'rev_user = actor_user' ] ]
- );
- $subsql[] = $dbSecondary->selectSQLText(
- [ 'revision', 'actor' ],
- [ 'actor_id', 'user_id' => 'rev_user', 'user_name' => 'rev_user_text' ],
- array_merge(
- [ "rev_user_text$like", 'rev_user = 0' ],
- $from ? [ "rev_user_text $from" ] : []
- ),
- $fname,
- array_merge( [ 'DISTINCT' ], $options ),
- [ 'actor' => [ 'LEFT JOIN', 'rev_user_text = actor_name' ] ]
- );
- $sql = $dbSecondary->unionQueries( $subsql, false ) . " ORDER BY user_name $sort";
- $sql = $dbSecondary->limitResult( $sql, $limit );
- $res = $dbSecondary->query( $sql, $fname );
}
$count = 0;
}
// For the new schema, just select from the actor table. For the
- // old and transitional schemas, select from user and left join
- // actor if it exists.
- if ( $wgActorTableSchemaMigrationStage === MIGRATION_NEW ) {
+ // old, select from user.
+ if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_READ_NEW ) {
$res = $dbSecondary->select(
'actor',
[ 'actor_id', 'user_id' => 'actor_user', 'user_name' => 'actor_name' ],
__METHOD__,
[ 'ORDER BY' => "user_id $sort" ]
);
- } elseif ( $wgActorTableSchemaMigrationStage === MIGRATION_OLD ) {
+ } else {
$res = $dbSecondary->select(
'user',
[ 'actor_id' => 'NULL', 'user_id' => 'user_id', 'user_name' => 'user_name' ],
__METHOD__,
[ 'ORDER BY' => "user_id $sort" ]
);
- } else {
- $res = $dbSecondary->select(
- [ 'user', 'actor' ],
- [ 'actor_id', 'user_id', 'user_name' ],
- array_merge( [ 'user_id' => $ids ], $from ? [ "user_id $from" ] : [] ),
- __METHOD__,
- [ 'ORDER BY' => "user_id $sort" ],
- [ 'actor' => [ 'LEFT JOIN', 'actor_user = user_id' ] ]
- );
}
$userIter = UserArray::newFromResult( $res );
$batchSize = count( $ids );
}
// For the new schema, just select from the actor table. For the
- // old and transitional schemas, select from user and left join
- // actor if it exists then merge in any unknown users (IPs and imports).
- if ( $wgActorTableSchemaMigrationStage === MIGRATION_NEW ) {
+ // old, select from user then merge in any unknown users (IPs and imports).
+ if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_READ_NEW ) {
$res = $dbSecondary->select(
'actor',
[ 'actor_id', 'user_id' => 'actor_user', 'user_name' => 'actor_name' ],
);
$userIter = UserArray::newFromResult( $res );
} else {
- if ( $wgActorTableSchemaMigrationStage === MIGRATION_OLD ) {
- $res = $dbSecondary->select(
- 'user',
- [ 'actor_id' => 'NULL', 'user_id', 'user_name' ],
- array_merge( [ 'user_name' => array_keys( $names ) ], $from ? [ "user_name $from" ] : [] ),
- __METHOD__
- );
- } else {
- $res = $dbSecondary->select(
- [ 'user', 'actor' ],
- [ 'actor_id', 'user_id', 'user_name' ],
- array_merge( [ 'user_name' => array_keys( $names ) ], $from ? [ "user_name $from" ] : [] ),
- __METHOD__,
- [],
- [ 'actor' => [ 'LEFT JOIN', 'actor_user = user_id' ] ]
- );
- }
+ $res = $dbSecondary->select(
+ 'user',
+ [ 'actor_id' => 'NULL', 'user_id', 'user_name' ],
+ array_merge( [ 'user_name' => array_keys( $names ) ], $from ? [ "user_name $from" ] : [] ),
+ __METHOD__
+ );
foreach ( $res as $row ) {
$names[$row->user_name] = $row;
}
$batchSize = count( $names );
}
- // During migration, force ordering on the client side because we're
- // having to combine multiple queries that would otherwise have
- // different sort orders.
- if ( $wgActorTableSchemaMigrationStage === MIGRATION_WRITE_BOTH ||
- $wgActorTableSchemaMigrationStage === MIGRATION_WRITE_NEW
- ) {
- $batchSize = 1;
- }
-
// With the new schema, the DB query will order by actor so update $this->orderBy to match.
- if ( $batchSize > 1 && $wgActorTableSchemaMigrationStage === MIGRATION_NEW ) {
+ if ( $batchSize > 1 && ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_READ_NEW ) ) {
$this->orderBy = 'actor';
}
$userIter->next();
}
- // Ugh. We have to run the query three times, once for each
- // possible 'orcond' from ActorMigration, and then merge them all
- // together in the proper order. And preserving the correct
- // $hookData for each one.
- // @todo When ActorMigration is removed, this can go back to a
- // single prepare and select.
- $merged = [];
- foreach ( [ 'actor', 'userid', 'username' ] as $which ) {
- if ( $this->prepareQuery( $users, $limit - $count, $which ) ) {
- $hookData = [];
- $res = $this->select( __METHOD__, [], $hookData );
- foreach ( $res as $row ) {
- $merged[] = [ $row, &$hookData ];
- }
- }
- }
- $neg = $this->params['dir'] == 'newer' ? 1 : -1;
- usort( $merged, function ( $a, $b ) use ( $neg, $batchSize ) {
- if ( $batchSize === 1 ) { // One user, can't be different
- $ret = 0;
- } elseif ( $this->orderBy === 'id' ) {
- $ret = $a[0]->rev_user <=> $b[0]->rev_user;
- } elseif ( $this->orderBy === 'name' ) {
- $ret = strcmp( $a[0]->rev_user_text, $b[0]->rev_user_text );
- } else {
- $ret = $a[0]->rev_actor <=> $b[0]->rev_actor;
- }
-
- if ( !$ret ) {
- $ret = strcmp(
- wfTimestamp( TS_MW, $a[0]->rev_timestamp ),
- wfTimestamp( TS_MW, $b[0]->rev_timestamp )
- );
- }
-
- if ( !$ret ) {
- $ret = $a[0]->rev_id <=> $b[0]->rev_id;
- }
-
- return $neg * $ret;
- } );
- $merged = array_slice( $merged, 0, $limit - $count + 1 );
- // (end "Ugh")
+ $hookData = [];
+ $this->prepareQuery( $users, $limit - $count );
+ $res = $this->select( __METHOD__, [], $hookData );
if ( $this->fld_sizediff ) {
$revIds = [];
- foreach ( $merged as $data ) {
- if ( $data[0]->rev_parent_id ) {
- $revIds[] = $data[0]->rev_parent_id;
+ foreach ( $res as $row ) {
+ if ( $row->rev_parent_id ) {
+ $revIds[] = $row->rev_parent_id;
}
}
$this->parentLens = MediaWikiServices::getInstance()->getRevisionStore()
->listRevisionSizes( $dbSecondary, $revIds );
}
- foreach ( $merged as $data ) {
- $row = $data[0];
- $hookData = &$data[1];
+ foreach ( $res as $row ) {
if ( ++$count > $limit ) {
// We've reached the one extra which shows that there are
// additional pages to be had. Stop here...
* Prepares the query and returns the limit of rows requested
* @param User[] $users
* @param int $limit
- * @param string $which 'actor', 'userid', or 'username'
- * @return bool
*/
- private function prepareQuery( array $users, $limit, $which ) {
+ private function prepareQuery( array $users, $limit ) {
global $wgActorTableSchemaMigrationStage, $wgChangeTagsSchemaMigrationStage;
$this->resetQueryParams();
$this->addJoinConds( $revQuery['joins'] );
$this->addFields( $revQuery['fields'] );
- $revWhere = ActorMigration::newMigration()->getWhere( $db, 'rev_user', $users );
- if ( !isset( $revWhere['orconds'][$which] ) ) {
- return false;
- }
- $this->addWhere( $revWhere['orconds'][$which] );
-
- if ( $wgActorTableSchemaMigrationStage === MIGRATION_NEW ) {
+ if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_READ_NEW ) {
+ $revWhere = ActorMigration::newMigration()->getWhere( $db, 'rev_user', $users );
$orderUserField = 'rev_actor';
$userField = $this->orderBy === 'actor' ? 'revactor_actor' : 'actor_name';
- } else {
- $orderUserField = $this->orderBy === 'id' ? 'rev_user' : 'rev_user_text';
- $userField = $revQuery['fields'][$orderUserField];
- }
- if ( $which === 'actor' ) {
$tsField = 'revactor_timestamp';
$idField = 'revactor_rev';
} else {
+ // If we're dealing with user names (rather than IDs) in read-old mode,
+ // pass false for ActorMigration::getWhere()'s $useId parameter so
+ // $revWhere['conds'] isn't an OR.
+ $revWhere = ActorMigration::newMigration()
+ ->getWhere( $db, 'rev_user', $users, $this->orderBy === 'id' );
+ $orderUserField = $this->orderBy === 'id' ? 'rev_user' : 'rev_user_text';
+ $userField = $revQuery['fields'][$orderUserField];
$tsField = 'rev_timestamp';
$idField = 'rev_id';
}
+ $this->addWhere( $revWhere['conds'] );
+
// Handle continue parameter
if ( !is_null( $this->params['continue'] ) ) {
$continue = explode( '|', $this->params['continue'] );
$this->addWhereFld( 'ct_tag', $this->params['tag'] );
}
}
-
- return true;
}
/**
*/
use MediaWiki\MediaWikiServices;
-use MediaWiki\Storage\RevisionRecord;
+use MediaWiki\Revision\RevisionRecord;
/**
* This query action allows clients to retrieve a list of recently modified pages
* @since 1.23
*/
-use MediaWiki\Storage\RevisionRecord;
+use MediaWiki\Revision\RevisionRecord;
/**
* API interface to RevDel. The API equivalent of Special:RevisionDelete.
*/
use MediaWiki\MediaWikiServices;
-use MediaWiki\Storage\RevisionStore;
+use MediaWiki\Revision\RevisionStore;
/**
* @ingroup API
}
$msg = null;
- if ( !\Hooks::run( 'LoginUserMigrated', [ $user, &$msg ] ) ) {
+ if ( !\Hooks::run( 'LoginUserMigrated', [ $user, &$msg ], '1.27' ) ) {
return $this->makeFailResponse(
$user, LoginForm::USER_MIGRATED, $msg, 'LoginUserMigrated'
);
$abort = LoginForm::ABORTED;
$msg = null;
- if ( !\Hooks::run( 'AbortLogin', [ $user, $password, &$abort, &$msg ] ) ) {
+ if ( !\Hooks::run( 'AbortLogin', [ $user, $password, &$abort, &$msg ], '1.27' ) ) {
return $this->makeFailResponse( $user, $abort, $msg, 'AbortLogin' );
}
public function testForAccountCreation( $user, $creator, array $reqs ) {
$abortError = '';
$abortStatus = null;
- if ( !\Hooks::run( 'AbortNewAccount', [ $user, &$abortError, &$abortStatus ] ) ) {
+ if ( !\Hooks::run( 'AbortNewAccount', [ $user, &$abortError, &$abortStatus ], '1.27' ) ) {
// Hook point to add extra creation throttles and blocks
$this->logger->debug( __METHOD__ . ': a hook blocked creation' );
if ( $abortStatus === null ) {
public function testUserForCreation( $user, $autocreate, array $options = [] ) {
if ( $autocreate !== false ) {
$abortError = '';
- if ( !\Hooks::run( 'AbortAutoAccount', [ $user, &$abortError ] ) ) {
+ if ( !\Hooks::run( 'AbortAutoAccount', [ $user, &$abortError ], '1.27' ) ) {
// Hook point to add extra creation throttles and blocks
$this->logger->debug( __METHOD__ . ": a hook blocked auto-creation: $abortError\n" );
return $this->makeFailResponse(
$cache = [];
- # Common conditions
- $conds = [
- 'page_is_redirect' => 0,
- 'page_namespace' => NS_MEDIAWIKI,
- ];
-
- $mostused = [];
+ $mostused = []; // list of "<cased message key>/<code>"
if ( $wgAdaptiveMessageCache && $code !== $wgLanguageCode ) {
if ( !$this->cache->has( $wgLanguageCode ) ) {
$this->load( $wgLanguageCode );
}
}
+ // Get the list of software-defined messages in core/extensions
+ $overridable = array_flip( Language::getMessageKeysFor( $wgLanguageCode ) );
+
+ // Common conditions
+ $conds = [
+ 'page_is_redirect' => 0,
+ 'page_namespace' => NS_MEDIAWIKI,
+ ];
if ( count( $mostused ) ) {
$conds['page_title'] = $mostused;
} elseif ( $code !== $wgLanguageCode ) {
$dbr->buildLike( $dbr->anyString(), '/', $dbr->anyString() );
}
- # Conditions to fetch oversized pages to ignore them
- $bigConds = $conds;
- $bigConds[] = 'page_len > ' . intval( $wgMaxMsgCacheEntrySize );
-
- # Load titles for all oversized pages in the MediaWiki namespace
+ // Set the stubs for oversized software-defined messages in the main cache map
$res = $dbr->select(
'page',
[ 'page_title', 'page_latest' ],
- $bigConds,
+ array_merge( $conds, [ 'page_len > ' . intval( $wgMaxMsgCacheEntrySize ) ] ),
__METHOD__ . "($code)-big"
);
foreach ( $res as $row ) {
- $cache[$row->page_title] = '!TOO BIG';
+ $name = $this->contLang->lcfirst( $row->page_title );
+ // Include entries/stubs for all keys in $mostused in adaptive mode
+ if ( $wgAdaptiveMessageCache || isset( $overridable[$name] ) ) {
+ $cache[$row->page_title] = '!TOO BIG';
+ }
// At least include revision ID so page changes are reflected in the hash
$cache['EXCESSIVE'][$row->page_title] = $row->page_latest;
}
- # Conditions to load the remaining pages with their contents
- $smallConds = $conds;
- $smallConds[] = 'page_len <= ' . intval( $wgMaxMsgCacheEntrySize );
-
+ // Set the text for small software-defined messages in the main cache map
$res = $dbr->select(
[ 'page', 'revision', 'text' ],
- [ 'page_title', 'old_id', 'old_text', 'old_flags' ],
- $smallConds,
+ [ 'page_title', 'page_latest', 'old_id', 'old_text', 'old_flags' ],
+ array_merge( $conds, [ 'page_len <= ' . intval( $wgMaxMsgCacheEntrySize ) ] ),
__METHOD__ . "($code)-small",
[],
[
'text' => [ 'JOIN', 'rev_text_id=old_id' ],
]
);
-
foreach ( $res as $row ) {
- $text = Revision::getRevisionText( $row );
- if ( $text === false ) {
- // Failed to fetch data; possible ES errors?
- // Store a marker to fetch on-demand as a workaround...
- // TODO Use a differnt marker
- $entry = '!TOO BIG';
- wfDebugLog(
- 'MessageCache',
- __METHOD__
- . ": failed to load message page text for {$row->page_title} ($code)"
- );
+ $name = $this->contLang->lcfirst( $row->page_title );
+ // Include entries/stubs for all keys in $mostused in adaptive mode
+ if ( $wgAdaptiveMessageCache || isset( $overridable[$name] ) ) {
+ $text = Revision::getRevisionText( $row );
+ if ( $text === false ) {
+ // Failed to fetch data; possible ES errors?
+ // Store a marker to fetch on-demand as a workaround...
+ // TODO Use a differnt marker
+ $entry = '!TOO BIG';
+ wfDebugLog(
+ 'MessageCache',
+ __METHOD__
+ . ": failed to load message page text for {$row->page_title} ($code)"
+ );
+ } else {
+ $entry = ' ' . $text;
+ }
+ $cache[$row->page_title] = $entry;
} else {
- $entry = ' ' . $text;
+ // T193271: cache object gets too big and slow to generate.
+ // At least include revision ID so page changes are reflected in the hash.
+ $cache['EXCESSIVE'][$row->page_title] = $row->page_latest;
}
- $cache[$row->page_title] = $entry;
}
$cache['VERSION'] = MSG_CACHE_VERSION;
return;
}
- // Reload messages from the database and pre-populate dc-local caches
- // as optimisation. Use the master DB to avoid race conditions.
- $cache = $this->loadFromDB( $code, self::FOR_UPDATE );
+ // Load the existing cache to update it in the local DC cache.
+ // The other DCs will see a hash mismatch.
+ if ( $this->load( $code, self::FOR_UPDATE ) ) {
+ $cache = $this->cache->get( $code );
+ } else {
+ // Err? Fall back to loading from the database.
+ $cache = $this->loadFromDB( $code, self::FOR_UPDATE );
+ }
// Check if individual cache keys should exist and update cache accordingly
$newTextByTitle = []; // map of (title => content)
+ $newBigTitles = []; // map of (title => latest revision ID), like EXCESSIVE in loadFromDB()
foreach ( $replacements as list( $title ) ) {
$page = WikiPage::factory( Title::makeTitle( NS_MEDIAWIKI, $title ) );
$page->loadPageData( $page::READ_LATEST );
// Remember the text for the blob store update later on
$newTextByTitle[$title] = $text;
// Note that if $text is false, then $cache should have a !NONEXISTANT entry
- if ( is_string( $text ) && strlen( $text ) > $wgMaxMsgCacheEntrySize ) {
- // Match logic of loadCachedMessagePageEntry()
- $this->wanCache->set(
- $this->bigMessageCacheKey( $cache['HASH'], $title ),
- ' ' . $text,
- $this->mExpiry
- );
+ if ( !is_string( $text ) ) {
+ $cache[$title] = '!NONEXISTENT';
+ } elseif ( strlen( $text ) > $wgMaxMsgCacheEntrySize ) {
+ $cache[$title] = '!TOO BIG';
+ $newBigTitles[$title] = $page->getLatest();
+ } else {
+ $cache[$title] = ' ' . $text;
}
}
+ // Update HASH for the new key. Incorporates various administrative keys,
+ // including the old HASH (and thereby the EXCESSIVE value from loadFromDB()
+ // and previous replace() calls), but that doesn't really matter since we
+ // only ever compare it for equality with a copy saved by saveToCaches().
+ $cache['HASH'] = md5( serialize( $cache + [ 'EXCESSIVE' => $newBigTitles ] ) );
+ // Update the too-big WAN cache entries now that we have the new HASH
+ foreach ( $newBigTitles as $title => $id ) {
+ // Match logic of loadCachedMessagePageEntry()
+ $this->wanCache->set(
+ $this->bigMessageCacheKey( $cache['HASH'], $title ),
+ ' ' . $newTextByTitle[$title],
+ $this->mExpiry
+ );
+ }
// Mark this cache as definitely being "latest" (non-volatile) so
// load() calls do not try to refresh the cache with replica DB data
$cache['LATEST'] = time();
Hooks::run( 'MessageCache::get', [ &$lckey ] );
// Loop through each language in the fallback list until we find something useful
- $lang = wfGetLangObj( $langcode );
$message = $this->getMessageFromFallbackChain(
- $lang,
+ wfGetLangObj( $langcode ),
$lckey,
!$this->mDisable && $useDB
);
$this->getMessagePageName( $langcode, $uckey ),
$langcode
);
-
if ( $message !== false ) {
return $message;
}
$this->load( $code );
$entry = $this->cache->getField( $code, $title );
+
if ( $entry !== null ) {
+ // Message page exists as an override of a software messages
if ( substr( $entry, 0, 1 ) === ' ' ) {
// The message exists and is not '!TOO BIG'
return (string)substr( $entry, 1 );
} elseif ( $entry === '!NONEXISTENT' ) {
+ // The text might be '-' or missing due to some data loss
return false;
}
- // Fall through and try invididual message cache below
- } else {
- // Message does not have a MediaWiki page definition
- $message = false;
- Hooks::run( 'MessagesPreLoad', [ $title, &$message, $code ] );
- if ( $message !== false ) {
- $this->cache->setField( $code, $title, ' ' . $message );
- } else {
- $this->cache->setField( $code, $title, '!NONEXISTENT' );
- }
-
- return $message;
- }
-
- if ( $this->cacheVolatile[$code] ) {
- $entry = false;
- // Make sure that individual keys respect the WAN cache holdoff period too
- LoggerFactory::getInstance( 'MessageCache' )->debug(
- __METHOD__ . ': loading volatile key \'{titleKey}\'',
- [ 'titleKey' => $title, 'code' => $code ] );
+ // Load the message page, utilizing the individual message cache.
+ // If the page does not exist, there will be no hook handler fallbacks.
+ $entry = $this->loadCachedMessagePageEntry(
+ $title,
+ $code,
+ $this->cache->getField( $code, 'HASH' )
+ );
} else {
- // Try the individual message cache
+ // Message page does not exist or does not override a software message.
+ // Load the message page, utilizing the individual message cache.
$entry = $this->loadCachedMessagePageEntry(
$title,
$code,
$this->cache->getField( $code, 'HASH' )
);
+ if ( substr( $entry, 0, 1 ) !== ' ' ) {
+ // Message does not have a MediaWiki page definition; try hook handlers
+ $message = false;
+ Hooks::run( 'MessagesPreLoad', [ $title, &$message, $code ] );
+ if ( $message !== false ) {
+ $this->cache->setField( $code, $title, ' ' . $message );
+ } else {
+ $this->cache->setField( $code, $title, '!NONEXISTENT' );
+ }
+
+ return $message;
+ }
}
if ( $entry !== false && substr( $entry, 0, 1 ) === ' ' ) {
- $this->cache->setField( $code, $title, $entry );
+ if ( $this->cacheVolatile[$code] ) {
+ // Make sure that individual keys respect the WAN cache holdoff period too
+ LoggerFactory::getInstance( 'MessageCache' )->debug(
+ __METHOD__ . ': loading volatile key \'{titleKey}\'',
+ [ 'titleKey' => $title, 'code' => $code ] );
+ } else {
+ $this->cache->setField( $code, $title, $entry );
+ }
// The message exists, so make sure a string is returned
return (string)substr( $entry, 1 );
}
$fields = [ 'user_name', 'user_real_name', 'user_registration', 'user_id' ];
$joinConds = [];
- if ( $wgActorTableSchemaMigrationStage > MIGRATION_OLD ) {
+ // Technically we shouldn't allow this without SCHEMA_COMPAT_READ_NEW,
+ // but it does little harm and might be needed for write callers loading a User.
+ if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_NEW ) {
$tables[] = 'actor';
$fields[] = 'actor_id';
$joinConds['actor'] = [
- $wgActorTableSchemaMigrationStage === MIGRATION_NEW ? 'JOIN' : 'LEFT JOIN',
+ ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_READ_NEW ) ? 'JOIN' : 'LEFT JOIN',
[ 'actor_user = user_id' ]
];
}
$this->cache[$userId]['name'] = $row->user_name;
$this->cache[$userId]['real_name'] = $row->user_real_name;
$this->cache[$userId]['registration'] = $row->user_registration;
- if ( $wgActorTableSchemaMigrationStage > MIGRATION_OLD ) {
+ if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_NEW ) {
$this->cache[$userId]['actor'] = $row->actor_id;
}
$usersToCheck[$userId] = $row->user_name;
global $wgActorTableSchemaMigrationStage;
wfDeprecated( __METHOD__, '1.31' );
- if ( $wgActorTableSchemaMigrationStage > MIGRATION_WRITE_BOTH ) {
+ if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_READ_NEW ) {
// If code is using this instead of self::getQueryInfo(), there's a
// decent chance it's going to try to directly access
// $row->rc_user or $row->rc_user_text and we can't give it
- // useful values here once those aren't being written anymore.
+ // useful values here once those aren't being used anymore.
throw new BadMethodCallException(
- 'Cannot use ' . __METHOD__ . ' when $wgActorTableSchemaMigrationStage > MIGRATION_WRITE_BOTH'
+ 'Cannot use ' . __METHOD__
+ . ' when $wgActorTableSchemaMigrationStage has SCHEMA_COMPAT_READ_NEW'
);
}
use MediaWiki\MediaWikiServices;
use Wikimedia\Rdbms\Database;
+use Wikimedia\Rdbms\DatabaseDomain;
use Wikimedia\Rdbms\Blob;
use Wikimedia\Rdbms\ResultWrapper;
use Wikimedia\Rdbms\DBConnectionError;
use Wikimedia\Rdbms\DBUnexpectedError;
+use Wikimedia\Rdbms\DBExpectedError;
/**
* @ingroup Database
return false;
}
- function open( $server, $user, $password, $dbName ) {
+ protected function open( $server, $user, $password, $dbName, $schema, $tablePrefix ) {
global $wgDBOracleDRCP;
+
if ( !function_exists( 'oci_connect' ) ) {
throw new DBConnectionError(
$this,
$this->close();
$this->user = $user;
$this->password = $password;
- // changed internal variables functions
- // mServer now holds the TNS endpoint
- // mDBname is schema name if different from username
if ( !$server ) {
- // backward compatibillity (server used to be null and TNS was supplied in dbname)
+ // Backward compatibility (server used to be null and TNS was supplied in dbname)
$this->server = $dbName;
- $this->dbName = $user;
+ $realDatabase = $user;
} else {
+ // $server now holds the TNS endpoint
$this->server = $server;
- if ( !$dbName ) {
- $this->dbName = $user;
- } else {
- $this->dbName = $dbName;
- }
+ // $dbName is schema name if different from username
+ $realDatabase = $dbName ?: $user;
}
if ( !strlen( $user ) ) { # e.g. the class is being loaded
}
Wikimedia\restoreWarnings();
- if ( $this->user != $this->dbName ) {
+ if ( $this->user != $realDatabase ) {
// change current schema in session
- $this->selectDB( $this->dbName );
+ $this->selectDB( $realDatabase );
+ } else {
+ $this->currentDomain = new DatabaseDomain(
+ $realDatabase,
+ null,
+ $tablePrefix
+ );
}
if ( !$this->conn ) {
atc.table_name
) || '_' ||
atc.column_name || '_SEQ' = '{$this->tablePrefix}' || asq.sequence_name
- AND asq.sequence_owner = upper('{$this->dbName}')
- AND atc.owner = upper('{$this->dbName}')" );
+ AND asq.sequence_owner = upper('{$this->getDBname()}')
+ AND atc.owner = upper('{$this->getDBname()}')" );
while ( ( $row = $result->fetchRow() ) !== false ) {
$this->sequenceData[$row[1]] = [
$listWhere = ' AND table_name LIKE \'' . strtoupper( $prefix ) . '%\'';
}
- $owner = strtoupper( $this->dbName );
+ $owner = strtoupper( $this->getDBname() );
$result = $this->doQuery( "SELECT table_name FROM all_tables " .
"WHERE owner='$owner' AND table_name NOT LIKE '%!_IDX\$_' ESCAPE '!' $listWhere" );
$table = $this->tableName( $table );
$table = strtoupper( $this->removeIdentifierQuotes( $table ) );
$index = strtoupper( $index );
- $owner = strtoupper( $this->dbName );
+ $owner = strtoupper( $this->getDBname() );
$sql = "SELECT 1 FROM all_indexes WHERE owner='$owner' AND index_name='{$table}_{$index}'";
$res = $this->doQuery( $sql );
if ( $res ) {
function tableExists( $table, $fname = __METHOD__ ) {
$table = $this->tableName( $table );
$table = $this->addQuotes( strtoupper( $this->removeIdentifierQuotes( $table ) ) );
- $owner = $this->addQuotes( strtoupper( $this->dbName ) );
+ $owner = $this->addQuotes( strtoupper( $this->getDBname() ) );
$sql = "SELECT 1 FROM all_tables WHERE owner=$owner AND table_name=$table";
$res = $this->doQuery( $sql );
if ( $res && $res->numRows() > 0 ) {
return true;
}
- function selectDB( $db ) {
- $this->dbName = $db;
- if ( $db == null || $db == $this->user ) {
+ protected function doSelectDomain( DatabaseDomain $domain ) {
+ if ( $domain->getSchema() !== null ) {
+ // We use the *database* aspect of $domain for schema, not the domain schema
+ throw new DBExpectedError( $this, __CLASS__ . ": domain schemas are not supported." );
+ }
+
+ $database = $domain->getDatabase();
+ if ( $database === null || $database === $this->user ) {
+ // Backward compatibility
+ $this->currentDomain = $domain;
+
return true;
}
- $sql = 'ALTER SESSION SET CURRENT_SCHEMA=' . strtoupper( $db );
+
+ // https://docs.oracle.com/javadb/10.8.3.0/ref/rrefsqlj32268.html
+ $encDatabase = $this->addIdentifierQuotes( strtoupper( $database ) );
+ $sql = "ALTER SESSION SET CURRENT_SCHEMA=$encDatabase";
$stmt = oci_parse( $this->conn, $sql );
Wikimedia\suppressWarnings();
$success = oci_execute( $stmt );
Wikimedia\restoreWarnings();
- if ( !$success ) {
+ if ( $success ) {
+ // Update that domain fields on success (no exception thrown)
+ $this->currentDomain = $domain;
+ } else {
$e = oci_error( $stmt );
- if ( $e['code'] != '1435' ) {
- $this->reportQueryError( $e['message'], $e['code'], $sql, __METHOD__ );
- }
-
- return false;
+ $this->reportQueryError( $e['message'], $e['code'], $sql, __METHOD__ );
}
return true;
// all deletions on these tables have transactions so final failure rollbacks these updates
// @todo: Normalize the schema to match MySQL, no special FKs and such
$table = $this->tableName( $table );
- if ( $table == $this->tableName( 'user' ) && $wgActorTableSchemaMigrationStage < MIGRATION_NEW ) {
+ if ( $table == $this->tableName( 'user' ) &&
+ ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_WRITE_OLD )
+ ) {
$this->update( 'archive', [ 'ar_user' => 0 ],
[ 'ar_user' => $conds['user_id'] ], $fname );
$this->update( 'ipblocks', [ 'ipb_user' => 0 ],
return 'BITOR(' . $fieldLeft . ', ' . $fieldRight . ')';
}
- function getDBname() {
- return $this->dbName;
- }
-
function getServer() {
return $this->server;
}
}
}
- /**
- * @param bool $value Whether to just immediately run updates in addUpdate()
- * @since 1.28
- * @deprecated 1.29 Causes issues in Web-executed jobs - see T165714 and T100085.
- */
- public static function setImmediateMode( $value ) {
- wfDeprecated( __METHOD__, '1.29' );
- }
-
/**
* @param DeferrableUpdate[] $queue
* @param DeferrableUpdate $update
* @ingroup DifferenceEngine
*/
-use MediaWiki\Storage\RevisionRecord;
-use MediaWiki\Storage\SlotRecord;
+use MediaWiki\Revision\RevisionRecord;
+use MediaWiki\Revision\SlotRecord;
/**
* DifferenceEngine is responsible for rendering the difference between two revisions as HTML.
static function selectFields() {
global $wgActorTableSchemaMigrationStage;
- if ( $wgActorTableSchemaMigrationStage > MIGRATION_WRITE_BOTH ) {
+ if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_READ_NEW ) {
// If code is using this instead of self::getQueryInfo(), there's a
// decent chance it's going to try to directly access
// $row->fa_user or $row->fa_user_text and we can't give it
- // useful values here once those aren't being written anymore.
+ // useful values here once those aren't being used anymore.
throw new BadMethodCallException(
- 'Cannot use ' . __METHOD__ . ' when $wgActorTableSchemaMigrationStage > MIGRATION_WRITE_BOTH'
+ 'Cannot use ' . __METHOD__
+ . ' when $wgActorTableSchemaMigrationStage has SCHEMA_COMPAT_READ_NEW'
);
}
'fa_minor_mime',
'fa_user',
'fa_user_text',
- 'fa_actor' => $wgActorTableSchemaMigrationStage > MIGRATION_OLD ? 'fa_actor' : 'NULL',
+ 'fa_actor' => 'NULL',
'fa_timestamp',
'fa_deleted',
'fa_deleted_timestamp', /* Used by LocalFileRestoreBatch */
*/
public function getMatchedLanguage( $userPreferredLanguage ) {
$handler = $this->getHandler();
- if ( $handler && method_exists( $handler, 'getMatchedLanguage' ) ) {
+ if ( $handler ) {
return $handler->getMatchedLanguage(
$userPreferredLanguage,
$handler->getAvailableLanguages( $this )
);
- } else {
- return null;
}
+
+ return null;
}
/**
return true;
}
- /**
- * @deprecated since 1.30, use File::getContentHeaders instead
- */
- function getStreamHeaders() {
- wfDeprecated( __METHOD__, '1.30' );
- return $this->getContentHeaders();
- }
-
/**
* @return string[] HTTP header name/value map to use for HEAD/GET request responses
* @since 1.30
global $wgActorTableSchemaMigrationStage;
wfDeprecated( __METHOD__, '1.31' );
- if ( $wgActorTableSchemaMigrationStage > MIGRATION_WRITE_BOTH ) {
+ if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_READ_NEW ) {
// If code is using this instead of self::getQueryInfo(), there's a
// decent chance it's going to try to directly access
// $row->img_user or $row->img_user_text and we can't give it
- // useful values here once those aren't being written anymore.
+ // useful values here once those aren't being used anymore.
throw new BadMethodCallException(
- 'Cannot use ' . __METHOD__ . ' when $wgActorTableSchemaMigrationStage > MIGRATION_WRITE_BOTH'
+ 'Cannot use ' . __METHOD__
+ . ' when $wgActorTableSchemaMigrationStage has SCHEMA_COMPAT_READ_NEW'
);
}
'img_minor_mime',
'img_user',
'img_user_text',
- 'img_actor' => $wgActorTableSchemaMigrationStage > MIGRATION_OLD ? 'img_actor' : 'NULL',
+ 'img_actor' => 'NULL',
'img_timestamp',
'img_sha1',
] + MediaWikiServices::getInstance()->getCommentStore()->getFields( 'img_description' );
}
}
- if ( $wgActorTableSchemaMigrationStage <= MIGRATION_WRITE_BOTH ) {
+ if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_WRITE_OLD ) {
$fields['oi_user'] = 'img_user';
$fields['oi_user_text'] = 'img_user_text';
}
- if ( $wgActorTableSchemaMigrationStage >= MIGRATION_WRITE_BOTH ) {
+ if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_WRITE_NEW ) {
$fields['oi_actor'] = 'img_actor';
}
- if ( $wgActorTableSchemaMigrationStage !== MIGRATION_OLD &&
- $wgActorTableSchemaMigrationStage !== MIGRATION_NEW
+ if (
+ ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_WRITE_BOTH ) === SCHEMA_COMPAT_WRITE_BOTH
) {
// Upgrade any rows that are still old-style. Otherwise an upgrade
// might be missed if a deletion happens while the migration script
}
}
- if ( $wgActorTableSchemaMigrationStage <= MIGRATION_WRITE_BOTH ) {
+ if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_WRITE_OLD ) {
$fields['fa_user'] = 'img_user';
$fields['fa_user_text'] = 'img_user_text';
}
- if ( $wgActorTableSchemaMigrationStage >= MIGRATION_WRITE_BOTH ) {
+ if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_WRITE_NEW ) {
$fields['fa_actor'] = 'img_actor';
}
- if ( $wgActorTableSchemaMigrationStage !== MIGRATION_OLD &&
- $wgActorTableSchemaMigrationStage !== MIGRATION_NEW
+ if (
+ ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_WRITE_BOTH ) === SCHEMA_COMPAT_WRITE_BOTH
) {
// Upgrade any rows that are still old-style. Otherwise an upgrade
// might be missed if a deletion happens while the migration script
global $wgActorTableSchemaMigrationStage;
wfDeprecated( __METHOD__, '1.31' );
- if ( $wgActorTableSchemaMigrationStage > MIGRATION_WRITE_BOTH ) {
+ if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_READ_NEW ) {
// If code is using this instead of self::getQueryInfo(), there's a
// decent chance it's going to try to directly access
// $row->oi_user or $row->oi_user_text and we can't give it
- // useful values here once those aren't being written anymore.
+ // useful values here once those aren't being used anymore.
throw new BadMethodCallException(
- 'Cannot use ' . __METHOD__ . ' when $wgActorTableSchemaMigrationStage > MIGRATION_WRITE_BOTH'
+ 'Cannot use ' . __METHOD__
+ . ' when $wgActorTableSchemaMigrationStage has SCHEMA_COMPAT_READ_NEW'
);
}
'oi_minor_mime',
'oi_user',
'oi_user_text',
- 'oi_actor' => $wgActorTableSchemaMigrationStage > MIGRATION_OLD ? 'oi_actor' : 'NULL',
+ 'oi_actor' => 'NULL',
'oi_timestamp',
'oi_deleted',
'oi_sha1',
public function getOOUI( $value ) {
if ( !empty( $this->mParams['rawrow'] ) ) {
if ( !( $value instanceof OOUI\FieldLayout ) ) {
- throw new Exception( "'default' must be a FieldLayout or subclass when using 'rawrow'" );
+ wfDeprecated( "'default' parameter as a string when using 'rawrow' " .
+ "(must be a FieldLayout or subclass)", '1.32' );
}
return $value;
}
/**
* @param string $siteName
* @param string|null $admin
- * @param array $option
+ * @param array $options
*/
- function __construct( $siteName, $admin = null, array $option = [] ) {
+ function __construct( $siteName, $admin = null, array $options = [] ) {
global $wgContLang;
parent::__construct();
- if ( isset( $option['scriptpath'] ) ) {
+ if ( isset( $options['scriptpath'] ) ) {
$this->specifiedScriptPath = true;
}
foreach ( $this->optionMap as $opt => $global ) {
- if ( isset( $option[$opt] ) ) {
- $GLOBALS[$global] = $option[$opt];
- $this->setVar( $global, $option[$opt] );
+ if ( isset( $options[$opt] ) ) {
+ $GLOBALS[$global] = $options[$opt];
+ $this->setVar( $global, $options[$opt] );
}
}
- if ( isset( $option['lang'] ) ) {
+ if ( isset( $options['lang'] ) ) {
global $wgLang, $wgLanguageCode;
- $this->setVar( '_UserLang', $option['lang'] );
- $wgLanguageCode = $option['lang'];
+ $this->setVar( '_UserLang', $options['lang'] );
+ $wgLanguageCode = $options['lang'];
$wgContLang = MediaWikiServices::getInstance()->getContentLanguage();
- $wgLang = Language::factory( $option['lang'] );
+ $wgLang = Language::factory( $options['lang'] );
RequestContext::getMain()->setLanguage( $wgLang );
}
$this->setVar( '_AdminName', $admin );
}
- if ( !isset( $option['installdbuser'] ) ) {
+ if ( !isset( $options['installdbuser'] ) ) {
$this->setVar( '_InstallUser',
$this->getVar( 'wgDBuser' ) );
$this->setVar( '_InstallPassword',
$this->getVar( 'wgDBpassword' ) );
} else {
$this->setVar( '_InstallUser',
- $option['installdbuser'] );
+ $options['installdbuser'] );
$this->setVar( '_InstallPassword',
- $option['installdbpass'] ?? "" );
+ $options['installdbpass'] ?? "" );
// Assume that if we're given the installer user, we'll create the account.
$this->setVar( '_CreateDBAccount', true );
}
- if ( isset( $option['pass'] ) ) {
- $this->setVar( '_AdminPassword', $option['pass'] );
+ if ( isset( $options['pass'] ) ) {
+ $this->setVar( '_AdminPassword', $options['pass'] );
}
// Detect and inject any extension found
- if ( isset( $option['with-extensions'] ) ) {
+ if ( isset( $options['extensions'] ) ) {
+ $status = $this->validateExtensions(
+ 'extension', 'extensions', $options['extensions'] );
+ if ( !$status->isOK() ) {
+ $this->showStatusMessage( $status );
+ }
+ $this->setVar( '_Extensions', $status->value );
+ } elseif ( isset( $options['with-extensions'] ) ) {
$this->setVar( '_Extensions', array_keys( $this->findExtensions() ) );
}
// Set up the default skins
- $skins = array_keys( $this->findExtensions( 'skins' ) );
+ if ( isset( $options['skins'] ) ) {
+ $status = $this->validateExtensions( 'skin', 'skins', $options['skins'] );
+ if ( !$status->isOK() ) {
+ $this->showStatusMessage( $status );
+ }
+ $skins = $status->value;
+ } else {
+ $skins = array_keys( $this->findExtensions( 'skins' ) );
+ }
$this->setVar( '_Skins', $skins );
if ( $skins ) {
}
}
+ private function validateExtensions( $type, $directory, $nameLists ) {
+ $extensions = [];
+ $status = new Status;
+ foreach ( (array)$nameLists as $nameList ) {
+ foreach ( explode( ',', $nameList ) as $name ) {
+ $name = trim( $name );
+ if ( $name === '' ) {
+ continue;
+ }
+ $extStatus = $this->getExtensionInfo( $type, $directory, $name );
+ if ( $extStatus->isOK() ) {
+ $extensions[] = $name;
+ } else {
+ $status->merge( $extStatus );
+ }
+ }
+ }
+ $extensions = array_unique( $extensions );
+ $status->value = $extensions;
+ return $status;
+ }
+
/**
* Main entry point.
*/
*/
protected function migrateActors() {
global $wgActorTableSchemaMigrationStage;
- if ( $wgActorTableSchemaMigrationStage >= MIGRATION_WRITE_NEW &&
+ if ( ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_WRITE_NEW ) &&
!$this->updateRowExists( 'MigrateActors' )
) {
$this->output(
}
/**
- * Finds extensions that follow the format /$directory/Name/Name.php,
- * and returns an array containing the value for 'Name' for each found extension.
+ * Find extensions or skins in a subdirectory of $IP.
+ * Returns an array containing the value for 'Name' for each found extension.
*
- * Reasonable values for $directory include 'extensions' (the default) and 'skins'.
- *
- * @param string $directory Directory to search in
+ * @param string $directory Directory to search in, relative to $IP, must be either "extensions"
+ * or "skins"
* @return array [ $extName => [ 'screenshots' => [ '...' ] ]
*/
public function findExtensions( $directory = 'extensions' ) {
+ switch ( $directory ) {
+ case 'extensions':
+ return $this->findExtensionsByType( 'extension', 'extensions' );
+ case 'skins':
+ return $this->findExtensionsByType( 'skin', 'skins' );
+ default:
+ throw new InvalidArgumentException( "Invalid extension type" );
+ }
+ }
+
+ /**
+ * Find extensions or skins, and return an array containing the value for 'Name' for each found
+ * extension.
+ *
+ * @param string $type Either "extension" or "skin"
+ * @param string $directory Directory to search in, relative to $IP
+ * @return array [ $extName => [ 'screenshots' => [ '...' ] ]
+ */
+ protected function findExtensionsByType( $type = 'extension', $directory = 'extensions' ) {
if ( $this->getVar( 'IP' ) === null ) {
return [];
}
return [];
}
- // extensions -> extension.json, skins -> skin.json
- $jsonFile = substr( $directory, 0, strlen( $directory ) - 1 ) . '.json';
-
$dh = opendir( $extDir );
$exts = [];
while ( ( $file = readdir( $dh ) ) !== false ) {
if ( !is_dir( "$extDir/$file" ) ) {
continue;
}
- $fullJsonFile = "$extDir/$file/$jsonFile";
- $isJson = file_exists( $fullJsonFile );
- $isPhp = false;
- if ( !$isJson ) {
- // Only fallback to PHP file if JSON doesn't exist
- $fullPhpFile = "$extDir/$file/$file.php";
- $isPhp = file_exists( $fullPhpFile );
- }
- if ( $isJson || $isPhp ) {
- // Extension exists. Now see if there are screenshots
- $exts[$file] = [];
- if ( is_dir( "$extDir/$file/screenshots" ) ) {
- $paths = glob( "$extDir/$file/screenshots/*.png" );
- foreach ( $paths as $path ) {
- $exts[$file]['screenshots'][] = str_replace( $extDir, "../$directory", $path );
- }
-
- }
- }
- if ( $isJson ) {
- $info = $this->readExtension( $fullJsonFile );
- if ( $info === false ) {
- continue;
- }
- $exts[$file] += $info;
+ $status = $this->getExtensionInfo( $type, $directory, $file );
+ if ( $status->isOK() ) {
+ $exts[$file] = $status->value;
}
}
closedir( $dh );
return $exts;
}
+ /**
+ * @param string $type Either "extension" or "skin"
+ * @param string $parentRelPath The parent directory relative to $IP
+ * @param string $name The extension or skin name
+ * @return Status An object containing an error list. If there were no errors, an associative
+ * array of information about the extension can be found in $status->value.
+ */
+ protected function getExtensionInfo( $type, $parentRelPath, $name ) {
+ if ( $this->getVar( 'IP' ) === null ) {
+ throw new Exception( 'Cannot find extensions since the IP variable is not yet set' );
+ }
+ if ( $type !== 'extension' && $type !== 'skin' ) {
+ throw new InvalidArgumentException( "Invalid extension type" );
+ }
+ $absDir = $this->getVar( 'IP' ) . "/$parentRelPath/$name";
+ $relDir = "../$parentRelPath/$name";
+ if ( !is_dir( $absDir ) ) {
+ return Status::newFatal( 'config-extension-not-found', $name );
+ }
+ $jsonFile = $type . '.json';
+ $fullJsonFile = "$absDir/$jsonFile";
+ $isJson = file_exists( $fullJsonFile );
+ $isPhp = false;
+ if ( !$isJson ) {
+ // Only fallback to PHP file if JSON doesn't exist
+ $fullPhpFile = "$absDir/$name.php";
+ $isPhp = file_exists( $fullPhpFile );
+ }
+ if ( !$isJson && !$isPhp ) {
+ return Status::newFatal( 'config-extension-not-found', $name );
+ }
+
+ // Extension exists. Now see if there are screenshots
+ $info = [];
+ if ( is_dir( "$absDir/screenshots" ) ) {
+ $paths = glob( "$absDir/screenshots/*.png" );
+ foreach ( $paths as $path ) {
+ $info['screenshots'][] = str_replace( $absDir, $relDir, $path );
+ }
+ }
+
+ if ( $isJson ) {
+ $jsonStatus = $this->readExtension( $fullJsonFile );
+ if ( !$jsonStatus->isOK() ) {
+ return $jsonStatus;
+ }
+ $info += $jsonStatus->value;
+ }
+
+ return Status::newGood( $info );
+ }
+
/**
* @param string $fullJsonFile
* @param array $extDeps
* @param array $skinDeps
*
- * @return array|bool False if this extension can't be loaded
+ * @return Status On success, an array of extension information is in $status->value. On
+ * failure, the Status object will have an error list.
*/
private function readExtension( $fullJsonFile, $extDeps = [], $skinDeps = [] ) {
$load = [
foreach ( $extDeps as $dep ) {
$fname = "$extDir/$dep/extension.json";
if ( !file_exists( $fname ) ) {
- return false;
+ return Status::newFatal( 'config-extension-not-found', $dep );
}
$load[$fname] = 1;
}
foreach ( $skinDeps as $dep ) {
$fname = "$skinDir/$dep/skin.json";
if ( !file_exists( $fname ) ) {
- return false;
+ return Status::newFatal( 'config-extension-not-found', $dep );
}
$load[$fname] = 1;
}
) {
// If something is incompatible with a dependency, we have no real
// option besides skipping it
- return false;
+ return Status::newFatal( 'config-extension-dependency',
+ basename( dirname( $fullJsonFile ) ), $e->getMessage() );
} elseif ( $e->missingExtensions || $e->missingSkins ) {
// There's an extension missing in the dependency tree,
// so add those to the dependency list and try again
);
}
// Some other kind of dependency error?
- return false;
+ return Status::newFatal( 'config-extension-dependency',
+ basename( dirname( $fullJsonFile ) ), $e->getMessage() );
}
$ret = [];
// The order of credits will be the order of $load,
}
$ret['type'] = $credits['type'];
- return $ret;
+ return Status::newGood( $ret );
}
/**
"config-skins-screenshots": "$1 (لقطات شاشة: $2)",
"config-extensions-requires": "$1 (يتطلب $2)",
"config-screenshot": "لقطة شاشة",
+ "config-extension-not-found": "لم يمكن العثور على ملف التسجيل للامتداد \"$1\"",
+ "config-extension-dependency": "خطأ اعتماد حدث أثناء تنصيب الامتداد \"$1\": $2",
"mainpagetext": "<strong>تم تثبيت ميدياويكي بنجاح.</strong>",
"mainpagedocfooter": "استشر [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents دليل المستخدم] لمعلومات حول استخدام برنامج الويكي.\n\n== البداية ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings قائمة إعدادات الضبط]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ أسئلة متكررة حول ميدياويكي]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce القائمة البريدية الخاصة بإصدار ميدياويكي]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam تعلم كيفية مكافحة السخام في الويكي الخاص بك]"
}
"config-skins-screenshot": "$1 ($2)",
"config-extensions-requires": "$1 (erfordert $2)",
"config-screenshot": "Bildschirmfoto",
+ "config-extension-not-found": "Die Registrierungsdatei für die Erweiterung „$1“ konnte nicht gefunden werden",
+ "config-extension-dependency": "Bei der Installation der Erweiterung „$1“ ist ein Abhängigkeitsfehler aufgetreten: $2",
"mainpagetext": "<strong>MediaWiki wurde installiert.</strong>",
"mainpagedocfooter": "Hilfe zur Benutzung und Konfiguration der Wiki-Software findest du im [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Benutzerhandbuch].\n\n== Starthilfen ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Liste der Konfigurationsvariablen]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki-FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Mailingliste neuer MediaWiki-Versionen]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Übersetze MediaWiki für deine Sprache]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Erfahre, wie du Spam auf deinem Wiki bekämpfen kannst]"
}
"config-skins-screenshot": "$1 ($2)",
"config-extensions-requires": "$1 (requires $2)",
"config-screenshot": "screenshot",
+ "config-extension-not-found": "Could not find the registration file for the extension \"$1\"",
+ "config-extension-dependency": "A dependency error was encountered while installing the extension \"$1\": $2",
"mainpagetext": "<strong>MediaWiki has been installed.</strong>",
"mainpagedocfooter": "Consult the [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents User's Guide] for information on using the wiki software.\n\n== Getting started ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Configuration settings list]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki release mailing list]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Localise MediaWiki for your language]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Learn how to combat spam on your wiki]"
}
"config-skins-screenshots": "$1 (captures d’écran : $2)",
"config-extensions-requires": "$1 (nécessite $2)",
"config-screenshot": "Captures d’écrans",
+ "config-extension-not-found": "Impossible de trouver le fichier d’inscription pour l’extension « $1 »",
+ "config-extension-dependency": "Une erreur de dépendance s’est produite en installant l’extension « $1 » : $2",
"mainpagetext": "<strong>MediaWiki a été installé.</strong>",
"mainpagedocfooter": "Consultez le [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Guide de l’utilisateur] pour plus d’informations sur l’utilisation de ce logiciel de wiki.\n\n== Pour démarrer ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Liste des paramètres de configuration]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ/fr Questions courantes sur MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Liste de discussion sur les distributions de MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Adaptez MediaWiki dans votre langue]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Apprendre comment lutter contre le pourriel dans votre wiki]"
}
"config-nofile": "Il file \"$1\" non può essere trovato. È stato eliminato?",
"config-extension-link": "Sapevi che il tuo wiki supporta le [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions estensioni]?\n\nPuoi navigare tra le [https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category estensioni per categoria].",
"config-extensions-requires": "$1 (richiesto $2)",
+ "config-extension-not-found": "Impossibile trovare il file di registrazione per l'estensione \"$1\"",
+ "config-extension-dependency": "Si è verificato un errore di dipendenza durante l'installazione dell'estensione \"$1\": $2",
"mainpagetext": "<strong>MediaWiki è stato installato.</strong>",
"mainpagedocfooter": "Consulta la [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents guida utente] per maggiori informazioni sull'uso di questo software wiki.\n\n== Per iniziare ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Impostazioni di configurazione]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Domande frequenti su MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Mailing list annunci MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Trova MediaWiki nella tua lingua]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Imparare a combattere lo spam sul tuo wiki]"
}
"config-skins-screenshots": "$1 (screenshots: $2)",
"config-extensions-requires": "$1 (requer $2)",
"config-screenshot": "screenshot",
+ "config-extension-not-found": "Não foi possível encontrar o arquivo de registo da extensão \"$1\"",
+ "config-extension-dependency": "Foi encontrado um erro de dependências ao instalar a extensão \"$1\": $2",
"mainpagetext": "<strong>O MediaWiki foi instalado.</strong>",
"mainpagedocfooter": "Consulte o [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Manual de Usuário] para informações de como usar o software wiki.\n\n== Começando ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Lista de opções de configuração]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ FAQ do MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Lista de discussão com avisos de novas versões do MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Traduza o MediaWiki para seu idioma]"
}
"config-install-mainpage-failed": "Não foi possível inserir a página principal: $1",
"config-install-done": "<strong>Parabéns!</strong>\nTerminou a instalação do MediaWiki.\n\nO instalador gerou um ficheiro <code>LocalSettings.php</code>.\nEste ficheiro contém todas as configurações.\n\nPrecisa de descarregar o ficheiro e colocá-lo no diretório de raiz da sua instalação (o mesmo diretório onde está o ficheiro index.php). Este descarregamento deverá ter sido iniciado automaticamente.\n\nSe o descarregamento não foi iniciado, ou se o cancelou, pode recomeçá-lo clicando na hiperligação abaixo:\n\n$3\n\n<strong>Nota</strong>: Se não o descarregar agora, o ficheiro que foi gerado deixará de estar disponível quando sair do processo de instalação.\n\nDepois de terminar o passo anterior, pode <strong>[$2 entrar na wiki]</strong>.",
"config-install-done-path": "<strong>Parabéns!</strong>\nTerminou a instalação do MediaWiki.\n\nO instalador gerou um ficheiro <code>LocalSettings.php</code>.\nEste ficheiro contém todas as configurações.\n\nPrecisa de descarregar o ficheiro e colocá-lo no diretório <code>$4</code>. Este descarregamento deverá ter sido iniciado automaticamente.\n\nSe o descarregamento não foi iniciado, ou se o cancelou, pode recomeçá-lo clicando a hiperligação abaixo:\n\n$3\n\n<strong>Nota</strong>: Se não fizer o descarregamento agora, o ficheiro que foi gerado deixará de estar disponível quando sair do processo de instalação.\n\nDepois de terminar o passo anterior, pode <strong>[$2 entrar na wiki]</strong>.",
- "config-install-success": "O MediaWiki foi instalado. Já pode visitar <$1$2> para ver a sua wiki.\nSe tiver dúvidas, veja a nossa lista de perguntas frequentes,\n<https://www.mediawiki.org/wiki/Manual:FAQ/pt>, ou utilize um dos fóruns de suporte indicados nessa página.",
+ "config-install-success": "O MediaWiki foi instalado. Já pode visitar <$1$2> para ver a sua wiki.\nSe tiver dúvidas, veja a nossa lista de perguntas frequentes,\n<https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ>, ou utilize um dos fóruns de suporte indicados nessa página.",
"config-download-localsettings": "Descarregar <code>LocalSettings.php</code>",
"config-help": "ajuda",
"config-help-tooltip": "clique para expandir",
"config-skins-screenshots": "$1 (capturas de ecrã: $2)",
"config-extensions-requires": "$1 (requer $2)",
"config-screenshot": "captura de ecrã",
+ "config-extension-not-found": "Não foi possível encontrar o ficheiro de registo da extensão \"$1\"",
+ "config-extension-dependency": "Foi encontrado um erro de dependências ao instalar a extensão \"$1\": $2",
"mainpagetext": "<strong>O MediaWiki foi instalado.</strong>",
"mainpagedocfooter": "Consulte a [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Ajuda do MediaWiki] para informações sobre o uso do software wiki.\n\n== Onde começar ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Lista de opções de configuração]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Perguntas e respostas frequentes sobre o MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Subscreva a lista de divulgação de novas versões do MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Regionalize o MediaWiki para a sua língua]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Aprenda a combater <i>spam</i> na sua wiki]"
}
"config-skins-screenshot": "Radio button text, $1 is the skin name, and $2 is a link to a screenshot of that skin, where the link text is {{msg-mw|config-screenshot}}.",
"config-extensions-requires": "Radio button text, $1 is the extension name, and $2 are links to other extensions that this one requires.\n{{Identical|Require}}",
"config-screenshot": "Link text for the link in {{msg-mw|config-skins-screenshot}}\n{{Identical|Screenshot}}",
+ "config-extension-not-found": "An error shown when an extension or skin named by the user could not be found.\n* $1 is the extension name",
+ "config-extension-dependency": "An error shown if an extension could not be loaded due to it depending on the wrong version of MediaWiki or an uninstallable extension.\n* $1 is the extension name\n* $2 is a more detailed explanation, in English",
"mainpagetext": "Along with {{msg-mw|mainpagedocfooter}}, the text you will see on the Main Page when your wiki is installed.",
"mainpagedocfooter": "Along with {{msg-mw|mainpagetext}}, the text you will see on the Main Page when your wiki is installed.\nThis might be a good place to put information about <nowiki>{{GRAMMAR:}}</nowiki>. See [[{{NAMESPACE}}:{{BASEPAGENAME}}/fi]] for an example. For languages having grammatical distinctions and not having an appropriate <nowiki>{{GRAMMAR:}}</nowiki> software available, a suggestion to check and possibly amend the messages having <nowiki>{{SITENAME}}</nowiki> may be valuable. See [[{{NAMESPACE}}:{{BASEPAGENAME}}/ksh]] for an example."
}
"config-skins-screenshot": "$1 ($2)",
"config-extensions-requires": "$1 (захтева $2)",
"config-screenshot": "снимак екрана",
+ "config-extension-not-found": "Није могуће пронаћи датотеку регистрације за додатак „$1”",
"mainpagetext": "<strong>Медијавики је инсталиран.</strong>",
"mainpagedocfooter": "Погледајте [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents кориснички водич] за коришћење програма.\n\n== Увод ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Помоћ у вези са подешавањима]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Често постављана питања]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Дописни списак о издањима Медијавикија]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Научите како да се борите против спама на свом викију]"
}
"config-skins-screenshots": "$1 (螢幕截圖: $2)",
"config-extensions-requires": "$1(需要 $2)",
"config-screenshot": "螢幕截圖",
+ "config-extension-not-found": "查無用於擴充功能「$1」的註冊檔案",
+ "config-extension-dependency": "當安裝擴充功能「$1」時發生相依性錯誤:$2",
"mainpagetext": "<strong>已安裝 MediaWiki。</strong>",
"mainpagedocfooter": "有關使用wiki的訊息,請參閱[https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents 使用者指南]。\n\n== 新手入門 ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings 系統設定]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki常見問題]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki郵寄清單]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources 將MediaWiki翻譯至您的語言]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam 了解如何在您的wiki上防禦破壞]"
}
--- /dev/null
+<?php
+
+/**
+ * Class DeletePageJob
+ */
+class DeletePageJob extends Job {
+ public function __construct( $title, $params ) {
+ parent::__construct( 'deletePage', $title, $params );
+ }
+
+ /**
+ * Execute the job
+ *
+ * @return bool
+ */
+ public function run() {
+ // Failure to load the page is not job failure.
+ // A parallel deletion operation may have already completed the page deletion.
+ $wikiPage = WikiPage::newFromID( $this->params['wikiPageId'] );
+ if ( $wikiPage ) {
+ $wikiPage->doDeleteArticleBatched(
+ $this->params['reason'],
+ $this->params['suppress'],
+ User::newFromId( $this->params['userId'] ),
+ json_decode( $this->params['tags'] ),
+ $this->params['logsubtype'],
+ false,
+ $this->getRequestId() );
+ }
+ return true;
+ }
+}
* @ingroup JobQueue
*/
use MediaWiki\MediaWikiServices;
-use MediaWiki\Storage\RevisionRecord;
+use MediaWiki\Revision\RevisionRecord;
/**
* Job to update link tables for pages
}
return false;
}
+
+ /**
+ * Whether to retry the job.
+ * @return bool
+ */
+ public function allowRetries() {
+ // ThumbnailRenderJob is a warmup for the thumbnails cache,
+ // so loosing it is not a problem. Most times the job fails
+ // for non-renderable or missing images which will not be fixed
+ // by a retry, but will create additional load on the renderer.
+ return false;
+ }
}
throw new DBUnexpectedError( $this, "Database selection is disallowed to enable reuse." );
}
+ public function selectDomain( $domain ) {
+ // Disallow things that might confuse the LoadBalancer tracking
+ throw new DBUnexpectedError( $this, "Database selection is disallowed to enable reuse." );
+ }
+
public function getDBname() {
return $this->__call( __FUNCTION__, func_get_args() );
}
protected $user;
/** @var string Password used to establish the current connection */
protected $password;
- /** @var string Database that this instance is currently connected to */
- protected $dbName;
/** @var array[] Map of (table => (dbname, schema, prefix) map) */
protected $tableAliases = [];
/** @var string[] Map of (index alias => index) */
/** @var bool Whether to suppress triggering of transaction end callbacks */
protected $trxEndCallbacksSuppressed = false;
- /** @var string */
- protected $tablePrefix = '';
- /** @var string */
- protected $schema = '';
/** @var int */
protected $flags;
/** @var array */
* @param array $params Parameters passed from Database::factory()
*/
protected function __construct( array $params ) {
- foreach ( [ 'host', 'user', 'password', 'dbname' ] as $name ) {
+ foreach ( [ 'host', 'user', 'password', 'dbname', 'schema', 'tablePrefix' ] as $name ) {
$this->connectionParams[$name] = $params[$name];
}
- $this->schema = $params['schema'];
- $this->tablePrefix = $params['tablePrefix'];
-
$this->cliMode = $params['cliMode'];
// Agent name is added to SQL queries in a comment, so make sure it can't break out
$this->agent = str_replace( '/', '-', $params['agent'] );
}
// Set initial dummy domain until open() sets the final DB/prefix
- $this->currentDomain = DatabaseDomain::newUnspecified();
+ $this->currentDomain = new DatabaseDomain(
+ $params['dbname'] != '' ? $params['dbname'] : null,
+ $params['schema'] != '' ? $params['schema'] : null,
+ $params['tablePrefix']
+ );
}
/**
}
// Establish the connection
$this->doInitConnection();
- // Set the domain object after open() sets the relevant fields
- if ( $this->dbName != '' ) {
- // Domains with server scope but a table prefix are not used by IDatabase classes
- $this->currentDomain = new DatabaseDomain( $this->dbName, null, $this->tablePrefix );
- }
}
/**
$this->connectionParams['host'],
$this->connectionParams['user'],
$this->connectionParams['password'],
- $this->connectionParams['dbname']
+ $this->connectionParams['dbname'],
+ $this->connectionParams['schema'],
+ $this->connectionParams['tablePrefix']
);
} else {
throw new InvalidArgumentException( "No database user provided." );
* @param string $user Database user name
* @param string $password Database user password
* @param string $dbName Database name
+ * @param string|null $schema Database schema name
+ * @param string $tablePrefix Table prefix
* @return bool
* @throws DBConnectionError
*/
- abstract protected function open( $server, $user, $password, $dbName );
+ abstract protected function open( $server, $user, $password, $dbName, $schema, $tablePrefix );
/**
* Construct a Database subclass instance given a database type and parameters
$p['flags'] = $p['flags'] ?? 0;
$p['variables'] = $p['variables'] ?? [];
$p['tablePrefix'] = $p['tablePrefix'] ?? '';
- $p['schema'] = $p['schema'] ?? '';
+ $p['schema'] = $p['schema'] ?? null;
$p['cliMode'] = $p['cliMode'] ?? ( PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg' );
$p['agent'] = $p['agent'] ?? '';
if ( !isset( $p['connLogger'] ) ) {
}
public function tablePrefix( $prefix = null ) {
- $old = $this->tablePrefix;
+ $old = $this->currentDomain->getTablePrefix();
if ( $prefix !== null ) {
- $this->tablePrefix = $prefix;
- $this->currentDomain = ( $this->dbName != '' )
- ? new DatabaseDomain( $this->dbName, null, $this->tablePrefix )
- : DatabaseDomain::newUnspecified();
+ $this->currentDomain = new DatabaseDomain(
+ $this->currentDomain->getDatabase(),
+ $this->currentDomain->getSchema(),
+ $prefix
+ );
}
return $old;
}
public function dbSchema( $schema = null ) {
- $old = $this->schema;
+ $old = $this->currentDomain->getSchema();
if ( $schema !== null ) {
- $this->schema = $schema;
+ $this->currentDomain = new DatabaseDomain(
+ $this->currentDomain->getDatabase(),
+ // DatabaseDomain uses null for unspecified schemas
+ strlen( $schema ) ? $schema : null,
+ $this->currentDomain->getTablePrefix()
+ );
}
- return $old;
+ return (string)$old;
+ }
+
+ /**
+ * @return string Schema to use to qualify relations in queries
+ */
+ protected function relationSchemaQualifier() {
+ return $this->dbSchema();
}
public function getLBInfo( $name = null ) {
return array_merge(
[
'db_server' => $this->server,
- 'db_name' => $this->dbName,
+ 'db_name' => $this->getDBname(),
'db_user' => $this->user,
],
$extras
return false;
}
- public function selectDB( $db ) {
- # Stub. Shouldn't cause serious problems if it's not overridden, but
- # if your database engine supports a concept similar to MySQL's
- # databases you may as well.
- $this->dbName = $db;
+ final public function selectDB( $db ) {
+ $this->selectDomain( new DatabaseDomain(
+ $db,
+ $this->currentDomain->getSchema(),
+ $this->currentDomain->getTablePrefix()
+ ) );
return true;
}
+ final public function selectDomain( $domain ) {
+ $this->doSelectDomain( DatabaseDomain::newFromId( $domain ) );
+ }
+
+ protected function doSelectDomain( DatabaseDomain $domain ) {
+ $this->currentDomain = $domain;
+ }
+
public function getDBname() {
- return $this->dbName;
+ return $this->currentDomain->getDatabase();
}
public function getServer() {
$database = $this->tableAliases[$table]['dbname'];
$schema = is_string( $this->tableAliases[$table]['schema'] )
? $this->tableAliases[$table]['schema']
- : $this->schema;
+ : $this->relationSchemaQualifier();
$prefix = is_string( $this->tableAliases[$table]['prefix'] )
? $this->tableAliases[$table]['prefix']
- : $this->tablePrefix;
+ : $this->tablePrefix();
} else {
$database = '';
- $schema = $this->schema; # Default schema
- $prefix = $this->tablePrefix; # Default prefix
+ $schema = $this->relationSchemaQualifier(); # Default schema
+ $prefix = $this->tablePrefix(); # Default prefix
}
}
$this->opened = false;
$this->conn = false;
try {
- $this->open( $this->server, $this->user, $this->password, $this->dbName );
+ $this->open(
+ $this->server,
+ $this->user,
+ $this->password,
+ $this->getDBname(),
+ $this->dbSchema(),
+ $this->tablePrefix()
+ );
$this->lastPing = microtime( true );
$ok = true;
$this->conn = false;
$this->trxEndCallbacks = []; // don't copy
$this->handleSessionLoss(); // no trx or locks anymore
- $this->open( $this->server, $this->user, $this->password, $this->dbName );
+ $this->open(
+ $this->server,
+ $this->user,
+ $this->password,
+ $this->getDBname(),
+ $this->dbSchema(),
+ $this->tablePrefix()
+ );
$this->lastPing = microtime( true );
}
}
parent::__construct( $params );
}
- protected function open( $server, $user, $password, $dbName ) {
+ protected function open( $server, $user, $password, $dbName, $schema, $tablePrefix ) {
# Test for driver support, to avoid suppressed fatal error
if ( !function_exists( 'sqlsrv_connect' ) ) {
throw new DBConnectionError(
$this->server = $server;
$this->user = $user;
$this->password = $password;
- $this->dbName = $dbName;
$connectionInfo = [];
- if ( $dbName ) {
+ if ( $dbName != '' ) {
$connectionInfo['Database'] = $dbName;
}
}
$this->opened = true;
+ $this->currentDomain = new DatabaseDomain(
+ ( $dbName != '' ) ? $dbName : null,
+ null,
+ $tablePrefix
+ );
return (bool)$this->conn;
}
}
if ( $schema === false ) {
- $schema = $this->schema;
+ $schema = $this->dbSchema();
}
$res = $this->query( "SELECT 1 FROM INFORMATION_SCHEMA.TABLES
$s );
}
- /**
- * @param string $db
- * @return bool
- */
- public function selectDB( $db ) {
- try {
- $this->dbName = $db;
- $this->query( "USE $db" );
- return true;
- } catch ( Exception $e ) {
- return false;
- }
+ protected function doSelectDomain( DatabaseDomain $domain ) {
+ $encDatabase = $this->addIdentifierQuotes( $domain->getDatabase() );
+ $this->query( "USE $encDatabase" );
+ // Update that domain fields on success (no exception thrown)
+ $this->currentDomain = $domain;
+
+ return true;
}
/**
private function populateColumnCaches() {
$res = $this->select( 'INFORMATION_SCHEMA.COLUMNS', '*',
[
- 'TABLE_CATALOG' => $this->dbName,
- 'TABLE_SCHEMA' => $this->schema,
+ 'TABLE_CATALOG' => $this->getDBname(),
+ 'TABLE_SCHEMA' => $this->dbSchema(),
'DATA_TYPE' => [ 'varbinary', 'binary', 'image', 'bit' ]
] );
return 'mysql';
}
- protected function open( $server, $user, $password, $dbName ) {
+ protected function open( $server, $user, $password, $dbName, $schema, $tablePrefix ) {
# Close/unset connection handle
$this->close();
$this->server = $server;
$this->user = $user;
$this->password = $password;
- $this->dbName = $dbName;
$this->installErrorHandler();
try {
- $this->conn = $this->mysqlConnect( $this->server );
+ $this->conn = $this->mysqlConnect( $this->server, $dbName );
} catch ( Exception $ex ) {
$this->restoreErrorHandler();
throw $ex;
}
if ( strlen( $dbName ) ) {
- Wikimedia\suppressWarnings();
- $success = $this->selectDB( $dbName );
- Wikimedia\restoreWarnings();
- if ( !$success ) {
- $error = $this->lastError();
- $this->queryLogger->error(
- "Error selecting database {db_name} on server {db_server}: {error}",
- $this->getLogContext( [
- 'method' => __METHOD__,
- 'error' => $error,
- ] )
- );
- throw new DBConnectionError( $this, "Error selecting database $dbName: $error" );
- }
+ $this->selectDomain( new DatabaseDomain( $dbName, null, $tablePrefix ) );
+ } else {
+ $this->currentDomain = new DatabaseDomain( null, null, $tablePrefix );
}
// Tell the server what we're communicating with
* Open a connection to a MySQL server
*
* @param string $realServer
+ * @param string|null $dbName
* @return mixed Raw connection
* @throws DBConnectionError
*/
- abstract protected function mysqlConnect( $realServer );
+ abstract protected function mysqlConnect( $realServer, $dbName );
/**
* Set the character set of the MySQL link
*/
public function listViews( $prefix = null, $fname = __METHOD__ ) {
// The name of the column containing the name of the VIEW
- $propertyName = 'Tables_in_' . $this->dbName;
+ $propertyName = 'Tables_in_' . $this->getDBname();
// Query for the VIEWS
$res = $this->query( 'SHOW FULL TABLES WHERE TABLE_TYPE = "VIEW"' );
/**
* @param string $realServer
+ * @param string|null $dbName
* @return bool|mysqli
* @throws DBConnectionError
*/
- protected function mysqlConnect( $realServer ) {
+ protected function mysqlConnect( $realServer, $dbName ) {
# Avoid suppressed fatal error, which is very hard to track down
if ( !function_exists( 'mysqli_init' ) ) {
throw new DBConnectionError( $this, "MySQLi functions missing,"
}
$mysqli->options( MYSQLI_OPT_CONNECT_TIMEOUT, 3 );
- if ( $mysqli->real_connect( $realServer, $this->user,
- $this->password, $this->dbName, $port, $socket, $connFlags )
- ) {
+ if ( $mysqli->real_connect(
+ $realServer,
+ $this->user,
+ $this->password,
+ $dbName,
+ $port,
+ $socket,
+ $connFlags
+ ) ) {
return $mysqli;
}
return $conn->affected_rows;
}
- /**
- * @param string $db
- * @return bool
- */
- function selectDB( $db ) {
+ function doSelectDomain( DatabaseDomain $domain ) {
$conn = $this->getBindingHandle();
- $this->dbName = $db;
+ if ( $domain->getSchema() !== null ) {
+ throw new DBExpectedError( $this, __CLASS__ . ": domain schemas are not supported." );
+ }
+
+ $database = $domain->getDatabase();
+ if ( !$conn->select_db( $database ) ) {
+ throw new DBExpectedError( $this, "Could not select database '$database'." );
+ }
+
+ // Update that domain fields on success (no exception thrown)
+ $this->currentDomain = $domain;
- return $conn->select_db( $db );
+ return true;
}
/**
return false;
}
- protected function open( $server, $user, $password, $dbName ) {
+ protected function open( $server, $user, $password, $dbName, $schema, $tablePrefix ) {
# Test for Postgres support, to avoid suppressed fatal error
if ( !function_exists( 'pg_connect' ) ) {
throw new DBConnectionError(
$this->server = $server;
$this->user = $user;
$this->password = $password;
- $this->dbName = $dbName;
$connectVars = [
// pg_connect() user $user as the default database. Since a database is *required*,
$this->query( "SET standard_conforming_strings = on", __METHOD__ );
$this->query( "SET bytea_output = 'escape'", __METHOD__ ); // PHP bug 53127
- $this->determineCoreSchema( $this->schema );
- // The schema to be used is now in the search path; no need for explicit qualification
- $this->schema = '';
+ $this->determineCoreSchema( $schema );
+ $this->currentDomain = new DatabaseDomain( $dbName, $schema, $tablePrefix );
- return $this->conn;
+ return (bool)$this->conn;
+ }
+
+ protected function relationSchemaQualifier() {
+ if ( $this->coreSchema === $this->currentDomain->getSchema() ) {
+ // The schema to be used is now in the search path; no need for explicit qualification
+ return '';
+ }
+
+ return parent::relationSchemaQualifier();
}
public function databasesAreIndependent() {
return true;
}
- /**
- * Postgres doesn't support selectDB in the same way MySQL does. So if the
- * DB name doesn't match the open connection, open a new one
- * @param string $db
- * @return bool
- * @throws DBUnexpectedError
- */
- public function selectDB( $db ) {
- if ( $this->dbName !== $db ) {
- return (bool)$this->open( $this->server, $this->user, $this->password, $db );
+ public function doSelectDomain( DatabaseDomain $domain ) {
+ if ( $this->getDBname() !== $domain->getDatabase() ) {
+ // Postgres doesn't support selectDB in the same way MySQL does.
+ // So if the DB name doesn't match the open connection, open a new one
+ $this->open(
+ $this->server,
+ $this->user,
+ $this->password,
+ $domain->getDatabase(),
+ $domain->getSchema(),
+ $domain->getTablePrefix()
+ );
} else {
- return true;
+ $this->currentDomain = $domain;
}
+
+ return true;
}
/**
return [ $startOpts, $useIndex, $preLimitTail, $postLimitTail, $ignoreIndex ];
}
- public function getDBname() {
- return $this->dbName;
- }
-
public function getServer() {
return $this->server;
}
if ( isset( $p['dbFilePath'] ) ) {
$this->dbPath = $p['dbFilePath'];
$lockDomain = md5( $this->dbPath );
+ // Use "X" for things like X.sqlite and ":memory:" for RAM-only DBs
+ if ( !isset( $p['dbname'] ) || !strlen( $p['dbname'] ) ) {
+ $p['dbname'] = preg_replace( '/\.sqlite\d?$/', '', basename( $this->dbPath ) );
+ }
} elseif ( isset( $p['dbDirectory'] ) ) {
$this->dbDir = $p['dbDirectory'];
$lockDomain = $p['dbname'];
*/
public static function newStandaloneInstance( $filename, array $p = [] ) {
$p['dbFilePath'] = $filename;
- $p['schema'] = false;
+ $p['schema'] = null;
$p['tablePrefix'] = '';
/** @var DatabaseSqlite $db */
$db = Database::factory( 'sqlite', $p );
protected function doInitConnection() {
if ( $this->dbPath !== null ) {
// Standalone .sqlite file mode.
- $this->openFile( $this->dbPath, $this->connectionParams['dbname'] );
+ $this->openFile(
+ $this->dbPath,
+ $this->connectionParams['dbname'],
+ $this->connectionParams['tablePrefix']
+ );
} elseif ( $this->dbDir !== null ) {
// Stock wiki mode using standard file names per DB
if ( strlen( $this->connectionParams['dbname'] ) ) {
$this->connectionParams['host'],
$this->connectionParams['user'],
$this->connectionParams['password'],
- $this->connectionParams['dbname']
+ $this->connectionParams['dbname'],
+ $this->connectionParams['schema'],
+ $this->connectionParams['tablePrefix']
);
} else {
// Caller will manually call open() later?
return false;
}
- protected function open( $server, $user, $pass, $dbName ) {
+ protected function open( $server, $user, $pass, $dbName, $schema, $tablePrefix ) {
$this->close();
$fileName = self::generateFileName( $this->dbDir, $dbName );
if ( !is_readable( $fileName ) ) {
throw new DBConnectionError( $this, "SQLite database not accessible" );
}
// Only $dbName is used, the other parameters are irrelevant for SQLite databases
- $this->openFile( $fileName, $dbName );
+ $this->openFile( $fileName, $dbName, $tablePrefix );
return (bool)$this->conn;
}
*
* @param string $fileName
* @param string $dbName
+ * @param string $tablePrefix
* @throws DBConnectionError
* @return PDO|bool SQL connection or false if failed
*/
- protected function openFile( $fileName, $dbName ) {
+ protected function openFile( $fileName, $dbName, $tablePrefix ) {
$err = false;
$this->dbPath = $fileName;
$this->opened = is_object( $this->conn );
if ( $this->opened ) {
- $this->dbName = $dbName;
+ $this->currentDomain = new DatabaseDomain( $dbName, null, $tablePrefix );
# Set error codes only, don't raise exceptions
$this->conn->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_SILENT );
# Enforce LIKE to be case sensitive, just like MySQL
/**
* Get/set the table prefix.
* @param string|null $prefix The table prefix to set, or omitted to leave it unchanged.
- * @return string The previous table prefix.
+ * @return string The previous table prefix
+ * @throws DBUnexpectedError
*/
public function tablePrefix( $prefix = null );
/**
* Get/set the db schema.
* @param string|null $schema The database schema to set, or omitted to leave it unchanged.
- * @return string The previous db schema.
+ * @return string The previous db schema
*/
public function dbSchema( $schema = null );
public function getFlag( $flag );
/**
+ * Return the currently selected domain ID
+ *
+ * Null components (database/schema) might change once a connection is established
+ *
* @return string
*/
public function getDomainID();
* Change the current database
*
* @param string $db
- * @return bool Success or failure
+ * @return bool True unless an exception was thrown
* @throws DBConnectionError If databasesAreIndependent() is true and an error occurs
+ * @throws DBError
+ * @deprecated Since 1.32
*/
public function selectDB( $db );
+ /**
+ * Set the current domain (database, schema, and table prefix)
+ *
+ * This will throw an error for some database types if the database unspecified
+ *
+ * @param string|DatabaseDomain $domain
+ * @since 1.32
+ * @throws DBConnectionError
+ */
+ public function selectDomain( $domain );
+
/**
* Get the current DB name
- * @return string
+ * @return string|null
*/
public function getDBname();
* @param int $i Server index
* @param string $domain Domain ID to open
* @param int $flags Class CONN_* constant bitfield
- * @return Database
+ * @return Database|bool Returns false on connection error
+ * @throws DBError When database selection fails
*/
private function openForeignConnection( $i, $domain, $flags = 0 ) {
$domainInstance = DatabaseDomain::newFromId( $domain );
- $dbName = $domainInstance->getDatabase();
- $prefix = $domainInstance->getTablePrefix();
$autoCommit = ( ( $flags & self::CONN_TRX_AUTOCOMMIT ) == self::CONN_TRX_AUTOCOMMIT );
if ( $autoCommit ) {
$connInUseKey = self::KEY_FOREIGN_INUSE;
}
+ /** @var Database $conn */
if ( isset( $this->conns[$connInUseKey][$i][$domain] ) ) {
// Reuse an in-use connection for the same domain
$conn = $this->conns[$connInUseKey][$i][$domain];
// Reuse a free connection from another domain
$conn = reset( $this->conns[$connFreeKey][$i] );
$oldDomain = key( $this->conns[$connFreeKey][$i] );
- if ( strlen( $dbName ) && !$conn->selectDB( $dbName ) ) {
- $this->lastError = "Error selecting database '$dbName' on server " .
- $conn->getServer() . " from client host {$this->hostname}";
- $this->errorConnection = $conn;
- $conn = false;
+ if ( $domainInstance->getDatabase() !== null ) {
+ $conn->selectDomain( $domainInstance );
} else {
- $conn->tablePrefix( $prefix );
- unset( $this->conns[$connFreeKey][$i][$oldDomain] );
- // Note that if $domain is an empty string, getDomainID() might not match it
- $this->conns[$connInUseKey][$i][$conn->getDomainId()] = $conn;
- $this->connLogger->debug( __METHOD__ .
- ": reusing free connection from $oldDomain for $domain" );
+ // Stay on the current database, but update the schema/prefix
+ $conn->dbSchema( $domainInstance->getSchema() );
+ $conn->tablePrefix( $domainInstance->getTablePrefix() );
}
+ unset( $this->conns[$connFreeKey][$i][$oldDomain] );
+ // Note that if $domain is an empty string, getDomainID() might not match it
+ $this->conns[$connInUseKey][$i][$conn->getDomainId()] = $conn;
+ $this->connLogger->debug( __METHOD__ .
+ ": reusing free connection from $oldDomain for $domain" );
} else {
if ( !isset( $this->servers[$i] ) || !is_array( $this->servers[$i] ) ) {
throw new InvalidArgumentException( "No server with index '$i'." );
) );
}
- protected function reallyOpenConnection( array $server, DatabaseDomain $domainOverride ) {
+ protected function reallyOpenConnection( array $server, DatabaseDomain $domain ) {
return $this->db;
}
}
$relations = $this->relations;
// Ensure actor relations are set
- if ( $wgActorTableSchemaMigrationStage >= MIGRATION_WRITE_BOTH &&
+ if ( ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_WRITE_NEW ) &&
empty( $relations['target_author_actor'] )
) {
$actorIds = [];
$params['authorActors'] = $actorIds;
}
}
- if ( $wgActorTableSchemaMigrationStage >= MIGRATION_WRITE_NEW ) {
+ if ( !( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_WRITE_OLD ) ) {
unset( $relations['target_author_id'], $relations['target_author_ip'] );
unset( $params['authorIds'], $params['authorIPs'] );
}
return [ $ext, $mime ];
}
- /**
- * @deprecated since 1.30, use MediaHandler::getContentHeaders instead
- * @param array $metadata
- * @return array
- */
- public function getStreamHeaders( $metadata ) {
- wfDeprecated( __METHOD__, '1.30' );
- return $this->getContentHeaders( $metadata );
- }
-
/**
* True if the handled types can be transformed
*
return [];
}
+ /**
+ * When overridden in a descendant class, returns a language code most suiting
+ *
+ * @since 1.32
+ *
+ * @param string $userPreferredLanguage Language code requesed
+ * @param string[] $availableLanguages Languages present in the file
+ * @return string|null Language code picked or null if not supported/available
+ */
+ public function getMatchedLanguage( $userPreferredLanguage, array $availableLanguages ) {
+ return null;
+ }
+
/**
* On file types that support renderings in multiple languages,
* which language is used by default if unspecified.
* @file
*/
use MediaWiki\MediaWikiServices;
-use MediaWiki\Storage\MutableRevisionRecord;
-use MediaWiki\Storage\RevisionRecord;
-use MediaWiki\Storage\SlotRecord;
+use MediaWiki\Revision\MutableRevisionRecord;
+use MediaWiki\Revision\RevisionRecord;
+use MediaWiki\Revision\SlotRecord;
/**
* Class for viewing MediaWiki article and history.
* Perform a deletion and output success or failure messages
* @param string $reason
* @param bool $suppress
+ * @param bool $immediate false allows deleting over time via the job queue
+ * @throws FatalError
+ * @throws MWException
*/
- public function doDelete( $reason, $suppress = false ) {
+ public function doDelete( $reason, $suppress = false, $immediate = false ) {
$error = '';
$context = $this->getContext();
$outputPage = $context->getOutput();
$user = $context->getUser();
- $status = $this->mPage->doDeleteArticleReal( $reason, $suppress, 0, true, $error, $user );
+ $status = $this->mPage->doDeleteArticleReal( $reason, $suppress, 0, true, $error, $user,
+ [], 'delete', $immediate );
- if ( $status->isGood() ) {
+ if ( $status->isOK() ) {
$deleted = $this->getTitle()->getPrefixedText();
$outputPage->setPageTitle( wfMessage( 'actioncomplete' ) );
$outputPage->setRobotPolicy( 'noindex,nofollow' );
- $loglink = '[[Special:Log/delete|' . wfMessage( 'deletionlog' )->text() . ']]';
-
- $outputPage->addWikiMsg( 'deletedtext', wfEscapeWikiText( $deleted ), $loglink );
-
- Hooks::run( 'ArticleDeleteAfterSuccess', [ $this->getTitle(), $outputPage ] );
+ if ( $status->isGood() ) {
+ $loglink = '[[Special:Log/delete|' . wfMessage( 'deletionlog' )->text() . ']]';
+ $outputPage->addWikiMsg( 'deletedtext', wfEscapeWikiText( $deleted ), $loglink );
+ Hooks::run( 'ArticleDeleteAfterSuccess', [ $this->getTitle(), $outputPage ] );
+ } else {
+ $outputPage->addWikiMsg( 'delete-scheduled', wfEscapeWikiText( $deleted ) );
+ }
$outputPage->returnToMain( false );
} else {
*/
public function doDeleteArticleReal(
$reason, $suppress = false, $u1 = null, $u2 = null, &$error = '', User $user = null,
- $tags = []
+ $tags = [], $immediate = false
) {
return $this->mPage->doDeleteArticleReal(
- $reason, $suppress, $u1, $u2, $error, $user, $tags
+ $reason, $suppress, $u1, $u2, $error, $user, $tags, 'delete', $immediate
);
}
* @param int|null $u1 Unused
* @param bool|null $u2 Unused
* @param string &$error
+ * @param bool $immediate false allows deleting over time via the job queue
* @return bool
+ * @throws FatalError
+ * @throws MWException
*/
public function doDeleteArticle(
- $reason, $suppress = false, $u1 = null, $u2 = null, &$error = ''
+ $reason, $suppress = false, $u1 = null, $u2 = null, &$error = '', $immediate = false
) {
- return $this->mPage->doDeleteArticle( $reason, $suppress, $u1, $u2, $error );
+ return $this->mPage->doDeleteArticle( $reason, $suppress, $u1, $u2, $error,
+ null, $immediate );
}
/**
*/
use MediaWiki\MediaWikiServices;
-use MediaWiki\Storage\RevisionRecord;
-use MediaWiki\Storage\RevisionStore;
+use MediaWiki\Revision\RevisionRecord;
+use MediaWiki\Revision\RevisionStore;
use MediaWiki\Storage\SqlBlobStore;
use Wikimedia\Assert\Assert;
use Wikimedia\Rdbms\IResultWrapper;
use MediaWiki\Edit\PreparedEdit;
use MediaWiki\Logger\LoggerFactory;
use MediaWiki\MediaWikiServices;
+use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\RevisionRenderer;
+use MediaWiki\Revision\RevisionStore;
+use MediaWiki\Revision\SlotRecord;
use MediaWiki\Storage\DerivedPageDataUpdater;
use MediaWiki\Storage\PageUpdater;
-use MediaWiki\Storage\RevisionRecord;
use MediaWiki\Storage\RevisionSlotsUpdate;
-use MediaWiki\Storage\RevisionStore;
-use MediaWiki\Storage\SlotRecord;
use Wikimedia\Assert\Assert;
use Wikimedia\Rdbms\FakeResultWrapper;
use Wikimedia\Rdbms\IDatabase;
// rd_fragment and rd_interwiki were added later, populate them if empty
if ( $row && !is_null( $row->rd_fragment ) && !is_null( $row->rd_interwiki ) ) {
+ // (T203942) We can't redirect to Media namespace because it's virtual.
+ // We don't want to modify Title objects farther down the
+ // line. So, let's fix this here by changing to File namespace.
+ if ( $row->rd_namespace == NS_MEDIA ) {
+ $namespace = NS_FILE;
+ } else {
+ $namespace = $row->rd_namespace;
+ }
$this->mRedirectTarget = Title::makeTitle(
- $row->rd_namespace, $row->rd_title,
+ $namespace, $row->rd_title,
$row->rd_fragment, $row->rd_interwiki
);
return $this->mRedirectTarget;
return implode( ':', $bits );
}
+ /**
+ * Determines if deletion of this page would be batched (executed over time by the job queue)
+ * or not (completed in the same request as the delete call).
+ *
+ * It is unlikely but possible that an edit from another request could push the page over the
+ * batching threshold after this function is called, but before the caller acts upon the
+ * return value. Callers must decide for themselves how to deal with this. $safetyMargin
+ * is provided as an unreliable but situationally useful help for some common cases.
+ *
+ * @param int $safetyMargin Added to the revision count when checking for batching
+ * @return bool True if deletion would be batched, false otherwise
+ */
+ public function isBatchedDelete( $safetyMargin = 0 ) {
+ global $wgDeleteRevisionsBatchSize;
+
+ $dbr = wfGetDB( DB_REPLICA );
+ $revCount = $this->getRevisionStore()->countRevisionsByPageId( $dbr, $this->getId() );
+ $revCount += $safetyMargin;
+
+ return $revCount >= $wgDeleteRevisionsBatchSize;
+ }
+
/**
* Same as doDeleteArticleReal(), but returns a simple boolean. This is kept around for
* backwards compatibility, if you care about error reporting you should use
* @param bool|null $u2 Unused
* @param array|string &$error Array of errors to append to
* @param User|null $user The deleting user
+ * @param bool $immediate false allows deleting over time via the job queue
* @return bool True if successful
+ * @throws FatalError
+ * @throws MWException
*/
public function doDeleteArticle(
- $reason, $suppress = false, $u1 = null, $u2 = null, &$error = '', User $user = null
+ $reason, $suppress = false, $u1 = null, $u2 = null, &$error = '', User $user = null,
+ $immediate = false
) {
- $status = $this->doDeleteArticleReal( $reason, $suppress, $u1, $u2, $error, $user );
- return $status->isGood();
+ $status = $this->doDeleteArticleReal( $reason, $suppress, $u1, $u2, $error, $user,
+ [], 'delete', $immediate );
+
+ // Returns true if the page was actually deleted, or is scheduled for deletion
+ return $status->isOK();
}
/**
* @param User|null $deleter The deleting user
* @param array $tags Tags to apply to the deletion action
* @param string $logsubtype
+ * @param bool $immediate false allows deleting over time via the job queue
* @return Status Status object; if successful, $status->value is the log_id of the
* deletion log entry. If the page couldn't be deleted because it wasn't
* found, $status is a non-fatal 'cannotdelete' error
+ * @throws FatalError
+ * @throws MWException
*/
public function doDeleteArticleReal(
$reason, $suppress = false, $u1 = null, $u2 = null, &$error = '', User $deleter = null,
- $tags = [], $logsubtype = 'delete'
+ $tags = [], $logsubtype = 'delete', $immediate = false
) {
- global $wgUser, $wgContentHandlerUseDB, $wgCommentTableSchemaMigrationStage,
- $wgActorTableSchemaMigrationStage, $wgMultiContentRevisionSchemaMigrationStage;
+ global $wgUser;
wfDebug( __METHOD__ . "\n" );
$status = Status::newGood();
- if ( $this->mTitle->getDBkey() === '' ) {
- $status->error( 'cannotdelete',
- wfEscapeWikiText( $this->getTitle()->getPrefixedText() ) );
- return $status;
- }
-
// Avoid PHP 7.1 warning of passing $this by reference
$wikiPage = $this;
return $status;
}
+ return $this->doDeleteArticleBatched( $reason, $suppress, $deleter, $tags,
+ $logsubtype, $immediate );
+ }
+
+ /**
+ * Back-end article deletion
+ *
+ * Only invokes batching via the job queue if necessary per $wgDeleteRevisionsBatchSize.
+ * Deletions can often be completed inline without involving the job queue.
+ *
+ * Potentially called many times per deletion operation for pages with many revisions.
+ */
+ public function doDeleteArticleBatched(
+ $reason, $suppress, User $deleter, $tags,
+ $logsubtype, $immediate = false, $webRequestId = null
+ ) {
+ wfDebug( __METHOD__ . "\n" );
+
+ $status = Status::newGood();
+
$dbw = wfGetDB( DB_MASTER );
$dbw->startAtomic( __METHOD__ );
return $status;
}
- // Given the lock above, we can be confident in the title and page ID values
- $namespace = $this->getTitle()->getNamespace();
- $dbKey = $this->getTitle()->getDBkey();
-
- // At this point we are now comitted to returning an OK
+ // At this point we are now committed to returning an OK
// status unless some DB query error or other exception comes up.
// This way callers don't have to call rollback() if $status is bad
// unless they actually try to catch exceptions (which is rare).
$content = null;
}
+ // Archive revisions. In immediate mode, archive all revisions. Otherwise, archive
+ // one batch of revisions and defer archival of any others to the job queue.
+ $explictTrxLogged = false;
+ while ( true ) {
+ $done = $this->archiveRevisions( $dbw, $id, $suppress );
+ if ( $done || !$immediate ) {
+ break;
+ }
+ $dbw->endAtomic( __METHOD__ );
+ if ( $dbw->explicitTrxActive() ) {
+ // Explict transactions may never happen here in practice. Log to be sure.
+ if ( !$explictTrxLogged ) {
+ $explictTrxLogged = true;
+ LoggerFactory::getInstance( 'wfDebug' )->debug(
+ 'explicit transaction active in ' . __METHOD__ . ' while deleting {title}', [
+ 'title' => $this->getTitle()->getText(),
+ ] );
+ }
+ continue;
+ }
+ if ( $dbw->trxLevel() ) {
+ $dbw->commit();
+ }
+ $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
+ $lbFactory->waitForReplication();
+ $dbw->startAtomic( __METHOD__ );
+ }
+
+ // If done archiving, also delete the article.
+ if ( !$done ) {
+ $dbw->endAtomic( __METHOD__ );
+
+ $jobParams = [
+ 'wikiPageId' => $id,
+ 'requestId' => $webRequestId ?? WebRequest::getRequestId(),
+ 'reason' => $reason,
+ 'suppress' => $suppress,
+ 'userId' => $deleter->getId(),
+ 'tags' => json_encode( $tags ),
+ 'logsubtype' => $logsubtype,
+ ];
+
+ $job = new DeletePageJob( $this->getTitle(), $jobParams );
+ JobQueueGroup::singleton()->push( $job );
+
+ $status->warning( 'delete-scheduled',
+ wfEscapeWikiText( $this->getTitle()->getPrefixedText() ) );
+ } else {
+ // Get archivedRevisionCount by db query, because there's no better alternative.
+ // Jobs cannot pass a count of archived revisions to the next job, because additional
+ // deletion operations can be started while the first is running. Jobs from each
+ // gracefully interleave, but would not know about each other's count. Deduplication
+ // in the job queue to avoid simultaneous deletion operations would add overhead.
+ // Number of archived revisions cannot be known beforehand, because edits can be made
+ // while deletion operations are being processed, changing the number of archivals.
+ $archivedRevisionCount = $dbw->selectRowCount(
+ 'archive', '1', [ 'ar_page_id' => $id ], __METHOD__
+ );
+
+ // Clone the title and wikiPage, so we have the information we need when
+ // we log and run the ArticleDeleteComplete hook.
+ $logTitle = clone $this->mTitle;
+ $wikiPageBeforeDelete = clone $this;
+
+ // Now that it's safely backed up, delete it
+ $dbw->delete( 'page', [ 'page_id' => $id ], __METHOD__ );
+
+ // Log the deletion, if the page was suppressed, put it in the suppression log instead
+ $logtype = $suppress ? 'suppress' : 'delete';
+
+ $logEntry = new ManualLogEntry( $logtype, $logsubtype );
+ $logEntry->setPerformer( $deleter );
+ $logEntry->setTarget( $logTitle );
+ $logEntry->setComment( $reason );
+ $logEntry->setTags( $tags );
+ $logid = $logEntry->insert();
+
+ $dbw->onTransactionPreCommitOrIdle(
+ function () use ( $logEntry, $logid ) {
+ // T58776: avoid deadlocks (especially from FileDeleteForm)
+ $logEntry->publish( $logid );
+ },
+ __METHOD__
+ );
+
+ $dbw->endAtomic( __METHOD__ );
+
+ $this->doDeleteUpdates( $id, $content, $revision, $deleter );
+
+ Hooks::run( 'ArticleDeleteComplete', [
+ &$wikiPageBeforeDelete,
+ &$deleter,
+ $reason,
+ $id,
+ $content,
+ $logEntry,
+ $archivedRevisionCount
+ ] );
+ $status->value = $logid;
+
+ // Show log excerpt on 404 pages rather than just a link
+ $cache = MediaWikiServices::getInstance()->getMainObjectStash();
+ $key = $cache->makeKey( 'page-recent-delete', md5( $logTitle->getPrefixedText() ) );
+ $cache->set( $key, 1, $cache::TTL_DAY );
+ }
+
+ return $status;
+ }
+
+ /**
+ * Archives revisions as part of page deletion.
+ *
+ * @param IDatabase $dbw
+ * @param int $id
+ * @param bool $suppress Suppress all revisions and log the deletion in
+ * the suppression log instead of the deletion log
+ * @return bool
+ */
+ protected function archiveRevisions( $dbw, $id, $suppress ) {
+ global $wgContentHandlerUseDB, $wgMultiContentRevisionSchemaMigrationStage,
+ $wgCommentTableSchemaMigrationStage, $wgActorTableSchemaMigrationStage,
+ $wgDeleteRevisionsBatchSize;
+
+ // Given the lock above, we can be confident in the title and page ID values
+ $namespace = $this->getTitle()->getNamespace();
+ $dbKey = $this->getTitle()->getDBkey();
+
$commentStore = CommentStore::getStore();
$actorMigration = ActorMigration::newMigration();
}
}
- // Get all of the page revisions
+ // Get as many of the page revisions as we are allowed to. The +1 lets us recognize the
+ // unusual case where there were exactly $wgDeleteRevisionBatchSize revisions remaining.
$res = $dbw->select(
$revQuery['tables'],
$revQuery['fields'],
[ 'rev_page' => $id ],
__METHOD__,
- [],
+ [ 'ORDER BY' => 'rev_timestamp ASC', 'LIMIT' => $wgDeleteRevisionsBatchSize + 1 ],
$revQuery['joins']
);
/** @var int[] Revision IDs of edits that were made by IPs */
$ipRevIds = [];
+ $done = true;
foreach ( $res as $row ) {
+ if ( count( $revids ) >= $wgDeleteRevisionsBatchSize ) {
+ $done = false;
+ break;
+ }
+
$comment = $commentStore->getComment( 'rev_comment', $row );
$user = User::newFromAnyId( $row->rev_user, $row->rev_user_text, $row->rev_actor );
$rowInsert = [
- 'ar_namespace' => $namespace,
- 'ar_title' => $dbKey,
- 'ar_timestamp' => $row->rev_timestamp,
- 'ar_minor_edit' => $row->rev_minor_edit,
- 'ar_rev_id' => $row->rev_id,
- 'ar_parent_id' => $row->rev_parent_id,
+ 'ar_namespace' => $namespace,
+ 'ar_title' => $dbKey,
+ 'ar_timestamp' => $row->rev_timestamp,
+ 'ar_minor_edit' => $row->rev_minor_edit,
+ 'ar_rev_id' => $row->rev_id,
+ 'ar_parent_id' => $row->rev_parent_id,
/**
* ar_text_id should probably not be written to when the multi content schema has
* been migrated to (wgMultiContentRevisionSchemaMigrationStage) however there is no
* Task: https://phabricator.wikimedia.org/T190148
* Copying the value from the revision table should not lead to any issues for now.
*/
- 'ar_len' => $row->rev_len,
- 'ar_page_id' => $id,
- 'ar_deleted' => $suppress ? $bitfield : $row->rev_deleted,
- 'ar_sha1' => $row->rev_sha1,
- ] + $commentStore->insert( $dbw, 'ar_comment', $comment )
+ 'ar_len' => $row->rev_len,
+ 'ar_page_id' => $id,
+ 'ar_deleted' => $suppress ? $bitfield : $row->rev_deleted,
+ 'ar_sha1' => $row->rev_sha1,
+ ] + $commentStore->insert( $dbw, 'ar_comment', $comment )
+ $actorMigration->getInsertValues( $dbw, 'ar_user', $user );
if ( $wgMultiContentRevisionSchemaMigrationStage & SCHEMA_COMPAT_WRITE_OLD ) {
$ipRevIds[] = $row->rev_id;
}
}
- // Copy them into the archive table
- $dbw->insert( 'archive', $rowsInsert, __METHOD__ );
- // Save this so we can pass it to the ArticleDeleteComplete hook.
- $archivedRevisionCount = $dbw->affectedRows();
- // Clone the title and wikiPage, so we have the information we need when
- // we log and run the ArticleDeleteComplete hook.
- $logTitle = clone $this->mTitle;
- $wikiPageBeforeDelete = clone $this;
+ // This conditional is just a sanity check
+ if ( count( $revids ) > 0 ) {
+ // Copy them into the archive table
+ $dbw->insert( 'archive', $rowsInsert, __METHOD__ );
- // Now that it's safely backed up, delete it
- $dbw->delete( 'page', [ 'page_id' => $id ], __METHOD__ );
- $dbw->delete( 'revision', [ 'rev_page' => $id ], __METHOD__ );
- if ( $wgCommentTableSchemaMigrationStage > MIGRATION_OLD ) {
- $dbw->delete( 'revision_comment_temp', [ 'revcomment_rev' => $revids ], __METHOD__ );
- }
- if ( $wgActorTableSchemaMigrationStage > MIGRATION_OLD ) {
- $dbw->delete( 'revision_actor_temp', [ 'revactor_rev' => $revids ], __METHOD__ );
- }
+ $dbw->delete( 'revision', [ 'rev_id' => $revids ], __METHOD__ );
+ if ( $wgCommentTableSchemaMigrationStage > MIGRATION_OLD ) {
+ $dbw->delete( 'revision_comment_temp', [ 'revcomment_rev' => $revids ], __METHOD__ );
+ }
+ if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_WRITE_NEW ) {
+ $dbw->delete( 'revision_actor_temp', [ 'revactor_rev' => $revids ], __METHOD__ );
+ }
- // Also delete records from ip_changes as applicable.
- if ( count( $ipRevIds ) > 0 ) {
- $dbw->delete( 'ip_changes', [ 'ipc_rev_id' => $ipRevIds ], __METHOD__ );
+ // Also delete records from ip_changes as applicable.
+ if ( count( $ipRevIds ) > 0 ) {
+ $dbw->delete( 'ip_changes', [ 'ipc_rev_id' => $ipRevIds ], __METHOD__ );
+ }
}
- // Log the deletion, if the page was suppressed, put it in the suppression log instead
- $logtype = $suppress ? 'suppress' : 'delete';
-
- $logEntry = new ManualLogEntry( $logtype, $logsubtype );
- $logEntry->setPerformer( $deleter );
- $logEntry->setTarget( $logTitle );
- $logEntry->setComment( $reason );
- $logEntry->setTags( $tags );
- $logid = $logEntry->insert();
-
- $dbw->onTransactionPreCommitOrIdle(
- function () use ( $logEntry, $logid ) {
- // T58776: avoid deadlocks (especially from FileDeleteForm)
- $logEntry->publish( $logid );
- },
- __METHOD__
- );
-
- $dbw->endAtomic( __METHOD__ );
-
- $this->doDeleteUpdates( $id, $content, $revision, $deleter );
-
- Hooks::run( 'ArticleDeleteComplete', [
- &$wikiPageBeforeDelete,
- &$deleter,
- $reason,
- $id,
- $content,
- $logEntry,
- $archivedRevisionCount
- ] );
- $status->value = $logid;
-
- // Show log excerpt on 404 pages rather than just a link
- $cache = MediaWikiServices::getInstance()->getMainObjectStash();
- $key = $cache->makeKey( 'page-recent-delete', md5( $logTitle->getPrefixedText() ) );
- $cache->set( $key, 1, $cache::TTL_DAY );
-
- return $status;
+ return $done;
}
/**
// Since we're not really outputting HTML, decode the entities and
// then re-encode the things that need hiding inside HTML comments.
$limitReport = htmlspecialchars_decode( $limitReport );
- // Run deprecated hook
- Hooks::run( 'ParserLimitReport', [ $this, &$limitReport ], '1.22' );
// Sanitize for comment. Note '‐' in the replacement is U+2010,
// which looks much like the problematic '-'.
return $skin->doEditSectionLink( $editsectionPage,
$editsectionSection,
$editsectionContent,
- $wgLang->getCode()
+ $wgLang
);
},
$text
);
}
- // TODO remove this method once old parser cache objects have expired, probably mid-October 2018
- public function __wakeup() {
- // T203716 remove wrapper that was added by logic in an older version of this class,
- // where the wrapper was included in mText. This might sometimes remove a wrapper that's
- // genuine content (manually added to a system message), but that will work out OK, see below.
- $text = $this->getRawText();
- $start = Html::openElement( 'div', [
- 'class' => 'mw-parser-output'
- ] );
- $startLen = strlen( $start );
- $end = Html::closeElement( 'div' );
- $endPos = strrpos( $text, $end );
- $endLen = strlen( $end );
- if ( substr( $text, 0, $startLen ) === $start && $endPos !== false
- // if the closing div is followed by real content, bail out of unwrapping
- && preg_match( '/^(?>\s*<!--.*?-->)*\s*$/s', substr( $text, $endPos + $endLen ) )
- ) {
- $text = substr( $text, $startLen );
- $text = substr( $text, 0, $endPos - $startLen ) .
- substr( $text, $endPos - $startLen + $endLen );
- $this->setText( $text );
- // We found a wrapper to remove, so the ParserOutput was probably created by the
- // code path that now contains an addWrapperDivClass( 'mw-parser-output' ) call,
- // but it did not contain it when this object was cached, so we need to fix the
- // wrapper class variable.
- // If this was a message with a manually added wrapper, we are technically wrong about
- // this but we were wrong about the unwrapping as well so it will work out just right,
- // except when this is a normal page view of such a message page, in which case
- // it will be single-wrapped instead of double-wrapped (harmless) or something wants
- // render the message with unwrap=true (in which case the message won't be wrapped even
- // though it should, but the few code paths using unwrap=true only do it for real pages).
- $this->clearWrapperDivClass();
- $this->addWrapperDivClass( 'mw-parser-output' );
- }
- }
-
/**
* Merges internal metadata such as flags, accessed options, and profiling info
* from $source into this ParserOutput. This should be used whenever the state of $source
*/
use MediaWiki\MediaWikiServices;
+use MediaWiki\Revision\MutableRevisionRecord;
+use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\RevisionRenderer;
-use MediaWiki\Storage\MutableRevisionRecord;
-use MediaWiki\Storage\RevisionRecord;
-use MediaWiki\Storage\RevisionStore;
-use MediaWiki\Storage\SlotRecord;
+use MediaWiki\Revision\RevisionStore;
+use MediaWiki\Revision\SlotRecord;
class PoolWorkArticleView extends PoolCounterWork {
/** @var WikiPage */
'pluralRules' => $language->getPluralRules(),
'digitGroupingPattern' => $language->digitGroupingPattern(),
'fallbackLanguages' => $language->getFallbackLanguages(),
+ 'bcp47Map' => LanguageCode::getNonstandardLanguageCodeMapping(),
];
}
$virtualOldBits |= $removedBits;
$status->successCount++;
- if ( $wgActorTableSchemaMigrationStage <= MIGRATION_WRITE_BOTH ) {
+ if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_WRITE_OLD ) {
if ( $item->getAuthorId() > 0 ) {
$authorIds[] = $item->getAuthorId();
} elseif ( IP::isIPAddress( $item->getAuthorName() ) ) {
$authorIPs[] = $item->getAuthorName();
}
}
- if ( $wgActorTableSchemaMigrationStage >= MIGRATION_WRITE_BOTH ) {
+ if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_WRITE_NEW ) {
$authorActors[] = $item->getAuthorActor();
}
// Log it
$authorFields = [];
- if ( $wgActorTableSchemaMigrationStage <= MIGRATION_WRITE_BOTH ) {
+ if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_WRITE_OLD ) {
$authorFields['authorIds'] = $authorIds;
$authorFields['authorIPs'] = $authorIPs;
}
- if ( $wgActorTableSchemaMigrationStage >= MIGRATION_WRITE_BOTH ) {
+ if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_WRITE_NEW ) {
$authorFields['authorActors'] = $authorActors;
}
$this->updateLog(
$userTitle = Title::makeTitleSafe( NS_USER, $name );
$userDbKey = $userTitle->getDBkey();
- if ( $wgActorTableSchemaMigrationStage < MIGRATION_NEW ) {
+ if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_WRITE_OLD ) {
# Hide name from live edits
$dbw->update(
'revision',
);
}
- if ( $wgActorTableSchemaMigrationStage > MIGRATION_OLD ) {
+ if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_WRITE_NEW ) {
$actorId = $dbw->selectField( 'actor', 'actor_id', [ 'actor_name' => $name ], __METHOD__ );
if ( $actorId ) {
# Hide name from live edits
* @param string $section The designation of the section being pointed to,
* to be included in the link, like "§ion=$section"
* @param string|null $tooltip The tooltip to use for the link: will be escaped
- * and wrapped in the 'editsectionhint' message
- * @param string $lang Language code
+ * and wrapped in the 'editsectionhint' message.
+ * Not setting this parameter is deprecated.
+ * @param Language|string $lang Language object or language code string.
+ * Type string is deprecated. Not setting this parameter is deprecated.
* @return string HTML to use for edit link
*/
public function doEditSectionLink( Title $nt, $section, $tooltip = null, $lang = false ) {
// HTML generated here should probably have userlangattributes
// added to it for LTR text on RTL pages
- $lang = wfGetLangObj( $lang );
+ if ( !$lang instanceof Language ) {
+ wfDeprecated( __METHOD__ . ' with other type than Language for $lang', '1.32' );
+ $lang = wfGetLangObj( $lang );
+ }
$attribs = [];
if ( !is_null( $tooltip ) ) {
);
$result .= '<span class="mw-editsection-bracket">]</span></span>';
- // Deprecated, use SkinEditSectionLinks hook instead
- Hooks::run(
- 'DoEditSectionLink',
- [ $this, $nt, $section, $tooltip, &$result, $lang ],
- '1.25'
- );
return $result;
}
// Both Hooks::run are explicit here to make findHooks.php happy
if ( $this->isSignup() ) {
- Hooks::run( 'UserCreateForm', [ &$template ] );
+ Hooks::run( 'UserCreateForm', [ &$template ], '1.27' );
if ( $oldTemplate !== $template ) {
wfDeprecated( "reference in UserCreateForm hook", '1.27' );
}
} else {
- Hooks::run( 'UserLoginForm', [ &$template ] );
+ Hooks::run( 'UserLoginForm', [ &$template ], '1.27' );
if ( $oldTemplate !== $template ) {
wfDeprecated( "reference in UserLoginForm hook", '1.27' );
}
$offenderName = $opts->getValue( 'offender' );
$offender = empty( $offenderName ) ? null : User::newFromName( $offenderName, false );
if ( $offender ) {
- if ( $wgActorTableSchemaMigrationStage === MIGRATION_NEW ) {
+ if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_READ_NEW ) {
$qc = [ 'ls_field' => 'target_author_actor', 'ls_value' => $offender->getActorId() ];
+ } elseif ( $offender->getId() > 0 ) {
+ $qc = [ 'ls_field' => 'target_author_id', 'ls_value' => $offender->getId() ];
} else {
- if ( $offender->getId() > 0 ) {
- $field = 'target_author_id';
- $value = $offender->getId();
- } else {
- $field = 'target_author_ip';
- $value = $offender->getName();
- }
- if ( !$offender->getActorId() ) {
- $qc = [ 'ls_field' => $field, 'ls_value' => $value ];
- } else {
- $db = wfGetDB( DB_REPLICA );
- $qc = [
- 'ls_field' => [ 'target_author_actor', $field ], // So LogPager::getQueryInfo() works right
- $db->makeList( [
- $db->makeList(
- [ 'ls_field' => 'target_author_actor', 'ls_value' => $offender->getActorId() ], LIST_AND
- ),
- $db->makeList( [ 'ls_field' => $field, 'ls_value' => $value ], LIST_AND ),
- ], LIST_OR ),
- ];
- }
+ $qc = [ 'ls_field' => 'target_author_ip', 'ls_value' => $offender->getName() ];
}
}
} else {
return;
}
+ $page = WikiPage::factory( $nt );
+
+ // Small safety margin to guard against concurrent edits
+ if ( $page->isBatchedDelete( 5 ) ) {
+ $this->showForm( [ [ 'movepage-delete-first' ] ] );
+
+ return;
+ }
+
$reason = $this->msg( 'delete_and_move_reason', $ot )->inContentLanguage()->text();
// Delete an associated image if there is
}
$error = ''; // passed by ref
- $page = WikiPage::factory( $nt );
$deleteStatus = $page->doDeleteArticleReal( $reason, false, 0, true, $error, $user );
if ( !$deleteStatus->isGood() ) {
$this->showForm( $deleteStatus->getErrorsArray() );
$message = $this->msg( 'newimagestext' )->inContentLanguage();
if ( !$message->isDisabled() ) {
$contLang = MediaWikiServices::getInstance()->getContentLanguage();
- $this->getOutput()->addWikiTextTidy(
+ $this->getOutput()->addWikiTextAsContent(
Html::rawElement( 'div',
[
*/
use MediaWiki\MediaWikiServices;
-use MediaWiki\Storage\RevisionRecord;
+use MediaWiki\Revision\RevisionRecord;
use Wikimedia\Rdbms\IResultWrapper;
/**
$conds = ActorMigration::newMigration()->getWhere( $this->mDb, 'rev_user', $user );
$queryInfo['conds'][] = $conds['conds'];
// Force the appropriate index to avoid bad query plans (T189026)
- if ( count( $conds['orconds'] ) === 1 ) {
- if ( isset( $conds['orconds']['actor'] ) ) {
- // @todo: This will need changing when revision_comment_temp goes away
- $queryInfo['options']['USE INDEX']['temp_rev_user'] = 'actor_timestamp';
- } else {
- $queryInfo['options']['USE INDEX']['revision'] =
- isset( $conds['orconds']['userid'] ) ? 'user_timestamp' : 'usertext_timestamp';
- }
+ if ( isset( $conds['orconds']['actor'] ) ) {
+ // @todo: This will need changing when revision_comment_temp goes away
+ $queryInfo['options']['USE INDEX']['temp_rev_user'] = 'actor_timestamp';
+ } else {
+ $queryInfo['options']['USE INDEX']['revision'] =
+ isset( $conds['orconds']['userid'] ) ? 'user_timestamp' : 'usertext_timestamp';
}
}
}
$conds['rc_patrolled'] = RecentChange::PRC_UNPATROLLED;
$conds['rc_namespace'] = NS_FILE;
- if ( $wgActorTableSchemaMigrationStage === MIGRATION_NEW ) {
+ if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_READ_NEW ) {
$jcond = 'rc_actor = ' . $imgQuery['fields']['img_actor'];
} else {
$rcQuery = ActorMigration::newMigration()->getJoin( 'rc_user' );
/**
* There are two ways to login with a bot password: "username@appId", "password" and
* "username", "appId@password". Transform it so it is always in the first form.
- * Returns [bot username, bot password, could be normal password?] where the last one is a flag
- * meaning this could either be a bot password or a normal password, it cannot be decided for
- * certain (although in such cases it almost always will be a bot password).
+ * Returns [bot username, bot password].
* If this cannot be a bot password login just return false.
* @param string $username
* @param string $password
if ( strlen( $password ) >= 32 && strpos( $username, $sep ) !== false ) {
// the separator is not valid in new usernames but might appear in legacy ones
if ( preg_match( '/^[0-9a-w]{32,}$/', $password ) ) {
- return [ $username, $password, true ];
+ return [ $username, $password ];
}
} elseif ( strlen( $password ) > 32 && strpos( $password, $sep ) !== false ) {
$segments = explode( $sep, $password );
$password = array_pop( $segments );
$appId = implode( $sep, $segments );
if ( preg_match( '/^[0-9a-w]{32,}$/', $password ) ) {
- return [ $username . $sep . $appId, $password, true ];
+ return [ $username . $sep . $appId, $password ];
}
}
return false;
public static function newFromActorId( $id ) {
global $wgActorTableSchemaMigrationStage;
- if ( $wgActorTableSchemaMigrationStage <= MIGRATION_OLD ) {
+ // Technically we shouldn't allow this without SCHEMA_COMPAT_READ_NEW,
+ // but it does little harm and might be needed for write callers loading a User.
+ if ( !( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_NEW ) ) {
throw new BadMethodCallException(
- 'Cannot use ' . __METHOD__ . ' when $wgActorTableSchemaMigrationStage is MIGRATION_OLD'
+ 'Cannot use ' . __METHOD__
+ . ' when $wgActorTableSchemaMigrationStage lacks SCHEMA_COMPAT_NEW'
);
}
$user = new User;
$user->mFrom = 'defaults';
- if ( $wgActorTableSchemaMigrationStage > MIGRATION_OLD && $actorId !== null ) {
+ // Technically we shouldn't allow this without SCHEMA_COMPAT_READ_NEW,
+ // but it does little harm and might be needed for write callers loading a User.
+ if ( ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_NEW ) && $actorId !== null ) {
$user->mActorId = (int)$actorId;
if ( $user->mActorId !== 0 ) {
$user->mFrom = 'actor';
$this->mGroupMemberships = null; // deferred
- if ( $wgActorTableSchemaMigrationStage > MIGRATION_OLD ) {
+ // Technically we shouldn't allow this without SCHEMA_COMPAT_READ_NEW,
+ // but it does little harm and might be needed for write callers loading a User.
+ if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_NEW ) {
if ( isset( $row->actor_id ) ) {
$this->mActorId = (int)$row->actor_id;
if ( $this->mActorId !== 0 ) {
public function getActorId( IDatabase $dbw = null ) {
global $wgActorTableSchemaMigrationStage;
- if ( $wgActorTableSchemaMigrationStage <= MIGRATION_OLD ) {
+ // Technically we should always return 0 without SCHEMA_COMPAT_READ_NEW,
+ // but it does little harm and might be needed for write callers loading a User.
+ if ( !( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_WRITE_NEW ) ) {
return 0;
}
// Currently $this->mActorId might be null if $this was loaded from a
// cache entry that was written when $wgActorTableSchemaMigrationStage
- // was MIGRATION_OLD. Once that is no longer a possibility (i.e. when
+ // was SCHEMA_COMPAT_OLD. Once that is no longer a possibility (i.e. when
// User::VERSION is incremented after $wgActorTableSchemaMigrationStage
// has been removed), that condition may be removed.
if ( $this->mActorId === null || !$this->mActorId && $dbw ) {
);
}
- if ( $wgActorTableSchemaMigrationStage > MIGRATION_OLD ) {
+ if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_WRITE_NEW ) {
$dbw->update(
'actor',
[ 'actor_name' => $this->mName ],
$dbw->insert( 'user', $fields, $fname, [ 'IGNORE' ] );
if ( $dbw->affectedRows() ) {
$newUser = self::newFromId( $dbw->insertId() );
+ $newUser->mName = $fields['user_name'];
+ $newUser->updateActorId( $dbw );
// Load the user from master to avoid replica lag
$newUser->load( self::READ_LATEST );
- $newUser->updateActorId( $dbw );
} else {
$newUser = null;
}
private function updateActorId( IDatabase $dbw ) {
global $wgActorTableSchemaMigrationStage;
- if ( $wgActorTableSchemaMigrationStage > MIGRATION_OLD ) {
+ if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_WRITE_NEW ) {
$dbw->insert(
'actor',
[ 'actor_user' => $this->mId, 'actor_name' => $this->mName ],
/**
* Get a list of implicit groups
+ * TODO: Should we deprecate this? It's trivial, but we don't want to encourage use of globals.
+ *
* @return array Array of Strings Array of internal group names
*/
public static function getImplicitGroups() {
global $wgImplicitGroups;
-
- $groups = $wgImplicitGroups;
- # Deprecated, use $wgImplicitGroups instead
- Hooks::run( 'UserGetImplicitGroups', [ &$groups ], '1.25' );
-
- return $groups;
+ return $wgImplicitGroups;
}
/**
],
'joins' => [],
];
- if ( $wgActorTableSchemaMigrationStage > MIGRATION_OLD ) {
+
+ // Technically we shouldn't allow this without SCHEMA_COMPAT_READ_NEW,
+ // but it does little harm and might be needed for write callers loading a User.
+ if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_NEW ) {
$ret['tables']['user_actor'] = 'actor';
$ret['fields'][] = 'user_actor.actor_id';
$ret['joins']['user_actor'] = [
- $wgActorTableSchemaMigrationStage === MIGRATION_NEW ? 'JOIN' : 'LEFT JOIN',
+ ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_READ_NEW ) ? 'JOIN' : 'LEFT JOIN',
[ 'user_actor.actor_user = user_id' ]
];
}
+
return $ret;
}
}
function validateVariant( $variant = null ) {
+ if ( $variant === null ) {
+ return null;
+ }
+ $variant = strtolower( $variant );
return $variant === $this->mLang->getCode() ? $variant : null;
}
/**
* Mapping of deprecated language codes that were used in previous
* versions of MediaWiki to up-to-date, current language codes.
+ * These may or may not be valid BCP 47 codes; they are included here
+ * because MediaWiki remapped these particular codes at some point.
*
* @var array Mapping from language code to language code
*
* @since 1.30
+ * @see https://meta.wikimedia.org/wiki/Special_language_codes
*/
private static $deprecatedLanguageCodeMapping = [
// Note that als is actually a valid ISO 639 code (Tosk Albanian), but it
// was previously used in MediaWiki for Alsatian, which comes under gsw
- 'als' => 'gsw',
- 'bat-smg' => 'sgs',
- 'be-x-old' => 'be-tarask',
- 'fiu-vro' => 'vro',
- 'roa-rup' => 'rup',
- 'zh-classical' => 'lzh',
- 'zh-min-nan' => 'nan',
- 'zh-yue' => 'yue',
+ 'als' => 'gsw', // T25215
+ 'bat-smg' => 'sgs', // T27522
+ 'be-x-old' => 'be-tarask', // T11823
+ 'fiu-vro' => 'vro', // T31186
+ 'roa-rup' => 'rup', // T17988
+ 'zh-classical' => 'lzh', // T30443
+ 'zh-min-nan' => 'nan', // T30442
+ 'zh-yue' => 'yue', // T30441
+ ];
+
+ /**
+ * Mapping of non-standard language codes used in MediaWiki to
+ * standardized BCP 47 codes. These are not deprecated (yet?):
+ * IANA may eventually recognize the subtag, in which case the `-x-`
+ * infix could be removed, or else we could rename the code in
+ * MediaWiki, in which case they'd move up to the above mapping
+ * of deprecated codes.
+ *
+ * As a rule, we preserve all distinctions made by MediaWiki
+ * internally. For example, `de-formal` becomes `de-x-formal`
+ * instead of just `de` because MediaWiki distinguishes `de-formal`
+ * from `de` (for example, for interface translations). Similarly,
+ * BCP 47 indicates that `kk-Cyrl` SHOULD not be used because it
+ * "typically does not add information", but in our case MediaWiki
+ * LanguageConverter distinguishes `kk` (render content in a mix of
+ * Kurdish variants) from `kk-Cyrl` (convert content to be uniformly
+ * Cyrillic). As the BCP 47 requirement is a SHOULD not a MUST,
+ * `kk-Cyrl` is a valid code, although some validators may emit
+ * a warning note.
+ *
+ * @var array Mapping from nonstandard codes to BCP 47 codes
+ *
+ * @since 1.32
+ * @see https://meta.wikimedia.org/wiki/Special_language_codes
+ * @see https://phabricator.wikimedia.org/T125073
+ */
+ private static $nonstandardLanguageCodeMapping = [
+ // All codes returned by Language::fetchLanguageNames() validated
+ // against IANA registry at
+ // https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry
+ // with help of validator at
+ // http://schneegans.de/lv/
+ 'cbk-zam' => 'cbk', // T124657
+ 'de-formal' => 'de-x-formal',
+ 'eml' => 'egl', // T36217
+ 'en-rtl' => 'en-x-rtl',
+ 'es-formal' => 'es-x-formal',
+ 'hu-formal' => 'hu-x-formal',
+ 'map-bms' => 'jv-x-bms', // [[en:Banyumasan_dialect]] T125073
+ 'mo' => 'ro-Cyrl-MD', // T125073
+ 'nrm' => 'nrf', // [[en:Norman_language]] T25216
+ 'nl-informal' => 'nl-x-informal',
+ 'roa-tara' => 'nap-x-tara', // [[en:Tarantino_dialect]]
+ 'simple' => 'en-simple',
+ 'sr-ec' => 'sr-Cyrl', // T117845
+ 'sr-el' => 'sr-Latn', // T117845
+
+ // Although these next codes aren't *wrong* per se, including
+ // both the script and the country code helps compatibility with
+ // other BCP 47 users. Note that MW also uses `zh-Hans`/`zh-Hant`,
+ // without a country code, and those should be left alone.
+ // (See $variantfallbacks in LanguageZh.php for Hans/Hant id.)
+ 'zh-cn' => 'zh-Hans-CN',
+ 'zh-sg' => 'zh-Hans-SG',
+ 'zh-my' => 'zh-Hans-MY',
+ 'zh-tw' => 'zh-Hant-TW',
+ 'zh-hk' => 'zh-Hant-HK',
+ 'zh-mo' => 'zh-Hant-MO',
];
/**
return self::$deprecatedLanguageCodeMapping;
}
+ /**
+ * Returns a mapping of non-standard language codes used by
+ * (current and previous version of) MediaWiki, mapped to standard
+ * BCP 47 names.
+ *
+ * This array is exported to JavaScript to ensure
+ * mediawiki.language.bcp47 stays in sync with LanguageCode::bcp47().
+ *
+ * @return string[]
+ *
+ * @since 1.32
+ */
+ public static function getNonstandardLanguageCodeMapping() {
+ $result = [];
+ foreach ( self::$deprecatedLanguageCodeMapping as $code => $ignore ) {
+ $result[$code] = self::bcp47( $code );
+ }
+ foreach ( self::$nonstandardLanguageCodeMapping as $code => $ignore ) {
+ $result[$code] = self::bcp47( $code );
+ }
+ return $result;
+ }
+
/**
* Replace deprecated language codes that were used in previous
* versions of MediaWiki to up-to-date, current language codes.
* See mediawiki.language.bcp47 for the JavaScript implementation.
*
* @param string $code The language code.
- * @return string The language code which complying with BCP 47 standards.
+ * @return string A language code complying with BCP 47 standards.
*
* @since 1.31
*/
public static function bcp47( $code ) {
+ $code = self::replaceDeprecatedCodes( strtolower( $code ) );
+ if ( isset( self::$nonstandardLanguageCodeMapping[$code] ) ) {
+ $code = self::$nonstandardLanguageCodeMapping[$code];
+ }
$codeSegment = explode( '-', $code );
$codeBCP = [];
foreach ( $codeSegment as $segNo => $seg ) {
$req = $this->validateVariant( $wgDefaultLanguageVariant );
}
+ $req = $this->validateVariant( $req );
+
// This function, unlike the other get*Variant functions, is
// not memoized (i.e. there return value is not cached) since
// new information might appear during processing after this
// is first called.
- if ( $this->validateVariant( $req ) ) {
+ if ( $req ) {
return $req;
}
return $this->mMainLanguageCode;
* @return mixed Returns the variant if it is valid, null otherwise
*/
public function validateVariant( $variant = null ) {
- if ( $variant !== null && in_array( $variant, $this->mVariants ) ) {
+ if ( $variant === null ) {
+ return null;
+ }
+ // Our internal variants are always lower-case; the variant we
+ // are validating may have mixed case.
+ $variant = LanguageCode::replaceDeprecatedCodes( strtolower( $variant ) );
+ if ( in_array( $variant, $this->mVariants ) ) {
return $variant;
}
+ // Browsers are supposed to use BCP 47 standard in the
+ // Accept-Language header, but not all of our internal
+ // mediawiki variant codes are BCP 47. Map BCP 47 code
+ // to our internal code.
+ foreach ( $this->mVariants as $v ) {
+ // Case-insensitive match (BCP 47 is mixed case)
+ if ( strtolower( LanguageCode::bcp47( $v ) ) === $variant ) {
+ return $v;
+ }
+ }
return null;
}
return $this->mHeaderVariant;
}
- // see if some supported language variant is set in the
+ // See if some supported language variant is set in the
// HTTP header.
$languages = array_keys( $wgRequest->getAcceptLang() );
if ( empty( $languages ) ) {
$convTable = $convRule->getConvTable();
$action = $convRule->getRulesAction();
foreach ( $convTable as $variant => $pair ) {
- if ( !$this->validateVariant( $variant ) ) {
+ $v = $this->validateVariant( $variant );
+ if ( !$v ) {
continue;
}
if ( $action == 'add' ) {
// More efficient than array_merge(), about 2.5 times.
foreach ( $pair as $from => $to ) {
- $this->mTables[$variant]->setPair( $from, $to );
+ $this->mTables[$v]->setPair( $from, $to );
}
} elseif ( $action == 'remove' ) {
- $this->mTables[$variant]->removeArray( $pair );
+ $this->mTables[$v]->removeArray( $pair );
}
}
}
'ba' => 'башҡортса', # Bashkir
'ban' => 'Basa Bali', # Balinese
'bar' => 'Boarisch', # Bavarian (Austro-Bavarian and South Tyrolean)
- 'bat-smg' => 'žemaitėška', # Samogitian (deprecated code, 'sgs' in ISO 693-3 since 2010-06-30 )
+ 'bat-smg' => 'žemaitėška', # Samogitian (deprecated code, 'sgs' in ISO 639-3 since 2010-06-30 )
'bbc' => 'Batak Toba', # Batak Toba (falls back to bbc-latn)
'bbc-latn' => 'Batak Toba', # Batak Toba
'bcc' => 'جهلسری بلوچی', # Southern Balochi
'lzh' => '文言', # Literary Chinese, T10217
'lzz' => 'Lazuri', # Laz
'mai' => 'मैथिली', # Maithili
- 'map-bms' => 'Basa Banyumasan', # Banyumasan
+ 'map-bms' => 'Basa Banyumasan', # Banyumasan ('jv-x-bms')
'mdf' => 'мокшень', # Moksha
'mg' => 'Malagasy', # Malagasy
'mh' => 'Ebon', # Marshallese
'mn' => 'монгол', # Halh Mongolian (Cyrillic) (ISO 639-3: khk)
'mni' => 'মেইতেই লোন্', # Manipuri/Meitei
'mnw' => 'ဘာသာ မန်', # Mon, T201583
- 'mo' => 'молдовеняскэ', # Moldovan, deprecated
+ 'mo' => 'молдовеняскэ', # Moldovan, deprecated (ISO 639-2: ro-Cyrl-MD)
'mr' => 'मराठी', # Marathi
'mrj' => 'кырык мары', # Hill Mari
'ms' => 'Bahasa Melayu', # Malay
'myv' => 'эрзянь', # Erzya
'mzn' => 'مازِرونی', # Mazanderani
'na' => 'Dorerin Naoero', # Nauruan
- 'nah' => 'Nāhuatl', # Nahuatl (not in ISO 639-3)
+ 'nah' => 'Nāhuatl', # Nahuatl (added to ISO 639-3 on 2006-10-31)
'nan' => 'Bân-lâm-gú', # Min-nan, T10217
'nap' => 'Napulitano', # Neapolitan, T45793
'nb' => 'norsk bokmål', # Norwegian (Bokmal)
'nn' => 'norsk nynorsk', # Norwegian (Nynorsk)
'no' => 'norsk', # Norwegian macro language (falls back to nb).
'nov' => 'Novial', # Novial
- 'nrm' => 'Nouormand', # Norman
+ 'nrm' => 'Nouormand', # Norman (invalid code; 'nrf' in ISO 639 since 2014)
'nso' => 'Sesotho sa Leboa', # Northern Sotho
'nv' => 'Diné bizaad', # Navajo
'ny' => 'Chi-Chewa', # Chichewa
'rmy' => 'Romani', # Vlax Romany
'rn' => 'Kirundi', # Rundi/Kirundi/Urundi
'ro' => 'română', # Romanian
- 'roa-rup' => 'armãneashti', # Aromanian (deprecated code, 'rup' exists in ISO 693-3)
- 'roa-tara' => 'tarandíne', # Tarantino
+ 'roa-rup' => 'armãneashti', # Aromanian (deprecated code, 'rup' exists in ISO 639-3)
+ 'roa-tara' => 'tarandíne', # Tarantino ('nap-x-tara')
'ru' => 'русский', # Russian
'rue' => 'русиньскый', # Rusyn
'rup' => 'armãneashti', # Aromanian
'tt-cyrl' => 'татарча', # Tatar (Cyrillic script) (default)
'tt-latn' => 'tatarça', # Tatar (Latin script)
'tum' => 'chiTumbuka', # Tumbuka
- 'tw' => 'Twi', # Twi, (FIXME!)
+ 'tw' => 'Twi', # Twi
'ty' => 'reo tahiti', # Tahitian
'tyv' => 'тыва дыл', # Tyvan
'tzm' => 'ⵜⴰⵎⴰⵣⵉⵖⵜ', # Tamazight
"create-local": "Tamah deskripsi lokal",
"delete": "Sampôh",
"undelete_short": "Bateuë sampôh {{PLURAL:$1|one edit|$1 edits}}",
- "viewdeleted_short": "Eu {{PLURAL:$1|saboh neuandam|$1 neuandam}} nyang geusampôh",
+ "viewdeleted_short": "Kalön {{PLURAL:$1|hasé peusaneut teusampôh}}",
"protect": "Peulindông",
"protect_change": "ubah",
"unprotect": "Gantoë neulindông",
"red-link-title": "$1 (laman hana)",
"sort-descending": "Peuurôt tren",
"sort-ascending": "Peuurôt ék",
- "nstab-main": "Miëng",
+ "nstab-main": "Laman",
"nstab-user": "Ureuëng ngui",
"nstab-media": "Laman media",
- "nstab-special": "Miëng kusuih",
+ "nstab-special": "Laman kusuih",
"nstab-project": "Laman buët",
"nstab-image": "Beureukaih",
"nstab-mediawiki": "Peusan",
"actionthrottled": "Buet geupeubataih",
"actionthrottledtext": "Sibagoe saboh seunipat lawan-spam, droeneuh geupeubataih nibak neupeulaku buet nyoe le that gö lam watèë paneuk, ngön droeneuh ka leubèh nibak bataih.\nNeuci lom lam padum minèt.",
"protectedpagetext": "Laman nyoe ka geupeulindông mangat bèk jeuet geuandam",
- "viewsourcetext": "Droeneuh jeuet neueu ngön neusalén nè nibak mieng nyoe.",
+ "viewsourcetext": "Droeneuh jeuet neukalön ngön neusalén nè nibak laman nyoe.",
"viewyourtext": "Droëneuh jeuet neukalön ngön neusalén nè nibak <strong>hasé peusaneut Droëneuh</strong> u laman nyoë.",
"protectedinterface": "Halaman nyoe na tèks muka keu muka keu peukakaih leumiëk ngön geupeulindông mangat bek jeuet jipeureuloh.\nKeu neuk tamah atawa ubah teujeumah keu ban dum wiki, neungui [https://translatewiki.net/ translatewiki.net], proyek lokalisasi MediaWiki.",
"mycustomcssprotected": "Droëneuh hana hak neuandam halaman CSS nyoe.",
"mycustomjsprotected": "Droëneuh hana idin neuandam halaman JavaScript nyoe.",
"mypreferencesprotected": "Droeneuh hana izin keu neuandam geunalak droeneuh.",
- "ns-specialprotected": "Laman khusuih bèk neuandam",
+ "ns-specialprotected": "Laman kusuih h'an jeuet geupeusaneut",
"titleprotected": "Nan nyoe ka geupeulindông nibak neuandam lé [[User:$1|$1]].\nDalèhjih nakeuh <em>$2</em>.",
"invalidtitle-knownnamespace": "Nan nyang hana sah ngön ruweueng nan \"$2\" ngön \"$3\"",
"exception-nologin": "Goh lom tamong log",
"summary": "Éhtisa:",
"subject": "Bhaih:",
"minoredit": "Nyoë lôn peusaneut bacut",
- "watchthis": "Kalön miëng nyoë",
+ "watchthis": "Kalön laman nyoë",
"savearticle": "Keubah laman",
"savechanges": "Keubah neuubah",
"publishpage": "Peuteubiet mieng",
"preview": "Eu dilèë",
"showpreview": "Peuleumah hasé",
"showdiff": "Peuleumah neuubah",
- "anoneditwarning": "<strong>Peuneugah:</strong> Droëneuh hana lom neutamong. Alamat IP-neuh jeuët deuh bak ureuëng la'én meunyö neumeuandam. Meunyö Droëneuh <strong>[$1 neutamong]</strong> atawa <strong>[$2 neudapeuta]</strong>, neuandamneuh jeuët teutuléh ateuëh nan Droëneuh ngön na lom meunapha'at nyang la'én.",
+ "anoneditwarning": "<strong>Peuneugah:</strong> Droëneuh goh lom neutamong. Alamat IP-neuh jeuët leumah bak ureuëng la'én meunyö neupeusaneut sipeue-peue. Meunyö Droëneuh <strong>[$1 neutamong]</strong> atawa <strong>[$2 neudapeuta]</strong>, atra neupeusaneut jeuët teutuléh ateuëh nan Droëneuh ngön na lom meunapha'at nyang la'én.",
"missingcommenttext": "Neupasoë beunalah di yup.",
"summary-preview": "Eu neuringkaih neuandam:",
"blockedtitle": "Ureueng ngui geutheun",
"newarticle": "(Barô)",
"newarticletext": "Droëneuh ka neuseutöt peunawôt u laman nyang goh na.\nKeu neupeugöt laman nyan, neukeutik lam plôk di yup (eu [$1 laman beunantu] keu haba leubèh le).\nMeunyö droëneuh trôk keunoë hana neusaja, neuteugön tèk '''back''' bak ''browser'''droëneuh.",
"anontalkpagetext": "----''Nyoe nakeuh ôn marit ureueng ngui nyang hana tamöng atawa hana geungui.''\nSaweub nyan, kamoe payah meukubah alamat IP-geuh keu meuparéksa. \nAlamat IP mungkén jingui lé padum-padum droe ureueng.\nMeunyoe droeneuh ureueng nyang hana tamöng nyan, tulông [[Special:CreateAccount|peugöt nan ureueng ngui]] atawa [[Special:UserLogin|tamöng log]] mangat meuteugah nibak bhah nyang hana meuphôm bak uroe la'én.",
- "noarticletext": "Hana naseukah jinoë lam miëng nyoë.\nDroëneuh jeuët [[Special:Search/{{PAGENAME}}|neuseutöt nan miëng nyoë]] bak miëng-miëng la’én, <span class=\"plainlinks\">[{{fullurl:{{#Special:Log}}|page={{FULLPAGENAMEE}}}} neuseutöt log nyang na hubôngan], atawa [{{fullurl:{{FULLPAGENAME}}|action=edit}} neupeugöt miëng nyoë]</span>.",
+ "noarticletext": "Hana naseukah jinoë lam laman nyoë.\nDroëneuh jeuët [[Special:Search/{{PAGENAME}}|neuseutöt nan laman nyoë]] bak laman-laman nyang la’én, <span class=\"plainlinks\">[{{fullurl:{{#Special:Log}}|page={{FULLPAGENAMEE}}}} neuseutöt log nyang na hubôngan], atawa [{{fullurl:{{FULLPAGENAME}}|action=edit}} neupeugöt laman nyoë]</span>.",
"noarticletext-nopermission": "Hana asoë bak laman nyoë jinoë.\nDroëneuh jeuët [[Special:Search/{{PAGENAME}}|neumita keu nan ôn nyoë]] bak laman-laman la'én,\natawa <span class=\"plainlinks\">[{{fullurl:{{#Special:Log}}|page={{FULLPAGENAMEE}}}} neumita log nyang na meuhubông]</span>, tapi Droëneuh hana idin keu neupeugöt laman nyoë",
"userpage-userdoesnotexist-view": "Ureueng ngui \"$1\" hana teudapeuta.",
"updated": "(Seubarô)",
"permissionserrorstext": "Droëneuh hana geupeuidin keu neupubuët nyoë, muroë {{PLURAL:$1|dalèh}} nyoë:",
"permissionserrorstext-withaction": "Droëneuh hana hak tamöng keu $2, muroë {{PLURAL:$1|choë|choë}} nyoë:",
"recreate-moveddeleted-warn": "'''Ingat: Droëneuh neupeugöt ulang saboh laman nyang ka tom geusampôh. ''',\n\nNeutimang-timang dilèë peuë ék patôt neulanjut atra nyang teungöh neupeusaneut.\nNyoë pat nakeuh log seunampôh nibak laman nyoë:",
- "moveddeleted-notice": "Mieng nyoë ka geusampôh.\nLog seunampôh, neulindông ngön peuninah keu mieng nyoe na di yup nyoe keu catatan.",
+ "moveddeleted-notice": "Laman nyoë ka geusampôh.\nLog seunampôh, neulindông ngön peuninah keu laman nyoe na di yup nyoe keu catatan.",
"log-fulllog": "Eu ban dum ceunatat",
"edit-hook-aborted": "Seunampôh geupeubateuë lé kaw'ét parser.\nHana jeuneulaih.",
"edit-gone-missing": "Han jeut peubarô ôn.\nÔn nyoe mungkén ka geusampôh.",
"edit-conflict": "Konflik peusaneut.",
"postedit-confirmation-saved": "Neuandam droeneuh ka meukubah.",
- "edit-already-exists": "Han jeut peugöt ôn barô.\nÔn nyoe ka lheuh na.",
+ "edit-already-exists": "Han jeuet peugöt laman barô.\nLaman nyoe ka lheueh na.",
"defaultmessagetext": "Naseukah peusan pukok",
"content-model-wikitext": "seunurat wiki",
"post-expand-template-inclusion-warning": "'''Ingat:''' Seunipat seunaleuëk nyang neunguy rayek that.\nLadôm seunaleuëk hana geupeurôh",
"revdelete-radio-unset": "Deuih",
"revdelete-log": "Alasan:",
"revdel-restore": "Gantoë neuleumah",
- "pagehist": "Riwayat miëng",
+ "pagehist": "Riwayat laman",
"deletedhist": "Riwayat nyang teusampôh",
"mergehistory-from": "Nè mieng:",
- "mergehistory-into": "Mieng nyang geutuju:",
+ "mergehistory-into": "Laman tujuan:",
"mergehistory-invalid-source": "Asai ôn payah nan nyang beutôi.",
"mergehistory-reason": "Alasan:",
"mergelog": "Peugabông log",
"viewprevnext": "Eu ($1 {{int:pipe-separator}} $2)($3)",
"searchmenu-exists": "'''Na laman ngön nan \"[[:$1]]\" bak wiki nyoe.'''",
"searchmenu-new": "<strong>Peugöt laman \"[[:$1]]\" bak wiki nyoë!</strong> {{PLURAL:$2|0=|Eu cit laman nyang geurumpok nibak meunita droëneuh.|Eu cit hasé mita nyang geurumpok.}}",
- "searchprofile-articles": "Miëng asoë",
+ "searchprofile-articles": "Laman asoë",
"searchprofile-images": "Multimedia",
"searchprofile-everything": "Ban dum",
"searchprofile-advanced": "Tingkat lanjut",
"searchprofile-articles-tooltip": "Mita bak $1",
"searchprofile-images-tooltip": "Mita beureukaih",
- "searchprofile-everything-tooltip": "Mita ban dum miëng asoë (rôh ôn marit)",
+ "searchprofile-everything-tooltip": "Mita ban dum laman asoë (rôh ôn marit)",
"searchprofile-advanced-tooltip": "Mita bak ruweuëng nan meupat-pat",
"search-result-size": "$1 ({{PLURAL:$2|1 narit|$2 narit}})",
"search-result-category-size": "{{PLURAL:$1|1 anggeeta|$1 anggeeta}} ({{PLURAL:$2|1 aneuk kawan|$2 aneuk kawan}}, {{PLURAL:$3|1 beureukaih|$3 beureukaih}})",
"prefs-email": "Peuniléh surat-e",
"prefs-rendering": "Neuleumah",
"saveprefs": "Keubah",
- "prefs-editing": "Neuandam",
+ "prefs-editing": "Peusaneut",
"searchresultshead": "Mita",
"stub-threshold-disabled": "Geupeumaté",
"timezoneuseoffset": "La'én (peuteuntèe bidajih)",
"recentchanges-summary": "Neukalön nyang ban meuubah bak wiki lam laman nyoe.",
"recentchanges-noresult": "Hana neuubah lam lheuëng watèë nyoë nyang paih ngön syarat",
"recentchanges-feed-description": "Seutöt neuubah barô lam wiki bak umpeuën nyoë.",
- "recentchanges-label-newpage": "Neuandam nyoë jipeugöt miëng barô",
- "recentchanges-label-minor": "Nyoe neuandam ubeut",
- "recentchanges-label-bot": "Neuandam nyoe geupubuet le bot",
- "recentchanges-label-unpatrolled": "Neuandam nyoe goh lom geukalon",
- "recentchanges-label-plusminus": "Seunipat miëng geugantoë lé jeumeulah bita nyoë",
+ "recentchanges-label-newpage": "Hasé peusaneut nyoë jipeugöt laman barô",
+ "recentchanges-label-minor": "Nyoe geupeusaneut bacut",
+ "recentchanges-label-bot": "Geupeusaneut lé bot",
+ "recentchanges-label-unpatrolled": "Hasé peusaneut nyoe goh lom geukalon",
+ "recentchanges-label-plusminus": "Seunipat laman geugantoë lé jeumeulah bita nyoë",
"recentchanges-legend-heading": "<strong>Hareutoë:</strong>",
"recentchanges-legend-newpage": "{{int:recentchanges-label-newpage}} (eu cit [[Special:NewPages|dapeuta laman barô]])",
"rcfilters-legend-heading": "<strong>Dapeuta seuningkat:</strong>",
"rcfilters-hours-title": "Jeum seuneulheueh",
"rcfilters-quickfilters": "Seunaréng teukeubah",
"rcfilters-savedqueries-defaultlabel": "Seunaréng teukeubah",
+ "rcfilters-show-new-changes": "Peuleumah neuubah barô",
"rcfilters-search-placeholder": "Saréng neuubah (ngui menu atawa plôk mita keu neusaréng nan)",
"rcfilters-filter-editsbyself-label": "Atra lôn peusaneut",
"rcfilters-filter-editsbyother-label": "Atra geupeusaneut lé gop",
"recentchangeslinked-feed": "Neuubah meuhubông",
"recentchangeslinked-toolbox": "Neuubah teukaw'èt",
"recentchangeslinked-title": "Neuubah nyang meukaw'èt ngön $1",
- "recentchangeslinked-summary": "Neupasoe saboh nan mieng keu neueu neuubah bak mieng nyang mupawôt u atawa nibak mieng nyan. (Keu neueu anggèeta nibak saboh kawan, neupasoe {{ns:category}}:Nan kawan). Neuubah bak mieng nyang na bak [[Special:Watchlist|dapeuta keunalön]] teutuléh <strong>teubai</strong>.",
- "recentchangeslinked-page": "Nan miëng:",
+ "recentchangeslinked-summary": "Neupasoe saboh nan laman keu neukalon neuubah bak laman nyang mupawôt u atawa nibak laman nyan. (Keu neukalon anggèeta nibak saboh kawan, neupasoe {{ns:category}}:Nan kawan). Neuubah bak laman nyang na bak [[Special:Watchlist|dapeuta keunalön]] teutuléh <strong>teubai</strong>.",
+ "recentchangeslinked-page": "Nan laman:",
"recentchangeslinked-to": "Peuleumah neuubah nibak laman-laman nyang mupawôt ngön laman nyang geubri",
"upload": "Peutamöng beureukaih",
"uploadbtn": "Peutamong beureukaih",
"mimesearch": "Mita MIME",
"listredirects": "Dapeuta peuninah",
"unusedtemplates": "Templat nyang hana geungui",
- "randompage": "Miëng baranggari",
+ "randompage": "Laman baranggari",
"randomredirect": "Peuninah saban sakri",
"statistics": "Keunira",
"doubleredirects": "Peuninah ganda",
"protectedpages-noredirect": "Peusom peuninah",
"listusers": "Dapeuta ureuëng ngui",
"usercreated": "{{GENDER:$3|Geupeugot}} bak $1 poh $2",
- "newpages": "Miëng barô",
+ "newpages": "Laman barô",
"newpages-username": "Ureuëng ngui:",
"ancientpages": "Laman paléng awai",
"move": "Pupinah",
"watch": "Kalön",
"watchthispage": "Kalön ôn nyoë",
"unwatch": "Bateuë kalön",
- "watchlist-details": "Na {{PLURAL:$1|$1 mieng}} lam dapeuta kalön Droeneuh, hana rôh mieng marit",
+ "watchlist-details": "Na {{PLURAL:$1|$1 laman}} lam dapeuta keunalön-neuh (rôh laman marit).",
"wlshowlast": "Peuleumah $1 jeum $2 uroe seuneulheueh",
"watchlist-hide": "Peusom",
"watchlist-options": "Peuniléh dapeuta kalön",
"deleteotherreason": "Nyang la’én/choë la’én:",
"deletereasonotherlist": "Choë la’én",
"rollbacklink": "pulang",
- "rollbacklinkcount": "peuriwang $1 {{PLURAL:$1|neuandam}}",
+ "rollbacklinkcount": "peuriwang $1 {{PLURAL:$1|hase peusaneut}}",
"changecontentmodel-submit": "Gantoe",
"protectlogpage": "Log lindông",
"protectedarticle": "peulindông \"[[$1]]\"",
"undelete-search-submit": "Mita",
"namespace": "Ruweuëng nan:",
"invert": "Peubalék peuniléh",
- "tooltip-invert": "Neuceuë kutak nyoë keu neupeusom neuubah miëng lam ruweuëng nan nyang neupiléh (ngön ruweuëng nan teukaw`èt meunyö neuceuë)",
+ "tooltip-invert": "Neuceuë kutak nyoë keu neupeusom neuubah laman lam ruweuëng nan nyang neupiléh (ngön ruweuëng nan teukaw`èt meunyö neuceuë)",
"namespace_association": "Ruweuëng nan meuhubông",
"tooltip-namespace_association": "Neuceuë kutak nyoë keu neupeurôh ruweuëng nan marit atawa bhaih nyang teukaw`èt ngön ruweuëng nan teupiléh",
"blanknamespace": "(Keue)",
"sp-contributions-submit": "Mita",
"whatlinkshere": "Peunawôt balék",
"whatlinkshere-title": "Laman nyang mupawôt u $1",
- "whatlinkshere-page": "Miëng:",
+ "whatlinkshere-page": "Laman:",
"linkshere": "Laman-laman nyoë meupawôt u '''$2''':",
"nolinkshere": "Hana halaman nyang teukaw'et u '''$2'''.",
"isredirect": "laman peuninah",
"movelogpage": "Log pinah",
"movereason": "Choë:",
"revertmove": "peuriwang",
- "export": "Peuteubiët miëng",
+ "export": "Èkspor laman",
"allmessages": "Peusan sistem",
"allmessagesname": "Nan",
"allmessagesdefault": "Naseukah pukok",
"thumbnail-more": "Peurayek",
"thumbnail_error": "Salah bak peugöt gamba cut: $1",
"importlogpage": "Log impor",
- "tooltip-pt-userpage": "Mieng {{GENDER:|ureueng ngui Droeneuh}}",
- "tooltip-pt-mytalk": "Mieng {{GENDER:|marit Droeneuh}}",
+ "tooltip-pt-userpage": "Laman {{GENDER:|ureueng ngui Droeneuh}}",
+ "tooltip-pt-mytalk": "Laman {{GENDER:|marit Droeneuh}}",
"tooltip-pt-preferences": "Atô",
"tooltip-pt-watchlist": "Dapeuta laman nyang lônkalön",
"tooltip-pt-mycontris": "Dapeuta beuneuri {{GENDER:|Droeneuh}}",
"tooltip-pt-login": "Droëneuh geupadan keu neutamong log, bah pih nyan hana geupeuwajéb.",
"tooltip-pt-logout": "Teubiët",
"tooltip-pt-createaccount": "Droëneuh geupadan keu neupeugöt saboh akun ngön neutamöng; bah pih nyan hana wajéb",
- "tooltip-ca-talk": "Marit miëng asoë",
+ "tooltip-ca-talk": "Marit laman asoë",
"tooltip-ca-edit": "Peusaneut laman nyoe",
"tooltip-ca-addsection": "Puphôn beunagi barô",
"tooltip-ca-viewsource": "Laman nyoë geulindông.\nDroëneuh jeuët neu’eu nèjih mantöng.",
- "tooltip-ca-history": "Geunantoë awai nibak miëng nyoë",
+ "tooltip-ca-history": "Geunantoë awai nibak laman nyoë",
"tooltip-ca-protect": "Peulindông laman nyoë",
"tooltip-ca-delete": "Sampôh laman nyoë",
"tooltip-ca-move": "Pupinah laman nyoë",
- "tooltip-ca-watch": "Tamah miëng nyoë u dapeuta kalön droëneuh",
+ "tooltip-ca-watch": "Tamah laman nyoë u dapeuta keunalön",
"tooltip-ca-unwatch": "Sampôh laman nyoë nibak dapeuta kalön droëneuh",
"tooltip-search": "Mita {{SITENAME}}",
"tooltip-search-go": "Mita saboh laman ngon nan nyang peureuséh lagèë nyoë meunyo na",
- "tooltip-search-fulltext": "Mita miëng nyang na asoë lagèë nyoë",
+ "tooltip-search-fulltext": "Mita laman nyang na asoë lagèë nyoë",
"tooltip-p-logo": "Saweuë ôn keuë",
"tooltip-n-mainpage": "Saweuë ôn keuë",
"tooltip-n-mainpage-description": "Saweuë ôn keuë",
"tooltip-n-recentchanges": "Dapeuta neuubah barô lam wiki.",
"tooltip-n-randompage": "Peuleumah laman baranggari",
"tooltip-n-help": "Bak mita bantu.",
- "tooltip-t-whatlinkshere": "Dapeuta ban dum miëng wiki nyang mupawôt keunoë",
+ "tooltip-t-whatlinkshere": "Dapeuta ban dum laman wiki nyang mupawôt keunoë",
"tooltip-t-recentchangeslinked": "Neuubah barô lam laman nyang meupawôt nibak laman nyoë",
"tooltip-feed-rss": "Umpeuën RSS keu laman nyoë",
- "tooltip-feed-atom": "Umpeuën Atom keu miëng nyoë",
+ "tooltip-feed-atom": "Umpeuën Atom keu laman nyoë",
"tooltip-t-contributions": "Dapeuta beuneuri {{GENDER:$1|ureuëng ngui nyoë}}",
"tooltip-t-emailuser": "Peu-ét surat elektronik keu {{GENDER:$1|ureueng ngui nyoe}}",
"tooltip-t-upload": "Peutamong beureukaih",
"tooltip-t-specialpages": "Dapeuta ban dum laman kusuih",
- "tooltip-t-print": "Seunalén rakam miëng nyoë",
- "tooltip-t-permalink": "Peunawôt teutap keu geunantoë miëng nyoë",
- "tooltip-ca-nstab-main": "Eu miëng asoë",
- "tooltip-ca-nstab-user": "Eu miëng ureuëng ngui",
- "tooltip-ca-nstab-special": "Nyoë nakeuh miëng kusuih, ngön h’an jeuët geuandam.",
+ "tooltip-t-print": "Seunalén rakam laman nyoë",
+ "tooltip-t-permalink": "Peunawôt teutap keu geunantoë laman nyoë",
+ "tooltip-ca-nstab-main": "Kalön laman asoë",
+ "tooltip-ca-nstab-user": "Kalön laman ureuëng ngui",
+ "tooltip-ca-nstab-special": "Nyoë laman kusuih, h’an jeuët geupeusaneut.",
"tooltip-ca-nstab-project": "Eu laman buët",
- "tooltip-ca-nstab-image": "Eu miëng beureukaih",
+ "tooltip-ca-nstab-image": "Kalön laman beureukaih",
"tooltip-ca-nstab-mediawiki": "Eu peusan sistem",
"tooltip-ca-nstab-template": "Eu seunaleuëk",
"tooltip-ca-nstab-help": "Eu laman beunantu",
- "tooltip-ca-nstab-category": "Eu miëng kawan",
+ "tooltip-ca-nstab-category": "Kalön laman kawan",
"tooltip-minoredit": "Peutanda nyoë sibagoë peusaneut bacut",
"tooltip-save": "Keubah neuubah Droëneuh",
"tooltip-preview": "Peuleumah neuubah Droëneuh, neungui nyoë sigohlom neukeubah!",
"tooltip-diff": "Peuleumah neuubah nyang ka Droëneuh peugöt",
"tooltip-compareselectedversions": "Ngiëng bida nibak duwa geunantoë laman nyang teupiléh",
"tooltip-watch": "Tamah laman nyoë u dapeuta kalön droëneuh",
- "tooltip-rollback": "\"Rollback\" jipeugisa keulayi neuandam ureueng tuléh seuneulheueh u laman nyoe ngön sigo teugön",
+ "tooltip-rollback": "\"Peuriwang\" jipeugisa atra geuandam lé ureueng tuléh seuneulheueh u laman nyoe ngön sigo teugön",
"tooltip-undo": "Peuriwang geunantoë nyoë ngön peuhah kutak peusaneut ngön cara eu hasé dilèë. Alasan jeuët geutamah bak kuta èhtisa.",
"tooltip-summary": "Pasoë éhtisa paneuk",
"interlanguage-link-title": "$1 – $2",
"pageinfo-title": "Keutrangan keu \"$1\"",
"pageinfo-header-basic": "Keutrangan peuneuphôn",
"pageinfo-header-edits": "Riwayat peusaneut",
- "pageinfo-header-restrictions": "Lindông mieng",
+ "pageinfo-header-restrictions": "Lindông laman",
"pageinfo-display-title": "Judul tampilan",
"pageinfo-default-sort": "Gunci urôt baku",
- "pageinfo-length": "Panyang mieng (lam bita)",
- "pageinfo-article-id": "ID Mieng",
- "pageinfo-language": "Bahsa asoe mieng",
- "pageinfo-content-model": "Modèl asoe mieng",
+ "pageinfo-length": "Panyang laman (lam bit)",
+ "pageinfo-article-id": "ID Laman",
+ "pageinfo-language": "Bahsa asoe laman",
+ "pageinfo-content-model": "Modèl asoe laman",
"pageinfo-robot-policy": "Geuindèks lé robot",
"pageinfo-robot-index": "Geupeuidin",
"pageinfo-robot-noindex": "Hana geupeuidin",
- "pageinfo-watchers": "Jumeulah ureueng kalön mieng",
- "pageinfo-redirects-name": "Jumeulah peuninah u mieng nyoe",
- "pageinfo-firstuser": "Ureueng peugot mieng",
- "pageinfo-firsttime": "Uroe buleuen pumeugot mieng",
+ "pageinfo-watchers": "Jumeulah ureueng kalön laman",
+ "pageinfo-redirects-name": "Jumeulah peuninah u laman nyoe",
+ "pageinfo-firstuser": "Ureueng peugöt laman",
+ "pageinfo-firsttime": "Uroe buleuen pumeugot laman",
"pageinfo-lastuser": "Ureueng peusaneut seuneulheueh",
"pageinfo-lasttime": "Uroe peusaneut seuneulheueh",
- "pageinfo-edits": "Jumeulah neuandam ban dum",
+ "pageinfo-edits": "Jumeulah hasé peusaneut ban dum",
"pageinfo-authors": "Jumeulah ban dum ureueng teumuléh nyang mubida",
- "pageinfo-recent-edits": "Jumeulah neuandam ban-ban nyoe (lam $1 nyoe)",
- "pageinfo-toolboxlink": "Keutrangan miëng",
+ "pageinfo-recent-edits": "Jumeulah peusaneut ban-ban nyoe (lam $1 nyoe)",
+ "pageinfo-toolboxlink": "Keutrangan laman",
"pageinfo-contentpage-yes": "Nyo",
"patrol-log-page": "Log patroli",
"previousdiff": "← Bida awai",
"nextdiff": "Geunantoë lheuëh nyan →",
"file-info-size": "$1 × $2 piksel, rayek beureukaih: $3, MIME jeunèh: $4",
- "file-info-size-pages": "$1 × $2 piksel, seunipat beureukaih: $3, jeunèh MIME: $4, $5 {{PLURAL:$5|mieng}}",
+ "file-info-size-pages": "$1 × $2 piksel, seunipat beureukaih: $3, jeunèh MIME: $4, $5 {{PLURAL:$5|laman}}",
"file-nohires": "Hana resolusi nyang leubèh manyang.",
"svg-long-desc": "Beureukah SVG, nominal $1 x $2 piksel, rayek beureukah: $3",
"show-big-image": "Beureukaih aseuli",
"confirm-unwatch-button": "Ka göt",
"confirm-unwatch-top": "Sampôh laman nyoë nibak dapeuta keunalön droëneuh?",
"imgmultipageprev": "← laman sigohlomjih",
- "imgmultipagenext": "mieng lheueh nyoe →",
+ "imgmultipagenext": "laman u keue →",
"imgmultigo": "Jak!",
- "imgmultigoto": "Jak u mieng $1",
+ "imgmultigoto": "Jak u laman $1",
"autosumm-new": "Geupeugöt laman ngön asoë '$1'",
"watchlisttools-clear": "Sampôh dapeuta keunalön",
"watchlisttools-view": "Peuleumah neuubah meukaw'èt",
"redirect-lookup": "Mita",
"redirect-value": "Nilai:",
"redirect-user": "ID ureueng ngui",
- "redirect-page": "ID Mieng",
- "redirect-revision": "Pubeutoi mieng",
+ "redirect-page": "ID Laman",
+ "redirect-revision": "Pubeutoi laman",
"redirect-file": "Nan beureukaih",
"fileduplicatesearch-submit": "Mita",
"specialpages": "Laman kusuih",
"tags-active-yes": "Nyo",
"tags-active-no": "H`an",
"tags-hitcount": "$1 {{PLURAL:$1|neuubah}}",
- "logentry-delete-delete": "$1 {{GENDER:$2|geusampôh}} miëng $3",
- "logentry-move-move": "$1 {{GENDER:$2|geupinah}} mieng $3 u $4",
- "logentry-move-move-noredirect": "$1 {{GENDER:$2|geupinah}} mieng $3 u $4 hana geubôh peuninah",
- "logentry-move-move_redir": "$1 {{GENDER:$2|geupinah}} mieng $3 u $4 ateueh mieng peuninah",
- "logentry-patrol-patrol-auto": "$1 otomatis {{GENDER:$2|geutanda}} revisi $4 nibak mieng $3 nyang geukawai",
+ "logentry-delete-delete": "$1 {{GENDER:$2|geusampôh}} laman $3",
+ "logentry-move-move": "$1 {{GENDER:$2|geupinah}} laman $3 u $4",
+ "logentry-move-move-noredirect": "$1 {{GENDER:$2|geupinah}} laman $3 u $4 hana geubôh peuninah",
+ "logentry-move-move_redir": "$1 {{GENDER:$2|geupinah}} laman $3 u $4 ateueh laman peuninah",
+ "logentry-patrol-patrol-auto": "$1 otomatis {{GENDER:$2|geutanda}} revisi $4 nibak laman $3 nyang geukawai",
"logentry-newusers-create": "$1 {{GENDER:$2|geupeugöt}} akun ureuëng ngui",
"logentry-upload-upload": "$1 {{GENDER:$2|geupasoe}} $3",
"searchsuggest-search": "Mita {{SITENAME}}",
"Matma Rex",
"Biggs ZA",
"Slashme",
- "Fitoschido"
+ "Fitoschido",
+ "Vlad5250"
]
},
"tog-underline": "Onderstreep skakels:",
"right-editsemiprotected": "Wysig bladsye wat as \"{{int:protect-level-autoconfirmed}}\" beskerm is",
"right-editinterface": "Wysig die gebruikerskoppelvlak",
"right-editusercss": "Wysig ander gebruikers se CSS-lêers",
+ "right-edituserjson": "Wysig ander gebruikers se JSON-lêers",
"right-edituserjs": "Wysig ander gebruikers se JS-lêers",
"right-editmyusercss": "Wysig u persoonlike CSS-bladsy",
+ "right-editmyuserjson": "Wysig u persoonlike JSON-bladsy",
"right-editmyuserjs": "Wysig u persoonlike JavaScript-bladsy",
"right-viewmywatchlist": "Wys u persoonlike dophoulys",
"right-editmywatchlist": "Wysig u persoonlike dophoulys. Sommige aksies sal steeds bladsye byvoeg, selfs sonder die bevoegdheid.",
"badarticleerror": "لا يمكن إجراء هذا الفعل على هذه الصفحة.",
"cannotdelete": "تعذّر حذف الصفحة أو الملف \"$1\".\nربما حذفها شخص آخر بالفعل.",
"cannotdelete-title": "تعذّر حذف الصفحة \"$1\"",
+ "delete-scheduled": "تمت جدولة الصفحة \"$1\" للحذف،\nيُرجَى التحلي بالصبر.",
"delete-hook-aborted": "أجهض خطّاف الحذف.\nلم يقدم أي توضيح.",
"no-null-revision": "تعذر إنشاء مراجعة جديدة فارغة لصفحة \"$1\"",
"badtitle": "عنوان سيء",
"movepage-moved": "'''نُقِلت \"$1\" إلى \"$2\"'''",
"movepage-moved-redirect": "أنشئت تحويلة.",
"movepage-moved-noredirect": "إنشاء التحويلة تم التغاضي عنه.",
+ "movepage-delete-first": "تحتوي الصفحة الهدف على عدد كبير جدا من المراجعات لحذفها كجزء من نقل الصفحة; يُرجَى أولا حذف الصفحة يدويا، ثم إعادة المحاولة.",
"articleexists": "توجد صفحة بهذا الاسم، أو أن الاسم الذي تم اختياره غير صالح.\nمن فضلك اختر اسم آخر.",
"cantmove-titleprotected": "لا يمكنك نقل صفحة إلى هذا الموقع، لأن العنوان الجديد تمت حمايته ضد الإنشاء",
"movetalk": "انقل صفحة النقاش المرفقة",
"badarticleerror": "Гэтае дзеяньне немагчыма выканаць на гэтай старонцы.",
"cannotdelete": "Немагчыма выдаліць старонку альбо файл «$1». Магчыма, яна ўжо выдаленая кімсьці іншым.",
"cannotdelete-title": "Немагчыма выдаліць старонку «$1»",
+ "delete-scheduled": "Старонка «$1» заплянаваная на выдаленьне.\nКалі ласка, будзьце цярплівымі.",
"delete-hook-aborted": "Выдаленьне скасаванае працэдурай-перахопнікам.\nТлумачэньняў не было.",
"no-null-revision": "Немагчыма стварыць нулявую вэрсію для старонкі «$1»",
"badtitle": "Няслушная назва",
"uploadstash-errclear": "Не атрымалася ачысьціць файлы.",
"uploadstash-refresh": "Абнавіць сьпіс файлаў",
"uploadstash-thumbnail": "прагляд мініятуры",
- "uploadstash-exception": "Ð\9dе магÑ\83 заÑ\85аваÑ\86Ñ\8c загÑ\80Ñ\83зкÑ\83 Ñ\9e Ñ\81Ñ\85овÑ\96Ñ\88Ñ\87Ñ\8b ($1): «$2».",
+ "uploadstash-exception": "Ð\9dе аÑ\82Ñ\80Ñ\8bмалаÑ\81Ñ\8f заÑ\85аваÑ\86Ñ\8c загÑ\80Ñ\83зкÑ\83 Ñ\9e Ñ\85ованкÑ\83 ($1): «$2».",
"uploadstash-bad-path": "Шлях не існуе.",
"uploadstash-bad-path-invalid": "Шлях не зьяўляецца слушным.",
"uploadstash-bad-path-unknown-type": "Невядомы тып «$1».",
"movepage-moved": "'''Старонка «$1» была перанесеная ў «$2»'''",
"movepage-moved-redirect": "Перанакіраваньне было створана.",
"movepage-moved-noredirect": "Перанакіраваньне не было створанае.",
+ "movepage-delete-first": "Мэтавая старонка мае зашмат вэрсіяў, каб выдаліць яе пры пераносе. Калі ласка, спачатку выдаліце старонку ўручную, а потым паспрабуйце яшчэ раз.",
"articleexists": "Старонка з такой назвай ужо існуе, альбо абраная Вамі назва недапушчальная. Калі ласка, абярыце іншую назву.",
"cantmove-titleprotected": "Немагчыма перанесьці старонку, таму што новая назва знаходзіцца ў сьпісе забароненых",
"movetalk": "Перанесьці таксама старонку абмеркаваньня",
"badarticleerror": "Denne funktion kan ikke udføres på denne side.",
"cannotdelete": "Kunne ikke slette siden eller filen \"$1\".\nDen kan være blevet slettet af en anden.",
"cannotdelete-title": "Kan ikke slette siden \"$1\"",
+ "delete-scheduled": "Siden \"$1\" er sat til at blive slettet.\nHav tålmodighed.",
"delete-hook-aborted": "Sletningen blev afbrudt af en programfunktion.\nDer blev ikke givet nogen forklaring.",
"no-null-revision": "Kunne ikke oprette nye tom revision for side \"$1\"",
"badtitle": "Ugyldig titel",
"badarticleerror": "Diese Aktion kann auf diese Seite nicht angewendet werden.",
"cannotdelete": "Die Seite oder Datei „$1“ konnte nicht gelöscht werden.\nMöglicherweise wurde sie bereits von jemand anderem gelöscht.",
"cannotdelete-title": "Seite „$1“ kann nicht gelöscht werden",
+ "delete-scheduled": "Die Löschung der Seite „$1“ wurde eingeplant.\nBitte habe Geduld.",
"delete-hook-aborted": "Die Löschung wurde von einer Programmerweiterung zu MediaWiki verhindert.\nEs ist hierzu keine Erklärung verfügbar.",
"no-null-revision": "Die neue Nullversion für die Seite „$1“ konnte nicht erstellt werden",
"badtitle": "Ungültiger Titel",
"movepage-moved": "'''Die Seite „$1“ wurde nach „$2“ verschoben.'''",
"movepage-moved-redirect": "Eine Weiterleitung wurde erstellt.",
"movepage-moved-noredirect": "Die Erstellung einer Weiterleitung wurde unterdrückt.",
+ "movepage-delete-first": "Die Zielseite hat zu viele Versionen, um sie als Teil einer Seitenverschiebung zu löschen. Bitte lösche zuerst die Seite manuell und versuche es dann erneut.",
"articleexists": "Unter diesem Namen existiert bereits eine Seite. Bitte wähle einen anderen Namen.",
"cantmove-titleprotected": "Die Verschiebung kann nicht durchgeführt werden, da der Zieltitel zur Erstellung gesperrt ist.",
"movetalk": "Sofern möglich, die Diskussionsseite mitverschieben",
"아라",
"Shirayuki",
"Gloria sah",
- "Macofe"
+ "Macofe",
+ "Vlad5250"
]
},
"tog-underline": "Tîra 'na rîga sòta i colegamèint.",
"right-editcontentmodel": "Mudéfica al mudèl ed còl ché dèinter int 'na pàgina.",
"right-editinterface": "Mudéfica al colegamèint tra sistēma e utèint",
"right-editusercss": "Mudéfica i file CSS 'd êter utèint",
+ "right-edituserjson": "Mudéfica i file JSON 'd êter utèint",
"right-edituserjs": "Mudéfica i file JS 'd êter utèint",
"right-editmyusercss": "Mudéfica i file CSS dal só utèint",
+ "right-editmyuserjson": "Mudéfica i file JSON dal só utèint",
"right-editmyuserjs": "Mudéfica i file JavaScript dal só utèint",
"right-viewmywatchlist": "Guêrda la lésta di tō tgnû 'd ôc specêl",
"right-editmywatchlist": "Mudéfica i tō tgnu 'd ôc. Da nutêr che soquânti asiòun a prân incòra zuntêr dal pàgini ânca sèinsa avèiren al dirét.",
"badarticleerror": "This action cannot be performed on this page.",
"cannotdelete": "The page or file \"$1\" could not be deleted.\nIt may have already been deleted by someone else.",
"cannotdelete-title": "Cannot delete page \"$1\"",
+ "delete-scheduled": "The page \"$1\" is scheduled for deletion.\nPlease be patient.",
"delete-hook-aborted": "Deletion aborted by hook.\nIt gave no explanation.",
"no-null-revision": "Could not create new null revision for page \"$1\"",
"badtitle": "Bad title",
"movepage-moved": "<strong>\"$1\" has been moved to \"$2\"</strong>",
"movepage-moved-redirect": "A redirect has been created.",
"movepage-moved-noredirect": "The creation of a redirect has been suppressed.",
+ "movepage-delete-first": "The target page has too many revisions to delete as part of a page move. Please first delete the page manually, then try again.",
"articleexists": "A page of that name already exists, or the name you have chosen is not valid.\nPlease choose another name.",
"cantmove-titleprotected": "You cannot move a page to this location because the new title has been protected from creation.",
"movetalk": "Move associated talk page",
"badarticleerror": "Cette action ne peut pas être effectuée sur cette page.",
"cannotdelete": "Impossible de supprimer la page ou le fichier « $1 ».\nLa suppression a peut-être déjà été effectuée par quelqu’un d’autre.",
"cannotdelete-title": "Impossible de supprimer la page « $1 »",
+ "delete-scheduled": "La page « $1 » est programmée pour être supprimée.\nVeuillez patienter.",
"delete-hook-aborted": "Suppression annulée par une extension.\nAucune explication n’a été fournie.",
"no-null-revision": "Impossible de créer une nouvelle révision vide pour la page « $1 »",
"badtitle": "Mauvais titre",
"movepage-moved": "<strong>« $1 » a été renommée en « $2 »</strong>",
"movepage-moved-redirect": "Une redirection depuis l’ancien nom a été créée.",
"movepage-moved-noredirect": "La création d’une redirection depuis l’ancien nom a été annulée.",
+ "movepage-delete-first": "La page cible a trop de révisions à supprimer pour le déplacement de page. Veuillez d’abord supprimer la page manuellement, puis réessayer.",
"articleexists": "Il existe déjà une page portant ce titre, ou le titre que vous avez choisi n'est pas correct.\nVeuillez en choisir un autre.",
"cantmove-titleprotected": "Vous ne pouvez pas déplacer une page vers cet emplacement car la création de page avec ce nouveau titre a été protégée.",
"movetalk": "Renommer aussi la page de discussion associée",
"badarticleerror": "לא ניתן לבצע את הפעולה הזאת בדף זה.",
"cannotdelete": "לא ניתן היה למחוק את הדף או את הקובץ \"$1\".\nייתכן שהוא כבר נמחק על־ידי מישהו אחר.",
"cannotdelete-title": "לא ניתן למחוק את הדף \"$1\"",
+ "delete-scheduled": "הדף \"$1\" ממתין למחיקה.\nנא להמתין.",
"delete-hook-aborted": "המחיקה הופסקה על־ידי מבנה Hook.\nלא ניתן הסבר.",
"no-null-revision": "לא ניתן היה ליצור גרסת־דמה בדף \"$1\"",
"badtitle": "כותרת שגויה",
"movepage-moved": "<strong>הדף \"$1\" הועבר לשם \"$2\"</strong>",
"movepage-moved-redirect": "נוצרה הפניה.",
"movepage-moved-noredirect": "יצירת ההפניה בוטלה.",
+ "movepage-delete-first": "לדף היעד יש גרסאות רבות מדי לצורך מחיקה כחלק מהעברת דף.\nראשית יש למחוק את הדף ידנית, ואז לנסות שוב.",
"articleexists": "קיים כבר דף באותו שם, או שהשם שבחרת אינו תקין.\nנא לבחור שם אחר.",
"cantmove-titleprotected": "אין לך הרשאה להעביר את הדף לכאן, כי השם החדש מוגן מפני יצירה.",
"movetalk": "העברה גם של דף השיחה",
"page-atom-feed": "Atom feed $1 ဍူ",
"red-link-title": "$1 (လိက်မေံ လ်ုအ်ှဍေၜး)",
"nstab-main": "လက်မေံသး",
- "nstab-user": "á\80\86á\80ºá\80¯á\80\9eá\80¯á\80²á\80¸á\80\80á\80ºá\80¯á\80\86á\80¬ á\80\9cá\80\80á\80ºá\80\99á\80±á\80¶",
+ "nstab-user": "á\80\86á\80ºá\80¯á\80\9eá\80¯á\80¶á\82\8bá\80\80á\80ºá\80¯á\80\86á\80¬á\82\8b á\80\9cá\80á\80\80á\80ºá\80\99á\80±á\80¶á\81\9cá\81 á\80¬á\80º",
"nstab-special": "လိက်မေံခေါဟ်",
"nstab-project": "ပ်ုရောဴဂျက်လိက်မေံၜၠါ်",
"nstab-image": "ဖိုင်",
"badarticleerror": "Dës Aktioun kann net op dëser Säit duerchgefouert ginn.",
"cannotdelete": "D'Bild oder d'Säit \"$1\" konnt net geläscht ginn.\nEt ka sinn datt et scho vun engem Anere geläscht gouf.",
"cannotdelete-title": "D'Säit \"$1\" kann net geläscht ginn",
+ "delete-scheduled": "D'Läsche vun der Säit \"$1\" ass programméiert.\nHutt w.e.g. e bësse Gedold.",
"delete-hook-aborted": "D'Läsche gouf vun enger Schnëttstell (hook) ofgebrach.\nEng Erklärung gouf net ginn.",
"no-null-revision": "Déi nei Nullversioun fir d'Säit \"$1\" konnt net ugeluecht ginn",
"badtitle": "Schlechten Titel",
"revdelete-unrestricted": "Limitatioune fir Administrateuren opgehuewen",
"logentry-block-block": "$1 {{GENDER:$2|huet}} {{GENDER:$4|$3}} fir eng Zäit vu(n) $5 $6 gespaart",
"logentry-block-unblock": "$1 {{GENDER:$2|huet}} d'Spär vum {{GENDER:$4|$3}} opgehuewen",
+ "logentry-block-reblock": "$1 {{GENDER:$2|huet}} d'Spärastellunge fir {{GENDER:$4|$3}} mat enger Spärdauer vu(n) $5 $6 geännert",
+ "logentry-suppress-reblock": "$1 {{GENDER:$2|huet}} d'Spärastellunge fir {{GENDER:$4|$3}} mat enger Spärdauer vu(n) $5 $6 geännert",
"logentry-import-upload": "$1 {{GENDER:$2|huet}} $3 duerch Eropluede vun engem Fichier importéiert",
"logentry-import-interwiki": "$1 huet $3 vun enger anerer Wiki {{GENDER:$2|importéiert}}",
"logentry-import-interwiki-details": "$1 {{GENDER:$2|huet}} $3 vu(n) $5 importéiert ({{PLURAL:$4|Eng Versioun|$4 Versiounen}})",
"Nersip",
"Manvydasz",
"Fitoschido",
- "Matěj Suchánek"
+ "Matěj Suchánek",
+ "Vlad5250"
]
},
"tog-underline": "Nuorodos pabraukimas:",
"right-editcontentmodel": "Redaguoti puslapio turinio modelį",
"right-editinterface": "Keisti naudotojo aplinką",
"right-editusercss": "Redaguoti kitų naudotojų CSS failus",
+ "right-edituserjson": "Redaguoti kitų naudotojų JSON failus",
"right-edituserjs": "Redaguoti kitų naudotojų JS failus",
"right-editmyusercss": "Redaguoti savo vartotojo CSS failus",
+ "right-editmyuserjson": "Redaguoti savo vartotojo JSON failus",
"right-editmyuserjs": "Redaguokite savo naudotojo vartotojo JavaScript failus",
"right-viewmywatchlist": "Peržiūrėti savo stebimųjų sąrašą",
"right-editmywatchlist": "Keiskite savo stebimųjų sąrašą. Atminkite, kad kai kurie veiksmai vis vien pridės puslapius netgi be tokios teisės.",
"badarticleerror": "Ова дејство не може да се спроведе на оваа страница.",
"cannotdelete": "Страницата или податотеката „$1“ не можеше да се избрише.\nМожеби некој друг веќе ја избришал.",
"cannotdelete-title": "Не можам да ја избришам страницата „$1“",
+ "delete-scheduled": "Страницата „$1“ е предвидена за бришење.\nВе молиме за трпеливост.",
"delete-hook-aborted": "Бришењето е прекинато со пресретник.\nНе е дадено никакво образложение.",
"no-null-revision": "Не можев да направам нова ништовна преработка на страницата „$1“",
"badtitle": "Неисправен наслов",
"action-editmyprivateinfo": "уредување на вашите лични податоци",
"action-editcontentmodel": "уредување на содржинскиот модел на страница",
"action-managechangetags": "создавање или (де)активирање на ознаки",
- "action-applychangetags": "Ñ\81Ñ\82аваÑ\9aе на ознаки заедно Ñ\81о напÑ\80евените промени",
+ "action-applychangetags": "Ñ\81Ñ\82аваÑ\9aе на ознаки заедно Ñ\81о ваÑ\88ите промени",
"action-changetags": "додавање и отстранување на произволни ознаки во поединечни преработки и дневнички записи",
"action-deletechangetags": "бришење ознаки од базата",
"action-purge": "превчитување на оваа страница",
"movepage-moved": "'''„$1“ е преместена под името „$2“'''",
"movepage-moved-redirect": "Направено е пренасочување.",
"movepage-moved-noredirect": "Создавањето на пренасочување е оневозможено.",
+ "movepage-delete-first": "Целната страница има премногу преработки за да можат да се избришат како дел од преместување на страница. Најпрвин избришете ја страницата рачно, па потоа обидете се пак.",
"articleexists": "Веќе постои страница со тоа име, или името што го одбравте е неважечко.\nИзберете друго име.",
"cantmove-titleprotected": "Не може да ја преместите страницата на тоа место бидејќи саканиот наслов е заштитен од создавање.",
"movetalk": "Премести ја и разговорната страница, ако е возможно.",
"movepage-moved": "'''\"$1\" is hernoemd naar \"$2\"'''",
"movepage-moved-redirect": "Er is een doorverwijzing aangemaakt.",
"movepage-moved-noredirect": "Er is geen doorverwijzing aangemaakt.",
+ "movepage-delete-first": "De doelpagina heeft te veel versies om deze als deel van een paginahernoeming te verwijderen. Verwijder de pagina handmatig en probeer het daarna opnieuw.",
"articleexists": "De pagina bestaat al of de paginanaam is ongeldig.\nKies een andere paginanaam.",
"cantmove-titleprotected": "U kunt geen pagina naar deze naam hernoemen, omdat deze naam beveiligd is tegen het aanmaken ervan.",
"movetalk": "Bijbehorende overlegpagina hernoemen",
"recentchangeslinked-page": "Sidenamn:",
"recentchangeslinked-to": "Vis endringar på sider som lenkjar til den gitte sida i staden",
"recentchanges-page-added-to-category": "[[:$1]] vart lagd til kategorien",
+ "recentchanges-page-added-to-category-bundled": "[[:$1]] lagt til i kategorien; [[Special:WhatLinksHere/$1|denne sida er inkludert i andre sider]]",
"recentchanges-page-removed-from-category": "[[:$1]] fjerna frå kategori",
+ "recentchanges-page-removed-from-category-bundled": "[[:$1]] fjernat frå kategori, [[Special:WhatLinksHere/$1|denne sida er inkludert i andre sider]]",
"upload": "Last opp fil",
"uploadbtn": "Last opp fil",
"reuploaddesc": "Attende til opplastingsskjemaet.",
"badarticleerror": "Esta ação não pode ser realizada nesta página.",
"cannotdelete": "Não foi possível eliminar a página ou arquivo $1.\nÉ possível que ele já tenha sido eliminado por outra pessoa.",
"cannotdelete-title": "Não é possível eliminar a página \"$1\"",
+ "delete-scheduled": "A página \"$1\" está agendada para eliminação.\nAguarde a mesma, por favor.",
"delete-hook-aborted": "A eliminação foi cancelada por um \"hook\".\nNão foi dada nenhuma explicação.",
"no-null-revision": "Não foi possível criar nova revisão nula para a página \"$1\"",
"badtitle": "Título inválido",
"movepage-moved": "'''\"$1\" foi movida para \"$2\"'''",
"movepage-moved-redirect": "Um redirecionamento foi criado.",
"movepage-moved-noredirect": "A criação de um redirecionamento foi suprimida.",
+ "movepage-delete-first": "A página de destino tem muitas revisões para excluir como parte de uma movimentação de página. Primeiro, exclua a página manualmente e tente novamente.",
"articleexists": "Uma página com este título já existe, ou o título que escolheu é inválido.\nPor favor, escolha outro nome.",
"cantmove-titleprotected": "Você não pode mover uma página para tal denominação uma vez que o novo título se encontra protegido contra criação",
"movetalk": "Mover também a página de discussão associada",
"badarticleerror": "Esta operação não pode ser realizada nesta página.",
"cannotdelete": "Não foi possível eliminar a página ou ficheiro \"$1\".\nPode já ter sido eliminado por outro utilizador.",
"cannotdelete-title": "Não é possível eliminar a página \"$1\"",
+ "delete-scheduled": "A página \"$1\" está agendada para eliminação.\nAguarde a mesma, por favor.",
"delete-hook-aborted": "A eliminação foi cancelada por um \"hook\".\nNão foi dada nenhuma explicação.",
"no-null-revision": "Não foi possível criar uma nova revisão nula para a página \"$1\"",
"badtitle": "Título inválido",
"movepage-moved": "<strong>\"$1\" foi movida para \"$2\"</strong>",
"movepage-moved-redirect": "Foi criado um redirecionamento.",
"movepage-moved-noredirect": "A criação de um redirecionamento foi suprimida.",
+ "movepage-delete-first": "A página de destino tem demasiadas revisões para apagá-las como parte de uma movimentação de página. Elimine-a primeiro manualmente e depois tente novamente, por favor.",
"articleexists": "Ou já existe uma página com este nome ou o nome que escolheu é inválido.\nEscolha outro nome, por favor.",
"cantmove-titleprotected": "Não pode mover uma página para esse destino, porque o novo título foi protegido para evitar a sua criação",
"movetalk": "Mover também a página de discussão associada",
"badarticleerror": "Used as error message in moving page.\n\nSee also:\n* {{msg-mw|Articleexists}}\n* {{msg-mw|Bad-target-model}}",
"cannotdelete": "Error message in deleting. Parameters:\n* $1 - page name or file name",
"cannotdelete-title": "Title of error page when the user cannot delete a page. Parameters:\n* $1 - the page name",
+ "delete-scheduled": "Warning message shown when page deletion is deferred to the job queue, and therefore is not immediate.",
"delete-hook-aborted": "Error message shown when an extension hook prevents a page deletion, but does not provide an error message.",
"no-null-revision": "Error message shown when no null revision could be created to reflect a protection level change.\n\nAbout \"null revision\":\n* Create a new null-revision for insertion into a page's history. This will not re-save the text, but simply refer to the text from the previous version.\n* Such revisions can for instance identify page rename operations and other such meta-modifications.\n\nParameters:\n* $1 - page title",
"badtitle": "The page title when a user requested a page with invalid page name. The content will be {{msg-mw|badtitletext}}.",
"movepage-moved": "Message displayed after successfully moving a page from source to target name.\n\nParameters:\n* $1 - the source page as a link with display name\n* $2 - the target page as a link with display name\n* $3 - (optional) the source page name without a link\n* $4 - (optional) the target page name without a link\nSee also:\n* {{msg-mw|Movepage-moved-redirect}}\n* {{msg-mw|Movepage-moved-noredirect}}",
"movepage-moved-redirect": "See also:\n* {{msg-mw|Movepage-moved}}\n* {{msg-mw|Movepage-moved-noredirect}}",
"movepage-moved-noredirect": "The message is shown after pagemove if checkbox \"{{int:move-leave-redirect}}\" was unselected before moving.\n\nSee also:\n* {{msg-mw|Movepage-moved}}\n* {{msg-mw|Movepage-moved-redirect}}",
+ "movepage-delete-first": "Error message shown when trying to move a page and delete the existing page by that name, but the existing page has too many revisions.",
"articleexists": "Used as error message when moving a page.\n\nSee also:\n* {{msg-mw|Badarticleerror}}\n* {{msg-mw|Bad-target-model}}",
"cantmove-titleprotected": "Used as error message when moving a page.",
"movetalk": "The text of the checkbox to watch the associated talk page to the page you are moving. This only appears when the talk page is not empty. Used in [[Special:MovePage]].\n\nSee also:\n* {{msg-mw|Move-page-legend|legend for the form}}\n* {{msg-mw|newtitle|label for new title}}\n* {{msg-mw|Movereason|label for textarea}}\n* {{msg-mw|Move-leave-redirect|label for checkbox}}\n* {{msg-mw|Fix-double-redirects|label for checkbox}}\n* {{msg-mw|Move-subpages|label for checkbox}}\n* {{msg-mw|Move-talk-subpages|label for checkbox}}\n* {{msg-mw|Move-watch|label for checkbox}}",
"variantname-gan-hans": "{{Optional}}\n\nVariant option for wikis with variants conversion enabled.",
"variantname-gan-hant": "{{Optional}}\n\nVariant option for wikis with variants conversion enabled.",
"variantname-gan": "{{Optional}}\n\nVariant option for wikis with variants conversion enabled.",
- "variantname-sr-ec": "{{optional}}\nVariant Option for wikis with variants conversion enabled.\n\nNote that <code>sr-ec</code> is not a conforming BCP47 language tag. Wikis should be migrated by:\n* allowing it only as a legacy alias of the preferred tag <code>sr-cyrl</code> (possibly insert a tracking category in templates as long as they must support the legacy tag),\n* making the new tag the default to look first, before looking for the old tag,\n* moving the translations to the new code by renaming them,\n* checking links in source pages still using the legacy tag to change it to the new tag,\n* possibly cleanup the redirect pages.",
- "variantname-sr-el": "{{optional}}\nVariant Option for wikis with variants conversion enabled.\n\nNote that <code>sr-el</code> is not a conforming BCP47 language tag. Wikis should be migrated by:\n* allowing it only as a legacy alias of the preferred tag <code>sr-latn</code> (possibly insert a tracking category in templates as long as they must support the legacy tag),\n* making the new tag the default to look first, before looking for the old tag,\n* moving the translations to the new code by renaming them,\n* checking links in source pages still using the legacy tag to change it to the new tag,\n* possibly cleanup the redirect pages.",
+ "variantname-sr-ec": "{{optional}}\nVariant Option for wikis with variants conversion enabled.\n\nNote that <code>sr-ec</code> is not a conforming BCP 47 language tag. Wikis should be migrated by:\n* allowing it only as a legacy alias of the preferred tag <code>sr-cyrl</code> (possibly insert a tracking category in templates as long as they must support the legacy tag),\n* making the new tag the default to look first, before looking for the old tag,\n* moving the translations to the new code by renaming them,\n* checking links in source pages still using the legacy tag to change it to the new tag,\n* possibly cleanup the redirect pages.",
+ "variantname-sr-el": "{{optional}}\nVariant Option for wikis with variants conversion enabled.\n\nNote that <code>sr-el</code> is not a conforming BCP 47 language tag. Wikis should be migrated by:\n* allowing it only as a legacy alias of the preferred tag <code>sr-latn</code> (possibly insert a tracking category in templates as long as they must support the legacy tag),\n* making the new tag the default to look first, before looking for the old tag,\n* moving the translations to the new code by renaming them,\n* checking links in source pages still using the legacy tag to change it to the new tag,\n* possibly cleanup the redirect pages.",
"variantname-sr": "{{optional}}\nVariant Option for wikis with variants conversion enabled.",
"variantname-kk-kz": "{{optional}}\nVariant Option for wikis with variants conversion enabled.",
"variantname-kk-tr": "{{optional}}\nVariant Option for wikis with variants conversion enabled.",
"badarticleerror": "Quest'azione non ge pò essere fatte sus 'a sta pàgene.",
"cannotdelete": "'A pàgene o 'u file \"$1\" non ge pò essere scangellate.\nPò essere ca ggià ha state scangellete da quacche otre.",
"cannotdelete-title": "Non ge puè scangellà 'a pàgene \"$1\"",
+ "delete-scheduled": "'A pàgene \"$1\" jè schedulate pa scangellazione.\nPe piacere tìne 'nu picche de pascenze.",
"delete-hook-aborted": "Cangiamende annullete da 'nu ''hook''.\nNon g'à date nisciune mutive.",
"no-null-revision": "Non ge se pò ccrejà 'na revisiona nove vacande pa pàgene \"$1\"",
"badtitle": "Titele sbagliete",
"rcfilters-exclude-button-on": "Scettanne le scacchiate",
"rcfilters-view-tags": "Cangiaminde taggate",
"rcfilters-liveupdates-button": "Aggiornaminde in tiembe reale",
+ "rcfilters-liveupdates-button-title-on": "Stute le aggiornaminde automatece",
"rcnotefrom": "Sotte {{PLURAL:$5|ste 'u cangiamende|stonne le cangiaminde}} da <strong>$3, $4</strong> ('nzigne a <strong>$1</strong> fatte vedè).",
"rclistfrom": "Fà vedè le urteme cangiaminde partenne da $3 $2",
"rcshowhideminor": "$1 cangiaminde stuèdeche",
"recentchangeslinked-to": "Fa vedè le cangiaminde de le pàggene colleghete a 'na certa pàgene",
"recentchanges-page-added-to-category": "[[:$1]] aggiunde a categorije",
"recentchanges-page-removed-from-category-bundled": "[[:$1]] luate da 'a categorije, [[Special:WhatLinksHere/$1|sta vôsce ste sckaffate jndr'à otre pàggene]]",
+ "autochange-username": "Cangiamende automateche de MediaUicchi",
"upload": "Careche 'u file",
"uploadbtn": "Careche 'nu fail",
"reuploaddesc": "Scangille 'u carecamende e tuerne a 'a schermete de le carecaminde",
"uploadstash-bad-path-invalid": "Percorse invalide.",
"uploadstash-bad-path-unknown-type": "Tipe scanusciute \"$1\".",
"uploadstash-bad-path-unrecognized-thumb-name": "Nome d'a miniature non acchiate.",
+ "uploadstash-bad-path-bad-format": "'A chiave \"$1\" non ge ste jndr'à 'nu formate appropriate.",
"invalid-chunk-offset": "distanze d'u chunk invalide",
"img-auth-accessdenied": "Accesse negate",
"img-auth-nopathinfo": "No se iacchie 'u percorse d'a 'mbormnazione.\n'U server tune adda essere 'mbostate pe passà sta 'le variabbile REQUEST_URI e/o PATH_INFO.\nCe jè accussì, pruève a abbilità $wgUsePathInfo.\n'Ndruche https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Image_Authorization.",
"apisandbox-dynamic-parameters-add-placeholder": "Nome d'u parametre",
"apisandbox-dynamic-error-exists": "'Nu parametre chiamate \"$1\" ggià esiste.",
"apisandbox-deprecated-parameters": "Parametre ca non già penzà",
+ "apisandbox-add-multi": "Aggiunge",
+ "apisandbox-submit-invalid-fields-title": "Quacche cambe non g'è valide",
"apisandbox-results": "Resultate",
"apisandbox-request-format-url-label": "Stringhe d'a 'nderrogazione de l'URL",
"apisandbox-request-url-label": "URL richieste:",
"tooltip-ca-nstab-category": "Vide a pàgene de le categorije",
"tooltip-minoredit": "Signe cumme a 'nu cangiaminde stuèdeche",
"tooltip-save": "Reggistre le cangiaminde ca è fatte",
+ "tooltip-publish": "Pubbleche le cangiaminde tune",
"tooltip-preview": "Fà l'andeprime de le cangiaminde ca ste face. Pe piacere falle prima cu reggistre 'a vôsce!",
"tooltip-diff": "Fà vedè ce cangiaminde e fatte a 'u teste.",
"tooltip-compareselectedversions": "Vide le differenze 'mbrà le doje versiune selezionete de sta pàgene.",
"group-bot.css": "/* 'U CSS ca se iacchie aqquà ave effettue seulamende sus a le bot */",
"group-sysop.css": "/* 'U CSS ca se iacchie aqquà ave effettue seulamende sus a le utinde amministrature */",
"group-bureaucrat.css": "/* 'U CSS ca se iacchie aqquà ave effettue seulamende sus a le utinde burocrate */",
+ "common.json": "/* Ogne JSON aqquà avène carecate pe tutte le utinde sus a ogne pàgene carecate. */",
"common.js": "/* Ogne JavaScript aqquà avène carecate pe tutte le utinde sus a ogne pàgene carecate. */",
"group-autoconfirmed.js": "/* Ogne JavaScript aqquà avène carecate pe le utinde autoconfirmatarie */",
"group-bot.js": "/* Ogne JavaScript aqquà avène carecate pe le bot */",
"confirm-unwatch-button": "OK",
"confirm-unwatch-top": "Vuè ccu live sta pàgene da chidde condrollate?",
"confirm-rollback-button": "OK",
+ "confirm-mcrundo-title": "Annulle 'u cangiamende",
+ "mcrundofailed": "Annullamende fallite",
"semicolon-separator": "; ",
"comma-separator": ", ",
"colon-separator": ": ",
"compare-title-not-exists": "'U titele ca è specificate non g'esiste.",
"compare-revision-not-exists": "'A revisione ca è specificate non g'esiste.",
"diff-form": "Differenze",
+ "permanentlink-revid": "ID d'a revisione",
"dberr-problems": "Sime spiacende! Stu site stè 'ngondre de le difficoltà tecniche.",
"dberr-again": "Aspitte quacche minute e pò recareche.",
"dberr-info": "(Non ge riuscime a trasè sus a'u server d'u database: $1)",
"badarticleerror": "Это действие не может быть выполнено на данной странице.",
"cannotdelete": "Невозможно удалить или переименовать страницу или файл «$1».\nВозможно, уже было произведено удаление.",
"cannotdelete-title": "Нельзя удалить страницу «$1»",
+ "delete-scheduled": "Страница «$1» запланирована для удаления.\nБудьте терпеливы.",
"delete-hook-aborted": "Правка отменена процедурой-перехватчиком.\nДополнительных пояснений не приведено.",
"no-null-revision": "Не удалось создать новую нулевую правку для страницы «$1»",
"badtitle": "Недопустимое название",
"action-editmyprivateinfo": "редактирование вашей частной информации",
"action-editcontentmodel": "редактирование контентной модели страницы",
"action-managechangetags": "создание и (де)активацию меток",
- "action-applychangetags": " применять теги наряду с Вашими изменениями",
+ "action-applychangetags": "применение меток вместе с Вашими изменениями",
"action-changetags": "добавление и удаление произвольных меток на отдельных изменениях и записях в журнале",
"action-deletechangetags": "удаление меток из базы данных",
"action-purge": "очистку кэша этой страницы",
"movepage-moved": "'''Страница «$1» переименована в «$2»'''",
"movepage-moved-redirect": "Было создано перенаправление.",
"movepage-moved-noredirect": "Создание перенаправления было подавлено.",
+ "movepage-delete-first": "У целевой страницы слишком много версий для удаления как части переименования страницы. Сначала удалите эту страницу вручную, а затем повторите попытку.",
"articleexists": "Страница с таким именем уже существует или указанное вами название недопустимо.\nПожалуйста, выберите другое название.",
"cantmove-titleprotected": "Невозможно переименовать страницу, так как новое название входит в список запрещённых.",
"movetalk": "Переименовать соответствующую страницу обсуждения",
"filedelete-archive-read-only": "Архивная директория «$1» не доступна для записи веб-серверу.",
"previousdiff": "← Предыдущая правка",
"nextdiff": "Следующая правка →",
- "mediawarning": "<strong>Внимание</strong>. Этот тип файла может содержать вредоносный программный код.\nПри его запуске ваша система может быть заражена.",
+ "mediawarning": "<strong>Внимание:</strong> Этот тип файла может содержать вредоносный программный код.\nПри его запуске ваша система может быть заражена.",
"imagemaxsize": "Ограничение на размер изображения для страницы описания файла",
"thumbsize": "Размер уменьшенной версии изображения:",
"widthheight": "$1 × $2",
"listgrouprights-namespaceprotection-header": "නාමඅවකාශය සීමා",
"listgrouprights-namespaceprotection-namespace": "නාමඅවකාශය",
"listgrouprights-namespaceprotection-restrictedto": "පරිශීලක සංස්කරණය කිරීමට ඉඩ (ව) රයිට්",
- "trackingcategories": "රà¶\82à¶\9c à·\81à·\92ලà·\8aපà·\93නà·\8a පà·\8aරවර්ග",
+ "trackingcategories": "නà·\92රà·\93à¶\9aà·\8aà·\82ණ පà·\8aâ\80\8dරවර්ග",
"trackingcategories-summary": "ස්වයංක්රීයව මාධ්යවිකි මෘදුකාංග විසින් ජනාකීර්ණ වන කාණ්ඩ සොයා ගැනීමට මෙම පිටුවෙහි ලැයිස්තුගත. නාමඅවකාශය: ඔවුන්ගේ නම් {{ns:8}} අදාළ පද්ධති පණිවුඩ වෙනස් වෙනස් කළ හැක.",
"trackingcategories-msg": "ට්රැකින් ප්රවර්ගය",
"trackingcategories-name": "පණිවිඩය නම",
"badarticleerror": "Na tej strani dejanja ne morem izvesti. Morda je bila stran med predložitvijo vaše zahteve že izbrisana.",
"cannotdelete": "Strani ali datoteke »$1« ni mogoče izbrisati.\nMorda jo je izbrisal že kdo drug.",
"cannotdelete-title": "Ne morem izbrisati strani »$1«",
+ "delete-scheduled": "Stran »$1« je načrtovana za brisanje.\nProsimo, bodite strpni.",
"delete-hook-aborted": "Zanka je prekinila brisanje.\nVrnila ni nobene razlage.",
"no-null-revision": "Ne morem ustvariti nove ničelne redakcije strani »$1«",
"badtitle": "Nepravilen naslov",
"right-editsitecss": "Urejanje CSS spletišča",
"right-editsitejson": "Urejanje JSON spletišča",
"right-editsitejs": "Urejanje JavaScripta spletišča",
- "right-editmyusercss": "Uredite svoje uporabniške datoteke CSS",
+ "right-editmyusercss": "Urejanje svojih uporabniških datotek CSS",
"right-editmyuserjson": "Urejanje svojih uporabniških datotek JSON",
- "right-editmyuserjs": "Uredite svoje uporabniške datoteke JavaScript",
+ "right-editmyuserjs": "Urejanje svojih uporabniških datotek JavaScript",
"right-viewmywatchlist": "Ogledovanje svojega spiska nadzorov",
"right-editmywatchlist": "Urejanje vašega spiska nadzorov. Vedite, da bodo nekatera dejanja dodala strani nanj tudi brez te pravice.",
"right-viewmyprivateinfo": "Ogled svojih zasebnih podatkov (npr. e-poštnega naslova, pravega imena)",
"movepage-moved": "Stran '''»$1«''' je prestavljena na naslov '''»$2«'''.",
"movepage-moved-redirect": "Preusmeritev je bila ustvarjena.",
"movepage-moved-noredirect": "Izdelava preusmeritve je bila zatrta.",
+ "movepage-delete-first": "Ciljna stran ima preveč redakcij, da bi jo lahko izbrisali kot del premikanja strani. Prosimo, da najprej ročno izbrišete stran in nato poskusite znova.",
"articleexists": "Izbrano ime je že zasedeno ali pa ni veljavno.\nProsimo, izberite drugo ime.",
"cantmove-titleprotected": "Strani ne morete premakniti na slednjo lokacijo, saj je nov naslov zaščiten pred ustvarjanjem",
"movetalk": "Prestavi tudi pogovorno stran",
"badarticleerror": "Ова радња се не може извршити на овој страници.",
"cannotdelete": "Није могуће избрисати страницу или датотеку „$1”.\nМогуће је да ју је неко већ избрисао.",
"cannotdelete-title": "Није могуће избрисати страницу „$1”",
+ "delete-scheduled": "Страница „$1” је заказана за брисање.\nБудите стрпљиви.",
"delete-hook-aborted": "Брисање је прекинула кука.\nНије дато никакво образложење.",
"no-null-revision": "Није могуће направити нову ништавну измену странице „$1”",
"badtitle": "Лош наслов",
"externaldberror": "Дошло је до грешке при потврди идентитета базе података или вам није дозвољено да ажурирате свој спољни налог.",
"login": "Пријава",
"login-security": "Потврда вашег индентитета",
- "nav-login-createaccount": "Ð\9fÑ\80иÑ\98ави ме / оÑ\82воÑ\80и налог",
+ "nav-login-createaccount": "Ð\9fÑ\80иÑ\98ава / Ñ\80егиÑ\81Ñ\82Ñ\80аÑ\86иÑ\98а",
"logout": "Одјава",
"userlogout": "Одјава",
"notloggedin": "Нисте пријављени",
"movepage-moved": "<strong>Страница „$1“ је премештена на наслов „$2“</strong>",
"movepage-moved-redirect": "Преусмерење је направљено.",
"movepage-moved-noredirect": "Стварање преусмерења је онемогућено.",
+ "movepage-delete-first": "Циљна страница има превише измена за брисање као део премештања странице. Прво ручно избришите страницу, па покушајте поново.",
"articleexists": "Страница са тим именом већ постоји или име које сте одабрали није важеће.\nОдаберите друго.",
"cantmove-titleprotected": "Не можете да преместите страницу на ову локацију јер је прављење новог наслова заштићено.",
"movetalk": "Премести и страницу за разговор",
"Wxyveronica",
"夢蝶葬花",
"Dcljr",
- "Phenolla"
+ "Phenolla",
+ "Hello903hello"
]
},
"tog-underline": "链接下划线:",
"badarticleerror": "無法在此頁進行該操作。",
"cannotdelete": "無法刪除頁面或檔案 \"$1\"。\n它可能已經被其他人刪除。",
"cannotdelete-title": "無法刪除頁面 \"$1\"",
+ "delete-scheduled": "頁面「$1」已被安排刪除。\n請耐心等待。",
"delete-hook-aborted": "刪除已被 Hook 中止。\n且未回應無任何說明。",
"no-null-revision": "無法建立頁面 \"$1\" 的新空白修訂",
"badtitle": "無效的標題",
"movepage-moved": "<strong>已移動 \"$1\" 至 \"$2\"</strong>",
"movepage-moved-redirect": "已建立重新導向頁面。",
"movepage-moved-noredirect": "已取消建立重新導向頁面。",
+ "movepage-delete-first": "目標頁面有太多修訂,而無法刪除作為頁面移動的部份。請先手動刪除頁面後再重試。",
"articleexists": "該頁面名稱已存在,或您選擇的名稱無效。\n請改選擇其他名稱。",
"cantmove-titleprotected": "您選擇的新標題已被禁止使用,您不可移動頁面到該位置。",
"movetalk": "移動相關的對話頁面",
}
$page = WikiPage::factory( $title );
$error = '';
- $success = $page->doDeleteArticle( $reason, false, 0, true, $error, $user );
+ $success = $page->doDeleteArticle( $reason, false, null, null, $error, $user, true );
if ( $success ) {
$this->output( " Deleted!\n" );
} else {
* @ingroup Maintenance
*/
-use MediaWiki\Storage\SlotRecord;
+use MediaWiki\Revision\SlotRecord;
require_once __DIR__ . '/Maintenance.php';
}
public function execute() {
- global $wgActorTableSchemaMigrationStage;
-
$dbw = $this->getDB( DB_MASTER );
// Autodetect mode...
$actorQuery = ActorMigration::newMigration()->getJoin( 'rev_user' );
- $needSpecialQuery = ( $wgActorTableSchemaMigrationStage !== MIGRATION_OLD &&
- $wgActorTableSchemaMigrationStage !== MIGRATION_NEW );
- if ( $needSpecialQuery ) {
- foreach ( $actorQuery['joins'] as &$j ) {
- $j[0] = 'JOIN'; // replace LEFT JOIN
- }
- unset( $j );
- }
-
if ( $backgroundMode ) {
$this->output( "Using replication-friendly background mode...\n" );
for ( $min = 0; $min <= $lastUser; $min += $chunkSize ) {
$max = $min + $chunkSize;
- if ( $needSpecialQuery ) {
- // Use separate subqueries to collect counts with the old
- // and new schemas, to avoid having to do whole-table scans.
- $result = $dbr->select(
- [
- 'user',
- 'rev1' => '('
- . $dbr->selectSQLText(
- [ 'revision', 'revision_actor_temp' ],
- [ 'rev_user', 'ct' => 'COUNT(*)' ],
- [
- "rev_user > $min AND rev_user <= $max",
- 'revactor_rev' => null,
- ],
- __METHOD__,
- [ 'GROUP BY' => 'rev_user' ],
- [ 'revision_actor_temp' => [ 'LEFT JOIN', 'revactor_rev = rev_id' ] ]
- ) . ')',
- 'rev2' => '('
- . $dbr->selectSQLText(
- [ 'revision' ] + $actorQuery['tables'],
- [ 'actor_user', 'ct' => 'COUNT(*)' ],
- "actor_user > $min AND actor_user <= $max",
- __METHOD__,
- [ 'GROUP BY' => 'actor_user' ],
- $actorQuery['joins']
- ) . ')',
- ],
- [ 'user_id', 'user_editcount' => 'COALESCE(rev1.ct,0) + COALESCE(rev2.ct,0)' ],
- "user_id > $min AND user_id <= $max",
- __METHOD__,
- [],
- [
- 'rev1' => [ 'LEFT JOIN', 'user_id = rev_user' ],
- 'rev2' => [ 'LEFT JOIN', 'user_id = actor_user' ],
- ]
- );
- } else {
- $revUser = $actorQuery['fields']['rev_user'];
- $result = $dbr->select(
- [ 'user', 'rev' => [ 'revision' ] + $actorQuery['tables'] ],
- [ 'user_id', 'user_editcount' => "COUNT($revUser)" ],
- "user_id > $min AND user_id <= $max",
- __METHOD__,
- [ 'GROUP BY' => 'user_id' ],
- [ 'rev' => [ 'LEFT JOIN', "user_id = $revUser" ] ] + $actorQuery['joins']
- );
- }
+ $revUser = $actorQuery['fields']['rev_user'];
+ $result = $dbr->select(
+ [ 'user', 'rev' => [ 'revision' ] + $actorQuery['tables'] ],
+ [ 'user_id', 'user_editcount' => "COUNT($revUser)" ],
+ "user_id > $min AND user_id <= $max",
+ __METHOD__,
+ [ 'GROUP BY' => 'user_id' ],
+ [ 'rev' => [ 'LEFT JOIN', "user_id = $revUser" ] ] + $actorQuery['joins']
+ );
foreach ( $result as $row ) {
$dbw->update( 'user',
$this->output( "Using single-query mode...\n" );
$user = $dbw->tableName( 'user' );
- if ( $needSpecialQuery ) {
- $subquery1 = $dbw->selectSQLText(
- [ 'revision', 'revision_actor_temp' ],
- [ 'COUNT(*)' ],
- [
- 'user_id = rev_user',
- 'revactor_rev' => null,
- ],
- __METHOD__,
- [],
- [ 'revision_actor_temp' => [ 'LEFT JOIN', 'revactor_rev = rev_id' ] ]
- );
- $subquery2 = $dbw->selectSQLText(
- [ 'revision' ] + $actorQuery['tables'],
- [ 'COUNT(*)' ],
- 'user_id = actor_user',
- __METHOD__,
- [],
- $actorQuery['joins']
- );
- $dbw->query(
- "UPDATE $user SET user_editcount=($subquery1) + ($subquery2)",
- __METHOD__
- );
- } else {
- $subquery = $dbw->selectSQLText(
- [ 'revision' ] + $actorQuery['tables'],
- [ 'COUNT(*)' ],
- [ 'user_id = ' . $actorQuery['fields']['rev_user'] ],
- __METHOD__,
- [],
- $actorQuery['joins']
- );
- $dbw->query( "UPDATE $user SET user_editcount=($subquery)", __METHOD__ );
- }
+ $subquery = $dbw->selectSQLText(
+ [ 'revision' ] + $actorQuery['tables'],
+ [ 'COUNT(*)' ],
+ [ 'user_id = ' . $actorQuery['fields']['rev_user'] ],
+ __METHOD__,
+ [],
+ $actorQuery['joins']
+ );
+ $dbw->query( "UPDATE $user SET user_editcount=($subquery)", __METHOD__ );
}
$this->output( "Done!\n" );
$this->addOption( 'env-checks', "Run environment checks only, don't change anything" );
$this->addOption( 'with-extensions', "Detect and include extensions" );
+ $this->addOption( 'extensions', 'Comma-separated list of extensions to install',
+ false, true, false, true );
+ $this->addOption( 'skins', 'Comma-separated list of skins to install (default: all)',
+ false, true, false, true );
}
public function getDbType() {
protected function doDBUpdates() {
global $wgActorTableSchemaMigrationStage;
- if ( $wgActorTableSchemaMigrationStage < MIGRATION_WRITE_NEW ) {
+ if ( !( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_WRITE_NEW ) ) {
$this->output(
- "...cannot update while \$wgActorTableSchemaMigrationStage < MIGRATION_WRITE_NEW\n"
+ "...cannot update while \$wgActorTableSchemaMigrationStage lacks SCHEMA_COMPAT_WRITE_NEW\n"
);
return false;
}
$table,
[
$actorField => $row->actor_id,
- $nameField => '',
],
array_intersect_key( (array)$row, $pkFilter ) + [
$actorField => 0
}
$this->beginTransaction( $dbw, __METHOD__ );
$dbw->insert( $newTable, $inserts, __METHOD__ );
- $dbw->update( $table, [ $nameField => '' ], [ $primaryKey => $updates ], __METHOD__ );
$countUpdated += $dbw->affectedRows();
$this->commitTransaction( $dbw, __METHOD__ );
}
*/
use MediaWiki\MediaWikiServices;
+use MediaWiki\Revision\SlotRecord;
use MediaWiki\Storage\NameTableStore;
-use MediaWiki\Storage\SlotRecord;
use MediaWiki\Storage\SqlBlobStore;
use Wikimedia\Assert\Assert;
use Wikimedia\Rdbms\IDatabase;
$tables = [ self::$tableMap[$prefix] ];
$fields = [];
$joins = [];
- if ( $wgActorTableSchemaMigrationStage < MIGRATION_NEW ) {
+ if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_WRITE_OLD ) {
+ // Read the old fields if we're still writing them regardless of read mode, to handle upgrades
$fields['userid'] = $prefix . '_user';
$fields['username'] = $prefix . '_user_text';
}
- if ( $wgActorTableSchemaMigrationStage > MIGRATION_OLD ) {
+ if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_WRITE_NEW ) {
+ // Read the new fields if we're writing them regardless of read mode, to handle upgrades
if ( $prefix === 'rev' ) {
$tables[] = 'revision_actor_temp';
$joins['revision_actor_temp'] = [
- $wgActorTableSchemaMigrationStage === MIGRATION_NEW ? 'JOIN' : 'LEFT JOIN',
+ ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_WRITE_OLD ) ? 'LEFT JOIN' : 'JOIN',
'rev_id = revactor_rev',
];
$fields['actorid'] = 'revactor_actor';
$log->addRelations( 'log_id', $items, $row->log_id );
// Query item author relations...
$fields = [];
- if ( $wgActorTableSchemaMigrationStage < MIGRATION_NEW ) {
+ if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_WRITE_OLD ) {
+ // Read the old fields if we're still writing them regardless of read mode, to handle upgrades
$fields['userid'] = 'log_user';
$fields['username'] = 'log_user_text';
}
- if ( $wgActorTableSchemaMigrationStage > MIGRATION_OLD ) {
+ if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_WRITE_NEW ) {
+ // Read the new fields if we're writing them regardless of read mode, to handle upgrades
$fields['actorid'] = 'log_actor';
}
// Add item author relations...
$userIds = $userIPs = $userActors = [];
foreach ( $sres as $srow ) {
- if ( $wgActorTableSchemaMigrationStage < MIGRATION_NEW ) {
+ if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_WRITE_OLD ) {
if ( $srow->userid > 0 ) {
$userIds[] = intval( $srow->userid );
} elseif ( $srow->username != '' ) {
$userIPs[] = $srow->username;
}
}
- if ( $wgActorTableSchemaMigrationStage > MIGRATION_OLD ) {
+ if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_WRITE_NEW ) {
if ( $srow->actorid ) {
$userActors[] = intval( $srow->actorid );
} elseif ( $srow->userid > 0 ) {
}
}
// Add item author relations...
- if ( $wgActorTableSchemaMigrationStage <= MIGRATION_WRITE_BOTH ) {
+ if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_WRITE_OLD ) {
$log->addRelations( 'target_author_id', $userIds, $row->log_id );
$log->addRelations( 'target_author_ip', $userIPs, $row->log_id );
}
- if ( $wgActorTableSchemaMigrationStage >= MIGRATION_WRITE_BOTH ) {
+ if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_WRITE_NEW ) {
$log->addRelations( 'target_author_actor', $userActors, $row->log_id );
}
}
if ( $total ) {
# Reassign edits
$this->output( "\nReassigning current edits..." );
- if ( $wgActorTableSchemaMigrationStage < MIGRATION_NEW ) {
+ if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_WRITE_OLD ) {
$dbw->update(
'revision',
[
'rev_user' => $to->getId(),
- 'rev_user_text' =>
- $wgActorTableSchemaMigrationStage <= MIGRATION_WRITE_BOTH ? $to->getName() : ''
+ 'rev_user_text' => $to->getName(),
],
$from->isLoggedIn()
? [ 'rev_user' => $from->getId() ] : [ 'rev_user_text' => $from->getName() ],
__METHOD__
);
}
- if ( $wgActorTableSchemaMigrationStage > MIGRATION_OLD ) {
+ if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_WRITE_NEW ) {
$dbw->update(
'revision_actor_temp',
[ 'revactor_actor' => $to->getActorId( $dbw ) ],
}
/**
- * Return user specifications
+ * Return user specifications for an UPDATE
* i.e. user => id, user_text => text
*
* @param IDatabase $dbw Database handle
global $wgActorTableSchemaMigrationStage;
$ret = [];
- if ( $wgActorTableSchemaMigrationStage < MIGRATION_NEW ) {
+ if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_WRITE_OLD ) {
$ret += [
$idfield => $user->getId(),
- $utfield => $wgActorTableSchemaMigrationStage <= MIGRATION_WRITE_BOTH ? $user->getName() : '',
+ $utfield => $user->getName(),
];
}
- if ( $wgActorTableSchemaMigrationStage > MIGRATION_OLD ) {
+ if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_WRITE_NEW ) {
$ret += [ $acfield => $user->getActorId( $dbw ) ];
}
return $ret;
$delUser = [];
$delActor = [];
$dbr = $this->getDB( DB_REPLICA );
- if ( $wgActorTableSchemaMigrationStage > MIGRATION_OLD ) {
+ if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_WRITE_NEW ) {
$res = $dbr->select(
[ 'user', 'actor' ],
[ 'user_id', 'user_name', 'user_touched', 'actor_id' ],
$this->output( "\nDeleting unused accounts..." );
$dbw = $this->getDB( DB_MASTER );
$dbw->delete( 'user', [ 'user_id' => $delUser ], __METHOD__ );
- if ( $wgActorTableSchemaMigrationStage > MIGRATION_OLD ) {
+ if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_WRITE_NEW ) {
# Keep actor rows referenced from ipblocks
$keep = $dbw->selectFieldValues(
'ipblocks', 'ipb_by_actor', [ 'ipb_by_actor' => $delActor ], __METHOD__
$dbw->delete( 'user_groups', [ 'ug_user' => $delUser ], __METHOD__ );
$dbw->delete( 'user_former_groups', [ 'ufg_user' => $delUser ], __METHOD__ );
$dbw->delete( 'user_properties', [ 'up_user' => $delUser ], __METHOD__ );
- if ( $wgActorTableSchemaMigrationStage > MIGRATION_OLD ) {
+ if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_WRITE_NEW ) {
$dbw->delete( 'logging', [ 'log_actor' => $delActor ], __METHOD__ );
$dbw->delete( 'recentchanges', [ 'rc_actor' => $delActor ], __METHOD__ );
}
- if ( $wgActorTableSchemaMigrationStage < MIGRATION_NEW ) {
+ if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_WRITE_OLD ) {
$dbw->delete( 'logging', [ 'log_user' => $delUser ], __METHOD__ );
$dbw->delete( 'recentchanges', [ 'rc_user' => $delUser ], __METHOD__ );
}
CLDRPluralRuleParser:
type: file
- src: https://raw.githubusercontent.com/santhoshtr/CLDRPluralRuleParser/v1.1.3/src/CLDRPluralRuleParser.js
- integrity: sha384-Y0qxTEDVQgh+N5In+vLbZLL2H7PEROnicj8vxof0mxR8kXcGysGE6OcF+cS+Ao0u
+ src: https://raw.githubusercontent.com/santhoshtr/CLDRPluralRuleParser/0dda851/src/CLDRPluralRuleParser.js
+ integrity: sha384-M4taeYYG2+9Ob1/La16iO+zlRRmBV5lBR3xUKkQT6kfkJ0aLbCi6yc0RYI1BDzdh
easy-deflate:
type: multi-file
sinonjs:
type: file
- src: https://sinonjs.org/releases/sinon-1.17.3.js
- integrity: sha384-8+RlaM2FW7qMqjxpM5NTVM0y6sTY+vTi/AHnk7Fd7NHjBye9sVxxsMjyxVJnPBtU
- dest: sinon-1.17.3.js
+ src: https://sinonjs.org/releases/sinon-1.17.7.js
+ integrity: sha384-wR63Jwy75KqwBfzCmXd6gYws6uj3qV/XMAybzXrkEYGYG3AQ58ZWwr1fVpkHa5e8
+ dest: sinon.js
$titles = [];
$actorQuery = ActorMigration::newMigration()
->getWhere( $dbr, 'rev_user', User::newFromName( $user, false ) );
- foreach ( $actorQuery['orconds'] as $cond ) {
- $results = $dbr->select(
- [ 'page', 'revision' ] + $actorQuery['tables'],
- [ 'page_namespace', 'page_title' ],
- [ $cond ],
- __METHOD__,
- [],
- [ 'revision' => [ 'JOIN', 'page_latest = rev_id' ] ] + $actorQuery['joins']
- );
- foreach ( $results as $row ) {
- $titles[] = Title::makeTitle( $row->page_namespace, $row->page_title );
- }
+ $results = $dbr->select(
+ [ 'page', 'revision' ] + $actorQuery['tables'],
+ [ 'page_namespace', 'page_title' ],
+ $actorQuery['conds'],
+ __METHOD__,
+ [],
+ [ 'revision' => [ 'JOIN', 'page_latest = rev_id' ] ] + $actorQuery['joins']
+ );
+ foreach ( $results as $row ) {
+ $titles[] = Title::makeTitle( $row->page_namespace, $row->page_title );
}
return $titles;
*/
use MediaWiki\MediaWikiServices;
-use MediaWiki\Storage\SlotRecord;
+use MediaWiki\Revision\SlotRecord;
require_once __DIR__ . '/../Maintenance.php';
}
function execute() {
- global $wgVersion, $wgLang, $wgAllowSchemaUpdates;
+ global $wgVersion, $wgLang, $wgAllowSchemaUpdates, $wgMessagesDirs;
if ( !$wgAllowSchemaUpdates
&& !( $this->hasOption( 'force' )
}
}
+ // T206765: We need to load the installer i18n files as some of errors come installer/updater code
+ $wgMessagesDirs['MediawikiInstaller'] = dirname( __DIR__ ) . '/includes/installer/i18n';
+
$lang = Language::factory( 'en' );
// Set global language to ensure localised errors are in English (T22633)
RequestContext::getMain()->setLanguage( $lang );
* Released under the MIT license
* http://opensource.org/licenses/MIT
*
- * @version 0.1.0
* @source https://github.com/santhoshtr/CLDRPluralRuleParser
* @author Santhosh Thottingal <santhosh.thottingal@gmail.com>
* @author Timo Tijhof
// AMD. Register as an anonymous module.
define(factory);
} else if (typeof exports === 'object') {
+ /* global module */
// Node. Does not work with strict CommonJS, but
// only CommonJS-like environments that support module.exports,
// like Node.
return null;
}
- debug(' -- passed ' + parseInt(result[0], 10) + ' ' + result[2] + ' ' + parseInt(result[4], 10));
+ debug(' -- passed ', parseInt(result[0], 10), result[2], parseInt(result[4], 10));
- return parseInt(result[0], 10) % parseInt(result[4], 10);
+ return parseFloat(result[0]) % parseInt(result[4], 10);
}
function not() {
var result = sequence([expression, whitespace, choice([_is_]), whitespace, value]);
if (result !== null) {
- debug(' -- passed is : ' + result[0] + ' == ' + parseInt(result[4], 10));
+ debug(' -- passed is :', result[0], ' == ', parseInt(result[4], 10));
return result[0] === parseInt(result[4], 10);
}
);
if (result !== null) {
- debug(' -- passed isnot: ' + result[0] + ' != ' + parseInt(result[4], 10));
+ debug(' -- passed isnot: ', result[0], ' != ', parseInt(result[4], 10));
return result[0] !== parseInt(result[4], 10);
}
result = sequence([expression, whitespace, _isnot_sign_, whitespace, rangeList]);
if (result !== null) {
- debug(' -- passed not_in: ' + result[0] + ' != ' + result[4]);
+ debug(' -- passed not_in: ', result[0], ' != ', result[4]);
range_list = result[4];
for (i = 0; i < range_list.length; i++) {
);
if (result !== null) {
- debug(' -- passed _in:' + result);
+ debug(' -- passed _in:', result);
range_list = result[5];
for (i = 0; i < range_list.length; i++) {
- if (parseInt(range_list[i], 10) === parseInt(result[0], 10)) {
+ if (parseInt(range_list[i], 10) === parseFloat(result[0])) {
return (result[1][0] !== 'not');
}
}
var result = sequence([whitespace, _and_, whitespace, relation]);
if (result !== null) {
- debug(' -- passed andTail' + result);
+ debug(' -- passed andTail', result);
return result[3];
}
var result = sequence([whitespace, _or_, whitespace, and]);
if (result !== null) {
- debug(' -- passed orTail: ' + result[3]);
+ debug(' -- passed orTail: ', result[3]);
return result[3];
}
}
if (pos !== rule.length) {
- debug('Warning: Rule not parsed completely. Parser stopped at ' + rule.substr(0, pos) + ' for rule: ' + rule);
+ debug('Warning: Rule not parsed completely. Parser stopped at ', rule.substr(0, pos), ' for rule: ', rule);
}
return result;
+++ /dev/null
-/**
- * Sinon.JS 1.17.3, 2016/01/27
- *
- * @author Christian Johansen (christian@cjohansen.no)
- * @author Contributors: https://github.com/cjohansen/Sinon.JS/blob/master/AUTHORS
- *
- * (The BSD License)
- *
- * Copyright (c) 2010-2014, Christian Johansen, christian@cjohansen.no
- * All rights reserved.
- *
- * Redistribution and use in source and binary forms, with or without modification,
- * are permitted provided that the following conditions are met:
- *
- * * Redistributions of source code must retain the above copyright notice,
- * this list of conditions and the following disclaimer.
- * * Redistributions in binary form must reproduce the above copyright notice,
- * this list of conditions and the following disclaimer in the documentation
- * and/or other materials provided with the distribution.
- * * Neither the name of Christian Johansen nor the names of his contributors
- * may be used to endorse or promote products derived from this software
- * without specific prior written permission.
- *
- * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
- * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
- * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
- * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
- * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
- * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
- * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
- * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
- * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
- * THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
- */
-
-(function (root, factory) {
- 'use strict';
- if (typeof define === 'function' && define.amd) {
- define('sinon', [], function () {
- return (root.sinon = factory());
- });
- } else if (typeof exports === 'object') {
- module.exports = factory();
- } else {
- root.sinon = factory();
- }
-}(this, function () {
- 'use strict';
- var samsam, formatio, lolex;
- (function () {
- function define(mod, deps, fn) {
- if (mod == "samsam") {
- samsam = deps();
- } else if (typeof deps === "function" && mod.length === 0) {
- lolex = deps();
- } else if (typeof fn === "function") {
- formatio = fn(samsam);
- }
- }
- define.amd = {};
-((typeof define === "function" && define.amd && function (m) { define("samsam", m); }) ||
- (typeof module === "object" &&
- function (m) { module.exports = m(); }) || // Node
- function (m) { this.samsam = m(); } // Browser globals
-)(function () {
- var o = Object.prototype;
- var div = typeof document !== "undefined" && document.createElement("div");
-
- function isNaN(value) {
- // Unlike global isNaN, this avoids type coercion
- // typeof check avoids IE host object issues, hat tip to
- // lodash
- var val = value; // JsLint thinks value !== value is "weird"
- return typeof value === "number" && value !== val;
- }
-
- function getClass(value) {
- // Returns the internal [[Class]] by calling Object.prototype.toString
- // with the provided value as this. Return value is a string, naming the
- // internal class, e.g. "Array"
- return o.toString.call(value).split(/[ \]]/)[1];
- }
-
- /**
- * @name samsam.isArguments
- * @param Object object
- *
- * Returns ``true`` if ``object`` is an ``arguments`` object,
- * ``false`` otherwise.
- */
- function isArguments(object) {
- if (getClass(object) === 'Arguments') { return true; }
- if (typeof object !== "object" || typeof object.length !== "number" ||
- getClass(object) === "Array") {
- return false;
- }
- if (typeof object.callee == "function") { return true; }
- try {
- object[object.length] = 6;
- delete object[object.length];
- } catch (e) {
- return true;
- }
- return false;
- }
-
- /**
- * @name samsam.isElement
- * @param Object object
- *
- * Returns ``true`` if ``object`` is a DOM element node. Unlike
- * Underscore.js/lodash, this function will return ``false`` if ``object``
- * is an *element-like* object, i.e. a regular object with a ``nodeType``
- * property that holds the value ``1``.
- */
- function isElement(object) {
- if (!object || object.nodeType !== 1 || !div) { return false; }
- try {
- object.appendChild(div);
- object.removeChild(div);
- } catch (e) {
- return false;
- }
- return true;
- }
-
- /**
- * @name samsam.keys
- * @param Object object
- *
- * Return an array of own property names.
- */
- function keys(object) {
- var ks = [], prop;
- for (prop in object) {
- if (o.hasOwnProperty.call(object, prop)) { ks.push(prop); }
- }
- return ks;
- }
-
- /**
- * @name samsam.isDate
- * @param Object value
- *
- * Returns true if the object is a ``Date``, or *date-like*. Duck typing
- * of date objects work by checking that the object has a ``getTime``
- * function whose return value equals the return value from the object's
- * ``valueOf``.
- */
- function isDate(value) {
- return typeof value.getTime == "function" &&
- value.getTime() == value.valueOf();
- }
-
- /**
- * @name samsam.isNegZero
- * @param Object value
- *
- * Returns ``true`` if ``value`` is ``-0``.
- */
- function isNegZero(value) {
- return value === 0 && 1 / value === -Infinity;
- }
-
- /**
- * @name samsam.equal
- * @param Object obj1
- * @param Object obj2
- *
- * Returns ``true`` if two objects are strictly equal. Compared to
- * ``===`` there are two exceptions:
- *
- * - NaN is considered equal to NaN
- * - -0 and +0 are not considered equal
- */
- function identical(obj1, obj2) {
- if (obj1 === obj2 || (isNaN(obj1) && isNaN(obj2))) {
- return obj1 !== 0 || isNegZero(obj1) === isNegZero(obj2);
- }
- }
-
-
- /**
- * @name samsam.deepEqual
- * @param Object obj1
- * @param Object obj2
- *
- * Deep equal comparison. Two values are "deep equal" if:
- *
- * - They are equal, according to samsam.identical
- * - They are both date objects representing the same time
- * - They are both arrays containing elements that are all deepEqual
- * - They are objects with the same set of properties, and each property
- * in ``obj1`` is deepEqual to the corresponding property in ``obj2``
- *
- * Supports cyclic objects.
- */
- function deepEqualCyclic(obj1, obj2) {
-
- // used for cyclic comparison
- // contain already visited objects
- var objects1 = [],
- objects2 = [],
- // contain pathes (position in the object structure)
- // of the already visited objects
- // indexes same as in objects arrays
- paths1 = [],
- paths2 = [],
- // contains combinations of already compared objects
- // in the manner: { "$1['ref']$2['ref']": true }
- compared = {};
-
- /**
- * used to check, if the value of a property is an object
- * (cyclic logic is only needed for objects)
- * only needed for cyclic logic
- */
- function isObject(value) {
-
- if (typeof value === 'object' && value !== null &&
- !(value instanceof Boolean) &&
- !(value instanceof Date) &&
- !(value instanceof Number) &&
- !(value instanceof RegExp) &&
- !(value instanceof String)) {
-
- return true;
- }
-
- return false;
- }
-
- /**
- * returns the index of the given object in the
- * given objects array, -1 if not contained
- * only needed for cyclic logic
- */
- function getIndex(objects, obj) {
-
- var i;
- for (i = 0; i < objects.length; i++) {
- if (objects[i] === obj) {
- return i;
- }
- }
-
- return -1;
- }
-
- // does the recursion for the deep equal check
- return (function deepEqual(obj1, obj2, path1, path2) {
- var type1 = typeof obj1;
- var type2 = typeof obj2;
-
- // == null also matches undefined
- if (obj1 === obj2 ||
- isNaN(obj1) || isNaN(obj2) ||
- obj1 == null || obj2 == null ||
- type1 !== "object" || type2 !== "object") {
-
- return identical(obj1, obj2);
- }
-
- // Elements are only equal if identical(expected, actual)
- if (isElement(obj1) || isElement(obj2)) { return false; }
-
- var isDate1 = isDate(obj1), isDate2 = isDate(obj2);
- if (isDate1 || isDate2) {
- if (!isDate1 || !isDate2 || obj1.getTime() !== obj2.getTime()) {
- return false;
- }
- }
-
- if (obj1 instanceof RegExp && obj2 instanceof RegExp) {
- if (obj1.toString() !== obj2.toString()) { return false; }
- }
-
- var class1 = getClass(obj1);
- var class2 = getClass(obj2);
- var keys1 = keys(obj1);
- var keys2 = keys(obj2);
-
- if (isArguments(obj1) || isArguments(obj2)) {
- if (obj1.length !== obj2.length) { return false; }
- } else {
- if (type1 !== type2 || class1 !== class2 ||
- keys1.length !== keys2.length) {
- return false;
- }
- }
-
- var key, i, l,
- // following vars are used for the cyclic logic
- value1, value2,
- isObject1, isObject2,
- index1, index2,
- newPath1, newPath2;
-
- for (i = 0, l = keys1.length; i < l; i++) {
- key = keys1[i];
- if (!o.hasOwnProperty.call(obj2, key)) {
- return false;
- }
-
- // Start of the cyclic logic
-
- value1 = obj1[key];
- value2 = obj2[key];
-
- isObject1 = isObject(value1);
- isObject2 = isObject(value2);
-
- // determine, if the objects were already visited
- // (it's faster to check for isObject first, than to
- // get -1 from getIndex for non objects)
- index1 = isObject1 ? getIndex(objects1, value1) : -1;
- index2 = isObject2 ? getIndex(objects2, value2) : -1;
-
- // determine the new pathes of the objects
- // - for non cyclic objects the current path will be extended
- // by current property name
- // - for cyclic objects the stored path is taken
- newPath1 = index1 !== -1
- ? paths1[index1]
- : path1 + '[' + JSON.stringify(key) + ']';
- newPath2 = index2 !== -1
- ? paths2[index2]
- : path2 + '[' + JSON.stringify(key) + ']';
-
- // stop recursion if current objects are already compared
- if (compared[newPath1 + newPath2]) {
- return true;
- }
-
- // remember the current objects and their pathes
- if (index1 === -1 && isObject1) {
- objects1.push(value1);
- paths1.push(newPath1);
- }
- if (index2 === -1 && isObject2) {
- objects2.push(value2);
- paths2.push(newPath2);
- }
-
- // remember that the current objects are already compared
- if (isObject1 && isObject2) {
- compared[newPath1 + newPath2] = true;
- }
-
- // End of cyclic logic
-
- // neither value1 nor value2 is a cycle
- // continue with next level
- if (!deepEqual(value1, value2, newPath1, newPath2)) {
- return false;
- }
- }
-
- return true;
-
- }(obj1, obj2, '$1', '$2'));
- }
-
- var match;
-
- function arrayContains(array, subset) {
- if (subset.length === 0) { return true; }
- var i, l, j, k;
- for (i = 0, l = array.length; i < l; ++i) {
- if (match(array[i], subset[0])) {
- for (j = 0, k = subset.length; j < k; ++j) {
- if (!match(array[i + j], subset[j])) { return false; }
- }
- return true;
- }
- }
- return false;
- }
-
- /**
- * @name samsam.match
- * @param Object object
- * @param Object matcher
- *
- * Compare arbitrary value ``object`` with matcher.
- */
- match = function match(object, matcher) {
- if (matcher && typeof matcher.test === "function") {
- return matcher.test(object);
- }
-
- if (typeof matcher === "function") {
- return matcher(object) === true;
- }
-
- if (typeof matcher === "string") {
- matcher = matcher.toLowerCase();
- var notNull = typeof object === "string" || !!object;
- return notNull &&
- (String(object)).toLowerCase().indexOf(matcher) >= 0;
- }
-
- if (typeof matcher === "number") {
- return matcher === object;
- }
-
- if (typeof matcher === "boolean") {
- return matcher === object;
- }
-
- if (typeof(matcher) === "undefined") {
- return typeof(object) === "undefined";
- }
-
- if (matcher === null) {
- return object === null;
- }
-
- if (getClass(object) === "Array" && getClass(matcher) === "Array") {
- return arrayContains(object, matcher);
- }
-
- if (matcher && typeof matcher === "object") {
- if (matcher === object) {
- return true;
- }
- var prop;
- for (prop in matcher) {
- var value = object[prop];
- if (typeof value === "undefined" &&
- typeof object.getAttribute === "function") {
- value = object.getAttribute(prop);
- }
- if (matcher[prop] === null || typeof matcher[prop] === 'undefined') {
- if (value !== matcher[prop]) {
- return false;
- }
- } else if (typeof value === "undefined" || !match(value, matcher[prop])) {
- return false;
- }
- }
- return true;
- }
-
- throw new Error("Matcher was not a string, a number, a " +
- "function, a boolean or an object");
- };
-
- return {
- isArguments: isArguments,
- isElement: isElement,
- isDate: isDate,
- isNegZero: isNegZero,
- identical: identical,
- deepEqual: deepEqualCyclic,
- match: match,
- keys: keys
- };
-});
-((typeof define === "function" && define.amd && function (m) {
- define("formatio", ["samsam"], m);
-}) || (typeof module === "object" && function (m) {
- module.exports = m(require("samsam"));
-}) || function (m) { this.formatio = m(this.samsam); }
-)(function (samsam) {
-
- var formatio = {
- excludeConstructors: ["Object", /^.$/],
- quoteStrings: true,
- limitChildrenCount: 0
- };
-
- var hasOwn = Object.prototype.hasOwnProperty;
-
- var specialObjects = [];
- if (typeof global !== "undefined") {
- specialObjects.push({ object: global, value: "[object global]" });
- }
- if (typeof document !== "undefined") {
- specialObjects.push({
- object: document,
- value: "[object HTMLDocument]"
- });
- }
- if (typeof window !== "undefined") {
- specialObjects.push({ object: window, value: "[object Window]" });
- }
-
- function functionName(func) {
- if (!func) { return ""; }
- if (func.displayName) { return func.displayName; }
- if (func.name) { return func.name; }
- var matches = func.toString().match(/function\s+([^\(]+)/m);
- return (matches && matches[1]) || "";
- }
-
- function constructorName(f, object) {
- var name = functionName(object && object.constructor);
- var excludes = f.excludeConstructors ||
- formatio.excludeConstructors || [];
-
- var i, l;
- for (i = 0, l = excludes.length; i < l; ++i) {
- if (typeof excludes[i] === "string" && excludes[i] === name) {
- return "";
- } else if (excludes[i].test && excludes[i].test(name)) {
- return "";
- }
- }
-
- return name;
- }
-
- function isCircular(object, objects) {
- if (typeof object !== "object") { return false; }
- var i, l;
- for (i = 0, l = objects.length; i < l; ++i) {
- if (objects[i] === object) { return true; }
- }
- return false;
- }
-
- function ascii(f, object, processed, indent) {
- if (typeof object === "string") {
- var qs = f.quoteStrings;
- var quote = typeof qs !== "boolean" || qs;
- return processed || quote ? '"' + object + '"' : object;
- }
-
- if (typeof object === "function" && !(object instanceof RegExp)) {
- return ascii.func(object);
- }
-
- processed = processed || [];
-
- if (isCircular(object, processed)) { return "[Circular]"; }
-
- if (Object.prototype.toString.call(object) === "[object Array]") {
- return ascii.array.call(f, object, processed);
- }
-
- if (!object) { return String((1/object) === -Infinity ? "-0" : object); }
- if (samsam.isElement(object)) { return ascii.element(object); }
-
- if (typeof object.toString === "function" &&
- object.toString !== Object.prototype.toString) {
- return object.toString();
- }
-
- var i, l;
- for (i = 0, l = specialObjects.length; i < l; i++) {
- if (object === specialObjects[i].object) {
- return specialObjects[i].value;
- }
- }
-
- return ascii.object.call(f, object, processed, indent);
- }
-
- ascii.func = function (func) {
- return "function " + functionName(func) + "() {}";
- };
-
- ascii.array = function (array, processed) {
- processed = processed || [];
- processed.push(array);
- var pieces = [];
- var i, l;
- l = (this.limitChildrenCount > 0) ?
- Math.min(this.limitChildrenCount, array.length) : array.length;
-
- for (i = 0; i < l; ++i) {
- pieces.push(ascii(this, array[i], processed));
- }
-
- if(l < array.length)
- pieces.push("[... " + (array.length - l) + " more elements]");
-
- return "[" + pieces.join(", ") + "]";
- };
-
- ascii.object = function (object, processed, indent) {
- processed = processed || [];
- processed.push(object);
- indent = indent || 0;
- var pieces = [], properties = samsam.keys(object).sort();
- var length = 3;
- var prop, str, obj, i, k, l;
- l = (this.limitChildrenCount > 0) ?
- Math.min(this.limitChildrenCount, properties.length) : properties.length;
-
- for (i = 0; i < l; ++i) {
- prop = properties[i];
- obj = object[prop];
-
- if (isCircular(obj, processed)) {
- str = "[Circular]";
- } else {
- str = ascii(this, obj, processed, indent + 2);
- }
-
- str = (/\s/.test(prop) ? '"' + prop + '"' : prop) + ": " + str;
- length += str.length;
- pieces.push(str);
- }
-
- var cons = constructorName(this, object);
- var prefix = cons ? "[" + cons + "] " : "";
- var is = "";
- for (i = 0, k = indent; i < k; ++i) { is += " "; }
-
- if(l < properties.length)
- pieces.push("[... " + (properties.length - l) + " more elements]");
-
- if (length + indent > 80) {
- return prefix + "{\n " + is + pieces.join(",\n " + is) + "\n" +
- is + "}";
- }
- return prefix + "{ " + pieces.join(", ") + " }";
- };
-
- ascii.element = function (element) {
- var tagName = element.tagName.toLowerCase();
- var attrs = element.attributes, attr, pairs = [], attrName, i, l, val;
-
- for (i = 0, l = attrs.length; i < l; ++i) {
- attr = attrs.item(i);
- attrName = attr.nodeName.toLowerCase().replace("html:", "");
- val = attr.nodeValue;
- if (attrName !== "contenteditable" || val !== "inherit") {
- if (!!val) { pairs.push(attrName + "=\"" + val + "\""); }
- }
- }
-
- var formatted = "<" + tagName + (pairs.length > 0 ? " " : "");
- var content = element.innerHTML;
-
- if (content.length > 20) {
- content = content.substr(0, 20) + "[...]";
- }
-
- var res = formatted + pairs.join(" ") + ">" + content +
- "</" + tagName + ">";
-
- return res.replace(/ contentEditable="inherit"/, "");
- };
-
- function Formatio(options) {
- for (var opt in options) {
- this[opt] = options[opt];
- }
- }
-
- Formatio.prototype = {
- functionName: functionName,
-
- configure: function (options) {
- return new Formatio(options);
- },
-
- constructorName: function (object) {
- return constructorName(this, object);
- },
-
- ascii: function (object, processed, indent) {
- return ascii(this, object, processed, indent);
- }
- };
-
- return Formatio.prototype;
-});
-!function(e){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=e();else if("function"==typeof define&&define.amd)define([],e);else{var f;"undefined"!=typeof window?f=window:"undefined"!=typeof global?f=global:"undefined"!=typeof self&&(f=self),f.lolex=e()}}(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){
-(function (global){
-/*global global, window*/
-/**
- * @author Christian Johansen (christian@cjohansen.no) and contributors
- * @license BSD
- *
- * Copyright (c) 2010-2014 Christian Johansen
- */
-
-(function (global) {
-
- // Make properties writable in IE, as per
- // http://www.adequatelygood.com/Replacing-setTimeout-Globally.html
- // JSLint being anal
- var glbl = global;
-
- global.setTimeout = glbl.setTimeout;
- global.clearTimeout = glbl.clearTimeout;
- global.setInterval = glbl.setInterval;
- global.clearInterval = glbl.clearInterval;
- global.Date = glbl.Date;
-
- // setImmediate is not a standard function
- // avoid adding the prop to the window object if not present
- if('setImmediate' in global) {
- global.setImmediate = glbl.setImmediate;
- global.clearImmediate = glbl.clearImmediate;
- }
-
- // node expects setTimeout/setInterval to return a fn object w/ .ref()/.unref()
- // browsers, a number.
- // see https://github.com/cjohansen/Sinon.JS/pull/436
-
- var NOOP = function () { return undefined; };
- var timeoutResult = setTimeout(NOOP, 0);
- var addTimerReturnsObject = typeof timeoutResult === "object";
- clearTimeout(timeoutResult);
-
- var NativeDate = Date;
- var uniqueTimerId = 1;
-
- /**
- * Parse strings like "01:10:00" (meaning 1 hour, 10 minutes, 0 seconds) into
- * number of milliseconds. This is used to support human-readable strings passed
- * to clock.tick()
- */
- function parseTime(str) {
- if (!str) {
- return 0;
- }
-
- var strings = str.split(":");
- var l = strings.length, i = l;
- var ms = 0, parsed;
-
- if (l > 3 || !/^(\d\d:){0,2}\d\d?$/.test(str)) {
- throw new Error("tick only understands numbers, 'm:s' and 'h:m:s'. Each part must be two digits");
- }
-
- while (i--) {
- parsed = parseInt(strings[i], 10);
-
- if (parsed >= 60) {
- throw new Error("Invalid time " + str);
- }
-
- ms += parsed * Math.pow(60, (l - i - 1));
- }
-
- return ms * 1000;
- }
-
- /**
- * Used to grok the `now` parameter to createClock.
- */
- function getEpoch(epoch) {
- if (!epoch) { return 0; }
- if (typeof epoch.getTime === "function") { return epoch.getTime(); }
- if (typeof epoch === "number") { return epoch; }
- throw new TypeError("now should be milliseconds since UNIX epoch");
- }
-
- function inRange(from, to, timer) {
- return timer && timer.callAt >= from && timer.callAt <= to;
- }
-
- function mirrorDateProperties(target, source) {
- var prop;
- for (prop in source) {
- if (source.hasOwnProperty(prop)) {
- target[prop] = source[prop];
- }
- }
-
- // set special now implementation
- if (source.now) {
- target.now = function now() {
- return target.clock.now;
- };
- } else {
- delete target.now;
- }
-
- // set special toSource implementation
- if (source.toSource) {
- target.toSource = function toSource() {
- return source.toSource();
- };
- } else {
- delete target.toSource;
- }
-
- // set special toString implementation
- target.toString = function toString() {
- return source.toString();
- };
-
- target.prototype = source.prototype;
- target.parse = source.parse;
- target.UTC = source.UTC;
- target.prototype.toUTCString = source.prototype.toUTCString;
-
- return target;
- }
-
- function createDate() {
- function ClockDate(year, month, date, hour, minute, second, ms) {
- // Defensive and verbose to avoid potential harm in passing
- // explicit undefined when user does not pass argument
- switch (arguments.length) {
- case 0:
- return new NativeDate(ClockDate.clock.now);
- case 1:
- return new NativeDate(year);
- case 2:
- return new NativeDate(year, month);
- case 3:
- return new NativeDate(year, month, date);
- case 4:
- return new NativeDate(year, month, date, hour);
- case 5:
- return new NativeDate(year, month, date, hour, minute);
- case 6:
- return new NativeDate(year, month, date, hour, minute, second);
- default:
- return new NativeDate(year, month, date, hour, minute, second, ms);
- }
- }
-
- return mirrorDateProperties(ClockDate, NativeDate);
- }
-
- function addTimer(clock, timer) {
- if (timer.func === undefined) {
- throw new Error("Callback must be provided to timer calls");
- }
-
- if (!clock.timers) {
- clock.timers = {};
- }
-
- timer.id = uniqueTimerId++;
- timer.createdAt = clock.now;
- timer.callAt = clock.now + (timer.delay || (clock.duringTick ? 1 : 0));
-
- clock.timers[timer.id] = timer;
-
- if (addTimerReturnsObject) {
- return {
- id: timer.id,
- ref: NOOP,
- unref: NOOP
- };
- }
-
- return timer.id;
- }
-
-
- function compareTimers(a, b) {
- // Sort first by absolute timing
- if (a.callAt < b.callAt) {
- return -1;
- }
- if (a.callAt > b.callAt) {
- return 1;
- }
-
- // Sort next by immediate, immediate timers take precedence
- if (a.immediate && !b.immediate) {
- return -1;
- }
- if (!a.immediate && b.immediate) {
- return 1;
- }
-
- // Sort next by creation time, earlier-created timers take precedence
- if (a.createdAt < b.createdAt) {
- return -1;
- }
- if (a.createdAt > b.createdAt) {
- return 1;
- }
-
- // Sort next by id, lower-id timers take precedence
- if (a.id < b.id) {
- return -1;
- }
- if (a.id > b.id) {
- return 1;
- }
-
- // As timer ids are unique, no fallback `0` is necessary
- }
-
- function firstTimerInRange(clock, from, to) {
- var timers = clock.timers,
- timer = null,
- id,
- isInRange;
-
- for (id in timers) {
- if (timers.hasOwnProperty(id)) {
- isInRange = inRange(from, to, timers[id]);
-
- if (isInRange && (!timer || compareTimers(timer, timers[id]) === 1)) {
- timer = timers[id];
- }
- }
- }
-
- return timer;
- }
-
- function firstTimer(clock) {
- var timers = clock.timers,
- timer = null,
- id;
-
- for (id in timers) {
- if (timers.hasOwnProperty(id)) {
- if (!timer || compareTimers(timer, timers[id]) === 1) {
- timer = timers[id];
- }
- }
- }
-
- return timer;
- }
-
- function callTimer(clock, timer) {
- var exception;
-
- if (typeof timer.interval === "number") {
- clock.timers[timer.id].callAt += timer.interval;
- } else {
- delete clock.timers[timer.id];
- }
-
- try {
- if (typeof timer.func === "function") {
- timer.func.apply(null, timer.args);
- } else {
- eval(timer.func);
- }
- } catch (e) {
- exception = e;
- }
-
- if (!clock.timers[timer.id]) {
- if (exception) {
- throw exception;
- }
- return;
- }
-
- if (exception) {
- throw exception;
- }
- }
-
- function timerType(timer) {
- if (timer.immediate) {
- return "Immediate";
- } else if (typeof timer.interval !== "undefined") {
- return "Interval";
- } else {
- return "Timeout";
- }
- }
-
- function clearTimer(clock, timerId, ttype) {
- if (!timerId) {
- // null appears to be allowed in most browsers, and appears to be
- // relied upon by some libraries, like Bootstrap carousel
- return;
- }
-
- if (!clock.timers) {
- clock.timers = [];
- }
-
- // in Node, timerId is an object with .ref()/.unref(), and
- // its .id field is the actual timer id.
- if (typeof timerId === "object") {
- timerId = timerId.id;
- }
-
- if (clock.timers.hasOwnProperty(timerId)) {
- // check that the ID matches a timer of the correct type
- var timer = clock.timers[timerId];
- if (timerType(timer) === ttype) {
- delete clock.timers[timerId];
- } else {
- throw new Error("Cannot clear timer: timer created with set" + ttype + "() but cleared with clear" + timerType(timer) + "()");
- }
- }
- }
-
- function uninstall(clock, target) {
- var method,
- i,
- l;
-
- for (i = 0, l = clock.methods.length; i < l; i++) {
- method = clock.methods[i];
-
- if (target[method].hadOwnProperty) {
- target[method] = clock["_" + method];
- } else {
- try {
- delete target[method];
- } catch (ignore) {}
- }
- }
-
- // Prevent multiple executions which will completely remove these props
- clock.methods = [];
- }
-
- function hijackMethod(target, method, clock) {
- var prop;
-
- clock[method].hadOwnProperty = Object.prototype.hasOwnProperty.call(target, method);
- clock["_" + method] = target[method];
-
- if (method === "Date") {
- var date = mirrorDateProperties(clock[method], target[method]);
- target[method] = date;
- } else {
- target[method] = function () {
- return clock[method].apply(clock, arguments);
- };
-
- for (prop in clock[method]) {
- if (clock[method].hasOwnProperty(prop)) {
- target[method][prop] = clock[method][prop];
- }
- }
- }
-
- target[method].clock = clock;
- }
-
- var timers = {
- setTimeout: setTimeout,
- clearTimeout: clearTimeout,
- setImmediate: global.setImmediate,
- clearImmediate: global.clearImmediate,
- setInterval: setInterval,
- clearInterval: clearInterval,
- Date: Date
- };
-
- var keys = Object.keys || function (obj) {
- var ks = [],
- key;
-
- for (key in obj) {
- if (obj.hasOwnProperty(key)) {
- ks.push(key);
- }
- }
-
- return ks;
- };
-
- exports.timers = timers;
-
- function createClock(now) {
- var clock = {
- now: getEpoch(now),
- timeouts: {},
- Date: createDate()
- };
-
- clock.Date.clock = clock;
-
- clock.setTimeout = function setTimeout(func, timeout) {
- return addTimer(clock, {
- func: func,
- args: Array.prototype.slice.call(arguments, 2),
- delay: timeout
- });
- };
-
- clock.clearTimeout = function clearTimeout(timerId) {
- return clearTimer(clock, timerId, "Timeout");
- };
-
- clock.setInterval = function setInterval(func, timeout) {
- return addTimer(clock, {
- func: func,
- args: Array.prototype.slice.call(arguments, 2),
- delay: timeout,
- interval: timeout
- });
- };
-
- clock.clearInterval = function clearInterval(timerId) {
- return clearTimer(clock, timerId, "Interval");
- };
-
- clock.setImmediate = function setImmediate(func) {
- return addTimer(clock, {
- func: func,
- args: Array.prototype.slice.call(arguments, 1),
- immediate: true
- });
- };
-
- clock.clearImmediate = function clearImmediate(timerId) {
- return clearTimer(clock, timerId, "Immediate");
- };
-
- clock.tick = function tick(ms) {
- ms = typeof ms === "number" ? ms : parseTime(ms);
- var tickFrom = clock.now, tickTo = clock.now + ms, previous = clock.now;
- var timer = firstTimerInRange(clock, tickFrom, tickTo);
- var oldNow;
-
- clock.duringTick = true;
-
- var firstException;
- while (timer && tickFrom <= tickTo) {
- if (clock.timers[timer.id]) {
- tickFrom = clock.now = timer.callAt;
- try {
- oldNow = clock.now;
- callTimer(clock, timer);
- // compensate for any setSystemTime() call during timer callback
- if (oldNow !== clock.now) {
- tickFrom += clock.now - oldNow;
- tickTo += clock.now - oldNow;
- previous += clock.now - oldNow;
- }
- } catch (e) {
- firstException = firstException || e;
- }
- }
-
- timer = firstTimerInRange(clock, previous, tickTo);
- previous = tickFrom;
- }
-
- clock.duringTick = false;
- clock.now = tickTo;
-
- if (firstException) {
- throw firstException;
- }
-
- return clock.now;
- };
-
- clock.next = function next() {
- var timer = firstTimer(clock);
- if (!timer) {
- return clock.now;
- }
-
- clock.duringTick = true;
- try {
- clock.now = timer.callAt;
- callTimer(clock, timer);
- return clock.now;
- } finally {
- clock.duringTick = false;
- }
- };
-
- clock.reset = function reset() {
- clock.timers = {};
- };
-
- clock.setSystemTime = function setSystemTime(now) {
- // determine time difference
- var newNow = getEpoch(now);
- var difference = newNow - clock.now;
-
- // update 'system clock'
- clock.now = newNow;
-
- // update timers and intervals to keep them stable
- for (var id in clock.timers) {
- if (clock.timers.hasOwnProperty(id)) {
- var timer = clock.timers[id];
- timer.createdAt += difference;
- timer.callAt += difference;
- }
- }
- };
-
- return clock;
- }
- exports.createClock = createClock;
-
- exports.install = function install(target, now, toFake) {
- var i,
- l;
-
- if (typeof target === "number") {
- toFake = now;
- now = target;
- target = null;
- }
-
- if (!target) {
- target = global;
- }
-
- var clock = createClock(now);
-
- clock.uninstall = function () {
- uninstall(clock, target);
- };
-
- clock.methods = toFake || [];
-
- if (clock.methods.length === 0) {
- clock.methods = keys(timers);
- }
-
- for (i = 0, l = clock.methods.length; i < l; i++) {
- hijackMethod(target, clock.methods[i], clock);
- }
-
- return clock;
- };
-
-}(global || this));
-
-}).call(this,typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : {})
-},{}]},{},[1])(1)
-});
- })();
- var define;
-/**
- * Sinon core utilities. For internal use only.
- *
- * @author Christian Johansen (christian@cjohansen.no)
- * @license BSD
- *
- * Copyright (c) 2010-2013 Christian Johansen
- */
-var sinon = (function () {
-"use strict";
- // eslint-disable-line no-unused-vars
-
- var sinonModule;
- var isNode = typeof module !== "undefined" && module.exports && typeof require === "function";
- var isAMD = typeof define === "function" && typeof define.amd === "object" && define.amd;
-
- function loadDependencies(require, exports, module) {
- sinonModule = module.exports = require("./sinon/util/core");
- require("./sinon/extend");
- require("./sinon/walk");
- require("./sinon/typeOf");
- require("./sinon/times_in_words");
- require("./sinon/spy");
- require("./sinon/call");
- require("./sinon/behavior");
- require("./sinon/stub");
- require("./sinon/mock");
- require("./sinon/collection");
- require("./sinon/assert");
- require("./sinon/sandbox");
- require("./sinon/test");
- require("./sinon/test_case");
- require("./sinon/match");
- require("./sinon/format");
- require("./sinon/log_error");
- }
-
- if (isAMD) {
- define(loadDependencies);
- } else if (isNode) {
- loadDependencies(require, module.exports, module);
- sinonModule = module.exports;
- } else {
- sinonModule = {};
- }
-
- return sinonModule;
-}());
-
-/**
- * @depend ../../sinon.js
- */
-/**
- * Sinon core utilities. For internal use only.
- *
- * @author Christian Johansen (christian@cjohansen.no)
- * @license BSD
- *
- * Copyright (c) 2010-2013 Christian Johansen
- */
-(function (sinonGlobal) {
-
- var div = typeof document !== "undefined" && document.createElement("div");
- var hasOwn = Object.prototype.hasOwnProperty;
-
- function isDOMNode(obj) {
- var success = false;
-
- try {
- obj.appendChild(div);
- success = div.parentNode === obj;
- } catch (e) {
- return false;
- } finally {
- try {
- obj.removeChild(div);
- } catch (e) {
- // Remove failed, not much we can do about that
- }
- }
-
- return success;
- }
-
- function isElement(obj) {
- return div && obj && obj.nodeType === 1 && isDOMNode(obj);
- }
-
- function isFunction(obj) {
- return typeof obj === "function" || !!(obj && obj.constructor && obj.call && obj.apply);
- }
-
- function isReallyNaN(val) {
- return typeof val === "number" && isNaN(val);
- }
-
- function mirrorProperties(target, source) {
- for (var prop in source) {
- if (!hasOwn.call(target, prop)) {
- target[prop] = source[prop];
- }
- }
- }
-
- function isRestorable(obj) {
- return typeof obj === "function" && typeof obj.restore === "function" && obj.restore.sinon;
- }
-
- // Cheap way to detect if we have ES5 support.
- var hasES5Support = "keys" in Object;
-
- function makeApi(sinon) {
- sinon.wrapMethod = function wrapMethod(object, property, method) {
- if (!object) {
- throw new TypeError("Should wrap property of object");
- }
-
- if (typeof method !== "function" && typeof method !== "object") {
- throw new TypeError("Method wrapper should be a function or a property descriptor");
- }
-
- function checkWrappedMethod(wrappedMethod) {
- var error;
-
- if (!isFunction(wrappedMethod)) {
- error = new TypeError("Attempted to wrap " + (typeof wrappedMethod) + " property " +
- property + " as function");
- } else if (wrappedMethod.restore && wrappedMethod.restore.sinon) {
- error = new TypeError("Attempted to wrap " + property + " which is already wrapped");
- } else if (wrappedMethod.calledBefore) {
- var verb = wrappedMethod.returns ? "stubbed" : "spied on";
- error = new TypeError("Attempted to wrap " + property + " which is already " + verb);
- }
-
- if (error) {
- if (wrappedMethod && wrappedMethod.stackTrace) {
- error.stack += "\n--------------\n" + wrappedMethod.stackTrace;
- }
- throw error;
- }
- }
-
- var error, wrappedMethod, i;
-
- // IE 8 does not support hasOwnProperty on the window object and Firefox has a problem
- // when using hasOwn.call on objects from other frames.
- var owned = object.hasOwnProperty ? object.hasOwnProperty(property) : hasOwn.call(object, property);
-
- if (hasES5Support) {
- var methodDesc = (typeof method === "function") ? {value: method} : method;
- var wrappedMethodDesc = sinon.getPropertyDescriptor(object, property);
-
- if (!wrappedMethodDesc) {
- error = new TypeError("Attempted to wrap " + (typeof wrappedMethod) + " property " +
- property + " as function");
- } else if (wrappedMethodDesc.restore && wrappedMethodDesc.restore.sinon) {
- error = new TypeError("Attempted to wrap " + property + " which is already wrapped");
- }
- if (error) {
- if (wrappedMethodDesc && wrappedMethodDesc.stackTrace) {
- error.stack += "\n--------------\n" + wrappedMethodDesc.stackTrace;
- }
- throw error;
- }
-
- var types = sinon.objectKeys(methodDesc);
- for (i = 0; i < types.length; i++) {
- wrappedMethod = wrappedMethodDesc[types[i]];
- checkWrappedMethod(wrappedMethod);
- }
-
- mirrorProperties(methodDesc, wrappedMethodDesc);
- for (i = 0; i < types.length; i++) {
- mirrorProperties(methodDesc[types[i]], wrappedMethodDesc[types[i]]);
- }
- Object.defineProperty(object, property, methodDesc);
- } else {
- wrappedMethod = object[property];
- checkWrappedMethod(wrappedMethod);
- object[property] = method;
- method.displayName = property;
- }
-
- method.displayName = property;
-
- // Set up a stack trace which can be used later to find what line of
- // code the original method was created on.
- method.stackTrace = (new Error("Stack Trace for original")).stack;
-
- method.restore = function () {
- // For prototype properties try to reset by delete first.
- // If this fails (ex: localStorage on mobile safari) then force a reset
- // via direct assignment.
- if (!owned) {
- // In some cases `delete` may throw an error
- try {
- delete object[property];
- } catch (e) {} // eslint-disable-line no-empty
- // For native code functions `delete` fails without throwing an error
- // on Chrome < 43, PhantomJS, etc.
- } else if (hasES5Support) {
- Object.defineProperty(object, property, wrappedMethodDesc);
- }
-
- // Use strict equality comparison to check failures then force a reset
- // via direct assignment.
- if (object[property] === method) {
- object[property] = wrappedMethod;
- }
- };
-
- method.restore.sinon = true;
-
- if (!hasES5Support) {
- mirrorProperties(method, wrappedMethod);
- }
-
- return method;
- };
-
- sinon.create = function create(proto) {
- var F = function () {};
- F.prototype = proto;
- return new F();
- };
-
- sinon.deepEqual = function deepEqual(a, b) {
- if (sinon.match && sinon.match.isMatcher(a)) {
- return a.test(b);
- }
-
- if (typeof a !== "object" || typeof b !== "object") {
- return isReallyNaN(a) && isReallyNaN(b) || a === b;
- }
-
- if (isElement(a) || isElement(b)) {
- return a === b;
- }
-
- if (a === b) {
- return true;
- }
-
- if ((a === null && b !== null) || (a !== null && b === null)) {
- return false;
- }
-
- if (a instanceof RegExp && b instanceof RegExp) {
- return (a.source === b.source) && (a.global === b.global) &&
- (a.ignoreCase === b.ignoreCase) && (a.multiline === b.multiline);
- }
-
- var aString = Object.prototype.toString.call(a);
- if (aString !== Object.prototype.toString.call(b)) {
- return false;
- }
-
- if (aString === "[object Date]") {
- return a.valueOf() === b.valueOf();
- }
-
- var prop;
- var aLength = 0;
- var bLength = 0;
-
- if (aString === "[object Array]" && a.length !== b.length) {
- return false;
- }
-
- for (prop in a) {
- if (a.hasOwnProperty(prop)) {
- aLength += 1;
-
- if (!(prop in b)) {
- return false;
- }
-
- if (!deepEqual(a[prop], b[prop])) {
- return false;
- }
- }
- }
-
- for (prop in b) {
- if (b.hasOwnProperty(prop)) {
- bLength += 1;
- }
- }
-
- return aLength === bLength;
- };
-
- sinon.functionName = function functionName(func) {
- var name = func.displayName || func.name;
-
- // Use function decomposition as a last resort to get function
- // name. Does not rely on function decomposition to work - if it
- // doesn't debugging will be slightly less informative
- // (i.e. toString will say 'spy' rather than 'myFunc').
- if (!name) {
- var matches = func.toString().match(/function ([^\s\(]+)/);
- name = matches && matches[1];
- }
-
- return name;
- };
-
- sinon.functionToString = function toString() {
- if (this.getCall && this.callCount) {
- var thisValue,
- prop;
- var i = this.callCount;
-
- while (i--) {
- thisValue = this.getCall(i).thisValue;
-
- for (prop in thisValue) {
- if (thisValue[prop] === this) {
- return prop;
- }
- }
- }
- }
-
- return this.displayName || "sinon fake";
- };
-
- sinon.objectKeys = function objectKeys(obj) {
- if (obj !== Object(obj)) {
- throw new TypeError("sinon.objectKeys called on a non-object");
- }
-
- var keys = [];
- var key;
- for (key in obj) {
- if (hasOwn.call(obj, key)) {
- keys.push(key);
- }
- }
-
- return keys;
- };
-
- sinon.getPropertyDescriptor = function getPropertyDescriptor(object, property) {
- var proto = object;
- var descriptor;
-
- while (proto && !(descriptor = Object.getOwnPropertyDescriptor(proto, property))) {
- proto = Object.getPrototypeOf(proto);
- }
- return descriptor;
- };
-
- sinon.getConfig = function (custom) {
- var config = {};
- custom = custom || {};
- var defaults = sinon.defaultConfig;
-
- for (var prop in defaults) {
- if (defaults.hasOwnProperty(prop)) {
- config[prop] = custom.hasOwnProperty(prop) ? custom[prop] : defaults[prop];
- }
- }
-
- return config;
- };
-
- sinon.defaultConfig = {
- injectIntoThis: true,
- injectInto: null,
- properties: ["spy", "stub", "mock", "clock", "server", "requests"],
- useFakeTimers: true,
- useFakeServer: true
- };
-
- sinon.timesInWords = function timesInWords(count) {
- return count === 1 && "once" ||
- count === 2 && "twice" ||
- count === 3 && "thrice" ||
- (count || 0) + " times";
- };
-
- sinon.calledInOrder = function (spies) {
- for (var i = 1, l = spies.length; i < l; i++) {
- if (!spies[i - 1].calledBefore(spies[i]) || !spies[i].called) {
- return false;
- }
- }
-
- return true;
- };
-
- sinon.orderByFirstCall = function (spies) {
- return spies.sort(function (a, b) {
- // uuid, won't ever be equal
- var aCall = a.getCall(0);
- var bCall = b.getCall(0);
- var aId = aCall && aCall.callId || -1;
- var bId = bCall && bCall.callId || -1;
-
- return aId < bId ? -1 : 1;
- });
- };
-
- sinon.createStubInstance = function (constructor) {
- if (typeof constructor !== "function") {
- throw new TypeError("The constructor should be a function.");
- }
- return sinon.stub(sinon.create(constructor.prototype));
- };
-
- sinon.restore = function (object) {
- if (object !== null && typeof object === "object") {
- for (var prop in object) {
- if (isRestorable(object[prop])) {
- object[prop].restore();
- }
- }
- } else if (isRestorable(object)) {
- object.restore();
- }
- };
-
- return sinon;
- }
-
- var isNode = typeof module !== "undefined" && module.exports && typeof require === "function";
- var isAMD = typeof define === "function" && typeof define.amd === "object" && define.amd;
-
- function loadDependencies(require, exports) {
- makeApi(exports);
- }
-
- if (isAMD) {
- define(loadDependencies);
- return;
- }
-
- if (isNode) {
- loadDependencies(require, module.exports, module);
- return;
- }
-
- if (sinonGlobal) {
- makeApi(sinonGlobal);
- }
-}(
- typeof sinon === "object" && sinon // eslint-disable-line no-undef
-));
-
-/**
- * @depend util/core.js
- */
-(function (sinonGlobal) {
-
- function makeApi(sinon) {
-
- // Adapted from https://developer.mozilla.org/en/docs/ECMAScript_DontEnum_attribute#JScript_DontEnum_Bug
- var hasDontEnumBug = (function () {
- var obj = {
- constructor: function () {
- return "0";
- },
- toString: function () {
- return "1";
- },
- valueOf: function () {
- return "2";
- },
- toLocaleString: function () {
- return "3";
- },
- prototype: function () {
- return "4";
- },
- isPrototypeOf: function () {
- return "5";
- },
- propertyIsEnumerable: function () {
- return "6";
- },
- hasOwnProperty: function () {
- return "7";
- },
- length: function () {
- return "8";
- },
- unique: function () {
- return "9";
- }
- };
-
- var result = [];
- for (var prop in obj) {
- if (obj.hasOwnProperty(prop)) {
- result.push(obj[prop]());
- }
- }
- return result.join("") !== "0123456789";
- })();
-
- /* Public: Extend target in place with all (own) properties from sources in-order. Thus, last source will
- * override properties in previous sources.
- *
- * target - The Object to extend
- * sources - Objects to copy properties from.
- *
- * Returns the extended target
- */
- function extend(target /*, sources */) {
- var sources = Array.prototype.slice.call(arguments, 1);
- var source, i, prop;
-
- for (i = 0; i < sources.length; i++) {
- source = sources[i];
-
- for (prop in source) {
- if (source.hasOwnProperty(prop)) {
- target[prop] = source[prop];
- }
- }
-
- // Make sure we copy (own) toString method even when in JScript with DontEnum bug
- // See https://developer.mozilla.org/en/docs/ECMAScript_DontEnum_attribute#JScript_DontEnum_Bug
- if (hasDontEnumBug && source.hasOwnProperty("toString") && source.toString !== target.toString) {
- target.toString = source.toString;
- }
- }
-
- return target;
- }
-
- sinon.extend = extend;
- return sinon.extend;
- }
-
- function loadDependencies(require, exports, module) {
- var sinon = require("./util/core");
- module.exports = makeApi(sinon);
- }
-
- var isNode = typeof module !== "undefined" && module.exports && typeof require === "function";
- var isAMD = typeof define === "function" && typeof define.amd === "object" && define.amd;
-
- if (isAMD) {
- define(loadDependencies);
- return;
- }
-
- if (isNode) {
- loadDependencies(require, module.exports, module);
- return;
- }
-
- if (sinonGlobal) {
- makeApi(sinonGlobal);
- }
-}(
- typeof sinon === "object" && sinon // eslint-disable-line no-undef
-));
-
-/**
- * @depend util/core.js
- */
-(function (sinonGlobal) {
-
- function makeApi(sinon) {
-
- function timesInWords(count) {
- switch (count) {
- case 1:
- return "once";
- case 2:
- return "twice";
- case 3:
- return "thrice";
- default:
- return (count || 0) + " times";
- }
- }
-
- sinon.timesInWords = timesInWords;
- return sinon.timesInWords;
- }
-
- function loadDependencies(require, exports, module) {
- var core = require("./util/core");
- module.exports = makeApi(core);
- }
-
- var isNode = typeof module !== "undefined" && module.exports && typeof require === "function";
- var isAMD = typeof define === "function" && typeof define.amd === "object" && define.amd;
-
- if (isAMD) {
- define(loadDependencies);
- return;
- }
-
- if (isNode) {
- loadDependencies(require, module.exports, module);
- return;
- }
-
- if (sinonGlobal) {
- makeApi(sinonGlobal);
- }
-}(
- typeof sinon === "object" && sinon // eslint-disable-line no-undef
-));
-
-/**
- * @depend util/core.js
- */
-/**
- * Format functions
- *
- * @author Christian Johansen (christian@cjohansen.no)
- * @license BSD
- *
- * Copyright (c) 2010-2014 Christian Johansen
- */
-(function (sinonGlobal) {
-
- function makeApi(sinon) {
- function typeOf(value) {
- if (value === null) {
- return "null";
- } else if (value === undefined) {
- return "undefined";
- }
- var string = Object.prototype.toString.call(value);
- return string.substring(8, string.length - 1).toLowerCase();
- }
-
- sinon.typeOf = typeOf;
- return sinon.typeOf;
- }
-
- function loadDependencies(require, exports, module) {
- var core = require("./util/core");
- module.exports = makeApi(core);
- }
-
- var isNode = typeof module !== "undefined" && module.exports && typeof require === "function";
- var isAMD = typeof define === "function" && typeof define.amd === "object" && define.amd;
-
- if (isAMD) {
- define(loadDependencies);
- return;
- }
-
- if (isNode) {
- loadDependencies(require, module.exports, module);
- return;
- }
-
- if (sinonGlobal) {
- makeApi(sinonGlobal);
- }
-}(
- typeof sinon === "object" && sinon // eslint-disable-line no-undef
-));
-
-/**
- * @depend util/core.js
- * @depend typeOf.js
- */
-/*jslint eqeqeq: false, onevar: false, plusplus: false*/
-/*global module, require, sinon*/
-/**
- * Match functions
- *
- * @author Maximilian Antoni (mail@maxantoni.de)
- * @license BSD
- *
- * Copyright (c) 2012 Maximilian Antoni
- */
-(function (sinonGlobal) {
-
- function makeApi(sinon) {
- function assertType(value, type, name) {
- var actual = sinon.typeOf(value);
- if (actual !== type) {
- throw new TypeError("Expected type of " + name + " to be " +
- type + ", but was " + actual);
- }
- }
-
- var matcher = {
- toString: function () {
- return this.message;
- }
- };
-
- function isMatcher(object) {
- return matcher.isPrototypeOf(object);
- }
-
- function matchObject(expectation, actual) {
- if (actual === null || actual === undefined) {
- return false;
- }
- for (var key in expectation) {
- if (expectation.hasOwnProperty(key)) {
- var exp = expectation[key];
- var act = actual[key];
- if (isMatcher(exp)) {
- if (!exp.test(act)) {
- return false;
- }
- } else if (sinon.typeOf(exp) === "object") {
- if (!matchObject(exp, act)) {
- return false;
- }
- } else if (!sinon.deepEqual(exp, act)) {
- return false;
- }
- }
- }
- return true;
- }
-
- function match(expectation, message) {
- var m = sinon.create(matcher);
- var type = sinon.typeOf(expectation);
- switch (type) {
- case "object":
- if (typeof expectation.test === "function") {
- m.test = function (actual) {
- return expectation.test(actual) === true;
- };
- m.message = "match(" + sinon.functionName(expectation.test) + ")";
- return m;
- }
- var str = [];
- for (var key in expectation) {
- if (expectation.hasOwnProperty(key)) {
- str.push(key + ": " + expectation[key]);
- }
- }
- m.test = function (actual) {
- return matchObject(expectation, actual);
- };
- m.message = "match(" + str.join(", ") + ")";
- break;
- case "number":
- m.test = function (actual) {
- // we need type coercion here
- return expectation == actual; // eslint-disable-line eqeqeq
- };
- break;
- case "string":
- m.test = function (actual) {
- if (typeof actual !== "string") {
- return false;
- }
- return actual.indexOf(expectation) !== -1;
- };
- m.message = "match(\"" + expectation + "\")";
- break;
- case "regexp":
- m.test = function (actual) {
- if (typeof actual !== "string") {
- return false;
- }
- return expectation.test(actual);
- };
- break;
- case "function":
- m.test = expectation;
- if (message) {
- m.message = message;
- } else {
- m.message = "match(" + sinon.functionName(expectation) + ")";
- }
- break;
- default:
- m.test = function (actual) {
- return sinon.deepEqual(expectation, actual);
- };
- }
- if (!m.message) {
- m.message = "match(" + expectation + ")";
- }
- return m;
- }
-
- matcher.or = function (m2) {
- if (!arguments.length) {
- throw new TypeError("Matcher expected");
- } else if (!isMatcher(m2)) {
- m2 = match(m2);
- }
- var m1 = this;
- var or = sinon.create(matcher);
- or.test = function (actual) {
- return m1.test(actual) || m2.test(actual);
- };
- or.message = m1.message + ".or(" + m2.message + ")";
- return or;
- };
-
- matcher.and = function (m2) {
- if (!arguments.length) {
- throw new TypeError("Matcher expected");
- } else if (!isMatcher(m2)) {
- m2 = match(m2);
- }
- var m1 = this;
- var and = sinon.create(matcher);
- and.test = function (actual) {
- return m1.test(actual) && m2.test(actual);
- };
- and.message = m1.message + ".and(" + m2.message + ")";
- return and;
- };
-
- match.isMatcher = isMatcher;
-
- match.any = match(function () {
- return true;
- }, "any");
-
- match.defined = match(function (actual) {
- return actual !== null && actual !== undefined;
- }, "defined");
-
- match.truthy = match(function (actual) {
- return !!actual;
- }, "truthy");
-
- match.falsy = match(function (actual) {
- return !actual;
- }, "falsy");
-
- match.same = function (expectation) {
- return match(function (actual) {
- return expectation === actual;
- }, "same(" + expectation + ")");
- };
-
- match.typeOf = function (type) {
- assertType(type, "string", "type");
- return match(function (actual) {
- return sinon.typeOf(actual) === type;
- }, "typeOf(\"" + type + "\")");
- };
-
- match.instanceOf = function (type) {
- assertType(type, "function", "type");
- return match(function (actual) {
- return actual instanceof type;
- }, "instanceOf(" + sinon.functionName(type) + ")");
- };
-
- function createPropertyMatcher(propertyTest, messagePrefix) {
- return function (property, value) {
- assertType(property, "string", "property");
- var onlyProperty = arguments.length === 1;
- var message = messagePrefix + "(\"" + property + "\"";
- if (!onlyProperty) {
- message += ", " + value;
- }
- message += ")";
- return match(function (actual) {
- if (actual === undefined || actual === null ||
- !propertyTest(actual, property)) {
- return false;
- }
- return onlyProperty || sinon.deepEqual(value, actual[property]);
- }, message);
- };
- }
-
- match.has = createPropertyMatcher(function (actual, property) {
- if (typeof actual === "object") {
- return property in actual;
- }
- return actual[property] !== undefined;
- }, "has");
-
- match.hasOwn = createPropertyMatcher(function (actual, property) {
- return actual.hasOwnProperty(property);
- }, "hasOwn");
-
- match.bool = match.typeOf("boolean");
- match.number = match.typeOf("number");
- match.string = match.typeOf("string");
- match.object = match.typeOf("object");
- match.func = match.typeOf("function");
- match.array = match.typeOf("array");
- match.regexp = match.typeOf("regexp");
- match.date = match.typeOf("date");
-
- sinon.match = match;
- return match;
- }
-
- var isNode = typeof module !== "undefined" && module.exports && typeof require === "function";
- var isAMD = typeof define === "function" && typeof define.amd === "object" && define.amd;
-
- function loadDependencies(require, exports, module) {
- var sinon = require("./util/core");
- require("./typeOf");
- module.exports = makeApi(sinon);
- }
-
- if (isAMD) {
- define(loadDependencies);
- return;
- }
-
- if (isNode) {
- loadDependencies(require, module.exports, module);
- return;
- }
-
- if (sinonGlobal) {
- makeApi(sinonGlobal);
- }
-}(
- typeof sinon === "object" && sinon // eslint-disable-line no-undef
-));
-
-/**
- * @depend util/core.js
- */
-/**
- * Format functions
- *
- * @author Christian Johansen (christian@cjohansen.no)
- * @license BSD
- *
- * Copyright (c) 2010-2014 Christian Johansen
- */
-(function (sinonGlobal, formatio) {
-
- function makeApi(sinon) {
- function valueFormatter(value) {
- return "" + value;
- }
-
- function getFormatioFormatter() {
- var formatter = formatio.configure({
- quoteStrings: false,
- limitChildrenCount: 250
- });
-
- function format() {
- return formatter.ascii.apply(formatter, arguments);
- }
-
- return format;
- }
-
- function getNodeFormatter() {
- try {
- var util = require("util");
- } catch (e) {
- /* Node, but no util module - would be very old, but better safe than sorry */
- }
-
- function format(v) {
- var isObjectWithNativeToString = typeof v === "object" && v.toString === Object.prototype.toString;
- return isObjectWithNativeToString ? util.inspect(v) : v;
- }
-
- return util ? format : valueFormatter;
- }
-
- var isNode = typeof module !== "undefined" && module.exports && typeof require === "function";
- var formatter;
-
- if (isNode) {
- try {
- formatio = require("formatio");
- }
- catch (e) {} // eslint-disable-line no-empty
- }
-
- if (formatio) {
- formatter = getFormatioFormatter();
- } else if (isNode) {
- formatter = getNodeFormatter();
- } else {
- formatter = valueFormatter;
- }
-
- sinon.format = formatter;
- return sinon.format;
- }
-
- function loadDependencies(require, exports, module) {
- var sinon = require("./util/core");
- module.exports = makeApi(sinon);
- }
-
- var isNode = typeof module !== "undefined" && module.exports && typeof require === "function";
- var isAMD = typeof define === "function" && typeof define.amd === "object" && define.amd;
-
- if (isAMD) {
- define(loadDependencies);
- return;
- }
-
- if (isNode) {
- loadDependencies(require, module.exports, module);
- return;
- }
-
- if (sinonGlobal) {
- makeApi(sinonGlobal);
- }
-}(
- typeof sinon === "object" && sinon, // eslint-disable-line no-undef
- typeof formatio === "object" && formatio // eslint-disable-line no-undef
-));
-
-/**
- * @depend util/core.js
- * @depend match.js
- * @depend format.js
- */
-/**
- * Spy calls
- *
- * @author Christian Johansen (christian@cjohansen.no)
- * @author Maximilian Antoni (mail@maxantoni.de)
- * @license BSD
- *
- * Copyright (c) 2010-2013 Christian Johansen
- * Copyright (c) 2013 Maximilian Antoni
- */
-(function (sinonGlobal) {
-
- var slice = Array.prototype.slice;
-
- function makeApi(sinon) {
- function throwYieldError(proxy, text, args) {
- var msg = sinon.functionName(proxy) + text;
- if (args.length) {
- msg += " Received [" + slice.call(args).join(", ") + "]";
- }
- throw new Error(msg);
- }
-
- var callProto = {
- calledOn: function calledOn(thisValue) {
- if (sinon.match && sinon.match.isMatcher(thisValue)) {
- return thisValue.test(this.thisValue);
- }
- return this.thisValue === thisValue;
- },
-
- calledWith: function calledWith() {
- var l = arguments.length;
- if (l > this.args.length) {
- return false;
- }
- for (var i = 0; i < l; i += 1) {
- if (!sinon.deepEqual(arguments[i], this.args[i])) {
- return false;
- }
- }
-
- return true;
- },
-
- calledWithMatch: function calledWithMatch() {
- var l = arguments.length;
- if (l > this.args.length) {
- return false;
- }
- for (var i = 0; i < l; i += 1) {
- var actual = this.args[i];
- var expectation = arguments[i];
- if (!sinon.match || !sinon.match(expectation).test(actual)) {
- return false;
- }
- }
- return true;
- },
-
- calledWithExactly: function calledWithExactly() {
- return arguments.length === this.args.length &&
- this.calledWith.apply(this, arguments);
- },
-
- notCalledWith: function notCalledWith() {
- return !this.calledWith.apply(this, arguments);
- },
-
- notCalledWithMatch: function notCalledWithMatch() {
- return !this.calledWithMatch.apply(this, arguments);
- },
-
- returned: function returned(value) {
- return sinon.deepEqual(value, this.returnValue);
- },
-
- threw: function threw(error) {
- if (typeof error === "undefined" || !this.exception) {
- return !!this.exception;
- }
-
- return this.exception === error || this.exception.name === error;
- },
-
- calledWithNew: function calledWithNew() {
- return this.proxy.prototype && this.thisValue instanceof this.proxy;
- },
-
- calledBefore: function (other) {
- return this.callId < other.callId;
- },
-
- calledAfter: function (other) {
- return this.callId > other.callId;
- },
-
- callArg: function (pos) {
- this.args[pos]();
- },
-
- callArgOn: function (pos, thisValue) {
- this.args[pos].apply(thisValue);
- },
-
- callArgWith: function (pos) {
- this.callArgOnWith.apply(this, [pos, null].concat(slice.call(arguments, 1)));
- },
-
- callArgOnWith: function (pos, thisValue) {
- var args = slice.call(arguments, 2);
- this.args[pos].apply(thisValue, args);
- },
-
- "yield": function () {
- this.yieldOn.apply(this, [null].concat(slice.call(arguments, 0)));
- },
-
- yieldOn: function (thisValue) {
- var args = this.args;
- for (var i = 0, l = args.length; i < l; ++i) {
- if (typeof args[i] === "function") {
- args[i].apply(thisValue, slice.call(arguments, 1));
- return;
- }
- }
- throwYieldError(this.proxy, " cannot yield since no callback was passed.", args);
- },
-
- yieldTo: function (prop) {
- this.yieldToOn.apply(this, [prop, null].concat(slice.call(arguments, 1)));
- },
-
- yieldToOn: function (prop, thisValue) {
- var args = this.args;
- for (var i = 0, l = args.length; i < l; ++i) {
- if (args[i] && typeof args[i][prop] === "function") {
- args[i][prop].apply(thisValue, slice.call(arguments, 2));
- return;
- }
- }
- throwYieldError(this.proxy, " cannot yield to '" + prop +
- "' since no callback was passed.", args);
- },
-
- getStackFrames: function () {
- // Omit the error message and the two top stack frames in sinon itself:
- return this.stack && this.stack.split("\n").slice(3);
- },
-
- toString: function () {
- var callStr = this.proxy ? this.proxy.toString() + "(" : "";
- var args = [];
-
- if (!this.args) {
- return ":(";
- }
-
- for (var i = 0, l = this.args.length; i < l; ++i) {
- args.push(sinon.format(this.args[i]));
- }
-
- callStr = callStr + args.join(", ") + ")";
-
- if (typeof this.returnValue !== "undefined") {
- callStr += " => " + sinon.format(this.returnValue);
- }
-
- if (this.exception) {
- callStr += " !" + this.exception.name;
-
- if (this.exception.message) {
- callStr += "(" + this.exception.message + ")";
- }
- }
- if (this.stack) {
- callStr += this.getStackFrames()[0].replace(/^\s*(?:at\s+|@)?/, " at ");
-
- }
-
- return callStr;
- }
- };
-
- callProto.invokeCallback = callProto.yield;
-
- function createSpyCall(spy, thisValue, args, returnValue, exception, id, stack) {
- if (typeof id !== "number") {
- throw new TypeError("Call id is not a number");
- }
- var proxyCall = sinon.create(callProto);
- proxyCall.proxy = spy;
- proxyCall.thisValue = thisValue;
- proxyCall.args = args;
- proxyCall.returnValue = returnValue;
- proxyCall.exception = exception;
- proxyCall.callId = id;
- proxyCall.stack = stack;
-
- return proxyCall;
- }
- createSpyCall.toString = callProto.toString; // used by mocks
-
- sinon.spyCall = createSpyCall;
- return createSpyCall;
- }
-
- var isNode = typeof module !== "undefined" && module.exports && typeof require === "function";
- var isAMD = typeof define === "function" && typeof define.amd === "object" && define.amd;
-
- function loadDependencies(require, exports, module) {
- var sinon = require("./util/core");
- require("./match");
- require("./format");
- module.exports = makeApi(sinon);
- }
-
- if (isAMD) {
- define(loadDependencies);
- return;
- }
-
- if (isNode) {
- loadDependencies(require, module.exports, module);
- return;
- }
-
- if (sinonGlobal) {
- makeApi(sinonGlobal);
- }
-}(
- typeof sinon === "object" && sinon // eslint-disable-line no-undef
-));
-
-/**
- * @depend times_in_words.js
- * @depend util/core.js
- * @depend extend.js
- * @depend call.js
- * @depend format.js
- */
-/**
- * Spy functions
- *
- * @author Christian Johansen (christian@cjohansen.no)
- * @license BSD
- *
- * Copyright (c) 2010-2013 Christian Johansen
- */
-(function (sinonGlobal) {
-
- function makeApi(sinon) {
- var push = Array.prototype.push;
- var slice = Array.prototype.slice;
- var callId = 0;
-
- function spy(object, property, types) {
- if (!property && typeof object === "function") {
- return spy.create(object);
- }
-
- if (!object && !property) {
- return spy.create(function () { });
- }
-
- if (types) {
- var methodDesc = sinon.getPropertyDescriptor(object, property);
- for (var i = 0; i < types.length; i++) {
- methodDesc[types[i]] = spy.create(methodDesc[types[i]]);
- }
- return sinon.wrapMethod(object, property, methodDesc);
- }
-
- return sinon.wrapMethod(object, property, spy.create(object[property]));
- }
-
- function matchingFake(fakes, args, strict) {
- if (!fakes) {
- return undefined;
- }
-
- for (var i = 0, l = fakes.length; i < l; i++) {
- if (fakes[i].matches(args, strict)) {
- return fakes[i];
- }
- }
- }
-
- function incrementCallCount() {
- this.called = true;
- this.callCount += 1;
- this.notCalled = false;
- this.calledOnce = this.callCount === 1;
- this.calledTwice = this.callCount === 2;
- this.calledThrice = this.callCount === 3;
- }
-
- function createCallProperties() {
- this.firstCall = this.getCall(0);
- this.secondCall = this.getCall(1);
- this.thirdCall = this.getCall(2);
- this.lastCall = this.getCall(this.callCount - 1);
- }
-
- var vars = "a,b,c,d,e,f,g,h,i,j,k,l";
- function createProxy(func, proxyLength) {
- // Retain the function length:
- var p;
- if (proxyLength) {
- eval("p = (function proxy(" + vars.substring(0, proxyLength * 2 - 1) + // eslint-disable-line no-eval
- ") { return p.invoke(func, this, slice.call(arguments)); });");
- } else {
- p = function proxy() {
- return p.invoke(func, this, slice.call(arguments));
- };
- }
- p.isSinonProxy = true;
- return p;
- }
-
- var uuid = 0;
-
- // Public API
- var spyApi = {
- reset: function () {
- if (this.invoking) {
- var err = new Error("Cannot reset Sinon function while invoking it. " +
- "Move the call to .reset outside of the callback.");
- err.name = "InvalidResetException";
- throw err;
- }
-
- this.called = false;
- this.notCalled = true;
- this.calledOnce = false;
- this.calledTwice = false;
- this.calledThrice = false;
- this.callCount = 0;
- this.firstCall = null;
- this.secondCall = null;
- this.thirdCall = null;
- this.lastCall = null;
- this.args = [];
- this.returnValues = [];
- this.thisValues = [];
- this.exceptions = [];
- this.callIds = [];
- this.stacks = [];
- if (this.fakes) {
- for (var i = 0; i < this.fakes.length; i++) {
- this.fakes[i].reset();
- }
- }
-
- return this;
- },
-
- create: function create(func, spyLength) {
- var name;
-
- if (typeof func !== "function") {
- func = function () { };
- } else {
- name = sinon.functionName(func);
- }
-
- if (!spyLength) {
- spyLength = func.length;
- }
-
- var proxy = createProxy(func, spyLength);
-
- sinon.extend(proxy, spy);
- delete proxy.create;
- sinon.extend(proxy, func);
-
- proxy.reset();
- proxy.prototype = func.prototype;
- proxy.displayName = name || "spy";
- proxy.toString = sinon.functionToString;
- proxy.instantiateFake = sinon.spy.create;
- proxy.id = "spy#" + uuid++;
-
- return proxy;
- },
-
- invoke: function invoke(func, thisValue, args) {
- var matching = matchingFake(this.fakes, args);
- var exception, returnValue;
-
- incrementCallCount.call(this);
- push.call(this.thisValues, thisValue);
- push.call(this.args, args);
- push.call(this.callIds, callId++);
-
- // Make call properties available from within the spied function:
- createCallProperties.call(this);
-
- try {
- this.invoking = true;
-
- if (matching) {
- returnValue = matching.invoke(func, thisValue, args);
- } else {
- returnValue = (this.func || func).apply(thisValue, args);
- }
-
- var thisCall = this.getCall(this.callCount - 1);
- if (thisCall.calledWithNew() && typeof returnValue !== "object") {
- returnValue = thisValue;
- }
- } catch (e) {
- exception = e;
- } finally {
- delete this.invoking;
- }
-
- push.call(this.exceptions, exception);
- push.call(this.returnValues, returnValue);
- push.call(this.stacks, new Error().stack);
-
- // Make return value and exception available in the calls:
- createCallProperties.call(this);
-
- if (exception !== undefined) {
- throw exception;
- }
-
- return returnValue;
- },
-
- named: function named(name) {
- this.displayName = name;
- return this;
- },
-
- getCall: function getCall(i) {
- if (i < 0 || i >= this.callCount) {
- return null;
- }
-
- return sinon.spyCall(this, this.thisValues[i], this.args[i],
- this.returnValues[i], this.exceptions[i],
- this.callIds[i], this.stacks[i]);
- },
-
- getCalls: function () {
- var calls = [];
- var i;
-
- for (i = 0; i < this.callCount; i++) {
- calls.push(this.getCall(i));
- }
-
- return calls;
- },
-
- calledBefore: function calledBefore(spyFn) {
- if (!this.called) {
- return false;
- }
-
- if (!spyFn.called) {
- return true;
- }
-
- return this.callIds[0] < spyFn.callIds[spyFn.callIds.length - 1];
- },
-
- calledAfter: function calledAfter(spyFn) {
- if (!this.called || !spyFn.called) {
- return false;
- }
-
- return this.callIds[this.callCount - 1] > spyFn.callIds[spyFn.callCount - 1];
- },
-
- withArgs: function () {
- var args = slice.call(arguments);
-
- if (this.fakes) {
- var match = matchingFake(this.fakes, args, true);
-
- if (match) {
- return match;
- }
- } else {
- this.fakes = [];
- }
-
- var original = this;
- var fake = this.instantiateFake();
- fake.matchingAguments = args;
- fake.parent = this;
- push.call(this.fakes, fake);
-
- fake.withArgs = function () {
- return original.withArgs.apply(original, arguments);
- };
-
- for (var i = 0; i < this.args.length; i++) {
- if (fake.matches(this.args[i])) {
- incrementCallCount.call(fake);
- push.call(fake.thisValues, this.thisValues[i]);
- push.call(fake.args, this.args[i]);
- push.call(fake.returnValues, this.returnValues[i]);
- push.call(fake.exceptions, this.exceptions[i]);
- push.call(fake.callIds, this.callIds[i]);
- }
- }
- createCallProperties.call(fake);
-
- return fake;
- },
-
- matches: function (args, strict) {
- var margs = this.matchingAguments;
-
- if (margs.length <= args.length &&
- sinon.deepEqual(margs, args.slice(0, margs.length))) {
- return !strict || margs.length === args.length;
- }
- },
-
- printf: function (format) {
- var spyInstance = this;
- var args = slice.call(arguments, 1);
- var formatter;
-
- return (format || "").replace(/%(.)/g, function (match, specifyer) {
- formatter = spyApi.formatters[specifyer];
-
- if (typeof formatter === "function") {
- return formatter.call(null, spyInstance, args);
- } else if (!isNaN(parseInt(specifyer, 10))) {
- return sinon.format(args[specifyer - 1]);
- }
-
- return "%" + specifyer;
- });
- }
- };
-
- function delegateToCalls(method, matchAny, actual, notCalled) {
- spyApi[method] = function () {
- if (!this.called) {
- if (notCalled) {
- return notCalled.apply(this, arguments);
- }
- return false;
- }
-
- var currentCall;
- var matches = 0;
-
- for (var i = 0, l = this.callCount; i < l; i += 1) {
- currentCall = this.getCall(i);
-
- if (currentCall[actual || method].apply(currentCall, arguments)) {
- matches += 1;
-
- if (matchAny) {
- return true;
- }
- }
- }
-
- return matches === this.callCount;
- };
- }
-
- delegateToCalls("calledOn", true);
- delegateToCalls("alwaysCalledOn", false, "calledOn");
- delegateToCalls("calledWith", true);
- delegateToCalls("calledWithMatch", true);
- delegateToCalls("alwaysCalledWith", false, "calledWith");
- delegateToCalls("alwaysCalledWithMatch", false, "calledWithMatch");
- delegateToCalls("calledWithExactly", true);
- delegateToCalls("alwaysCalledWithExactly", false, "calledWithExactly");
- delegateToCalls("neverCalledWith", false, "notCalledWith", function () {
- return true;
- });
- delegateToCalls("neverCalledWithMatch", false, "notCalledWithMatch", function () {
- return true;
- });
- delegateToCalls("threw", true);
- delegateToCalls("alwaysThrew", false, "threw");
- delegateToCalls("returned", true);
- delegateToCalls("alwaysReturned", false, "returned");
- delegateToCalls("calledWithNew", true);
- delegateToCalls("alwaysCalledWithNew", false, "calledWithNew");
- delegateToCalls("callArg", false, "callArgWith", function () {
- throw new Error(this.toString() + " cannot call arg since it was not yet invoked.");
- });
- spyApi.callArgWith = spyApi.callArg;
- delegateToCalls("callArgOn", false, "callArgOnWith", function () {
- throw new Error(this.toString() + " cannot call arg since it was not yet invoked.");
- });
- spyApi.callArgOnWith = spyApi.callArgOn;
- delegateToCalls("yield", false, "yield", function () {
- throw new Error(this.toString() + " cannot yield since it was not yet invoked.");
- });
- // "invokeCallback" is an alias for "yield" since "yield" is invalid in strict mode.
- spyApi.invokeCallback = spyApi.yield;
- delegateToCalls("yieldOn", false, "yieldOn", function () {
- throw new Error(this.toString() + " cannot yield since it was not yet invoked.");
- });
- delegateToCalls("yieldTo", false, "yieldTo", function (property) {
- throw new Error(this.toString() + " cannot yield to '" + property +
- "' since it was not yet invoked.");
- });
- delegateToCalls("yieldToOn", false, "yieldToOn", function (property) {
- throw new Error(this.toString() + " cannot yield to '" + property +
- "' since it was not yet invoked.");
- });
-
- spyApi.formatters = {
- c: function (spyInstance) {
- return sinon.timesInWords(spyInstance.callCount);
- },
-
- n: function (spyInstance) {
- return spyInstance.toString();
- },
-
- C: function (spyInstance) {
- var calls = [];
-
- for (var i = 0, l = spyInstance.callCount; i < l; ++i) {
- var stringifiedCall = " " + spyInstance.getCall(i).toString();
- if (/\n/.test(calls[i - 1])) {
- stringifiedCall = "\n" + stringifiedCall;
- }
- push.call(calls, stringifiedCall);
- }
-
- return calls.length > 0 ? "\n" + calls.join("\n") : "";
- },
-
- t: function (spyInstance) {
- var objects = [];
-
- for (var i = 0, l = spyInstance.callCount; i < l; ++i) {
- push.call(objects, sinon.format(spyInstance.thisValues[i]));
- }
-
- return objects.join(", ");
- },
-
- "*": function (spyInstance, args) {
- var formatted = [];
-
- for (var i = 0, l = args.length; i < l; ++i) {
- push.call(formatted, sinon.format(args[i]));
- }
-
- return formatted.join(", ");
- }
- };
-
- sinon.extend(spy, spyApi);
-
- spy.spyCall = sinon.spyCall;
- sinon.spy = spy;
-
- return spy;
- }
-
- var isNode = typeof module !== "undefined" && module.exports && typeof require === "function";
- var isAMD = typeof define === "function" && typeof define.amd === "object" && define.amd;
-
- function loadDependencies(require, exports, module) {
- var core = require("./util/core");
- require("./call");
- require("./extend");
- require("./times_in_words");
- require("./format");
- module.exports = makeApi(core);
- }
-
- if (isAMD) {
- define(loadDependencies);
- return;
- }
-
- if (isNode) {
- loadDependencies(require, module.exports, module);
- return;
- }
-
- if (sinonGlobal) {
- makeApi(sinonGlobal);
- }
-}(
- typeof sinon === "object" && sinon // eslint-disable-line no-undef
-));
-
-/**
- * @depend util/core.js
- * @depend extend.js
- */
-/**
- * Stub behavior
- *
- * @author Christian Johansen (christian@cjohansen.no)
- * @author Tim Fischbach (mail@timfischbach.de)
- * @license BSD
- *
- * Copyright (c) 2010-2013 Christian Johansen
- */
-(function (sinonGlobal) {
-
- var slice = Array.prototype.slice;
- var join = Array.prototype.join;
- var useLeftMostCallback = -1;
- var useRightMostCallback = -2;
-
- var nextTick = (function () {
- if (typeof process === "object" && typeof process.nextTick === "function") {
- return process.nextTick;
- }
-
- if (typeof setImmediate === "function") {
- return setImmediate;
- }
-
- return function (callback) {
- setTimeout(callback, 0);
- };
- })();
-
- function throwsException(error, message) {
- if (typeof error === "string") {
- this.exception = new Error(message || "");
- this.exception.name = error;
- } else if (!error) {
- this.exception = new Error("Error");
- } else {
- this.exception = error;
- }
-
- return this;
- }
-
- function getCallback(behavior, args) {
- var callArgAt = behavior.callArgAt;
-
- if (callArgAt >= 0) {
- return args[callArgAt];
- }
-
- var argumentList;
-
- if (callArgAt === useLeftMostCallback) {
- argumentList = args;
- }
-
- if (callArgAt === useRightMostCallback) {
- argumentList = slice.call(args).reverse();
- }
-
- var callArgProp = behavior.callArgProp;
-
- for (var i = 0, l = argumentList.length; i < l; ++i) {
- if (!callArgProp && typeof argumentList[i] === "function") {
- return argumentList[i];
- }
-
- if (callArgProp && argumentList[i] &&
- typeof argumentList[i][callArgProp] === "function") {
- return argumentList[i][callArgProp];
- }
- }
-
- return null;
- }
-
- function makeApi(sinon) {
- function getCallbackError(behavior, func, args) {
- if (behavior.callArgAt < 0) {
- var msg;
-
- if (behavior.callArgProp) {
- msg = sinon.functionName(behavior.stub) +
- " expected to yield to '" + behavior.callArgProp +
- "', but no object with such a property was passed.";
- } else {
- msg = sinon.functionName(behavior.stub) +
- " expected to yield, but no callback was passed.";
- }
-
- if (args.length > 0) {
- msg += " Received [" + join.call(args, ", ") + "]";
- }
-
- return msg;
- }
-
- return "argument at index " + behavior.callArgAt + " is not a function: " + func;
- }
-
- function callCallback(behavior, args) {
- if (typeof behavior.callArgAt === "number") {
- var func = getCallback(behavior, args);
-
- if (typeof func !== "function") {
- throw new TypeError(getCallbackError(behavior, func, args));
- }
-
- if (behavior.callbackAsync) {
- nextTick(function () {
- func.apply(behavior.callbackContext, behavior.callbackArguments);
- });
- } else {
- func.apply(behavior.callbackContext, behavior.callbackArguments);
- }
- }
- }
-
- var proto = {
- create: function create(stub) {
- var behavior = sinon.extend({}, sinon.behavior);
- delete behavior.create;
- behavior.stub = stub;
-
- return behavior;
- },
-
- isPresent: function isPresent() {
- return (typeof this.callArgAt === "number" ||
- this.exception ||
- typeof this.returnArgAt === "number" ||
- this.returnThis ||
- this.returnValueDefined);
- },
-
- invoke: function invoke(context, args) {
- callCallback(this, args);
-
- if (this.exception) {
- throw this.exception;
- } else if (typeof this.returnArgAt === "number") {
- return args[this.returnArgAt];
- } else if (this.returnThis) {
- return context;
- }
-
- return this.returnValue;
- },
-
- onCall: function onCall(index) {
- return this.stub.onCall(index);
- },
-
- onFirstCall: function onFirstCall() {
- return this.stub.onFirstCall();
- },
-
- onSecondCall: function onSecondCall() {
- return this.stub.onSecondCall();
- },
-
- onThirdCall: function onThirdCall() {
- return this.stub.onThirdCall();
- },
-
- withArgs: function withArgs(/* arguments */) {
- throw new Error(
- "Defining a stub by invoking \"stub.onCall(...).withArgs(...)\" " +
- "is not supported. Use \"stub.withArgs(...).onCall(...)\" " +
- "to define sequential behavior for calls with certain arguments."
- );
- },
-
- callsArg: function callsArg(pos) {
- if (typeof pos !== "number") {
- throw new TypeError("argument index is not number");
- }
-
- this.callArgAt = pos;
- this.callbackArguments = [];
- this.callbackContext = undefined;
- this.callArgProp = undefined;
- this.callbackAsync = false;
-
- return this;
- },
-
- callsArgOn: function callsArgOn(pos, context) {
- if (typeof pos !== "number") {
- throw new TypeError("argument index is not number");
- }
- if (typeof context !== "object") {
- throw new TypeError("argument context is not an object");
- }
-
- this.callArgAt = pos;
- this.callbackArguments = [];
- this.callbackContext = context;
- this.callArgProp = undefined;
- this.callbackAsync = false;
-
- return this;
- },
-
- callsArgWith: function callsArgWith(pos) {
- if (typeof pos !== "number") {
- throw new TypeError("argument index is not number");
- }
-
- this.callArgAt = pos;
- this.callbackArguments = slice.call(arguments, 1);
- this.callbackContext = undefined;
- this.callArgProp = undefined;
- this.callbackAsync = false;
-
- return this;
- },
-
- callsArgOnWith: function callsArgWith(pos, context) {
- if (typeof pos !== "number") {
- throw new TypeError("argument index is not number");
- }
- if (typeof context !== "object") {
- throw new TypeError("argument context is not an object");
- }
-
- this.callArgAt = pos;
- this.callbackArguments = slice.call(arguments, 2);
- this.callbackContext = context;
- this.callArgProp = undefined;
- this.callbackAsync = false;
-
- return this;
- },
-
- yields: function () {
- this.callArgAt = useLeftMostCallback;
- this.callbackArguments = slice.call(arguments, 0);
- this.callbackContext = undefined;
- this.callArgProp = undefined;
- this.callbackAsync = false;
-
- return this;
- },
-
- yieldsRight: function () {
- this.callArgAt = useRightMostCallback;
- this.callbackArguments = slice.call(arguments, 0);
- this.callbackContext = undefined;
- this.callArgProp = undefined;
- this.callbackAsync = false;
-
- return this;
- },
-
- yieldsOn: function (context) {
- if (typeof context !== "object") {
- throw new TypeError("argument context is not an object");
- }
-
- this.callArgAt = useLeftMostCallback;
- this.callbackArguments = slice.call(arguments, 1);
- this.callbackContext = context;
- this.callArgProp = undefined;
- this.callbackAsync = false;
-
- return this;
- },
-
- yieldsTo: function (prop) {
- this.callArgAt = useLeftMostCallback;
- this.callbackArguments = slice.call(arguments, 1);
- this.callbackContext = undefined;
- this.callArgProp = prop;
- this.callbackAsync = false;
-
- return this;
- },
-
- yieldsToOn: function (prop, context) {
- if (typeof context !== "object") {
- throw new TypeError("argument context is not an object");
- }
-
- this.callArgAt = useLeftMostCallback;
- this.callbackArguments = slice.call(arguments, 2);
- this.callbackContext = context;
- this.callArgProp = prop;
- this.callbackAsync = false;
-
- return this;
- },
-
- throws: throwsException,
- throwsException: throwsException,
-
- returns: function returns(value) {
- this.returnValue = value;
- this.returnValueDefined = true;
- this.exception = undefined;
-
- return this;
- },
-
- returnsArg: function returnsArg(pos) {
- if (typeof pos !== "number") {
- throw new TypeError("argument index is not number");
- }
-
- this.returnArgAt = pos;
-
- return this;
- },
-
- returnsThis: function returnsThis() {
- this.returnThis = true;
-
- return this;
- }
- };
-
- function createAsyncVersion(syncFnName) {
- return function () {
- var result = this[syncFnName].apply(this, arguments);
- this.callbackAsync = true;
- return result;
- };
- }
-
- // create asynchronous versions of callsArg* and yields* methods
- for (var method in proto) {
- // need to avoid creating anotherasync versions of the newly added async methods
- if (proto.hasOwnProperty(method) && method.match(/^(callsArg|yields)/) && !method.match(/Async/)) {
- proto[method + "Async"] = createAsyncVersion(method);
- }
- }
-
- sinon.behavior = proto;
- return proto;
- }
-
- var isNode = typeof module !== "undefined" && module.exports && typeof require === "function";
- var isAMD = typeof define === "function" && typeof define.amd === "object" && define.amd;
-
- function loadDependencies(require, exports, module) {
- var sinon = require("./util/core");
- require("./extend");
- module.exports = makeApi(sinon);
- }
-
- if (isAMD) {
- define(loadDependencies);
- return;
- }
-
- if (isNode) {
- loadDependencies(require, module.exports, module);
- return;
- }
-
- if (sinonGlobal) {
- makeApi(sinonGlobal);
- }
-}(
- typeof sinon === "object" && sinon // eslint-disable-line no-undef
-));
-
-/**
- * @depend util/core.js
- */
-(function (sinonGlobal) {
-
- function makeApi(sinon) {
- function walkInternal(obj, iterator, context, originalObj, seen) {
- var proto, prop;
-
- if (typeof Object.getOwnPropertyNames !== "function") {
- // We explicitly want to enumerate through all of the prototype's properties
- // in this case, therefore we deliberately leave out an own property check.
- /* eslint-disable guard-for-in */
- for (prop in obj) {
- iterator.call(context, obj[prop], prop, obj);
- }
- /* eslint-enable guard-for-in */
-
- return;
- }
-
- Object.getOwnPropertyNames(obj).forEach(function (k) {
- if (!seen[k]) {
- seen[k] = true;
- var target = typeof Object.getOwnPropertyDescriptor(obj, k).get === "function" ?
- originalObj : obj;
- iterator.call(context, target[k], k, target);
- }
- });
-
- proto = Object.getPrototypeOf(obj);
- if (proto) {
- walkInternal(proto, iterator, context, originalObj, seen);
- }
- }
-
- /* Public: walks the prototype chain of an object and iterates over every own property
- * name encountered. The iterator is called in the same fashion that Array.prototype.forEach
- * works, where it is passed the value, key, and own object as the 1st, 2nd, and 3rd positional
- * argument, respectively. In cases where Object.getOwnPropertyNames is not available, walk will
- * default to using a simple for..in loop.
- *
- * obj - The object to walk the prototype chain for.
- * iterator - The function to be called on each pass of the walk.
- * context - (Optional) When given, the iterator will be called with this object as the receiver.
- */
- function walk(obj, iterator, context) {
- return walkInternal(obj, iterator, context, obj, {});
- }
-
- sinon.walk = walk;
- return sinon.walk;
- }
-
- function loadDependencies(require, exports, module) {
- var sinon = require("./util/core");
- module.exports = makeApi(sinon);
- }
-
- var isNode = typeof module !== "undefined" && module.exports && typeof require === "function";
- var isAMD = typeof define === "function" && typeof define.amd === "object" && define.amd;
-
- if (isAMD) {
- define(loadDependencies);
- return;
- }
-
- if (isNode) {
- loadDependencies(require, module.exports, module);
- return;
- }
-
- if (sinonGlobal) {
- makeApi(sinonGlobal);
- }
-}(
- typeof sinon === "object" && sinon // eslint-disable-line no-undef
-));
-
-/**
- * @depend util/core.js
- * @depend extend.js
- * @depend spy.js
- * @depend behavior.js
- * @depend walk.js
- */
-/**
- * Stub functions
- *
- * @author Christian Johansen (christian@cjohansen.no)
- * @license BSD
- *
- * Copyright (c) 2010-2013 Christian Johansen
- */
-(function (sinonGlobal) {
-
- function makeApi(sinon) {
- function stub(object, property, func) {
- if (!!func && typeof func !== "function" && typeof func !== "object") {
- throw new TypeError("Custom stub should be a function or a property descriptor");
- }
-
- var wrapper;
-
- if (func) {
- if (typeof func === "function") {
- wrapper = sinon.spy && sinon.spy.create ? sinon.spy.create(func) : func;
- } else {
- wrapper = func;
- if (sinon.spy && sinon.spy.create) {
- var types = sinon.objectKeys(wrapper);
- for (var i = 0; i < types.length; i++) {
- wrapper[types[i]] = sinon.spy.create(wrapper[types[i]]);
- }
- }
- }
- } else {
- var stubLength = 0;
- if (typeof object === "object" && typeof object[property] === "function") {
- stubLength = object[property].length;
- }
- wrapper = stub.create(stubLength);
- }
-
- if (!object && typeof property === "undefined") {
- return sinon.stub.create();
- }
-
- if (typeof property === "undefined" && typeof object === "object") {
- sinon.walk(object || {}, function (value, prop, propOwner) {
- // we don't want to stub things like toString(), valueOf(), etc. so we only stub if the object
- // is not Object.prototype
- if (
- propOwner !== Object.prototype &&
- prop !== "constructor" &&
- typeof sinon.getPropertyDescriptor(propOwner, prop).value === "function"
- ) {
- stub(object, prop);
- }
- });
-
- return object;
- }
-
- return sinon.wrapMethod(object, property, wrapper);
- }
-
-
- /*eslint-disable no-use-before-define*/
- function getParentBehaviour(stubInstance) {
- return (stubInstance.parent && getCurrentBehavior(stubInstance.parent));
- }
-
- function getDefaultBehavior(stubInstance) {
- return stubInstance.defaultBehavior ||
- getParentBehaviour(stubInstance) ||
- sinon.behavior.create(stubInstance);
- }
-
- function getCurrentBehavior(stubInstance) {
- var behavior = stubInstance.behaviors[stubInstance.callCount - 1];
- return behavior && behavior.isPresent() ? behavior : getDefaultBehavior(stubInstance);
- }
- /*eslint-enable no-use-before-define*/
-
- var uuid = 0;
-
- var proto = {
- create: function create(stubLength) {
- var functionStub = function () {
- return getCurrentBehavior(functionStub).invoke(this, arguments);
- };
-
- functionStub.id = "stub#" + uuid++;
- var orig = functionStub;
- functionStub = sinon.spy.create(functionStub, stubLength);
- functionStub.func = orig;
-
- sinon.extend(functionStub, stub);
- functionStub.instantiateFake = sinon.stub.create;
- functionStub.displayName = "stub";
- functionStub.toString = sinon.functionToString;
-
- functionStub.defaultBehavior = null;
- functionStub.behaviors = [];
-
- return functionStub;
- },
-
- resetBehavior: function () {
- var i;
-
- this.defaultBehavior = null;
- this.behaviors = [];
-
- delete this.returnValue;
- delete this.returnArgAt;
- this.returnThis = false;
-
- if (this.fakes) {
- for (i = 0; i < this.fakes.length; i++) {
- this.fakes[i].resetBehavior();
- }
- }
- },
-
- onCall: function onCall(index) {
- if (!this.behaviors[index]) {
- this.behaviors[index] = sinon.behavior.create(this);
- }
-
- return this.behaviors[index];
- },
-
- onFirstCall: function onFirstCall() {
- return this.onCall(0);
- },
-
- onSecondCall: function onSecondCall() {
- return this.onCall(1);
- },
-
- onThirdCall: function onThirdCall() {
- return this.onCall(2);
- }
- };
-
- function createBehavior(behaviorMethod) {
- return function () {
- this.defaultBehavior = this.defaultBehavior || sinon.behavior.create(this);
- this.defaultBehavior[behaviorMethod].apply(this.defaultBehavior, arguments);
- return this;
- };
- }
-
- for (var method in sinon.behavior) {
- if (sinon.behavior.hasOwnProperty(method) &&
- !proto.hasOwnProperty(method) &&
- method !== "create" &&
- method !== "withArgs" &&
- method !== "invoke") {
- proto[method] = createBehavior(method);
- }
- }
-
- sinon.extend(stub, proto);
- sinon.stub = stub;
-
- return stub;
- }
-
- var isNode = typeof module !== "undefined" && module.exports && typeof require === "function";
- var isAMD = typeof define === "function" && typeof define.amd === "object" && define.amd;
-
- function loadDependencies(require, exports, module) {
- var core = require("./util/core");
- require("./behavior");
- require("./spy");
- require("./extend");
- module.exports = makeApi(core);
- }
-
- if (isAMD) {
- define(loadDependencies);
- return;
- }
-
- if (isNode) {
- loadDependencies(require, module.exports, module);
- return;
- }
-
- if (sinonGlobal) {
- makeApi(sinonGlobal);
- }
-}(
- typeof sinon === "object" && sinon // eslint-disable-line no-undef
-));
-
-/**
- * @depend times_in_words.js
- * @depend util/core.js
- * @depend call.js
- * @depend extend.js
- * @depend match.js
- * @depend spy.js
- * @depend stub.js
- * @depend format.js
- */
-/**
- * Mock functions.
- *
- * @author Christian Johansen (christian@cjohansen.no)
- * @license BSD
- *
- * Copyright (c) 2010-2013 Christian Johansen
- */
-(function (sinonGlobal) {
-
- function makeApi(sinon) {
- var push = [].push;
- var match = sinon.match;
-
- function mock(object) {
- // if (typeof console !== undefined && console.warn) {
- // console.warn("mock will be removed from Sinon.JS v2.0");
- // }
-
- if (!object) {
- return sinon.expectation.create("Anonymous mock");
- }
-
- return mock.create(object);
- }
-
- function each(collection, callback) {
- if (!collection) {
- return;
- }
-
- for (var i = 0, l = collection.length; i < l; i += 1) {
- callback(collection[i]);
- }
- }
-
- function arrayEquals(arr1, arr2, compareLength) {
- if (compareLength && (arr1.length !== arr2.length)) {
- return false;
- }
-
- for (var i = 0, l = arr1.length; i < l; i++) {
- if (!sinon.deepEqual(arr1[i], arr2[i])) {
- return false;
- }
- }
- return true;
- }
-
- sinon.extend(mock, {
- create: function create(object) {
- if (!object) {
- throw new TypeError("object is null");
- }
-
- var mockObject = sinon.extend({}, mock);
- mockObject.object = object;
- delete mockObject.create;
-
- return mockObject;
- },
-
- expects: function expects(method) {
- if (!method) {
- throw new TypeError("method is falsy");
- }
-
- if (!this.expectations) {
- this.expectations = {};
- this.proxies = [];
- }
-
- if (!this.expectations[method]) {
- this.expectations[method] = [];
- var mockObject = this;
-
- sinon.wrapMethod(this.object, method, function () {
- return mockObject.invokeMethod(method, this, arguments);
- });
-
- push.call(this.proxies, method);
- }
-
- var expectation = sinon.expectation.create(method);
- push.call(this.expectations[method], expectation);
-
- return expectation;
- },
-
- restore: function restore() {
- var object = this.object;
-
- each(this.proxies, function (proxy) {
- if (typeof object[proxy].restore === "function") {
- object[proxy].restore();
- }
- });
- },
-
- verify: function verify() {
- var expectations = this.expectations || {};
- var messages = [];
- var met = [];
-
- each(this.proxies, function (proxy) {
- each(expectations[proxy], function (expectation) {
- if (!expectation.met()) {
- push.call(messages, expectation.toString());
- } else {
- push.call(met, expectation.toString());
- }
- });
- });
-
- this.restore();
-
- if (messages.length > 0) {
- sinon.expectation.fail(messages.concat(met).join("\n"));
- } else if (met.length > 0) {
- sinon.expectation.pass(messages.concat(met).join("\n"));
- }
-
- return true;
- },
-
- invokeMethod: function invokeMethod(method, thisValue, args) {
- var expectations = this.expectations && this.expectations[method] ? this.expectations[method] : [];
- var expectationsWithMatchingArgs = [];
- var currentArgs = args || [];
- var i, available;
-
- for (i = 0; i < expectations.length; i += 1) {
- var expectedArgs = expectations[i].expectedArguments || [];
- if (arrayEquals(expectedArgs, currentArgs, expectations[i].expectsExactArgCount)) {
- expectationsWithMatchingArgs.push(expectations[i]);
- }
- }
-
- for (i = 0; i < expectationsWithMatchingArgs.length; i += 1) {
- if (!expectationsWithMatchingArgs[i].met() &&
- expectationsWithMatchingArgs[i].allowsCall(thisValue, args)) {
- return expectationsWithMatchingArgs[i].apply(thisValue, args);
- }
- }
-
- var messages = [];
- var exhausted = 0;
-
- for (i = 0; i < expectationsWithMatchingArgs.length; i += 1) {
- if (expectationsWithMatchingArgs[i].allowsCall(thisValue, args)) {
- available = available || expectationsWithMatchingArgs[i];
- } else {
- exhausted += 1;
- }
- }
-
- if (available && exhausted === 0) {
- return available.apply(thisValue, args);
- }
-
- for (i = 0; i < expectations.length; i += 1) {
- push.call(messages, " " + expectations[i].toString());
- }
-
- messages.unshift("Unexpected call: " + sinon.spyCall.toString.call({
- proxy: method,
- args: args
- }));
-
- sinon.expectation.fail(messages.join("\n"));
- }
- });
-
- var times = sinon.timesInWords;
- var slice = Array.prototype.slice;
-
- function callCountInWords(callCount) {
- if (callCount === 0) {
- return "never called";
- }
-
- return "called " + times(callCount);
- }
-
- function expectedCallCountInWords(expectation) {
- var min = expectation.minCalls;
- var max = expectation.maxCalls;
-
- if (typeof min === "number" && typeof max === "number") {
- var str = times(min);
-
- if (min !== max) {
- str = "at least " + str + " and at most " + times(max);
- }
-
- return str;
- }
-
- if (typeof min === "number") {
- return "at least " + times(min);
- }
-
- return "at most " + times(max);
- }
-
- function receivedMinCalls(expectation) {
- var hasMinLimit = typeof expectation.minCalls === "number";
- return !hasMinLimit || expectation.callCount >= expectation.minCalls;
- }
-
- function receivedMaxCalls(expectation) {
- if (typeof expectation.maxCalls !== "number") {
- return false;
- }
-
- return expectation.callCount === expectation.maxCalls;
- }
-
- function verifyMatcher(possibleMatcher, arg) {
- var isMatcher = match && match.isMatcher(possibleMatcher);
-
- return isMatcher && possibleMatcher.test(arg) || true;
- }
-
- sinon.expectation = {
- minCalls: 1,
- maxCalls: 1,
-
- create: function create(methodName) {
- var expectation = sinon.extend(sinon.stub.create(), sinon.expectation);
- delete expectation.create;
- expectation.method = methodName;
-
- return expectation;
- },
-
- invoke: function invoke(func, thisValue, args) {
- this.verifyCallAllowed(thisValue, args);
-
- return sinon.spy.invoke.apply(this, arguments);
- },
-
- atLeast: function atLeast(num) {
- if (typeof num !== "number") {
- throw new TypeError("'" + num + "' is not number");
- }
-
- if (!this.limitsSet) {
- this.maxCalls = null;
- this.limitsSet = true;
- }
-
- this.minCalls = num;
-
- return this;
- },
-
- atMost: function atMost(num) {
- if (typeof num !== "number") {
- throw new TypeError("'" + num + "' is not number");
- }
-
- if (!this.limitsSet) {
- this.minCalls = null;
- this.limitsSet = true;
- }
-
- this.maxCalls = num;
-
- return this;
- },
-
- never: function never() {
- return this.exactly(0);
- },
-
- once: function once() {
- return this.exactly(1);
- },
-
- twice: function twice() {
- return this.exactly(2);
- },
-
- thrice: function thrice() {
- return this.exactly(3);
- },
-
- exactly: function exactly(num) {
- if (typeof num !== "number") {
- throw new TypeError("'" + num + "' is not a number");
- }
-
- this.atLeast(num);
- return this.atMost(num);
- },
-
- met: function met() {
- return !this.failed && receivedMinCalls(this);
- },
-
- verifyCallAllowed: function verifyCallAllowed(thisValue, args) {
- if (receivedMaxCalls(this)) {
- this.failed = true;
- sinon.expectation.fail(this.method + " already called " + times(this.maxCalls));
- }
-
- if ("expectedThis" in this && this.expectedThis !== thisValue) {
- sinon.expectation.fail(this.method + " called with " + thisValue + " as thisValue, expected " +
- this.expectedThis);
- }
-
- if (!("expectedArguments" in this)) {
- return;
- }
-
- if (!args) {
- sinon.expectation.fail(this.method + " received no arguments, expected " +
- sinon.format(this.expectedArguments));
- }
-
- if (args.length < this.expectedArguments.length) {
- sinon.expectation.fail(this.method + " received too few arguments (" + sinon.format(args) +
- "), expected " + sinon.format(this.expectedArguments));
- }
-
- if (this.expectsExactArgCount &&
- args.length !== this.expectedArguments.length) {
- sinon.expectation.fail(this.method + " received too many arguments (" + sinon.format(args) +
- "), expected " + sinon.format(this.expectedArguments));
- }
-
- for (var i = 0, l = this.expectedArguments.length; i < l; i += 1) {
-
- if (!verifyMatcher(this.expectedArguments[i], args[i])) {
- sinon.expectation.fail(this.method + " received wrong arguments " + sinon.format(args) +
- ", didn't match " + this.expectedArguments.toString());
- }
-
- if (!sinon.deepEqual(this.expectedArguments[i], args[i])) {
- sinon.expectation.fail(this.method + " received wrong arguments " + sinon.format(args) +
- ", expected " + sinon.format(this.expectedArguments));
- }
- }
- },
-
- allowsCall: function allowsCall(thisValue, args) {
- if (this.met() && receivedMaxCalls(this)) {
- return false;
- }
-
- if ("expectedThis" in this && this.expectedThis !== thisValue) {
- return false;
- }
-
- if (!("expectedArguments" in this)) {
- return true;
- }
-
- args = args || [];
-
- if (args.length < this.expectedArguments.length) {
- return false;
- }
-
- if (this.expectsExactArgCount &&
- args.length !== this.expectedArguments.length) {
- return false;
- }
-
- for (var i = 0, l = this.expectedArguments.length; i < l; i += 1) {
- if (!verifyMatcher(this.expectedArguments[i], args[i])) {
- return false;
- }
-
- if (!sinon.deepEqual(this.expectedArguments[i], args[i])) {
- return false;
- }
- }
-
- return true;
- },
-
- withArgs: function withArgs() {
- this.expectedArguments = slice.call(arguments);
- return this;
- },
-
- withExactArgs: function withExactArgs() {
- this.withArgs.apply(this, arguments);
- this.expectsExactArgCount = true;
- return this;
- },
-
- on: function on(thisValue) {
- this.expectedThis = thisValue;
- return this;
- },
-
- toString: function () {
- var args = (this.expectedArguments || []).slice();
-
- if (!this.expectsExactArgCount) {
- push.call(args, "[...]");
- }
-
- var callStr = sinon.spyCall.toString.call({
- proxy: this.method || "anonymous mock expectation",
- args: args
- });
-
- var message = callStr.replace(", [...", "[, ...") + " " +
- expectedCallCountInWords(this);
-
- if (this.met()) {
- return "Expectation met: " + message;
- }
-
- return "Expected " + message + " (" +
- callCountInWords(this.callCount) + ")";
- },
-
- verify: function verify() {
- if (!this.met()) {
- sinon.expectation.fail(this.toString());
- } else {
- sinon.expectation.pass(this.toString());
- }
-
- return true;
- },
-
- pass: function pass(message) {
- sinon.assert.pass(message);
- },
-
- fail: function fail(message) {
- var exception = new Error(message);
- exception.name = "ExpectationError";
-
- throw exception;
- }
- };
-
- sinon.mock = mock;
- return mock;
- }
-
- var isNode = typeof module !== "undefined" && module.exports && typeof require === "function";
- var isAMD = typeof define === "function" && typeof define.amd === "object" && define.amd;
-
- function loadDependencies(require, exports, module) {
- var sinon = require("./util/core");
- require("./times_in_words");
- require("./call");
- require("./extend");
- require("./match");
- require("./spy");
- require("./stub");
- require("./format");
-
- module.exports = makeApi(sinon);
- }
-
- if (isAMD) {
- define(loadDependencies);
- return;
- }
-
- if (isNode) {
- loadDependencies(require, module.exports, module);
- return;
- }
-
- if (sinonGlobal) {
- makeApi(sinonGlobal);
- }
-}(
- typeof sinon === "object" && sinon // eslint-disable-line no-undef
-));
-
-/**
- * @depend util/core.js
- * @depend spy.js
- * @depend stub.js
- * @depend mock.js
- */
-/**
- * Collections of stubs, spies and mocks.
- *
- * @author Christian Johansen (christian@cjohansen.no)
- * @license BSD
- *
- * Copyright (c) 2010-2013 Christian Johansen
- */
-(function (sinonGlobal) {
-
- var push = [].push;
- var hasOwnProperty = Object.prototype.hasOwnProperty;
-
- function getFakes(fakeCollection) {
- if (!fakeCollection.fakes) {
- fakeCollection.fakes = [];
- }
-
- return fakeCollection.fakes;
- }
-
- function each(fakeCollection, method) {
- var fakes = getFakes(fakeCollection);
-
- for (var i = 0, l = fakes.length; i < l; i += 1) {
- if (typeof fakes[i][method] === "function") {
- fakes[i][method]();
- }
- }
- }
-
- function compact(fakeCollection) {
- var fakes = getFakes(fakeCollection);
- var i = 0;
- while (i < fakes.length) {
- fakes.splice(i, 1);
- }
- }
-
- function makeApi(sinon) {
- var collection = {
- verify: function resolve() {
- each(this, "verify");
- },
-
- restore: function restore() {
- each(this, "restore");
- compact(this);
- },
-
- reset: function restore() {
- each(this, "reset");
- },
-
- verifyAndRestore: function verifyAndRestore() {
- var exception;
-
- try {
- this.verify();
- } catch (e) {
- exception = e;
- }
-
- this.restore();
-
- if (exception) {
- throw exception;
- }
- },
-
- add: function add(fake) {
- push.call(getFakes(this), fake);
- return fake;
- },
-
- spy: function spy() {
- return this.add(sinon.spy.apply(sinon, arguments));
- },
-
- stub: function stub(object, property, value) {
- if (property) {
- var original = object[property];
-
- if (typeof original !== "function") {
- if (!hasOwnProperty.call(object, property)) {
- throw new TypeError("Cannot stub non-existent own property " + property);
- }
-
- object[property] = value;
-
- return this.add({
- restore: function () {
- object[property] = original;
- }
- });
- }
- }
- if (!property && !!object && typeof object === "object") {
- var stubbedObj = sinon.stub.apply(sinon, arguments);
-
- for (var prop in stubbedObj) {
- if (typeof stubbedObj[prop] === "function") {
- this.add(stubbedObj[prop]);
- }
- }
-
- return stubbedObj;
- }
-
- return this.add(sinon.stub.apply(sinon, arguments));
- },
-
- mock: function mock() {
- return this.add(sinon.mock.apply(sinon, arguments));
- },
-
- inject: function inject(obj) {
- var col = this;
-
- obj.spy = function () {
- return col.spy.apply(col, arguments);
- };
-
- obj.stub = function () {
- return col.stub.apply(col, arguments);
- };
-
- obj.mock = function () {
- return col.mock.apply(col, arguments);
- };
-
- return obj;
- }
- };
-
- sinon.collection = collection;
- return collection;
- }
-
- var isNode = typeof module !== "undefined" && module.exports && typeof require === "function";
- var isAMD = typeof define === "function" && typeof define.amd === "object" && define.amd;
-
- function loadDependencies(require, exports, module) {
- var sinon = require("./util/core");
- require("./mock");
- require("./spy");
- require("./stub");
- module.exports = makeApi(sinon);
- }
-
- if (isAMD) {
- define(loadDependencies);
- return;
- }
-
- if (isNode) {
- loadDependencies(require, module.exports, module);
- return;
- }
-
- if (sinonGlobal) {
- makeApi(sinonGlobal);
- }
-}(
- typeof sinon === "object" && sinon // eslint-disable-line no-undef
-));
-
-/**
- * Fake timer API
- * setTimeout
- * setInterval
- * clearTimeout
- * clearInterval
- * tick
- * reset
- * Date
- *
- * Inspired by jsUnitMockTimeOut from JsUnit
- *
- * @author Christian Johansen (christian@cjohansen.no)
- * @license BSD
- *
- * Copyright (c) 2010-2013 Christian Johansen
- */
-(function () {
-
- function makeApi(s, lol) {
- /*global lolex */
- var llx = typeof lolex !== "undefined" ? lolex : lol;
-
- s.useFakeTimers = function () {
- var now;
- var methods = Array.prototype.slice.call(arguments);
-
- if (typeof methods[0] === "string") {
- now = 0;
- } else {
- now = methods.shift();
- }
-
- var clock = llx.install(now || 0, methods);
- clock.restore = clock.uninstall;
- return clock;
- };
-
- s.clock = {
- create: function (now) {
- return llx.createClock(now);
- }
- };
-
- s.timers = {
- setTimeout: setTimeout,
- clearTimeout: clearTimeout,
- setImmediate: (typeof setImmediate !== "undefined" ? setImmediate : undefined),
- clearImmediate: (typeof clearImmediate !== "undefined" ? clearImmediate : undefined),
- setInterval: setInterval,
- clearInterval: clearInterval,
- Date: Date
- };
- }
-
- var isNode = typeof module !== "undefined" && module.exports && typeof require === "function";
- var isAMD = typeof define === "function" && typeof define.amd === "object" && define.amd;
-
- function loadDependencies(require, epxorts, module, lolex) {
- var core = require("./core");
- makeApi(core, lolex);
- module.exports = core;
- }
-
- if (isAMD) {
- define(loadDependencies);
- } else if (isNode) {
- loadDependencies(require, module.exports, module, require("lolex"));
- } else {
- makeApi(sinon); // eslint-disable-line no-undef
- }
-}());
-
-/**
- * Minimal Event interface implementation
- *
- * Original implementation by Sven Fuchs: https://gist.github.com/995028
- * Modifications and tests by Christian Johansen.
- *
- * @author Sven Fuchs (svenfuchs@artweb-design.de)
- * @author Christian Johansen (christian@cjohansen.no)
- * @license BSD
- *
- * Copyright (c) 2011 Sven Fuchs, Christian Johansen
- */
-if (typeof sinon === "undefined") {
- this.sinon = {};
-}
-
-(function () {
-
- var push = [].push;
-
- function makeApi(sinon) {
- sinon.Event = function Event(type, bubbles, cancelable, target) {
- this.initEvent(type, bubbles, cancelable, target);
- };
-
- sinon.Event.prototype = {
- initEvent: function (type, bubbles, cancelable, target) {
- this.type = type;
- this.bubbles = bubbles;
- this.cancelable = cancelable;
- this.target = target;
- },
-
- stopPropagation: function () {},
-
- preventDefault: function () {
- this.defaultPrevented = true;
- }
- };
-
- sinon.ProgressEvent = function ProgressEvent(type, progressEventRaw, target) {
- this.initEvent(type, false, false, target);
- this.loaded = progressEventRaw.loaded || null;
- this.total = progressEventRaw.total || null;
- this.lengthComputable = !!progressEventRaw.total;
- };
-
- sinon.ProgressEvent.prototype = new sinon.Event();
-
- sinon.ProgressEvent.prototype.constructor = sinon.ProgressEvent;
-
- sinon.CustomEvent = function CustomEvent(type, customData, target) {
- this.initEvent(type, false, false, target);
- this.detail = customData.detail || null;
- };
-
- sinon.CustomEvent.prototype = new sinon.Event();
-
- sinon.CustomEvent.prototype.constructor = sinon.CustomEvent;
-
- sinon.EventTarget = {
- addEventListener: function addEventListener(event, listener) {
- this.eventListeners = this.eventListeners || {};
- this.eventListeners[event] = this.eventListeners[event] || [];
- push.call(this.eventListeners[event], listener);
- },
-
- removeEventListener: function removeEventListener(event, listener) {
- var listeners = this.eventListeners && this.eventListeners[event] || [];
-
- for (var i = 0, l = listeners.length; i < l; ++i) {
- if (listeners[i] === listener) {
- return listeners.splice(i, 1);
- }
- }
- },
-
- dispatchEvent: function dispatchEvent(event) {
- var type = event.type;
- var listeners = this.eventListeners && this.eventListeners[type] || [];
-
- for (var i = 0; i < listeners.length; i++) {
- if (typeof listeners[i] === "function") {
- listeners[i].call(this, event);
- } else {
- listeners[i].handleEvent(event);
- }
- }
-
- return !!event.defaultPrevented;
- }
- };
- }
-
- var isNode = typeof module !== "undefined" && module.exports && typeof require === "function";
- var isAMD = typeof define === "function" && typeof define.amd === "object" && define.amd;
-
- function loadDependencies(require) {
- var sinon = require("./core");
- makeApi(sinon);
- }
-
- if (isAMD) {
- define(loadDependencies);
- } else if (isNode) {
- loadDependencies(require);
- } else {
- makeApi(sinon); // eslint-disable-line no-undef
- }
-}());
-
-/**
- * @depend util/core.js
- */
-/**
- * Logs errors
- *
- * @author Christian Johansen (christian@cjohansen.no)
- * @license BSD
- *
- * Copyright (c) 2010-2014 Christian Johansen
- */
-(function (sinonGlobal) {
-
- // cache a reference to setTimeout, so that our reference won't be stubbed out
- // when using fake timers and errors will still get logged
- // https://github.com/cjohansen/Sinon.JS/issues/381
- var realSetTimeout = setTimeout;
-
- function makeApi(sinon) {
-
- function log() {}
-
- function logError(label, err) {
- var msg = label + " threw exception: ";
-
- function throwLoggedError() {
- err.message = msg + err.message;
- throw err;
- }
-
- sinon.log(msg + "[" + err.name + "] " + err.message);
-
- if (err.stack) {
- sinon.log(err.stack);
- }
-
- if (logError.useImmediateExceptions) {
- throwLoggedError();
- } else {
- logError.setTimeout(throwLoggedError, 0);
- }
- }
-
- // When set to true, any errors logged will be thrown immediately;
- // If set to false, the errors will be thrown in separate execution frame.
- logError.useImmediateExceptions = false;
-
- // wrap realSetTimeout with something we can stub in tests
- logError.setTimeout = function (func, timeout) {
- realSetTimeout(func, timeout);
- };
-
- var exports = {};
- exports.log = sinon.log = log;
- exports.logError = sinon.logError = logError;
-
- return exports;
- }
-
- function loadDependencies(require, exports, module) {
- var sinon = require("./util/core");
- module.exports = makeApi(sinon);
- }
-
- var isNode = typeof module !== "undefined" && module.exports && typeof require === "function";
- var isAMD = typeof define === "function" && typeof define.amd === "object" && define.amd;
-
- if (isAMD) {
- define(loadDependencies);
- return;
- }
-
- if (isNode) {
- loadDependencies(require, module.exports, module);
- return;
- }
-
- if (sinonGlobal) {
- makeApi(sinonGlobal);
- }
-}(
- typeof sinon === "object" && sinon // eslint-disable-line no-undef
-));
-
-/**
- * @depend core.js
- * @depend ../extend.js
- * @depend event.js
- * @depend ../log_error.js
- */
-/**
- * Fake XDomainRequest object
- */
-
-/**
- * Returns the global to prevent assigning values to 'this' when this is undefined.
- * This can occur when files are interpreted by node in strict mode.
- * @private
- */
-function getGlobal() {
-
- return typeof window !== "undefined" ? window : global;
-}
-
-if (typeof sinon === "undefined") {
- if (typeof this === "undefined") {
- getGlobal().sinon = {};
- } else {
- this.sinon = {};
- }
-}
-
-// wrapper for global
-(function (global) {
-
- var xdr = { XDomainRequest: global.XDomainRequest };
- xdr.GlobalXDomainRequest = global.XDomainRequest;
- xdr.supportsXDR = typeof xdr.GlobalXDomainRequest !== "undefined";
- xdr.workingXDR = xdr.supportsXDR ? xdr.GlobalXDomainRequest : false;
-
- function makeApi(sinon) {
- sinon.xdr = xdr;
-
- function FakeXDomainRequest() {
- this.readyState = FakeXDomainRequest.UNSENT;
- this.requestBody = null;
- this.requestHeaders = {};
- this.status = 0;
- this.timeout = null;
-
- if (typeof FakeXDomainRequest.onCreate === "function") {
- FakeXDomainRequest.onCreate(this);
- }
- }
-
- function verifyState(x) {
- if (x.readyState !== FakeXDomainRequest.OPENED) {
- throw new Error("INVALID_STATE_ERR");
- }
-
- if (x.sendFlag) {
- throw new Error("INVALID_STATE_ERR");
- }
- }
-
- function verifyRequestSent(x) {
- if (x.readyState === FakeXDomainRequest.UNSENT) {
- throw new Error("Request not sent");
- }
- if (x.readyState === FakeXDomainRequest.DONE) {
- throw new Error("Request done");
- }
- }
-
- function verifyResponseBodyType(body) {
- if (typeof body !== "string") {
- var error = new Error("Attempted to respond to fake XDomainRequest with " +
- body + ", which is not a string.");
- error.name = "InvalidBodyException";
- throw error;
- }
- }
-
- sinon.extend(FakeXDomainRequest.prototype, sinon.EventTarget, {
- open: function open(method, url) {
- this.method = method;
- this.url = url;
-
- this.responseText = null;
- this.sendFlag = false;
-
- this.readyStateChange(FakeXDomainRequest.OPENED);
- },
-
- readyStateChange: function readyStateChange(state) {
- this.readyState = state;
- var eventName = "";
- switch (this.readyState) {
- case FakeXDomainRequest.UNSENT:
- break;
- case FakeXDomainRequest.OPENED:
- break;
- case FakeXDomainRequest.LOADING:
- if (this.sendFlag) {
- //raise the progress event
- eventName = "onprogress";
- }
- break;
- case FakeXDomainRequest.DONE:
- if (this.isTimeout) {
- eventName = "ontimeout";
- } else if (this.errorFlag || (this.status < 200 || this.status > 299)) {
- eventName = "onerror";
- } else {
- eventName = "onload";
- }
- break;
- }
-
- // raising event (if defined)
- if (eventName) {
- if (typeof this[eventName] === "function") {
- try {
- this[eventName]();
- } catch (e) {
- sinon.logError("Fake XHR " + eventName + " handler", e);
- }
- }
- }
- },
-
- send: function send(data) {
- verifyState(this);
-
- if (!/^(get|head)$/i.test(this.method)) {
- this.requestBody = data;
- }
- this.requestHeaders["Content-Type"] = "text/plain;charset=utf-8";
-
- this.errorFlag = false;
- this.sendFlag = true;
- this.readyStateChange(FakeXDomainRequest.OPENED);
-
- if (typeof this.onSend === "function") {
- this.onSend(this);
- }
- },
-
- abort: function abort() {
- this.aborted = true;
- this.responseText = null;
- this.errorFlag = true;
-
- if (this.readyState > sinon.FakeXDomainRequest.UNSENT && this.sendFlag) {
- this.readyStateChange(sinon.FakeXDomainRequest.DONE);
- this.sendFlag = false;
- }
- },
-
- setResponseBody: function setResponseBody(body) {
- verifyRequestSent(this);
- verifyResponseBodyType(body);
-
- var chunkSize = this.chunkSize || 10;
- var index = 0;
- this.responseText = "";
-
- do {
- this.readyStateChange(FakeXDomainRequest.LOADING);
- this.responseText += body.substring(index, index + chunkSize);
- index += chunkSize;
- } while (index < body.length);
-
- this.readyStateChange(FakeXDomainRequest.DONE);
- },
-
- respond: function respond(status, contentType, body) {
- // content-type ignored, since XDomainRequest does not carry this
- // we keep the same syntax for respond(...) as for FakeXMLHttpRequest to ease
- // test integration across browsers
- this.status = typeof status === "number" ? status : 200;
- this.setResponseBody(body || "");
- },
-
- simulatetimeout: function simulatetimeout() {
- this.status = 0;
- this.isTimeout = true;
- // Access to this should actually throw an error
- this.responseText = undefined;
- this.readyStateChange(FakeXDomainRequest.DONE);
- }
- });
-
- sinon.extend(FakeXDomainRequest, {
- UNSENT: 0,
- OPENED: 1,
- LOADING: 3,
- DONE: 4
- });
-
- sinon.useFakeXDomainRequest = function useFakeXDomainRequest() {
- sinon.FakeXDomainRequest.restore = function restore(keepOnCreate) {
- if (xdr.supportsXDR) {
- global.XDomainRequest = xdr.GlobalXDomainRequest;
- }
-
- delete sinon.FakeXDomainRequest.restore;
-
- if (keepOnCreate !== true) {
- delete sinon.FakeXDomainRequest.onCreate;
- }
- };
- if (xdr.supportsXDR) {
- global.XDomainRequest = sinon.FakeXDomainRequest;
- }
- return sinon.FakeXDomainRequest;
- };
-
- sinon.FakeXDomainRequest = FakeXDomainRequest;
- }
-
- var isNode = typeof module !== "undefined" && module.exports && typeof require === "function";
- var isAMD = typeof define === "function" && typeof define.amd === "object" && define.amd;
-
- function loadDependencies(require, exports, module) {
- var sinon = require("./core");
- require("../extend");
- require("./event");
- require("../log_error");
- makeApi(sinon);
- module.exports = sinon;
- }
-
- if (isAMD) {
- define(loadDependencies);
- } else if (isNode) {
- loadDependencies(require, module.exports, module);
- } else {
- makeApi(sinon); // eslint-disable-line no-undef
- }
-})(typeof global !== "undefined" ? global : self);
-
-/**
- * @depend core.js
- * @depend ../extend.js
- * @depend event.js
- * @depend ../log_error.js
- */
-/**
- * Fake XMLHttpRequest object
- *
- * @author Christian Johansen (christian@cjohansen.no)
- * @license BSD
- *
- * Copyright (c) 2010-2013 Christian Johansen
- */
-(function (sinonGlobal, global) {
-
- function getWorkingXHR(globalScope) {
- var supportsXHR = typeof globalScope.XMLHttpRequest !== "undefined";
- if (supportsXHR) {
- return globalScope.XMLHttpRequest;
- }
-
- var supportsActiveX = typeof globalScope.ActiveXObject !== "undefined";
- if (supportsActiveX) {
- return function () {
- return new globalScope.ActiveXObject("MSXML2.XMLHTTP.3.0");
- };
- }
-
- return false;
- }
-
- var supportsProgress = typeof ProgressEvent !== "undefined";
- var supportsCustomEvent = typeof CustomEvent !== "undefined";
- var supportsFormData = typeof FormData !== "undefined";
- var supportsArrayBuffer = typeof ArrayBuffer !== "undefined";
- var supportsBlob = typeof Blob === "function";
- var sinonXhr = { XMLHttpRequest: global.XMLHttpRequest };
- sinonXhr.GlobalXMLHttpRequest = global.XMLHttpRequest;
- sinonXhr.GlobalActiveXObject = global.ActiveXObject;
- sinonXhr.supportsActiveX = typeof sinonXhr.GlobalActiveXObject !== "undefined";
- sinonXhr.supportsXHR = typeof sinonXhr.GlobalXMLHttpRequest !== "undefined";
- sinonXhr.workingXHR = getWorkingXHR(global);
- sinonXhr.supportsCORS = sinonXhr.supportsXHR && "withCredentials" in (new sinonXhr.GlobalXMLHttpRequest());
-
- var unsafeHeaders = {
- "Accept-Charset": true,
- "Accept-Encoding": true,
- Connection: true,
- "Content-Length": true,
- Cookie: true,
- Cookie2: true,
- "Content-Transfer-Encoding": true,
- Date: true,
- Expect: true,
- Host: true,
- "Keep-Alive": true,
- Referer: true,
- TE: true,
- Trailer: true,
- "Transfer-Encoding": true,
- Upgrade: true,
- "User-Agent": true,
- Via: true
- };
-
- // An upload object is created for each
- // FakeXMLHttpRequest and allows upload
- // events to be simulated using uploadProgress
- // and uploadError.
- function UploadProgress() {
- this.eventListeners = {
- progress: [],
- load: [],
- abort: [],
- error: []
- };
- }
-
- UploadProgress.prototype.addEventListener = function addEventListener(event, listener) {
- this.eventListeners[event].push(listener);
- };
-
- UploadProgress.prototype.removeEventListener = function removeEventListener(event, listener) {
- var listeners = this.eventListeners[event] || [];
-
- for (var i = 0, l = listeners.length; i < l; ++i) {
- if (listeners[i] === listener) {
- return listeners.splice(i, 1);
- }
- }
- };
-
- UploadProgress.prototype.dispatchEvent = function dispatchEvent(event) {
- var listeners = this.eventListeners[event.type] || [];
-
- for (var i = 0, listener; (listener = listeners[i]) != null; i++) {
- listener(event);
- }
- };
-
- // Note that for FakeXMLHttpRequest to work pre ES5
- // we lose some of the alignment with the spec.
- // To ensure as close a match as possible,
- // set responseType before calling open, send or respond;
- function FakeXMLHttpRequest() {
- this.readyState = FakeXMLHttpRequest.UNSENT;
- this.requestHeaders = {};
- this.requestBody = null;
- this.status = 0;
- this.statusText = "";
- this.upload = new UploadProgress();
- this.responseType = "";
- this.response = "";
- if (sinonXhr.supportsCORS) {
- this.withCredentials = false;
- }
-
- var xhr = this;
- var events = ["loadstart", "load", "abort", "loadend"];
-
- function addEventListener(eventName) {
- xhr.addEventListener(eventName, function (event) {
- var listener = xhr["on" + eventName];
-
- if (listener && typeof listener === "function") {
- listener.call(this, event);
- }
- });
- }
-
- for (var i = events.length - 1; i >= 0; i--) {
- addEventListener(events[i]);
- }
-
- if (typeof FakeXMLHttpRequest.onCreate === "function") {
- FakeXMLHttpRequest.onCreate(this);
- }
- }
-
- function verifyState(xhr) {
- if (xhr.readyState !== FakeXMLHttpRequest.OPENED) {
- throw new Error("INVALID_STATE_ERR");
- }
-
- if (xhr.sendFlag) {
- throw new Error("INVALID_STATE_ERR");
- }
- }
-
- function getHeader(headers, header) {
- header = header.toLowerCase();
-
- for (var h in headers) {
- if (h.toLowerCase() === header) {
- return h;
- }
- }
-
- return null;
- }
-
- // filtering to enable a white-list version of Sinon FakeXhr,
- // where whitelisted requests are passed through to real XHR
- function each(collection, callback) {
- if (!collection) {
- return;
- }
-
- for (var i = 0, l = collection.length; i < l; i += 1) {
- callback(collection[i]);
- }
- }
- function some(collection, callback) {
- for (var index = 0; index < collection.length; index++) {
- if (callback(collection[index]) === true) {
- return true;
- }
- }
- return false;
- }
- // largest arity in XHR is 5 - XHR#open
- var apply = function (obj, method, args) {
- switch (args.length) {
- case 0: return obj[method]();
- case 1: return obj[method](args[0]);
- case 2: return obj[method](args[0], args[1]);
- case 3: return obj[method](args[0], args[1], args[2]);
- case 4: return obj[method](args[0], args[1], args[2], args[3]);
- case 5: return obj[method](args[0], args[1], args[2], args[3], args[4]);
- }
- };
-
- FakeXMLHttpRequest.filters = [];
- FakeXMLHttpRequest.addFilter = function addFilter(fn) {
- this.filters.push(fn);
- };
- var IE6Re = /MSIE 6/;
- FakeXMLHttpRequest.defake = function defake(fakeXhr, xhrArgs) {
- var xhr = new sinonXhr.workingXHR(); // eslint-disable-line new-cap
-
- each([
- "open",
- "setRequestHeader",
- "send",
- "abort",
- "getResponseHeader",
- "getAllResponseHeaders",
- "addEventListener",
- "overrideMimeType",
- "removeEventListener"
- ], function (method) {
- fakeXhr[method] = function () {
- return apply(xhr, method, arguments);
- };
- });
-
- var copyAttrs = function (args) {
- each(args, function (attr) {
- try {
- fakeXhr[attr] = xhr[attr];
- } catch (e) {
- if (!IE6Re.test(navigator.userAgent)) {
- throw e;
- }
- }
- });
- };
-
- var stateChange = function stateChange() {
- fakeXhr.readyState = xhr.readyState;
- if (xhr.readyState >= FakeXMLHttpRequest.HEADERS_RECEIVED) {
- copyAttrs(["status", "statusText"]);
- }
- if (xhr.readyState >= FakeXMLHttpRequest.LOADING) {
- copyAttrs(["responseText", "response"]);
- }
- if (xhr.readyState === FakeXMLHttpRequest.DONE) {
- copyAttrs(["responseXML"]);
- }
- if (fakeXhr.onreadystatechange) {
- fakeXhr.onreadystatechange.call(fakeXhr, { target: fakeXhr });
- }
- };
-
- if (xhr.addEventListener) {
- for (var event in fakeXhr.eventListeners) {
- if (fakeXhr.eventListeners.hasOwnProperty(event)) {
-
- /*eslint-disable no-loop-func*/
- each(fakeXhr.eventListeners[event], function (handler) {
- xhr.addEventListener(event, handler);
- });
- /*eslint-enable no-loop-func*/
- }
- }
- xhr.addEventListener("readystatechange", stateChange);
- } else {
- xhr.onreadystatechange = stateChange;
- }
- apply(xhr, "open", xhrArgs);
- };
- FakeXMLHttpRequest.useFilters = false;
-
- function verifyRequestOpened(xhr) {
- if (xhr.readyState !== FakeXMLHttpRequest.OPENED) {
- throw new Error("INVALID_STATE_ERR - " + xhr.readyState);
- }
- }
-
- function verifyRequestSent(xhr) {
- if (xhr.readyState === FakeXMLHttpRequest.DONE) {
- throw new Error("Request done");
- }
- }
-
- function verifyHeadersReceived(xhr) {
- if (xhr.async && xhr.readyState !== FakeXMLHttpRequest.HEADERS_RECEIVED) {
- throw new Error("No headers received");
- }
- }
-
- function verifyResponseBodyType(body) {
- if (typeof body !== "string") {
- var error = new Error("Attempted to respond to fake XMLHttpRequest with " +
- body + ", which is not a string.");
- error.name = "InvalidBodyException";
- throw error;
- }
- }
-
- function convertToArrayBuffer(body) {
- var buffer = new ArrayBuffer(body.length);
- var view = new Uint8Array(buffer);
- for (var i = 0; i < body.length; i++) {
- var charCode = body.charCodeAt(i);
- if (charCode >= 256) {
- throw new TypeError("arraybuffer or blob responseTypes require binary string, " +
- "invalid character " + body[i] + " found.");
- }
- view[i] = charCode;
- }
- return buffer;
- }
-
- function isXmlContentType(contentType) {
- return !contentType || /(text\/xml)|(application\/xml)|(\+xml)/.test(contentType);
- }
-
- function convertResponseBody(responseType, contentType, body) {
- if (responseType === "" || responseType === "text") {
- return body;
- } else if (supportsArrayBuffer && responseType === "arraybuffer") {
- return convertToArrayBuffer(body);
- } else if (responseType === "json") {
- try {
- return JSON.parse(body);
- } catch (e) {
- // Return parsing failure as null
- return null;
- }
- } else if (supportsBlob && responseType === "blob") {
- var blobOptions = {};
- if (contentType) {
- blobOptions.type = contentType;
- }
- return new Blob([convertToArrayBuffer(body)], blobOptions);
- } else if (responseType === "document") {
- if (isXmlContentType(contentType)) {
- return FakeXMLHttpRequest.parseXML(body);
- }
- return null;
- }
- throw new Error("Invalid responseType " + responseType);
- }
-
- function clearResponse(xhr) {
- if (xhr.responseType === "" || xhr.responseType === "text") {
- xhr.response = xhr.responseText = "";
- } else {
- xhr.response = xhr.responseText = null;
- }
- xhr.responseXML = null;
- }
-
- FakeXMLHttpRequest.parseXML = function parseXML(text) {
- // Treat empty string as parsing failure
- if (text !== "") {
- try {
- if (typeof DOMParser !== "undefined") {
- var parser = new DOMParser();
- return parser.parseFromString(text, "text/xml");
- }
- var xmlDoc = new window.ActiveXObject("Microsoft.XMLDOM");
- xmlDoc.async = "false";
- xmlDoc.loadXML(text);
- return xmlDoc;
- } catch (e) {
- // Unable to parse XML - no biggie
- }
- }
-
- return null;
- };
-
- FakeXMLHttpRequest.statusCodes = {
- 100: "Continue",
- 101: "Switching Protocols",
- 200: "OK",
- 201: "Created",
- 202: "Accepted",
- 203: "Non-Authoritative Information",
- 204: "No Content",
- 205: "Reset Content",
- 206: "Partial Content",
- 207: "Multi-Status",
- 300: "Multiple Choice",
- 301: "Moved Permanently",
- 302: "Found",
- 303: "See Other",
- 304: "Not Modified",
- 305: "Use Proxy",
- 307: "Temporary Redirect",
- 400: "Bad Request",
- 401: "Unauthorized",
- 402: "Payment Required",
- 403: "Forbidden",
- 404: "Not Found",
- 405: "Method Not Allowed",
- 406: "Not Acceptable",
- 407: "Proxy Authentication Required",
- 408: "Request Timeout",
- 409: "Conflict",
- 410: "Gone",
- 411: "Length Required",
- 412: "Precondition Failed",
- 413: "Request Entity Too Large",
- 414: "Request-URI Too Long",
- 415: "Unsupported Media Type",
- 416: "Requested Range Not Satisfiable",
- 417: "Expectation Failed",
- 422: "Unprocessable Entity",
- 500: "Internal Server Error",
- 501: "Not Implemented",
- 502: "Bad Gateway",
- 503: "Service Unavailable",
- 504: "Gateway Timeout",
- 505: "HTTP Version Not Supported"
- };
-
- function makeApi(sinon) {
- sinon.xhr = sinonXhr;
-
- sinon.extend(FakeXMLHttpRequest.prototype, sinon.EventTarget, {
- async: true,
-
- open: function open(method, url, async, username, password) {
- this.method = method;
- this.url = url;
- this.async = typeof async === "boolean" ? async : true;
- this.username = username;
- this.password = password;
- clearResponse(this);
- this.requestHeaders = {};
- this.sendFlag = false;
-
- if (FakeXMLHttpRequest.useFilters === true) {
- var xhrArgs = arguments;
- var defake = some(FakeXMLHttpRequest.filters, function (filter) {
- return filter.apply(this, xhrArgs);
- });
- if (defake) {
- return FakeXMLHttpRequest.defake(this, arguments);
- }
- }
- this.readyStateChange(FakeXMLHttpRequest.OPENED);
- },
-
- readyStateChange: function readyStateChange(state) {
- this.readyState = state;
-
- var readyStateChangeEvent = new sinon.Event("readystatechange", false, false, this);
-
- if (typeof this.onreadystatechange === "function") {
- try {
- this.onreadystatechange(readyStateChangeEvent);
- } catch (e) {
- sinon.logError("Fake XHR onreadystatechange handler", e);
- }
- }
-
- switch (this.readyState) {
- case FakeXMLHttpRequest.DONE:
- if (supportsProgress) {
- this.upload.dispatchEvent(new sinon.ProgressEvent("progress", {loaded: 100, total: 100}));
- this.dispatchEvent(new sinon.ProgressEvent("progress", {loaded: 100, total: 100}));
- }
- this.upload.dispatchEvent(new sinon.Event("load", false, false, this));
- this.dispatchEvent(new sinon.Event("load", false, false, this));
- this.dispatchEvent(new sinon.Event("loadend", false, false, this));
- break;
- }
-
- this.dispatchEvent(readyStateChangeEvent);
- },
-
- setRequestHeader: function setRequestHeader(header, value) {
- verifyState(this);
-
- if (unsafeHeaders[header] || /^(Sec-|Proxy-)/.test(header)) {
- throw new Error("Refused to set unsafe header \"" + header + "\"");
- }
-
- if (this.requestHeaders[header]) {
- this.requestHeaders[header] += "," + value;
- } else {
- this.requestHeaders[header] = value;
- }
- },
-
- // Helps testing
- setResponseHeaders: function setResponseHeaders(headers) {
- verifyRequestOpened(this);
- this.responseHeaders = {};
-
- for (var header in headers) {
- if (headers.hasOwnProperty(header)) {
- this.responseHeaders[header] = headers[header];
- }
- }
-
- if (this.async) {
- this.readyStateChange(FakeXMLHttpRequest.HEADERS_RECEIVED);
- } else {
- this.readyState = FakeXMLHttpRequest.HEADERS_RECEIVED;
- }
- },
-
- // Currently treats ALL data as a DOMString (i.e. no Document)
- send: function send(data) {
- verifyState(this);
-
- if (!/^(get|head)$/i.test(this.method)) {
- var contentType = getHeader(this.requestHeaders, "Content-Type");
- if (this.requestHeaders[contentType]) {
- var value = this.requestHeaders[contentType].split(";");
- this.requestHeaders[contentType] = value[0] + ";charset=utf-8";
- } else if (supportsFormData && !(data instanceof FormData)) {
- this.requestHeaders["Content-Type"] = "text/plain;charset=utf-8";
- }
-
- this.requestBody = data;
- }
-
- this.errorFlag = false;
- this.sendFlag = this.async;
- clearResponse(this);
- this.readyStateChange(FakeXMLHttpRequest.OPENED);
-
- if (typeof this.onSend === "function") {
- this.onSend(this);
- }
-
- this.dispatchEvent(new sinon.Event("loadstart", false, false, this));
- },
-
- abort: function abort() {
- this.aborted = true;
- clearResponse(this);
- this.errorFlag = true;
- this.requestHeaders = {};
- this.responseHeaders = {};
-
- if (this.readyState > FakeXMLHttpRequest.UNSENT && this.sendFlag) {
- this.readyStateChange(FakeXMLHttpRequest.DONE);
- this.sendFlag = false;
- }
-
- this.readyState = FakeXMLHttpRequest.UNSENT;
-
- this.dispatchEvent(new sinon.Event("abort", false, false, this));
-
- this.upload.dispatchEvent(new sinon.Event("abort", false, false, this));
-
- if (typeof this.onerror === "function") {
- this.onerror();
- }
- },
-
- getResponseHeader: function getResponseHeader(header) {
- if (this.readyState < FakeXMLHttpRequest.HEADERS_RECEIVED) {
- return null;
- }
-
- if (/^Set-Cookie2?$/i.test(header)) {
- return null;
- }
-
- header = getHeader(this.responseHeaders, header);
-
- return this.responseHeaders[header] || null;
- },
-
- getAllResponseHeaders: function getAllResponseHeaders() {
- if (this.readyState < FakeXMLHttpRequest.HEADERS_RECEIVED) {
- return "";
- }
-
- var headers = "";
-
- for (var header in this.responseHeaders) {
- if (this.responseHeaders.hasOwnProperty(header) &&
- !/^Set-Cookie2?$/i.test(header)) {
- headers += header + ": " + this.responseHeaders[header] + "\r\n";
- }
- }
-
- return headers;
- },
-
- setResponseBody: function setResponseBody(body) {
- verifyRequestSent(this);
- verifyHeadersReceived(this);
- verifyResponseBodyType(body);
- var contentType = this.getResponseHeader("Content-Type");
-
- var isTextResponse = this.responseType === "" || this.responseType === "text";
- clearResponse(this);
- if (this.async) {
- var chunkSize = this.chunkSize || 10;
- var index = 0;
-
- do {
- this.readyStateChange(FakeXMLHttpRequest.LOADING);
-
- if (isTextResponse) {
- this.responseText = this.response += body.substring(index, index + chunkSize);
- }
- index += chunkSize;
- } while (index < body.length);
- }
-
- this.response = convertResponseBody(this.responseType, contentType, body);
- if (isTextResponse) {
- this.responseText = this.response;
- }
-
- if (this.responseType === "document") {
- this.responseXML = this.response;
- } else if (this.responseType === "" && isXmlContentType(contentType)) {
- this.responseXML = FakeXMLHttpRequest.parseXML(this.responseText);
- }
- this.readyStateChange(FakeXMLHttpRequest.DONE);
- },
-
- respond: function respond(status, headers, body) {
- this.status = typeof status === "number" ? status : 200;
- this.statusText = FakeXMLHttpRequest.statusCodes[this.status];
- this.setResponseHeaders(headers || {});
- this.setResponseBody(body || "");
- },
-
- uploadProgress: function uploadProgress(progressEventRaw) {
- if (supportsProgress) {
- this.upload.dispatchEvent(new sinon.ProgressEvent("progress", progressEventRaw));
- }
- },
-
- downloadProgress: function downloadProgress(progressEventRaw) {
- if (supportsProgress) {
- this.dispatchEvent(new sinon.ProgressEvent("progress", progressEventRaw));
- }
- },
-
- uploadError: function uploadError(error) {
- if (supportsCustomEvent) {
- this.upload.dispatchEvent(new sinon.CustomEvent("error", {detail: error}));
- }
- }
- });
-
- sinon.extend(FakeXMLHttpRequest, {
- UNSENT: 0,
- OPENED: 1,
- HEADERS_RECEIVED: 2,
- LOADING: 3,
- DONE: 4
- });
-
- sinon.useFakeXMLHttpRequest = function () {
- FakeXMLHttpRequest.restore = function restore(keepOnCreate) {
- if (sinonXhr.supportsXHR) {
- global.XMLHttpRequest = sinonXhr.GlobalXMLHttpRequest;
- }
-
- if (sinonXhr.supportsActiveX) {
- global.ActiveXObject = sinonXhr.GlobalActiveXObject;
- }
-
- delete FakeXMLHttpRequest.restore;
-
- if (keepOnCreate !== true) {
- delete FakeXMLHttpRequest.onCreate;
- }
- };
- if (sinonXhr.supportsXHR) {
- global.XMLHttpRequest = FakeXMLHttpRequest;
- }
-
- if (sinonXhr.supportsActiveX) {
- global.ActiveXObject = function ActiveXObject(objId) {
- if (objId === "Microsoft.XMLHTTP" || /^Msxml2\.XMLHTTP/i.test(objId)) {
-
- return new FakeXMLHttpRequest();
- }
-
- return new sinonXhr.GlobalActiveXObject(objId);
- };
- }
-
- return FakeXMLHttpRequest;
- };
-
- sinon.FakeXMLHttpRequest = FakeXMLHttpRequest;
- }
-
- var isNode = typeof module !== "undefined" && module.exports && typeof require === "function";
- var isAMD = typeof define === "function" && typeof define.amd === "object" && define.amd;
-
- function loadDependencies(require, exports, module) {
- var sinon = require("./core");
- require("../extend");
- require("./event");
- require("../log_error");
- makeApi(sinon);
- module.exports = sinon;
- }
-
- if (isAMD) {
- define(loadDependencies);
- return;
- }
-
- if (isNode) {
- loadDependencies(require, module.exports, module);
- return;
- }
-
- if (sinonGlobal) {
- makeApi(sinonGlobal);
- }
-}(
- typeof sinon === "object" && sinon, // eslint-disable-line no-undef
- typeof global !== "undefined" ? global : self
-));
-
-/**
- * @depend fake_xdomain_request.js
- * @depend fake_xml_http_request.js
- * @depend ../format.js
- * @depend ../log_error.js
- */
-/**
- * The Sinon "server" mimics a web server that receives requests from
- * sinon.FakeXMLHttpRequest and provides an API to respond to those requests,
- * both synchronously and asynchronously. To respond synchronuously, canned
- * answers have to be provided upfront.
- *
- * @author Christian Johansen (christian@cjohansen.no)
- * @license BSD
- *
- * Copyright (c) 2010-2013 Christian Johansen
- */
-(function () {
-
- var push = [].push;
-
- function responseArray(handler) {
- var response = handler;
-
- if (Object.prototype.toString.call(handler) !== "[object Array]") {
- response = [200, {}, handler];
- }
-
- if (typeof response[2] !== "string") {
- throw new TypeError("Fake server response body should be string, but was " +
- typeof response[2]);
- }
-
- return response;
- }
-
- var wloc = typeof window !== "undefined" ? window.location : {};
- var rCurrLoc = new RegExp("^" + wloc.protocol + "//" + wloc.host);
-
- function matchOne(response, reqMethod, reqUrl) {
- var rmeth = response.method;
- var matchMethod = !rmeth || rmeth.toLowerCase() === reqMethod.toLowerCase();
- var url = response.url;
- var matchUrl = !url || url === reqUrl || (typeof url.test === "function" && url.test(reqUrl));
-
- return matchMethod && matchUrl;
- }
-
- function match(response, request) {
- var requestUrl = request.url;
-
- if (!/^https?:\/\//.test(requestUrl) || rCurrLoc.test(requestUrl)) {
- requestUrl = requestUrl.replace(rCurrLoc, "");
- }
-
- if (matchOne(response, this.getHTTPMethod(request), requestUrl)) {
- if (typeof response.response === "function") {
- var ru = response.url;
- var args = [request].concat(ru && typeof ru.exec === "function" ? ru.exec(requestUrl).slice(1) : []);
- return response.response.apply(response, args);
- }
-
- return true;
- }
-
- return false;
- }
-
- function makeApi(sinon) {
- sinon.fakeServer = {
- create: function (config) {
- var server = sinon.create(this);
- server.configure(config);
- if (!sinon.xhr.supportsCORS) {
- this.xhr = sinon.useFakeXDomainRequest();
- } else {
- this.xhr = sinon.useFakeXMLHttpRequest();
- }
- server.requests = [];
-
- this.xhr.onCreate = function (xhrObj) {
- server.addRequest(xhrObj);
- };
-
- return server;
- },
- configure: function (config) {
- var whitelist = {
- "autoRespond": true,
- "autoRespondAfter": true,
- "respondImmediately": true,
- "fakeHTTPMethods": true
- };
- var setting;
-
- config = config || {};
- for (setting in config) {
- if (whitelist.hasOwnProperty(setting) && config.hasOwnProperty(setting)) {
- this[setting] = config[setting];
- }
- }
- },
- addRequest: function addRequest(xhrObj) {
- var server = this;
- push.call(this.requests, xhrObj);
-
- xhrObj.onSend = function () {
- server.handleRequest(this);
-
- if (server.respondImmediately) {
- server.respond();
- } else if (server.autoRespond && !server.responding) {
- setTimeout(function () {
- server.responding = false;
- server.respond();
- }, server.autoRespondAfter || 10);
-
- server.responding = true;
- }
- };
- },
-
- getHTTPMethod: function getHTTPMethod(request) {
- if (this.fakeHTTPMethods && /post/i.test(request.method)) {
- var matches = (request.requestBody || "").match(/_method=([^\b;]+)/);
- return matches ? matches[1] : request.method;
- }
-
- return request.method;
- },
-
- handleRequest: function handleRequest(xhr) {
- if (xhr.async) {
- if (!this.queue) {
- this.queue = [];
- }
-
- push.call(this.queue, xhr);
- } else {
- this.processRequest(xhr);
- }
- },
-
- log: function log(response, request) {
- var str;
-
- str = "Request:\n" + sinon.format(request) + "\n\n";
- str += "Response:\n" + sinon.format(response) + "\n\n";
-
- sinon.log(str);
- },
-
- respondWith: function respondWith(method, url, body) {
- if (arguments.length === 1 && typeof method !== "function") {
- this.response = responseArray(method);
- return;
- }
-
- if (!this.responses) {
- this.responses = [];
- }
-
- if (arguments.length === 1) {
- body = method;
- url = method = null;
- }
-
- if (arguments.length === 2) {
- body = url;
- url = method;
- method = null;
- }
-
- push.call(this.responses, {
- method: method,
- url: url,
- response: typeof body === "function" ? body : responseArray(body)
- });
- },
-
- respond: function respond() {
- if (arguments.length > 0) {
- this.respondWith.apply(this, arguments);
- }
-
- var queue = this.queue || [];
- var requests = queue.splice(0, queue.length);
-
- for (var i = 0; i < requests.length; i++) {
- this.processRequest(requests[i]);
- }
- },
-
- processRequest: function processRequest(request) {
- try {
- if (request.aborted) {
- return;
- }
-
- var response = this.response || [404, {}, ""];
-
- if (this.responses) {
- for (var l = this.responses.length, i = l - 1; i >= 0; i--) {
- if (match.call(this, this.responses[i], request)) {
- response = this.responses[i].response;
- break;
- }
- }
- }
-
- if (request.readyState !== 4) {
- this.log(response, request);
-
- request.respond(response[0], response[1], response[2]);
- }
- } catch (e) {
- sinon.logError("Fake server request processing", e);
- }
- },
-
- restore: function restore() {
- return this.xhr.restore && this.xhr.restore.apply(this.xhr, arguments);
- }
- };
- }
-
- var isNode = typeof module !== "undefined" && module.exports && typeof require === "function";
- var isAMD = typeof define === "function" && typeof define.amd === "object" && define.amd;
-
- function loadDependencies(require, exports, module) {
- var sinon = require("./core");
- require("./fake_xdomain_request");
- require("./fake_xml_http_request");
- require("../format");
- makeApi(sinon);
- module.exports = sinon;
- }
-
- if (isAMD) {
- define(loadDependencies);
- } else if (isNode) {
- loadDependencies(require, module.exports, module);
- } else {
- makeApi(sinon); // eslint-disable-line no-undef
- }
-}());
-
-/**
- * @depend fake_server.js
- * @depend fake_timers.js
- */
-/**
- * Add-on for sinon.fakeServer that automatically handles a fake timer along with
- * the FakeXMLHttpRequest. The direct inspiration for this add-on is jQuery
- * 1.3.x, which does not use xhr object's onreadystatehandler at all - instead,
- * it polls the object for completion with setInterval. Dispite the direct
- * motivation, there is nothing jQuery-specific in this file, so it can be used
- * in any environment where the ajax implementation depends on setInterval or
- * setTimeout.
- *
- * @author Christian Johansen (christian@cjohansen.no)
- * @license BSD
- *
- * Copyright (c) 2010-2013 Christian Johansen
- */
-(function () {
-
- function makeApi(sinon) {
- function Server() {}
- Server.prototype = sinon.fakeServer;
-
- sinon.fakeServerWithClock = new Server();
-
- sinon.fakeServerWithClock.addRequest = function addRequest(xhr) {
- if (xhr.async) {
- if (typeof setTimeout.clock === "object") {
- this.clock = setTimeout.clock;
- } else {
- this.clock = sinon.useFakeTimers();
- this.resetClock = true;
- }
-
- if (!this.longestTimeout) {
- var clockSetTimeout = this.clock.setTimeout;
- var clockSetInterval = this.clock.setInterval;
- var server = this;
-
- this.clock.setTimeout = function (fn, timeout) {
- server.longestTimeout = Math.max(timeout, server.longestTimeout || 0);
-
- return clockSetTimeout.apply(this, arguments);
- };
-
- this.clock.setInterval = function (fn, timeout) {
- server.longestTimeout = Math.max(timeout, server.longestTimeout || 0);
-
- return clockSetInterval.apply(this, arguments);
- };
- }
- }
-
- return sinon.fakeServer.addRequest.call(this, xhr);
- };
-
- sinon.fakeServerWithClock.respond = function respond() {
- var returnVal = sinon.fakeServer.respond.apply(this, arguments);
-
- if (this.clock) {
- this.clock.tick(this.longestTimeout || 0);
- this.longestTimeout = 0;
-
- if (this.resetClock) {
- this.clock.restore();
- this.resetClock = false;
- }
- }
-
- return returnVal;
- };
-
- sinon.fakeServerWithClock.restore = function restore() {
- if (this.clock) {
- this.clock.restore();
- }
-
- return sinon.fakeServer.restore.apply(this, arguments);
- };
- }
-
- var isNode = typeof module !== "undefined" && module.exports && typeof require === "function";
- var isAMD = typeof define === "function" && typeof define.amd === "object" && define.amd;
-
- function loadDependencies(require) {
- var sinon = require("./core");
- require("./fake_server");
- require("./fake_timers");
- makeApi(sinon);
- }
-
- if (isAMD) {
- define(loadDependencies);
- } else if (isNode) {
- loadDependencies(require);
- } else {
- makeApi(sinon); // eslint-disable-line no-undef
- }
-}());
-
-/**
- * @depend util/core.js
- * @depend extend.js
- * @depend collection.js
- * @depend util/fake_timers.js
- * @depend util/fake_server_with_clock.js
- */
-/**
- * Manages fake collections as well as fake utilities such as Sinon's
- * timers and fake XHR implementation in one convenient object.
- *
- * @author Christian Johansen (christian@cjohansen.no)
- * @license BSD
- *
- * Copyright (c) 2010-2013 Christian Johansen
- */
-(function (sinonGlobal) {
-
- function makeApi(sinon) {
- var push = [].push;
-
- function exposeValue(sandbox, config, key, value) {
- if (!value) {
- return;
- }
-
- if (config.injectInto && !(key in config.injectInto)) {
- config.injectInto[key] = value;
- sandbox.injectedKeys.push(key);
- } else {
- push.call(sandbox.args, value);
- }
- }
-
- function prepareSandboxFromConfig(config) {
- var sandbox = sinon.create(sinon.sandbox);
-
- if (config.useFakeServer) {
- if (typeof config.useFakeServer === "object") {
- sandbox.serverPrototype = config.useFakeServer;
- }
-
- sandbox.useFakeServer();
- }
-
- if (config.useFakeTimers) {
- if (typeof config.useFakeTimers === "object") {
- sandbox.useFakeTimers.apply(sandbox, config.useFakeTimers);
- } else {
- sandbox.useFakeTimers();
- }
- }
-
- return sandbox;
- }
-
- sinon.sandbox = sinon.extend(sinon.create(sinon.collection), {
- useFakeTimers: function useFakeTimers() {
- this.clock = sinon.useFakeTimers.apply(sinon, arguments);
-
- return this.add(this.clock);
- },
-
- serverPrototype: sinon.fakeServer,
-
- useFakeServer: function useFakeServer() {
- var proto = this.serverPrototype || sinon.fakeServer;
-
- if (!proto || !proto.create) {
- return null;
- }
-
- this.server = proto.create();
- return this.add(this.server);
- },
-
- inject: function (obj) {
- sinon.collection.inject.call(this, obj);
-
- if (this.clock) {
- obj.clock = this.clock;
- }
-
- if (this.server) {
- obj.server = this.server;
- obj.requests = this.server.requests;
- }
-
- obj.match = sinon.match;
-
- return obj;
- },
-
- restore: function () {
- sinon.collection.restore.apply(this, arguments);
- this.restoreContext();
- },
-
- restoreContext: function () {
- if (this.injectedKeys) {
- for (var i = 0, j = this.injectedKeys.length; i < j; i++) {
- delete this.injectInto[this.injectedKeys[i]];
- }
- this.injectedKeys = [];
- }
- },
-
- create: function (config) {
- if (!config) {
- return sinon.create(sinon.sandbox);
- }
-
- var sandbox = prepareSandboxFromConfig(config);
- sandbox.args = sandbox.args || [];
- sandbox.injectedKeys = [];
- sandbox.injectInto = config.injectInto;
- var prop,
- value;
- var exposed = sandbox.inject({});
-
- if (config.properties) {
- for (var i = 0, l = config.properties.length; i < l; i++) {
- prop = config.properties[i];
- value = exposed[prop] || prop === "sandbox" && sandbox;
- exposeValue(sandbox, config, prop, value);
- }
- } else {
- exposeValue(sandbox, config, "sandbox", value);
- }
-
- return sandbox;
- },
-
- match: sinon.match
- });
-
- sinon.sandbox.useFakeXMLHttpRequest = sinon.sandbox.useFakeServer;
-
- return sinon.sandbox;
- }
-
- var isNode = typeof module !== "undefined" && module.exports && typeof require === "function";
- var isAMD = typeof define === "function" && typeof define.amd === "object" && define.amd;
-
- function loadDependencies(require, exports, module) {
- var sinon = require("./util/core");
- require("./extend");
- require("./util/fake_server_with_clock");
- require("./util/fake_timers");
- require("./collection");
- module.exports = makeApi(sinon);
- }
-
- if (isAMD) {
- define(loadDependencies);
- return;
- }
-
- if (isNode) {
- loadDependencies(require, module.exports, module);
- return;
- }
-
- if (sinonGlobal) {
- makeApi(sinonGlobal);
- }
-}(
- typeof sinon === "object" && sinon // eslint-disable-line no-undef
-));
-
-/**
- * @depend util/core.js
- * @depend sandbox.js
- */
-/**
- * Test function, sandboxes fakes
- *
- * @author Christian Johansen (christian@cjohansen.no)
- * @license BSD
- *
- * Copyright (c) 2010-2013 Christian Johansen
- */
-(function (sinonGlobal) {
-
- function makeApi(sinon) {
- var slice = Array.prototype.slice;
-
- function test(callback) {
- var type = typeof callback;
-
- if (type !== "function") {
- throw new TypeError("sinon.test needs to wrap a test function, got " + type);
- }
-
- function sinonSandboxedTest() {
- var config = sinon.getConfig(sinon.config);
- config.injectInto = config.injectIntoThis && this || config.injectInto;
- var sandbox = sinon.sandbox.create(config);
- var args = slice.call(arguments);
- var oldDone = args.length && args[args.length - 1];
- var exception, result;
-
- if (typeof oldDone === "function") {
- args[args.length - 1] = function sinonDone(res) {
- if (res) {
- sandbox.restore();
- } else {
- sandbox.verifyAndRestore();
- }
- oldDone(res);
- };
- }
-
- try {
- result = callback.apply(this, args.concat(sandbox.args));
- } catch (e) {
- exception = e;
- }
-
- if (typeof oldDone !== "function") {
- if (typeof exception !== "undefined") {
- sandbox.restore();
- throw exception;
- } else {
- sandbox.verifyAndRestore();
- }
- }
-
- return result;
- }
-
- if (callback.length) {
- return function sinonAsyncSandboxedTest(done) { // eslint-disable-line no-unused-vars
- return sinonSandboxedTest.apply(this, arguments);
- };
- }
-
- return sinonSandboxedTest;
- }
-
- test.config = {
- injectIntoThis: true,
- injectInto: null,
- properties: ["spy", "stub", "mock", "clock", "server", "requests"],
- useFakeTimers: true,
- useFakeServer: true
- };
-
- sinon.test = test;
- return test;
- }
-
- var isNode = typeof module !== "undefined" && module.exports && typeof require === "function";
- var isAMD = typeof define === "function" && typeof define.amd === "object" && define.amd;
-
- function loadDependencies(require, exports, module) {
- var core = require("./util/core");
- require("./sandbox");
- module.exports = makeApi(core);
- }
-
- if (isAMD) {
- define(loadDependencies);
- } else if (isNode) {
- loadDependencies(require, module.exports, module);
- } else if (sinonGlobal) {
- makeApi(sinonGlobal);
- }
-}(typeof sinon === "object" && sinon || null)); // eslint-disable-line no-undef
-
-/**
- * @depend util/core.js
- * @depend test.js
- */
-/**
- * Test case, sandboxes all test functions
- *
- * @author Christian Johansen (christian@cjohansen.no)
- * @license BSD
- *
- * Copyright (c) 2010-2013 Christian Johansen
- */
-(function (sinonGlobal) {
-
- function createTest(property, setUp, tearDown) {
- return function () {
- if (setUp) {
- setUp.apply(this, arguments);
- }
-
- var exception, result;
-
- try {
- result = property.apply(this, arguments);
- } catch (e) {
- exception = e;
- }
-
- if (tearDown) {
- tearDown.apply(this, arguments);
- }
-
- if (exception) {
- throw exception;
- }
-
- return result;
- };
- }
-
- function makeApi(sinon) {
- function testCase(tests, prefix) {
- if (!tests || typeof tests !== "object") {
- throw new TypeError("sinon.testCase needs an object with test functions");
- }
-
- prefix = prefix || "test";
- var rPrefix = new RegExp("^" + prefix);
- var methods = {};
- var setUp = tests.setUp;
- var tearDown = tests.tearDown;
- var testName,
- property,
- method;
-
- for (testName in tests) {
- if (tests.hasOwnProperty(testName) && !/^(setUp|tearDown)$/.test(testName)) {
- property = tests[testName];
-
- if (typeof property === "function" && rPrefix.test(testName)) {
- method = property;
-
- if (setUp || tearDown) {
- method = createTest(property, setUp, tearDown);
- }
-
- methods[testName] = sinon.test(method);
- } else {
- methods[testName] = tests[testName];
- }
- }
- }
-
- return methods;
- }
-
- sinon.testCase = testCase;
- return testCase;
- }
-
- var isNode = typeof module !== "undefined" && module.exports && typeof require === "function";
- var isAMD = typeof define === "function" && typeof define.amd === "object" && define.amd;
-
- function loadDependencies(require, exports, module) {
- var core = require("./util/core");
- require("./test");
- module.exports = makeApi(core);
- }
-
- if (isAMD) {
- define(loadDependencies);
- return;
- }
-
- if (isNode) {
- loadDependencies(require, module.exports, module);
- return;
- }
-
- if (sinonGlobal) {
- makeApi(sinonGlobal);
- }
-}(
- typeof sinon === "object" && sinon // eslint-disable-line no-undef
-));
-
-/**
- * @depend times_in_words.js
- * @depend util/core.js
- * @depend match.js
- * @depend format.js
- */
-/**
- * Assertions matching the test spy retrieval interface.
- *
- * @author Christian Johansen (christian@cjohansen.no)
- * @license BSD
- *
- * Copyright (c) 2010-2013 Christian Johansen
- */
-(function (sinonGlobal, global) {
-
- var slice = Array.prototype.slice;
-
- function makeApi(sinon) {
- var assert;
-
- function verifyIsStub() {
- var method;
-
- for (var i = 0, l = arguments.length; i < l; ++i) {
- method = arguments[i];
-
- if (!method) {
- assert.fail("fake is not a spy");
- }
-
- if (method.proxy && method.proxy.isSinonProxy) {
- verifyIsStub(method.proxy);
- } else {
- if (typeof method !== "function") {
- assert.fail(method + " is not a function");
- }
-
- if (typeof method.getCall !== "function") {
- assert.fail(method + " is not stubbed");
- }
- }
-
- }
- }
-
- function failAssertion(object, msg) {
- object = object || global;
- var failMethod = object.fail || assert.fail;
- failMethod.call(object, msg);
- }
-
- function mirrorPropAsAssertion(name, method, message) {
- if (arguments.length === 2) {
- message = method;
- method = name;
- }
-
- assert[name] = function (fake) {
- verifyIsStub(fake);
-
- var args = slice.call(arguments, 1);
- var failed = false;
-
- if (typeof method === "function") {
- failed = !method(fake);
- } else {
- failed = typeof fake[method] === "function" ?
- !fake[method].apply(fake, args) : !fake[method];
- }
-
- if (failed) {
- failAssertion(this, (fake.printf || fake.proxy.printf).apply(fake, [message].concat(args)));
- } else {
- assert.pass(name);
- }
- };
- }
-
- function exposedName(prefix, prop) {
- return !prefix || /^fail/.test(prop) ? prop :
- prefix + prop.slice(0, 1).toUpperCase() + prop.slice(1);
- }
-
- assert = {
- failException: "AssertError",
-
- fail: function fail(message) {
- var error = new Error(message);
- error.name = this.failException || assert.failException;
-
- throw error;
- },
-
- pass: function pass() {},
-
- callOrder: function assertCallOrder() {
- verifyIsStub.apply(null, arguments);
- var expected = "";
- var actual = "";
-
- if (!sinon.calledInOrder(arguments)) {
- try {
- expected = [].join.call(arguments, ", ");
- var calls = slice.call(arguments);
- var i = calls.length;
- while (i) {
- if (!calls[--i].called) {
- calls.splice(i, 1);
- }
- }
- actual = sinon.orderByFirstCall(calls).join(", ");
- } catch (e) {
- // If this fails, we'll just fall back to the blank string
- }
-
- failAssertion(this, "expected " + expected + " to be " +
- "called in order but were called as " + actual);
- } else {
- assert.pass("callOrder");
- }
- },
-
- callCount: function assertCallCount(method, count) {
- verifyIsStub(method);
-
- if (method.callCount !== count) {
- var msg = "expected %n to be called " + sinon.timesInWords(count) +
- " but was called %c%C";
- failAssertion(this, method.printf(msg));
- } else {
- assert.pass("callCount");
- }
- },
-
- expose: function expose(target, options) {
- if (!target) {
- throw new TypeError("target is null or undefined");
- }
-
- var o = options || {};
- var prefix = typeof o.prefix === "undefined" && "assert" || o.prefix;
- var includeFail = typeof o.includeFail === "undefined" || !!o.includeFail;
-
- for (var method in this) {
- if (method !== "expose" && (includeFail || !/^(fail)/.test(method))) {
- target[exposedName(prefix, method)] = this[method];
- }
- }
-
- return target;
- },
-
- match: function match(actual, expectation) {
- var matcher = sinon.match(expectation);
- if (matcher.test(actual)) {
- assert.pass("match");
- } else {
- var formatted = [
- "expected value to match",
- " expected = " + sinon.format(expectation),
- " actual = " + sinon.format(actual)
- ];
-
- failAssertion(this, formatted.join("\n"));
- }
- }
- };
-
- mirrorPropAsAssertion("called", "expected %n to have been called at least once but was never called");
- mirrorPropAsAssertion("notCalled", function (spy) {
- return !spy.called;
- }, "expected %n to not have been called but was called %c%C");
- mirrorPropAsAssertion("calledOnce", "expected %n to be called once but was called %c%C");
- mirrorPropAsAssertion("calledTwice", "expected %n to be called twice but was called %c%C");
- mirrorPropAsAssertion("calledThrice", "expected %n to be called thrice but was called %c%C");
- mirrorPropAsAssertion("calledOn", "expected %n to be called with %1 as this but was called with %t");
- mirrorPropAsAssertion(
- "alwaysCalledOn",
- "expected %n to always be called with %1 as this but was called with %t"
- );
- mirrorPropAsAssertion("calledWithNew", "expected %n to be called with new");
- mirrorPropAsAssertion("alwaysCalledWithNew", "expected %n to always be called with new");
- mirrorPropAsAssertion("calledWith", "expected %n to be called with arguments %*%C");
- mirrorPropAsAssertion("calledWithMatch", "expected %n to be called with match %*%C");
- mirrorPropAsAssertion("alwaysCalledWith", "expected %n to always be called with arguments %*%C");
- mirrorPropAsAssertion("alwaysCalledWithMatch", "expected %n to always be called with match %*%C");
- mirrorPropAsAssertion("calledWithExactly", "expected %n to be called with exact arguments %*%C");
- mirrorPropAsAssertion("alwaysCalledWithExactly", "expected %n to always be called with exact arguments %*%C");
- mirrorPropAsAssertion("neverCalledWith", "expected %n to never be called with arguments %*%C");
- mirrorPropAsAssertion("neverCalledWithMatch", "expected %n to never be called with match %*%C");
- mirrorPropAsAssertion("threw", "%n did not throw exception%C");
- mirrorPropAsAssertion("alwaysThrew", "%n did not always throw exception%C");
-
- sinon.assert = assert;
- return assert;
- }
-
- var isNode = typeof module !== "undefined" && module.exports && typeof require === "function";
- var isAMD = typeof define === "function" && typeof define.amd === "object" && define.amd;
-
- function loadDependencies(require, exports, module) {
- var sinon = require("./util/core");
- require("./match");
- require("./format");
- module.exports = makeApi(sinon);
- }
-
- if (isAMD) {
- define(loadDependencies);
- return;
- }
-
- if (isNode) {
- loadDependencies(require, module.exports, module);
- return;
- }
-
- if (sinonGlobal) {
- makeApi(sinonGlobal);
- }
-}(
- typeof sinon === "object" && sinon, // eslint-disable-line no-undef
- typeof global !== "undefined" ? global : self
-));
-
- return sinon;
-}));
--- /dev/null
+/**
+ * Sinon.JS 1.17.7, 2017/02/15
+ *
+ * @author Christian Johansen (christian@cjohansen.no)
+ * @author Contributors: https://github.com/cjohansen/Sinon.JS/blob/master/AUTHORS
+ *
+ * (The BSD License)
+ *
+ * Copyright (c) 2010-2014, Christian Johansen, christian@cjohansen.no
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without modification,
+ * are permitted provided that the following conditions are met:
+ *
+ * * Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * * Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ * * Neither the name of Christian Johansen nor the names of his contributors
+ * may be used to endorse or promote products derived from this software
+ * without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+ * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+ * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+ * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+ * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
+ * THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+(function (root, factory) {
+ 'use strict';
+ if (typeof define === 'function' && define.amd) {
+ define('sinon', [], function () {
+ return (root.sinon = factory());
+ });
+ } else if (typeof exports === 'object') {
+ module.exports = factory();
+ } else {
+ root.sinon = factory();
+ }
+}(this, function () {
+ 'use strict';
+ var samsam, formatio, lolex;
+ (function () {
+ function define(mod, deps, fn) {
+ if (mod == "samsam") {
+ samsam = deps();
+ } else if (typeof deps === "function" && mod.length === 0) {
+ lolex = deps();
+ } else if (typeof fn === "function") {
+ formatio = fn(samsam);
+ }
+ }
+ define.amd = {};
+((typeof define === "function" && define.amd && function (m) { define("samsam", m); }) ||
+ (typeof module === "object" &&
+ function (m) { module.exports = m(); }) || // Node
+ function (m) { this.samsam = m(); } // Browser globals
+)(function () {
+ var o = Object.prototype;
+ var div = typeof document !== "undefined" && document.createElement("div");
+
+ function isNaN(value) {
+ // Unlike global isNaN, this avoids type coercion
+ // typeof check avoids IE host object issues, hat tip to
+ // lodash
+ var val = value; // JsLint thinks value !== value is "weird"
+ return typeof value === "number" && value !== val;
+ }
+
+ function getClass(value) {
+ // Returns the internal [[Class]] by calling Object.prototype.toString
+ // with the provided value as this. Return value is a string, naming the
+ // internal class, e.g. "Array"
+ return o.toString.call(value).split(/[ \]]/)[1];
+ }
+
+ /**
+ * @name samsam.isArguments
+ * @param Object object
+ *
+ * Returns ``true`` if ``object`` is an ``arguments`` object,
+ * ``false`` otherwise.
+ */
+ function isArguments(object) {
+ if (getClass(object) === 'Arguments') { return true; }
+ if (typeof object !== "object" || typeof object.length !== "number" ||
+ getClass(object) === "Array") {
+ return false;
+ }
+ if (typeof object.callee == "function") { return true; }
+ try {
+ object[object.length] = 6;
+ delete object[object.length];
+ } catch (e) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * @name samsam.isElement
+ * @param Object object
+ *
+ * Returns ``true`` if ``object`` is a DOM element node. Unlike
+ * Underscore.js/lodash, this function will return ``false`` if ``object``
+ * is an *element-like* object, i.e. a regular object with a ``nodeType``
+ * property that holds the value ``1``.
+ */
+ function isElement(object) {
+ if (!object || object.nodeType !== 1 || !div) { return false; }
+ try {
+ object.appendChild(div);
+ object.removeChild(div);
+ } catch (e) {
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * @name samsam.keys
+ * @param Object object
+ *
+ * Return an array of own property names.
+ */
+ function keys(object) {
+ var ks = [], prop;
+ for (prop in object) {
+ if (o.hasOwnProperty.call(object, prop)) { ks.push(prop); }
+ }
+ return ks;
+ }
+
+ /**
+ * @name samsam.isDate
+ * @param Object value
+ *
+ * Returns true if the object is a ``Date``, or *date-like*. Duck typing
+ * of date objects work by checking that the object has a ``getTime``
+ * function whose return value equals the return value from the object's
+ * ``valueOf``.
+ */
+ function isDate(value) {
+ return typeof value.getTime == "function" &&
+ value.getTime() == value.valueOf();
+ }
+
+ /**
+ * @name samsam.isNegZero
+ * @param Object value
+ *
+ * Returns ``true`` if ``value`` is ``-0``.
+ */
+ function isNegZero(value) {
+ return value === 0 && 1 / value === -Infinity;
+ }
+
+ /**
+ * @name samsam.equal
+ * @param Object obj1
+ * @param Object obj2
+ *
+ * Returns ``true`` if two objects are strictly equal. Compared to
+ * ``===`` there are two exceptions:
+ *
+ * - NaN is considered equal to NaN
+ * - -0 and +0 are not considered equal
+ */
+ function identical(obj1, obj2) {
+ if (obj1 === obj2 || (isNaN(obj1) && isNaN(obj2))) {
+ return obj1 !== 0 || isNegZero(obj1) === isNegZero(obj2);
+ }
+ }
+
+ function isSet(val) {
+ if (typeof Set !== 'undefined' && val instanceof Set) {
+ return true;
+ }
+ }
+
+ function isSubset(s1, s2, compare) {
+ var values1 = Array.from(s1);
+ var values2 = Array.from(s2);
+
+ for (var i = 0; i < values1.length; i++) {
+ var includes = false;
+
+ for (var j = 0; j < values2.length; j++) {
+ if (compare(values2[j], values1[i])) {
+ includes = true;
+ break;
+ }
+ }
+
+ if (!includes) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * @name samsam.deepEqual
+ * @param Object obj1
+ * @param Object obj2
+ *
+ * Deep equal comparison. Two values are "deep equal" if:
+ *
+ * - They are equal, according to samsam.identical
+ * - They are both date objects representing the same time
+ * - They are both arrays containing elements that are all deepEqual
+ * - They are objects with the same set of properties, and each property
+ * in ``obj1`` is deepEqual to the corresponding property in ``obj2``
+ *
+ * Supports cyclic objects.
+ */
+ function deepEqualCyclic(obj1, obj2) {
+
+ // used for cyclic comparison
+ // contain already visited objects
+ var objects1 = [],
+ objects2 = [],
+ // contain pathes (position in the object structure)
+ // of the already visited objects
+ // indexes same as in objects arrays
+ paths1 = [],
+ paths2 = [],
+ // contains combinations of already compared objects
+ // in the manner: { "$1['ref']$2['ref']": true }
+ compared = {};
+
+ /**
+ * used to check, if the value of a property is an object
+ * (cyclic logic is only needed for objects)
+ * only needed for cyclic logic
+ */
+ function isObject(value) {
+
+ if (typeof value === 'object' && value !== null &&
+ !(value instanceof Boolean) &&
+ !(value instanceof Date) &&
+ !(value instanceof Number) &&
+ !(value instanceof RegExp) &&
+ !(value instanceof String)) {
+
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * returns the index of the given object in the
+ * given objects array, -1 if not contained
+ * only needed for cyclic logic
+ */
+ function getIndex(objects, obj) {
+
+ var i;
+ for (i = 0; i < objects.length; i++) {
+ if (objects[i] === obj) {
+ return i;
+ }
+ }
+
+ return -1;
+ }
+
+ // does the recursion for the deep equal check
+ return (function deepEqual(obj1, obj2, path1, path2) {
+ var type1 = typeof obj1;
+ var type2 = typeof obj2;
+
+ // == null also matches undefined
+ if (obj1 === obj2 ||
+ isNaN(obj1) || isNaN(obj2) ||
+ obj1 == null || obj2 == null ||
+ type1 !== "object" || type2 !== "object") {
+
+ return identical(obj1, obj2);
+ }
+
+ // Elements are only equal if identical(expected, actual)
+ if (isElement(obj1) || isElement(obj2)) { return false; }
+
+ var isDate1 = isDate(obj1), isDate2 = isDate(obj2);
+ if (isDate1 || isDate2) {
+ if (!isDate1 || !isDate2 || obj1.getTime() !== obj2.getTime()) {
+ return false;
+ }
+ }
+
+ if (obj1 instanceof RegExp && obj2 instanceof RegExp) {
+ if (obj1.toString() !== obj2.toString()) { return false; }
+ }
+
+ var class1 = getClass(obj1);
+ var class2 = getClass(obj2);
+ var keys1 = keys(obj1);
+ var keys2 = keys(obj2);
+
+ if (isArguments(obj1) || isArguments(obj2)) {
+ if (obj1.length !== obj2.length) { return false; }
+ } else {
+ if (type1 !== type2 || class1 !== class2 ||
+ keys1.length !== keys2.length) {
+ return false;
+ }
+ }
+
+ if (isSet(obj1) || isSet(obj2)) {
+ if (!isSet(obj1) || !isSet(obj2) || obj1.size !== obj2.size) {
+ return false;
+ }
+
+ return isSubset(obj1, obj2, deepEqual);
+ }
+
+ var key, i, l,
+ // following vars are used for the cyclic logic
+ value1, value2,
+ isObject1, isObject2,
+ index1, index2,
+ newPath1, newPath2;
+
+ for (i = 0, l = keys1.length; i < l; i++) {
+ key = keys1[i];
+ if (!o.hasOwnProperty.call(obj2, key)) {
+ return false;
+ }
+
+ // Start of the cyclic logic
+
+ value1 = obj1[key];
+ value2 = obj2[key];
+
+ isObject1 = isObject(value1);
+ isObject2 = isObject(value2);
+
+ // determine, if the objects were already visited
+ // (it's faster to check for isObject first, than to
+ // get -1 from getIndex for non objects)
+ index1 = isObject1 ? getIndex(objects1, value1) : -1;
+ index2 = isObject2 ? getIndex(objects2, value2) : -1;
+
+ // determine the new pathes of the objects
+ // - for non cyclic objects the current path will be extended
+ // by current property name
+ // - for cyclic objects the stored path is taken
+ newPath1 = index1 !== -1
+ ? paths1[index1]
+ : path1 + '[' + JSON.stringify(key) + ']';
+ newPath2 = index2 !== -1
+ ? paths2[index2]
+ : path2 + '[' + JSON.stringify(key) + ']';
+
+ // stop recursion if current objects are already compared
+ if (compared[newPath1 + newPath2]) {
+ return true;
+ }
+
+ // remember the current objects and their pathes
+ if (index1 === -1 && isObject1) {
+ objects1.push(value1);
+ paths1.push(newPath1);
+ }
+ if (index2 === -1 && isObject2) {
+ objects2.push(value2);
+ paths2.push(newPath2);
+ }
+
+ // remember that the current objects are already compared
+ if (isObject1 && isObject2) {
+ compared[newPath1 + newPath2] = true;
+ }
+
+ // End of cyclic logic
+
+ // neither value1 nor value2 is a cycle
+ // continue with next level
+ if (!deepEqual(value1, value2, newPath1, newPath2)) {
+ return false;
+ }
+ }
+
+ return true;
+
+ }(obj1, obj2, '$1', '$2'));
+ }
+
+ function arrayContains(array, subset, compare) {
+ if (subset.length === 0) { return true; }
+ var i, l, j, k;
+ for (i = 0, l = array.length; i < l; ++i) {
+ if (compare(array[i], subset[0])) {
+ for (j = 0, k = subset.length; j < k; ++j) {
+ if ((i + j) >= l) { return false; }
+ if (!compare(array[i + j], subset[j])) { return false; }
+ }
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * @name samsam.match
+ * @param Object object
+ * @param Object matcher
+ *
+ * Compare arbitrary value ``object`` with matcher.
+ */
+ function match(object, matcher) {
+ if (matcher && typeof matcher.test === "function") {
+ return matcher.test(object);
+ }
+
+ if (typeof matcher === "function") {
+ return matcher(object) === true;
+ }
+
+ if (typeof matcher === "string") {
+ matcher = matcher.toLowerCase();
+ var notNull = typeof object === "string" || !!object;
+ return notNull &&
+ (String(object)).toLowerCase().indexOf(matcher) >= 0;
+ }
+
+ if (typeof matcher === "number") {
+ return matcher === object;
+ }
+
+ if (typeof matcher === "boolean") {
+ return matcher === object;
+ }
+
+ if (typeof(matcher) === "undefined") {
+ return typeof(object) === "undefined";
+ }
+
+ if (matcher === null) {
+ return object === null;
+ }
+
+ if (isSet(object)) {
+ return isSubset(matcher, object, match);
+ }
+
+ if (getClass(object) === "Array" && getClass(matcher) === "Array") {
+ return arrayContains(object, matcher, match);
+ }
+
+ if (matcher && typeof matcher === "object") {
+ if (matcher === object) {
+ return true;
+ }
+ var prop;
+ for (prop in matcher) {
+ var value = object[prop];
+ if (typeof value === "undefined" &&
+ typeof object.getAttribute === "function") {
+ value = object.getAttribute(prop);
+ }
+ if (matcher[prop] === null || typeof matcher[prop] === 'undefined') {
+ if (value !== matcher[prop]) {
+ return false;
+ }
+ } else if (typeof value === "undefined" || !match(value, matcher[prop])) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ throw new Error("Matcher was not a string, a number, a " +
+ "function, a boolean or an object");
+ }
+
+ return {
+ isArguments: isArguments,
+ isElement: isElement,
+ isDate: isDate,
+ isNegZero: isNegZero,
+ identical: identical,
+ deepEqual: deepEqualCyclic,
+ match: match,
+ keys: keys
+ };
+});
+((typeof define === "function" && define.amd && function (m) {
+ define("formatio", ["samsam"], m);
+}) || (typeof module === "object" && function (m) {
+ module.exports = m(require("samsam"));
+}) || function (m) { this.formatio = m(this.samsam); }
+)(function (samsam) {
+
+ var formatio = {
+ excludeConstructors: ["Object", /^.$/],
+ quoteStrings: true,
+ limitChildrenCount: 0
+ };
+
+ var hasOwn = Object.prototype.hasOwnProperty;
+
+ var specialObjects = [];
+ if (typeof global !== "undefined") {
+ specialObjects.push({ object: global, value: "[object global]" });
+ }
+ if (typeof document !== "undefined") {
+ specialObjects.push({
+ object: document,
+ value: "[object HTMLDocument]"
+ });
+ }
+ if (typeof window !== "undefined") {
+ specialObjects.push({ object: window, value: "[object Window]" });
+ }
+
+ function functionName(func) {
+ if (!func) { return ""; }
+ if (func.displayName) { return func.displayName; }
+ if (func.name) { return func.name; }
+ var matches = func.toString().match(/function\s+([^\(]+)/m);
+ return (matches && matches[1]) || "";
+ }
+
+ function constructorName(f, object) {
+ var name = functionName(object && object.constructor);
+ var excludes = f.excludeConstructors ||
+ formatio.excludeConstructors || [];
+
+ var i, l;
+ for (i = 0, l = excludes.length; i < l; ++i) {
+ if (typeof excludes[i] === "string" && excludes[i] === name) {
+ return "";
+ } else if (excludes[i].test && excludes[i].test(name)) {
+ return "";
+ }
+ }
+
+ return name;
+ }
+
+ function isCircular(object, objects) {
+ if (typeof object !== "object") { return false; }
+ var i, l;
+ for (i = 0, l = objects.length; i < l; ++i) {
+ if (objects[i] === object) { return true; }
+ }
+ return false;
+ }
+
+ function ascii(f, object, processed, indent) {
+ if (typeof object === "string") {
+ var qs = f.quoteStrings;
+ var quote = typeof qs !== "boolean" || qs;
+ return processed || quote ? '"' + object + '"' : object;
+ }
+
+ if (typeof object === "function" && !(object instanceof RegExp)) {
+ return ascii.func(object);
+ }
+
+ processed = processed || [];
+
+ if (isCircular(object, processed)) { return "[Circular]"; }
+
+ if (Object.prototype.toString.call(object) === "[object Array]") {
+ return ascii.array.call(f, object, processed);
+ }
+
+ if (!object) { return String((1/object) === -Infinity ? "-0" : object); }
+ if (samsam.isElement(object)) { return ascii.element(object); }
+
+ if (typeof object.toString === "function" &&
+ object.toString !== Object.prototype.toString) {
+ return object.toString();
+ }
+
+ var i, l;
+ for (i = 0, l = specialObjects.length; i < l; i++) {
+ if (object === specialObjects[i].object) {
+ return specialObjects[i].value;
+ }
+ }
+
+ return ascii.object.call(f, object, processed, indent);
+ }
+
+ ascii.func = function (func) {
+ return "function " + functionName(func) + "() {}";
+ };
+
+ ascii.array = function (array, processed) {
+ processed = processed || [];
+ processed.push(array);
+ var pieces = [];
+ var i, l;
+ l = (this.limitChildrenCount > 0) ?
+ Math.min(this.limitChildrenCount, array.length) : array.length;
+
+ for (i = 0; i < l; ++i) {
+ pieces.push(ascii(this, array[i], processed));
+ }
+
+ if(l < array.length)
+ pieces.push("[... " + (array.length - l) + " more elements]");
+
+ return "[" + pieces.join(", ") + "]";
+ };
+
+ ascii.object = function (object, processed, indent) {
+ processed = processed || [];
+ processed.push(object);
+ indent = indent || 0;
+ var pieces = [], properties = samsam.keys(object).sort();
+ var length = 3;
+ var prop, str, obj, i, k, l;
+ l = (this.limitChildrenCount > 0) ?
+ Math.min(this.limitChildrenCount, properties.length) : properties.length;
+
+ for (i = 0; i < l; ++i) {
+ prop = properties[i];
+ obj = object[prop];
+
+ if (isCircular(obj, processed)) {
+ str = "[Circular]";
+ } else {
+ str = ascii(this, obj, processed, indent + 2);
+ }
+
+ str = (/\s/.test(prop) ? '"' + prop + '"' : prop) + ": " + str;
+ length += str.length;
+ pieces.push(str);
+ }
+
+ var cons = constructorName(this, object);
+ var prefix = cons ? "[" + cons + "] " : "";
+ var is = "";
+ for (i = 0, k = indent; i < k; ++i) { is += " "; }
+
+ if(l < properties.length)
+ pieces.push("[... " + (properties.length - l) + " more elements]");
+
+ if (length + indent > 80) {
+ return prefix + "{\n " + is + pieces.join(",\n " + is) + "\n" +
+ is + "}";
+ }
+ return prefix + "{ " + pieces.join(", ") + " }";
+ };
+
+ ascii.element = function (element) {
+ var tagName = element.tagName.toLowerCase();
+ var attrs = element.attributes, attr, pairs = [], attrName, i, l, val;
+
+ for (i = 0, l = attrs.length; i < l; ++i) {
+ attr = attrs.item(i);
+ attrName = attr.nodeName.toLowerCase().replace("html:", "");
+ val = attr.nodeValue;
+ if (attrName !== "contenteditable" || val !== "inherit") {
+ if (!!val) { pairs.push(attrName + "=\"" + val + "\""); }
+ }
+ }
+
+ var formatted = "<" + tagName + (pairs.length > 0 ? " " : "");
+ var content = element.innerHTML;
+
+ if (content.length > 20) {
+ content = content.substr(0, 20) + "[...]";
+ }
+
+ var res = formatted + pairs.join(" ") + ">" + content +
+ "</" + tagName + ">";
+
+ return res.replace(/ contentEditable="inherit"/, "");
+ };
+
+ function Formatio(options) {
+ for (var opt in options) {
+ this[opt] = options[opt];
+ }
+ }
+
+ Formatio.prototype = {
+ functionName: functionName,
+
+ configure: function (options) {
+ return new Formatio(options);
+ },
+
+ constructorName: function (object) {
+ return constructorName(this, object);
+ },
+
+ ascii: function (object, processed, indent) {
+ return ascii(this, object, processed, indent);
+ }
+ };
+
+ return Formatio.prototype;
+});
+(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.lolex = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){
+(function (global){
+(function (global) {
+
+ var userAgent = global.navigator && global.navigator.userAgent;
+ var isRunningInIE = userAgent && userAgent.indexOf("MSIE ") > -1;
+
+ // Make properties writable in IE, as per
+ // http://www.adequatelygood.com/Replacing-setTimeout-Globally.html
+ if (isRunningInIE) {
+ global.setTimeout = global.setTimeout;
+ global.clearTimeout = global.clearTimeout;
+ global.setInterval = global.setInterval;
+ global.clearInterval = global.clearInterval;
+ global.Date = global.Date;
+ }
+
+ // setImmediate is not a standard function
+ // avoid adding the prop to the window object if not present
+ if (global.setImmediate !== undefined) {
+ global.setImmediate = global.setImmediate;
+ global.clearImmediate = global.clearImmediate;
+ }
+
+ // node expects setTimeout/setInterval to return a fn object w/ .ref()/.unref()
+ // browsers, a number.
+ // see https://github.com/cjohansen/Sinon.JS/pull/436
+
+ var NOOP = function () { return undefined; };
+ var timeoutResult = setTimeout(NOOP, 0);
+ var addTimerReturnsObject = typeof timeoutResult === "object";
+ var hrtimePresent = (global.process && typeof global.process.hrtime === "function");
+ clearTimeout(timeoutResult);
+
+ var NativeDate = Date;
+ var uniqueTimerId = 1;
+
+ /**
+ * Parse strings like "01:10:00" (meaning 1 hour, 10 minutes, 0 seconds) into
+ * number of milliseconds. This is used to support human-readable strings passed
+ * to clock.tick()
+ */
+ function parseTime(str) {
+ if (!str) {
+ return 0;
+ }
+
+ var strings = str.split(":");
+ var l = strings.length, i = l;
+ var ms = 0, parsed;
+
+ if (l > 3 || !/^(\d\d:){0,2}\d\d?$/.test(str)) {
+ throw new Error("tick only understands numbers, 'm:s' and 'h:m:s'. Each part must be two digits");
+ }
+
+ while (i--) {
+ parsed = parseInt(strings[i], 10);
+
+ if (parsed >= 60) {
+ throw new Error("Invalid time " + str);
+ }
+
+ ms += parsed * Math.pow(60, (l - i - 1));
+ }
+
+ return ms * 1000;
+ }
+
+ /**
+ * Floor function that also works for negative numbers
+ */
+ function fixedFloor(n) {
+ return (n >= 0 ? Math.floor(n) : Math.ceil(n));
+ }
+
+ /**
+ * % operator that also works for negative numbers
+ */
+ function fixedModulo(n, m) {
+ return ((n % m) + m) % m;
+ }
+
+ /**
+ * Used to grok the `now` parameter to createClock.
+ */
+ function getEpoch(epoch) {
+ if (!epoch) { return 0; }
+ if (typeof epoch.getTime === "function") { return epoch.getTime(); }
+ if (typeof epoch === "number") { return epoch; }
+ throw new TypeError("now should be milliseconds since UNIX epoch");
+ }
+
+ function inRange(from, to, timer) {
+ return timer && timer.callAt >= from && timer.callAt <= to;
+ }
+
+ function mirrorDateProperties(target, source) {
+ var prop;
+ for (prop in source) {
+ if (source.hasOwnProperty(prop)) {
+ target[prop] = source[prop];
+ }
+ }
+
+ // set special now implementation
+ if (source.now) {
+ target.now = function now() {
+ return target.clock.now;
+ };
+ } else {
+ delete target.now;
+ }
+
+ // set special toSource implementation
+ if (source.toSource) {
+ target.toSource = function toSource() {
+ return source.toSource();
+ };
+ } else {
+ delete target.toSource;
+ }
+
+ // set special toString implementation
+ target.toString = function toString() {
+ return source.toString();
+ };
+
+ target.prototype = source.prototype;
+ target.parse = source.parse;
+ target.UTC = source.UTC;
+ target.prototype.toUTCString = source.prototype.toUTCString;
+
+ return target;
+ }
+
+ function createDate() {
+ function ClockDate(year, month, date, hour, minute, second, ms) {
+ // Defensive and verbose to avoid potential harm in passing
+ // explicit undefined when user does not pass argument
+ switch (arguments.length) {
+ case 0:
+ return new NativeDate(ClockDate.clock.now);
+ case 1:
+ return new NativeDate(year);
+ case 2:
+ return new NativeDate(year, month);
+ case 3:
+ return new NativeDate(year, month, date);
+ case 4:
+ return new NativeDate(year, month, date, hour);
+ case 5:
+ return new NativeDate(year, month, date, hour, minute);
+ case 6:
+ return new NativeDate(year, month, date, hour, minute, second);
+ default:
+ return new NativeDate(year, month, date, hour, minute, second, ms);
+ }
+ }
+
+ return mirrorDateProperties(ClockDate, NativeDate);
+ }
+
+ function addTimer(clock, timer) {
+ if (timer.func === undefined) {
+ throw new Error("Callback must be provided to timer calls");
+ }
+
+ if (!clock.timers) {
+ clock.timers = {};
+ }
+
+ timer.id = uniqueTimerId++;
+ timer.createdAt = clock.now;
+ timer.callAt = clock.now + (parseInt(timer.delay) || (clock.duringTick ? 1 : 0));
+
+ clock.timers[timer.id] = timer;
+
+ if (addTimerReturnsObject) {
+ return {
+ id: timer.id,
+ ref: NOOP,
+ unref: NOOP
+ };
+ }
+
+ return timer.id;
+ }
+
+
+ /* eslint consistent-return: "off" */
+ function compareTimers(a, b) {
+ // Sort first by absolute timing
+ if (a.callAt < b.callAt) {
+ return -1;
+ }
+ if (a.callAt > b.callAt) {
+ return 1;
+ }
+
+ // Sort next by immediate, immediate timers take precedence
+ if (a.immediate && !b.immediate) {
+ return -1;
+ }
+ if (!a.immediate && b.immediate) {
+ return 1;
+ }
+
+ // Sort next by creation time, earlier-created timers take precedence
+ if (a.createdAt < b.createdAt) {
+ return -1;
+ }
+ if (a.createdAt > b.createdAt) {
+ return 1;
+ }
+
+ // Sort next by id, lower-id timers take precedence
+ if (a.id < b.id) {
+ return -1;
+ }
+ if (a.id > b.id) {
+ return 1;
+ }
+
+ // As timer ids are unique, no fallback `0` is necessary
+ }
+
+ function firstTimerInRange(clock, from, to) {
+ var timers = clock.timers,
+ timer = null,
+ id,
+ isInRange;
+
+ for (id in timers) {
+ if (timers.hasOwnProperty(id)) {
+ isInRange = inRange(from, to, timers[id]);
+
+ if (isInRange && (!timer || compareTimers(timer, timers[id]) === 1)) {
+ timer = timers[id];
+ }
+ }
+ }
+
+ return timer;
+ }
+
+ function firstTimer(clock) {
+ var timers = clock.timers,
+ timer = null,
+ id;
+
+ for (id in timers) {
+ if (timers.hasOwnProperty(id)) {
+ if (!timer || compareTimers(timer, timers[id]) === 1) {
+ timer = timers[id];
+ }
+ }
+ }
+
+ return timer;
+ }
+
+ function lastTimer(clock) {
+ var timers = clock.timers,
+ timer = null,
+ id;
+
+ for (id in timers) {
+ if (timers.hasOwnProperty(id)) {
+ if (!timer || compareTimers(timer, timers[id]) === -1) {
+ timer = timers[id];
+ }
+ }
+ }
+
+ return timer;
+ }
+
+ function callTimer(clock, timer) {
+ var exception;
+
+ if (typeof timer.interval === "number") {
+ clock.timers[timer.id].callAt += timer.interval;
+ } else {
+ delete clock.timers[timer.id];
+ }
+
+ try {
+ if (typeof timer.func === "function") {
+ timer.func.apply(null, timer.args);
+ } else {
+ /* eslint no-eval: "off" */
+ eval(timer.func);
+ }
+ } catch (e) {
+ exception = e;
+ }
+
+ if (!clock.timers[timer.id]) {
+ if (exception) {
+ throw exception;
+ }
+ return;
+ }
+
+ if (exception) {
+ throw exception;
+ }
+ }
+
+ function timerType(timer) {
+ if (timer.immediate) {
+ return "Immediate";
+ }
+ if (timer.interval !== undefined) {
+ return "Interval";
+ }
+ return "Timeout";
+ }
+
+ function clearTimer(clock, timerId, ttype) {
+ if (!timerId) {
+ // null appears to be allowed in most browsers, and appears to be
+ // relied upon by some libraries, like Bootstrap carousel
+ return;
+ }
+
+ if (!clock.timers) {
+ clock.timers = [];
+ }
+
+ // in Node, timerId is an object with .ref()/.unref(), and
+ // its .id field is the actual timer id.
+ if (typeof timerId === "object") {
+ timerId = timerId.id;
+ }
+
+ if (clock.timers.hasOwnProperty(timerId)) {
+ // check that the ID matches a timer of the correct type
+ var timer = clock.timers[timerId];
+ if (timerType(timer) === ttype) {
+ delete clock.timers[timerId];
+ } else {
+ throw new Error("Cannot clear timer: timer created with set" + timerType(timer)
+ + "() but cleared with clear" + ttype + "()");
+ }
+ }
+ }
+
+ function uninstall(clock, target) {
+ var method,
+ i,
+ l;
+ var installedHrTime = "_hrtime";
+
+ for (i = 0, l = clock.methods.length; i < l; i++) {
+ method = clock.methods[i];
+ if (method === "hrtime" && target.process) {
+ target.process.hrtime = clock[installedHrTime];
+ } else {
+ if (target[method] && target[method].hadOwnProperty) {
+ target[method] = clock["_" + method];
+ } else {
+ try {
+ delete target[method];
+ } catch (ignore) { /* eslint empty-block: "off" */ }
+ }
+ }
+ }
+
+ // Prevent multiple executions which will completely remove these props
+ clock.methods = [];
+ }
+
+ function hijackMethod(target, method, clock) {
+ var prop;
+
+ clock[method].hadOwnProperty = Object.prototype.hasOwnProperty.call(target, method);
+ clock["_" + method] = target[method];
+
+ if (method === "Date") {
+ var date = mirrorDateProperties(clock[method], target[method]);
+ target[method] = date;
+ } else {
+ target[method] = function () {
+ return clock[method].apply(clock, arguments);
+ };
+
+ for (prop in clock[method]) {
+ if (clock[method].hasOwnProperty(prop)) {
+ target[method][prop] = clock[method][prop];
+ }
+ }
+ }
+
+ target[method].clock = clock;
+ }
+
+ var timers = {
+ setTimeout: setTimeout,
+ clearTimeout: clearTimeout,
+ setImmediate: global.setImmediate,
+ clearImmediate: global.clearImmediate,
+ setInterval: setInterval,
+ clearInterval: clearInterval,
+ Date: Date
+ };
+
+ if (hrtimePresent) {
+ timers.hrtime = global.process.hrtime;
+ }
+
+ var keys = Object.keys || function (obj) {
+ var ks = [],
+ key;
+
+ for (key in obj) {
+ if (obj.hasOwnProperty(key)) {
+ ks.push(key);
+ }
+ }
+
+ return ks;
+ };
+
+ exports.timers = timers;
+
+ function createClock(now, loopLimit) {
+ loopLimit = loopLimit || 1000;
+
+ var clock = {
+ now: getEpoch(now),
+ hrNow: 0,
+ timeouts: {},
+ Date: createDate(),
+ loopLimit: loopLimit
+ };
+
+ clock.Date.clock = clock;
+
+ clock.setTimeout = function setTimeout(func, timeout) {
+ return addTimer(clock, {
+ func: func,
+ args: Array.prototype.slice.call(arguments, 2),
+ delay: timeout
+ });
+ };
+
+ clock.clearTimeout = function clearTimeout(timerId) {
+ return clearTimer(clock, timerId, "Timeout");
+ };
+
+ clock.setInterval = function setInterval(func, timeout) {
+ return addTimer(clock, {
+ func: func,
+ args: Array.prototype.slice.call(arguments, 2),
+ delay: timeout,
+ interval: timeout
+ });
+ };
+
+ clock.clearInterval = function clearInterval(timerId) {
+ return clearTimer(clock, timerId, "Interval");
+ };
+
+ clock.setImmediate = function setImmediate(func) {
+ return addTimer(clock, {
+ func: func,
+ args: Array.prototype.slice.call(arguments, 1),
+ immediate: true
+ });
+ };
+
+ clock.clearImmediate = function clearImmediate(timerId) {
+ return clearTimer(clock, timerId, "Immediate");
+ };
+
+ clock.tick = function tick(ms) {
+ ms = typeof ms === "number" ? ms : parseTime(ms);
+ var tickFrom = clock.now, tickTo = clock.now + ms, previous = clock.now;
+ var timer = firstTimerInRange(clock, tickFrom, tickTo);
+ var oldNow;
+
+ clock.duringTick = true;
+
+ function updateHrTime(newNow) {
+ clock.hrNow += (newNow - clock.now);
+ }
+
+ var firstException;
+ while (timer && tickFrom <= tickTo) {
+ if (clock.timers[timer.id]) {
+ updateHrTime(timer.callAt);
+ tickFrom = timer.callAt;
+ clock.now = timer.callAt;
+ try {
+ oldNow = clock.now;
+ callTimer(clock, timer);
+ // compensate for any setSystemTime() call during timer callback
+ if (oldNow !== clock.now) {
+ tickFrom += clock.now - oldNow;
+ tickTo += clock.now - oldNow;
+ previous += clock.now - oldNow;
+ }
+ } catch (e) {
+ firstException = firstException || e;
+ }
+ }
+
+ timer = firstTimerInRange(clock, previous, tickTo);
+ previous = tickFrom;
+ }
+
+ clock.duringTick = false;
+ updateHrTime(tickTo);
+ clock.now = tickTo;
+
+ if (firstException) {
+ throw firstException;
+ }
+
+ return clock.now;
+ };
+
+ clock.next = function next() {
+ var timer = firstTimer(clock);
+ if (!timer) {
+ return clock.now;
+ }
+
+ clock.duringTick = true;
+ try {
+ clock.now = timer.callAt;
+ callTimer(clock, timer);
+ return clock.now;
+ } finally {
+ clock.duringTick = false;
+ }
+ };
+
+ clock.runAll = function runAll() {
+ var numTimers, i;
+ for (i = 0; i < clock.loopLimit; i++) {
+ if (!clock.timers) {
+ return clock.now;
+ }
+
+ numTimers = Object.keys(clock.timers).length;
+ if (numTimers === 0) {
+ return clock.now;
+ }
+
+ clock.next();
+ }
+
+ throw new Error("Aborting after running " + clock.loopLimit + "timers, assuming an infinite loop!");
+ };
+
+ clock.runToLast = function runToLast() {
+ var timer = lastTimer(clock);
+ if (!timer) {
+ return clock.now;
+ }
+
+ return clock.tick(timer.callAt);
+ };
+
+ clock.reset = function reset() {
+ clock.timers = {};
+ };
+
+ clock.setSystemTime = function setSystemTime(now) {
+ // determine time difference
+ var newNow = getEpoch(now);
+ var difference = newNow - clock.now;
+ var id, timer;
+
+ // update 'system clock'
+ clock.now = newNow;
+
+ // update timers and intervals to keep them stable
+ for (id in clock.timers) {
+ if (clock.timers.hasOwnProperty(id)) {
+ timer = clock.timers[id];
+ timer.createdAt += difference;
+ timer.callAt += difference;
+ }
+ }
+ };
+
+ if (hrtimePresent) {
+ clock.hrtime = function (prev) {
+ if (Array.isArray(prev)) {
+ var oldSecs = (prev[0] + prev[1] / 1e9);
+ var newSecs = (clock.hrNow / 1000);
+ var difference = (newSecs - oldSecs);
+ var secs = fixedFloor(difference);
+ var nanosecs = fixedModulo(difference * 1e9, 1e9);
+ return [
+ secs,
+ nanosecs
+ ];
+ }
+ return [
+ fixedFloor(clock.hrNow / 1000),
+ fixedModulo(clock.hrNow * 1e6, 1e9)
+ ];
+ };
+ }
+
+ return clock;
+ }
+ exports.createClock = createClock;
+
+ exports.install = function install(target, now, toFake, loopLimit) {
+ var i,
+ l;
+
+ if (typeof target === "number") {
+ toFake = now;
+ now = target;
+ target = null;
+ }
+
+ if (!target) {
+ target = global;
+ }
+
+ var clock = createClock(now, loopLimit);
+
+ clock.uninstall = function () {
+ uninstall(clock, target);
+ };
+
+ clock.methods = toFake || [];
+
+ if (clock.methods.length === 0) {
+ clock.methods = keys(timers);
+ }
+
+ for (i = 0, l = clock.methods.length; i < l; i++) {
+ if (clock.methods[i] === "hrtime") {
+ if (target.process && typeof target.process.hrtime === "function") {
+ hijackMethod(target.process, clock.methods[i], clock);
+ }
+ } else {
+ hijackMethod(target, clock.methods[i], clock);
+ }
+ }
+
+ return clock;
+ };
+
+}(global || this));
+
+}).call(this,typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : {})
+},{}]},{},[1])(1)
+});
+ })();
+ var define;
+/**
+ * Sinon core utilities. For internal use only.
+ *
+ * @author Christian Johansen (christian@cjohansen.no)
+ * @license BSD
+ *
+ * Copyright (c) 2010-2013 Christian Johansen
+ */
+var sinon = (function () {
+"use strict";
+ // eslint-disable-line no-unused-vars
+
+ var sinonModule;
+ var isNode = typeof module !== "undefined" && module.exports && typeof require === "function";
+ var isAMD = typeof define === "function" && typeof define.amd === "object" && define.amd;
+
+ function loadDependencies(require, exports, module) {
+ sinonModule = module.exports = require("./sinon/util/core");
+ require("./sinon/extend");
+ require("./sinon/walk");
+ require("./sinon/typeOf");
+ require("./sinon/times_in_words");
+ require("./sinon/spy");
+ require("./sinon/call");
+ require("./sinon/behavior");
+ require("./sinon/stub");
+ require("./sinon/mock");
+ require("./sinon/collection");
+ require("./sinon/assert");
+ require("./sinon/sandbox");
+ require("./sinon/test");
+ require("./sinon/test_case");
+ require("./sinon/match");
+ require("./sinon/format");
+ require("./sinon/log_error");
+ }
+
+ if (isAMD) {
+ define(loadDependencies);
+ } else if (isNode) {
+ loadDependencies(require, module.exports, module);
+ sinonModule = module.exports;
+ } else {
+ sinonModule = {};
+ }
+
+ return sinonModule;
+}());
+
+/**
+ * @depend ../../sinon.js
+ */
+/**
+ * Sinon core utilities. For internal use only.
+ *
+ * @author Christian Johansen (christian@cjohansen.no)
+ * @license BSD
+ *
+ * Copyright (c) 2010-2013 Christian Johansen
+ */
+(function (sinonGlobal) {
+
+ var div = typeof document !== "undefined" && document.createElement("div");
+ var hasOwn = Object.prototype.hasOwnProperty;
+
+ function isDOMNode(obj) {
+ var success = false;
+
+ try {
+ obj.appendChild(div);
+ success = div.parentNode === obj;
+ } catch (e) {
+ return false;
+ } finally {
+ try {
+ obj.removeChild(div);
+ } catch (e) {
+ // Remove failed, not much we can do about that
+ }
+ }
+
+ return success;
+ }
+
+ function isElement(obj) {
+ return div && obj && obj.nodeType === 1 && isDOMNode(obj);
+ }
+
+ function isFunction(obj) {
+ return typeof obj === "function" || !!(obj && obj.constructor && obj.call && obj.apply);
+ }
+
+ function isReallyNaN(val) {
+ return typeof val === "number" && isNaN(val);
+ }
+
+ function mirrorProperties(target, source) {
+ for (var prop in source) {
+ if (!hasOwn.call(target, prop)) {
+ target[prop] = source[prop];
+ }
+ }
+ }
+
+ function isRestorable(obj) {
+ return typeof obj === "function" && typeof obj.restore === "function" && obj.restore.sinon;
+ }
+
+ // Cheap way to detect if we have ES5 support.
+ var hasES5Support = "keys" in Object;
+
+ function makeApi(sinon) {
+ sinon.wrapMethod = function wrapMethod(object, property, method) {
+ if (!object) {
+ throw new TypeError("Should wrap property of object");
+ }
+
+ if (typeof method !== "function" && typeof method !== "object") {
+ throw new TypeError("Method wrapper should be a function or a property descriptor");
+ }
+
+ function checkWrappedMethod(wrappedMethod) {
+ var error;
+
+ if (!isFunction(wrappedMethod)) {
+ error = new TypeError("Attempted to wrap " + (typeof wrappedMethod) + " property " +
+ property + " as function");
+ } else if (wrappedMethod.restore && wrappedMethod.restore.sinon) {
+ error = new TypeError("Attempted to wrap " + property + " which is already wrapped");
+ } else if (wrappedMethod.calledBefore) {
+ var verb = wrappedMethod.returns ? "stubbed" : "spied on";
+ error = new TypeError("Attempted to wrap " + property + " which is already " + verb);
+ }
+
+ if (error) {
+ if (wrappedMethod && wrappedMethod.stackTrace) {
+ error.stack += "\n--------------\n" + wrappedMethod.stackTrace;
+ }
+ throw error;
+ }
+ }
+
+ var error, wrappedMethod, i;
+
+ function simplePropertyAssignment() {
+ wrappedMethod = object[property];
+ checkWrappedMethod(wrappedMethod);
+ object[property] = method;
+ method.displayName = property;
+ }
+
+ // IE 8 does not support hasOwnProperty on the window object and Firefox has a problem
+ // when using hasOwn.call on objects from other frames.
+ var owned = (object.hasOwnProperty && object.hasOwnProperty === hasOwn) ?
+ object.hasOwnProperty(property) : hasOwn.call(object, property);
+
+ if (hasES5Support) {
+ var methodDesc = (typeof method === "function") ? {value: method} : method;
+ var wrappedMethodDesc = sinon.getPropertyDescriptor(object, property);
+
+ if (!wrappedMethodDesc) {
+ error = new TypeError("Attempted to wrap " + (typeof wrappedMethod) + " property " +
+ property + " as function");
+ } else if (wrappedMethodDesc.restore && wrappedMethodDesc.restore.sinon) {
+ error = new TypeError("Attempted to wrap " + property + " which is already wrapped");
+ }
+ if (error) {
+ if (wrappedMethodDesc && wrappedMethodDesc.stackTrace) {
+ error.stack += "\n--------------\n" + wrappedMethodDesc.stackTrace;
+ }
+ throw error;
+ }
+
+ var types = sinon.objectKeys(methodDesc);
+ for (i = 0; i < types.length; i++) {
+ wrappedMethod = wrappedMethodDesc[types[i]];
+ checkWrappedMethod(wrappedMethod);
+ }
+
+ mirrorProperties(methodDesc, wrappedMethodDesc);
+ for (i = 0; i < types.length; i++) {
+ mirrorProperties(methodDesc[types[i]], wrappedMethodDesc[types[i]]);
+ }
+ Object.defineProperty(object, property, methodDesc);
+
+ // catch failing assignment
+ // this is the converse of the check in `.restore` below
+ if ( typeof method === "function" && object[property] !== method ) {
+ // correct any wrongdoings caused by the defineProperty call above,
+ // such as adding new items (if object was a Storage object)
+ delete object[property];
+ simplePropertyAssignment();
+ }
+ } else {
+ simplePropertyAssignment();
+ }
+
+ method.displayName = property;
+
+ // Set up a stack trace which can be used later to find what line of
+ // code the original method was created on.
+ method.stackTrace = (new Error("Stack Trace for original")).stack;
+
+ method.restore = function () {
+ // For prototype properties try to reset by delete first.
+ // If this fails (ex: localStorage on mobile safari) then force a reset
+ // via direct assignment.
+ if (!owned) {
+ // In some cases `delete` may throw an error
+ try {
+ delete object[property];
+ } catch (e) {} // eslint-disable-line no-empty
+ // For native code functions `delete` fails without throwing an error
+ // on Chrome < 43, PhantomJS, etc.
+ } else if (hasES5Support) {
+ Object.defineProperty(object, property, wrappedMethodDesc);
+ }
+
+ // this only supports ES5 getter/setter, for ES3.1 and lower
+ // __lookupSetter__ / __lookupGetter__ should be integrated
+ if (hasES5Support) {
+ var checkDesc = sinon.getPropertyDescriptor(object, property);
+ if (checkDesc.value === method) {
+ object[property] = wrappedMethod;
+ }
+
+ // Use strict equality comparison to check failures then force a reset
+ // via direct assignment.
+ } else if (object[property] === method) {
+ object[property] = wrappedMethod;
+ }
+ };
+
+ method.restore.sinon = true;
+
+ if (!hasES5Support) {
+ mirrorProperties(method, wrappedMethod);
+ }
+
+ return method;
+ };
+
+ sinon.create = function create(proto) {
+ var F = function () {};
+ F.prototype = proto;
+ return new F();
+ };
+
+ sinon.deepEqual = function deepEqual(a, b) {
+ if (sinon.match && sinon.match.isMatcher(a)) {
+ return a.test(b);
+ }
+
+ if (typeof a !== "object" || typeof b !== "object") {
+ return isReallyNaN(a) && isReallyNaN(b) || a === b;
+ }
+
+ if (isElement(a) || isElement(b)) {
+ return a === b;
+ }
+
+ if (a === b) {
+ return true;
+ }
+
+ if ((a === null && b !== null) || (a !== null && b === null)) {
+ return false;
+ }
+
+ if (a instanceof RegExp && b instanceof RegExp) {
+ return (a.source === b.source) && (a.global === b.global) &&
+ (a.ignoreCase === b.ignoreCase) && (a.multiline === b.multiline);
+ }
+
+ var aString = Object.prototype.toString.call(a);
+ if (aString !== Object.prototype.toString.call(b)) {
+ return false;
+ }
+
+ if (aString === "[object Date]") {
+ return a.valueOf() === b.valueOf();
+ }
+
+ var prop;
+ var aLength = 0;
+ var bLength = 0;
+
+ if (aString === "[object Array]" && a.length !== b.length) {
+ return false;
+ }
+
+ for (prop in a) {
+ if (hasOwn.call(a, prop)) {
+ aLength += 1;
+
+ if (!(prop in b)) {
+ return false;
+ }
+
+ if (!deepEqual(a[prop], b[prop])) {
+ return false;
+ }
+ }
+ }
+
+ for (prop in b) {
+ if (hasOwn.call(b, prop)) {
+ bLength += 1;
+ }
+ }
+
+ return aLength === bLength;
+ };
+
+ sinon.functionName = function functionName(func) {
+ var name = func.displayName || func.name;
+
+ // Use function decomposition as a last resort to get function
+ // name. Does not rely on function decomposition to work - if it
+ // doesn't debugging will be slightly less informative
+ // (i.e. toString will say 'spy' rather than 'myFunc').
+ if (!name) {
+ var matches = func.toString().match(/function ([^\s\(]+)/);
+ name = matches && matches[1];
+ }
+
+ return name;
+ };
+
+ sinon.functionToString = function toString() {
+ if (this.getCall && this.callCount) {
+ var thisValue,
+ prop;
+ var i = this.callCount;
+
+ while (i--) {
+ thisValue = this.getCall(i).thisValue;
+
+ for (prop in thisValue) {
+ if (thisValue[prop] === this) {
+ return prop;
+ }
+ }
+ }
+ }
+
+ return this.displayName || "sinon fake";
+ };
+
+ sinon.objectKeys = function objectKeys(obj) {
+ if (obj !== Object(obj)) {
+ throw new TypeError("sinon.objectKeys called on a non-object");
+ }
+
+ var keys = [];
+ var key;
+ for (key in obj) {
+ if (hasOwn.call(obj, key)) {
+ keys.push(key);
+ }
+ }
+
+ return keys;
+ };
+
+ sinon.getPropertyDescriptor = function getPropertyDescriptor(object, property) {
+ var proto = object;
+ var descriptor;
+
+ while (proto && !(descriptor = Object.getOwnPropertyDescriptor(proto, property))) {
+ proto = Object.getPrototypeOf(proto);
+ }
+ return descriptor;
+ };
+
+ sinon.getConfig = function (custom) {
+ var config = {};
+ custom = custom || {};
+ var defaults = sinon.defaultConfig;
+
+ for (var prop in defaults) {
+ if (defaults.hasOwnProperty(prop)) {
+ config[prop] = custom.hasOwnProperty(prop) ? custom[prop] : defaults[prop];
+ }
+ }
+
+ return config;
+ };
+
+ sinon.defaultConfig = {
+ injectIntoThis: true,
+ injectInto: null,
+ properties: ["spy", "stub", "mock", "clock", "server", "requests"],
+ useFakeTimers: true,
+ useFakeServer: true
+ };
+
+ sinon.timesInWords = function timesInWords(count) {
+ return count === 1 && "once" ||
+ count === 2 && "twice" ||
+ count === 3 && "thrice" ||
+ (count || 0) + " times";
+ };
+
+ sinon.calledInOrder = function (spies) {
+ for (var i = 1, l = spies.length; i < l; i++) {
+ if (!spies[i - 1].calledBefore(spies[i]) || !spies[i].called) {
+ return false;
+ }
+ }
+
+ return true;
+ };
+
+ sinon.orderByFirstCall = function (spies) {
+ return spies.sort(function (a, b) {
+ // uuid, won't ever be equal
+ var aCall = a.getCall(0);
+ var bCall = b.getCall(0);
+ var aId = aCall && aCall.callId || -1;
+ var bId = bCall && bCall.callId || -1;
+
+ return aId < bId ? -1 : 1;
+ });
+ };
+
+ sinon.createStubInstance = function (constructor) {
+ if (typeof constructor !== "function") {
+ throw new TypeError("The constructor should be a function.");
+ }
+ return sinon.stub(sinon.create(constructor.prototype));
+ };
+
+ sinon.restore = function (object) {
+ if (object !== null && typeof object === "object") {
+ for (var prop in object) {
+ if (isRestorable(object[prop])) {
+ object[prop].restore();
+ }
+ }
+ } else if (isRestorable(object)) {
+ object.restore();
+ }
+ };
+
+ return sinon;
+ }
+
+ var isNode = typeof module !== "undefined" && module.exports && typeof require === "function";
+ var isAMD = typeof define === "function" && typeof define.amd === "object" && define.amd;
+
+ function loadDependencies(require, exports) {
+ makeApi(exports);
+ }
+
+ if (isAMD) {
+ define(loadDependencies);
+ return;
+ }
+
+ if (isNode) {
+ loadDependencies(require, module.exports, module);
+ return;
+ }
+
+ if (sinonGlobal) {
+ makeApi(sinonGlobal);
+ }
+}(
+ typeof sinon === "object" && sinon // eslint-disable-line no-undef
+));
+
+/**
+ * @depend util/core.js
+ */
+(function (sinonGlobal) {
+
+ function makeApi(sinon) {
+
+ // Adapted from https://developer.mozilla.org/en/docs/ECMAScript_DontEnum_attribute#JScript_DontEnum_Bug
+ var hasDontEnumBug = (function () {
+ var obj = {
+ constructor: function () {
+ return "0";
+ },
+ toString: function () {
+ return "1";
+ },
+ valueOf: function () {
+ return "2";
+ },
+ toLocaleString: function () {
+ return "3";
+ },
+ prototype: function () {
+ return "4";
+ },
+ isPrototypeOf: function () {
+ return "5";
+ },
+ propertyIsEnumerable: function () {
+ return "6";
+ },
+ hasOwnProperty: function () {
+ return "7";
+ },
+ length: function () {
+ return "8";
+ },
+ unique: function () {
+ return "9";
+ }
+ };
+
+ var result = [];
+ for (var prop in obj) {
+ if (obj.hasOwnProperty(prop)) {
+ result.push(obj[prop]());
+ }
+ }
+ return result.join("") !== "0123456789";
+ })();
+
+ /* Public: Extend target in place with all (own) properties from sources in-order. Thus, last source will
+ * override properties in previous sources.
+ *
+ * target - The Object to extend
+ * sources - Objects to copy properties from.
+ *
+ * Returns the extended target
+ */
+ function extend(target /*, sources */) {
+ var sources = Array.prototype.slice.call(arguments, 1);
+ var source, i, prop;
+
+ for (i = 0; i < sources.length; i++) {
+ source = sources[i];
+
+ for (prop in source) {
+ if (source.hasOwnProperty(prop)) {
+ target[prop] = source[prop];
+ }
+ }
+
+ // Make sure we copy (own) toString method even when in JScript with DontEnum bug
+ // See https://developer.mozilla.org/en/docs/ECMAScript_DontEnum_attribute#JScript_DontEnum_Bug
+ if (hasDontEnumBug && source.hasOwnProperty("toString") && source.toString !== target.toString) {
+ target.toString = source.toString;
+ }
+ }
+
+ return target;
+ }
+
+ sinon.extend = extend;
+ return sinon.extend;
+ }
+
+ function loadDependencies(require, exports, module) {
+ var sinon = require("./util/core");
+ module.exports = makeApi(sinon);
+ }
+
+ var isNode = typeof module !== "undefined" && module.exports && typeof require === "function";
+ var isAMD = typeof define === "function" && typeof define.amd === "object" && define.amd;
+
+ if (isAMD) {
+ define(loadDependencies);
+ return;
+ }
+
+ if (isNode) {
+ loadDependencies(require, module.exports, module);
+ return;
+ }
+
+ if (sinonGlobal) {
+ makeApi(sinonGlobal);
+ }
+}(
+ typeof sinon === "object" && sinon // eslint-disable-line no-undef
+));
+
+/**
+ * @depend util/core.js
+ */
+(function (sinonGlobal) {
+
+ function makeApi(sinon) {
+
+ function timesInWords(count) {
+ switch (count) {
+ case 1:
+ return "once";
+ case 2:
+ return "twice";
+ case 3:
+ return "thrice";
+ default:
+ return (count || 0) + " times";
+ }
+ }
+
+ sinon.timesInWords = timesInWords;
+ return sinon.timesInWords;
+ }
+
+ function loadDependencies(require, exports, module) {
+ var core = require("./util/core");
+ module.exports = makeApi(core);
+ }
+
+ var isNode = typeof module !== "undefined" && module.exports && typeof require === "function";
+ var isAMD = typeof define === "function" && typeof define.amd === "object" && define.amd;
+
+ if (isAMD) {
+ define(loadDependencies);
+ return;
+ }
+
+ if (isNode) {
+ loadDependencies(require, module.exports, module);
+ return;
+ }
+
+ if (sinonGlobal) {
+ makeApi(sinonGlobal);
+ }
+}(
+ typeof sinon === "object" && sinon // eslint-disable-line no-undef
+));
+
+/**
+ * @depend util/core.js
+ */
+/**
+ * Format functions
+ *
+ * @author Christian Johansen (christian@cjohansen.no)
+ * @license BSD
+ *
+ * Copyright (c) 2010-2014 Christian Johansen
+ */
+(function (sinonGlobal) {
+
+ function makeApi(sinon) {
+ function typeOf(value) {
+ if (value === null) {
+ return "null";
+ } else if (value === undefined) {
+ return "undefined";
+ }
+ var string = Object.prototype.toString.call(value);
+ return string.substring(8, string.length - 1).toLowerCase();
+ }
+
+ sinon.typeOf = typeOf;
+ return sinon.typeOf;
+ }
+
+ function loadDependencies(require, exports, module) {
+ var core = require("./util/core");
+ module.exports = makeApi(core);
+ }
+
+ var isNode = typeof module !== "undefined" && module.exports && typeof require === "function";
+ var isAMD = typeof define === "function" && typeof define.amd === "object" && define.amd;
+
+ if (isAMD) {
+ define(loadDependencies);
+ return;
+ }
+
+ if (isNode) {
+ loadDependencies(require, module.exports, module);
+ return;
+ }
+
+ if (sinonGlobal) {
+ makeApi(sinonGlobal);
+ }
+}(
+ typeof sinon === "object" && sinon // eslint-disable-line no-undef
+));
+
+/**
+ * @depend util/core.js
+ * @depend typeOf.js
+ */
+/*jslint eqeqeq: false, onevar: false, plusplus: false*/
+/*global module, require, sinon*/
+/**
+ * Match functions
+ *
+ * @author Maximilian Antoni (mail@maxantoni.de)
+ * @license BSD
+ *
+ * Copyright (c) 2012 Maximilian Antoni
+ */
+(function (sinonGlobal) {
+
+ function makeApi(sinon) {
+ function assertType(value, type, name) {
+ var actual = sinon.typeOf(value);
+ if (actual !== type) {
+ throw new TypeError("Expected type of " + name + " to be " +
+ type + ", but was " + actual);
+ }
+ }
+
+ var matcher = {
+ toString: function () {
+ return this.message;
+ }
+ };
+
+ function isMatcher(object) {
+ return matcher.isPrototypeOf(object);
+ }
+
+ function matchObject(expectation, actual) {
+ if (actual === null || actual === undefined) {
+ return false;
+ }
+ for (var key in expectation) {
+ if (expectation.hasOwnProperty(key)) {
+ var exp = expectation[key];
+ var act = actual[key];
+ if (isMatcher(exp)) {
+ if (!exp.test(act)) {
+ return false;
+ }
+ } else if (sinon.typeOf(exp) === "object") {
+ if (!matchObject(exp, act)) {
+ return false;
+ }
+ } else if (!sinon.deepEqual(exp, act)) {
+ return false;
+ }
+ }
+ }
+ return true;
+ }
+
+ function match(expectation, message) {
+ var m = sinon.create(matcher);
+ var type = sinon.typeOf(expectation);
+ switch (type) {
+ case "object":
+ if (typeof expectation.test === "function") {
+ m.test = function (actual) {
+ return expectation.test(actual) === true;
+ };
+ m.message = "match(" + sinon.functionName(expectation.test) + ")";
+ return m;
+ }
+ var str = [];
+ for (var key in expectation) {
+ if (expectation.hasOwnProperty(key)) {
+ str.push(key + ": " + expectation[key]);
+ }
+ }
+ m.test = function (actual) {
+ return matchObject(expectation, actual);
+ };
+ m.message = "match(" + str.join(", ") + ")";
+ break;
+ case "number":
+ m.test = function (actual) {
+ // we need type coercion here
+ return expectation == actual; // eslint-disable-line eqeqeq
+ };
+ break;
+ case "string":
+ m.test = function (actual) {
+ if (typeof actual !== "string") {
+ return false;
+ }
+ return actual.indexOf(expectation) !== -1;
+ };
+ m.message = "match(\"" + expectation + "\")";
+ break;
+ case "regexp":
+ m.test = function (actual) {
+ if (typeof actual !== "string") {
+ return false;
+ }
+ return expectation.test(actual);
+ };
+ break;
+ case "function":
+ m.test = expectation;
+ if (message) {
+ m.message = message;
+ } else {
+ m.message = "match(" + sinon.functionName(expectation) + ")";
+ }
+ break;
+ default:
+ m.test = function (actual) {
+ return sinon.deepEqual(expectation, actual);
+ };
+ }
+ if (!m.message) {
+ m.message = "match(" + expectation + ")";
+ }
+ return m;
+ }
+
+ matcher.or = function (m2) {
+ if (!arguments.length) {
+ throw new TypeError("Matcher expected");
+ } else if (!isMatcher(m2)) {
+ m2 = match(m2);
+ }
+ var m1 = this;
+ var or = sinon.create(matcher);
+ or.test = function (actual) {
+ return m1.test(actual) || m2.test(actual);
+ };
+ or.message = m1.message + ".or(" + m2.message + ")";
+ return or;
+ };
+
+ matcher.and = function (m2) {
+ if (!arguments.length) {
+ throw new TypeError("Matcher expected");
+ } else if (!isMatcher(m2)) {
+ m2 = match(m2);
+ }
+ var m1 = this;
+ var and = sinon.create(matcher);
+ and.test = function (actual) {
+ return m1.test(actual) && m2.test(actual);
+ };
+ and.message = m1.message + ".and(" + m2.message + ")";
+ return and;
+ };
+
+ match.isMatcher = isMatcher;
+
+ match.any = match(function () {
+ return true;
+ }, "any");
+
+ match.defined = match(function (actual) {
+ return actual !== null && actual !== undefined;
+ }, "defined");
+
+ match.truthy = match(function (actual) {
+ return !!actual;
+ }, "truthy");
+
+ match.falsy = match(function (actual) {
+ return !actual;
+ }, "falsy");
+
+ match.same = function (expectation) {
+ return match(function (actual) {
+ return expectation === actual;
+ }, "same(" + expectation + ")");
+ };
+
+ match.typeOf = function (type) {
+ assertType(type, "string", "type");
+ return match(function (actual) {
+ return sinon.typeOf(actual) === type;
+ }, "typeOf(\"" + type + "\")");
+ };
+
+ match.instanceOf = function (type) {
+ assertType(type, "function", "type");
+ return match(function (actual) {
+ return actual instanceof type;
+ }, "instanceOf(" + sinon.functionName(type) + ")");
+ };
+
+ function createPropertyMatcher(propertyTest, messagePrefix) {
+ return function (property, value) {
+ assertType(property, "string", "property");
+ var onlyProperty = arguments.length === 1;
+ var message = messagePrefix + "(\"" + property + "\"";
+ if (!onlyProperty) {
+ message += ", " + value;
+ }
+ message += ")";
+ return match(function (actual) {
+ if (actual === undefined || actual === null ||
+ !propertyTest(actual, property)) {
+ return false;
+ }
+ return onlyProperty || sinon.deepEqual(value, actual[property]);
+ }, message);
+ };
+ }
+
+ match.has = createPropertyMatcher(function (actual, property) {
+ if (typeof actual === "object") {
+ return property in actual;
+ }
+ return actual[property] !== undefined;
+ }, "has");
+
+ match.hasOwn = createPropertyMatcher(function (actual, property) {
+ return actual.hasOwnProperty(property);
+ }, "hasOwn");
+
+ match.bool = match.typeOf("boolean");
+ match.number = match.typeOf("number");
+ match.string = match.typeOf("string");
+ match.object = match.typeOf("object");
+ match.func = match.typeOf("function");
+ match.array = match.typeOf("array");
+ match.regexp = match.typeOf("regexp");
+ match.date = match.typeOf("date");
+
+ sinon.match = match;
+ return match;
+ }
+
+ var isNode = typeof module !== "undefined" && module.exports && typeof require === "function";
+ var isAMD = typeof define === "function" && typeof define.amd === "object" && define.amd;
+
+ function loadDependencies(require, exports, module) {
+ var sinon = require("./util/core");
+ require("./typeOf");
+ module.exports = makeApi(sinon);
+ }
+
+ if (isAMD) {
+ define(loadDependencies);
+ return;
+ }
+
+ if (isNode) {
+ loadDependencies(require, module.exports, module);
+ return;
+ }
+
+ if (sinonGlobal) {
+ makeApi(sinonGlobal);
+ }
+}(
+ typeof sinon === "object" && sinon // eslint-disable-line no-undef
+));
+
+/**
+ * @depend util/core.js
+ */
+/**
+ * Format functions
+ *
+ * @author Christian Johansen (christian@cjohansen.no)
+ * @license BSD
+ *
+ * Copyright (c) 2010-2014 Christian Johansen
+ */
+(function (sinonGlobal, formatio) {
+
+ function makeApi(sinon) {
+ function valueFormatter(value) {
+ return "" + value;
+ }
+
+ function getFormatioFormatter() {
+ var formatter = formatio.configure({
+ quoteStrings: false,
+ limitChildrenCount: 250
+ });
+
+ function format() {
+ return formatter.ascii.apply(formatter, arguments);
+ }
+
+ return format;
+ }
+
+ function getNodeFormatter() {
+ try {
+ var util = require("util");
+ } catch (e) {
+ /* Node, but no util module - would be very old, but better safe than sorry */
+ }
+
+ function format(v) {
+ var isObjectWithNativeToString = typeof v === "object" && v.toString === Object.prototype.toString;
+ return isObjectWithNativeToString ? util.inspect(v) : v;
+ }
+
+ return util ? format : valueFormatter;
+ }
+
+ var isNode = typeof module !== "undefined" && module.exports && typeof require === "function";
+ var formatter;
+
+ if (isNode) {
+ try {
+ formatio = require("formatio");
+ }
+ catch (e) {} // eslint-disable-line no-empty
+ }
+
+ if (formatio) {
+ formatter = getFormatioFormatter();
+ } else if (isNode) {
+ formatter = getNodeFormatter();
+ } else {
+ formatter = valueFormatter;
+ }
+
+ sinon.format = formatter;
+ return sinon.format;
+ }
+
+ function loadDependencies(require, exports, module) {
+ var sinon = require("./util/core");
+ module.exports = makeApi(sinon);
+ }
+
+ var isNode = typeof module !== "undefined" && module.exports && typeof require === "function";
+ var isAMD = typeof define === "function" && typeof define.amd === "object" && define.amd;
+
+ if (isAMD) {
+ define(loadDependencies);
+ return;
+ }
+
+ if (isNode) {
+ loadDependencies(require, module.exports, module);
+ return;
+ }
+
+ if (sinonGlobal) {
+ makeApi(sinonGlobal);
+ }
+}(
+ typeof sinon === "object" && sinon, // eslint-disable-line no-undef
+ typeof formatio === "object" && formatio // eslint-disable-line no-undef
+));
+
+/**
+ * @depend util/core.js
+ * @depend match.js
+ * @depend format.js
+ */
+/**
+ * Spy calls
+ *
+ * @author Christian Johansen (christian@cjohansen.no)
+ * @author Maximilian Antoni (mail@maxantoni.de)
+ * @license BSD
+ *
+ * Copyright (c) 2010-2013 Christian Johansen
+ * Copyright (c) 2013 Maximilian Antoni
+ */
+(function (sinonGlobal) {
+
+ var slice = Array.prototype.slice;
+
+ function makeApi(sinon) {
+ function throwYieldError(proxy, text, args) {
+ var msg = sinon.functionName(proxy) + text;
+ if (args.length) {
+ msg += " Received [" + slice.call(args).join(", ") + "]";
+ }
+ throw new Error(msg);
+ }
+
+ var callProto = {
+ calledOn: function calledOn(thisValue) {
+ if (sinon.match && sinon.match.isMatcher(thisValue)) {
+ return thisValue.test(this.thisValue);
+ }
+ return this.thisValue === thisValue;
+ },
+
+ calledWith: function calledWith() {
+ var l = arguments.length;
+ if (l > this.args.length) {
+ return false;
+ }
+ for (var i = 0; i < l; i += 1) {
+ if (!sinon.deepEqual(arguments[i], this.args[i])) {
+ return false;
+ }
+ }
+
+ return true;
+ },
+
+ calledWithMatch: function calledWithMatch() {
+ var l = arguments.length;
+ if (l > this.args.length) {
+ return false;
+ }
+ for (var i = 0; i < l; i += 1) {
+ var actual = this.args[i];
+ var expectation = arguments[i];
+ if (!sinon.match || !sinon.match(expectation).test(actual)) {
+ return false;
+ }
+ }
+ return true;
+ },
+
+ calledWithExactly: function calledWithExactly() {
+ return arguments.length === this.args.length &&
+ this.calledWith.apply(this, arguments);
+ },
+
+ notCalledWith: function notCalledWith() {
+ return !this.calledWith.apply(this, arguments);
+ },
+
+ notCalledWithMatch: function notCalledWithMatch() {
+ return !this.calledWithMatch.apply(this, arguments);
+ },
+
+ returned: function returned(value) {
+ return sinon.deepEqual(value, this.returnValue);
+ },
+
+ threw: function threw(error) {
+ if (typeof error === "undefined" || !this.exception) {
+ return !!this.exception;
+ }
+
+ return this.exception === error || this.exception.name === error;
+ },
+
+ calledWithNew: function calledWithNew() {
+ return this.proxy.prototype && this.thisValue instanceof this.proxy;
+ },
+
+ calledBefore: function (other) {
+ return this.callId < other.callId;
+ },
+
+ calledAfter: function (other) {
+ return this.callId > other.callId;
+ },
+
+ callArg: function (pos) {
+ this.args[pos]();
+ },
+
+ callArgOn: function (pos, thisValue) {
+ this.args[pos].apply(thisValue);
+ },
+
+ callArgWith: function (pos) {
+ this.callArgOnWith.apply(this, [pos, null].concat(slice.call(arguments, 1)));
+ },
+
+ callArgOnWith: function (pos, thisValue) {
+ var args = slice.call(arguments, 2);
+ this.args[pos].apply(thisValue, args);
+ },
+
+ "yield": function () {
+ this.yieldOn.apply(this, [null].concat(slice.call(arguments, 0)));
+ },
+
+ yieldOn: function (thisValue) {
+ var args = this.args;
+ for (var i = 0, l = args.length; i < l; ++i) {
+ if (typeof args[i] === "function") {
+ args[i].apply(thisValue, slice.call(arguments, 1));
+ return;
+ }
+ }
+ throwYieldError(this.proxy, " cannot yield since no callback was passed.", args);
+ },
+
+ yieldTo: function (prop) {
+ this.yieldToOn.apply(this, [prop, null].concat(slice.call(arguments, 1)));
+ },
+
+ yieldToOn: function (prop, thisValue) {
+ var args = this.args;
+ for (var i = 0, l = args.length; i < l; ++i) {
+ if (args[i] && typeof args[i][prop] === "function") {
+ args[i][prop].apply(thisValue, slice.call(arguments, 2));
+ return;
+ }
+ }
+ throwYieldError(this.proxy, " cannot yield to '" + prop +
+ "' since no callback was passed.", args);
+ },
+
+ getStackFrames: function () {
+ // Omit the error message and the two top stack frames in sinon itself:
+ return this.stack && this.stack.split("\n").slice(3);
+ },
+
+ toString: function () {
+ var callStr = this.proxy ? this.proxy.toString() + "(" : "";
+ var args = [];
+
+ if (!this.args) {
+ return ":(";
+ }
+
+ for (var i = 0, l = this.args.length; i < l; ++i) {
+ args.push(sinon.format(this.args[i]));
+ }
+
+ callStr = callStr + args.join(", ") + ")";
+
+ if (typeof this.returnValue !== "undefined") {
+ callStr += " => " + sinon.format(this.returnValue);
+ }
+
+ if (this.exception) {
+ callStr += " !" + this.exception.name;
+
+ if (this.exception.message) {
+ callStr += "(" + this.exception.message + ")";
+ }
+ }
+ if (this.stack) {
+ callStr += this.getStackFrames()[0].replace(/^\s*(?:at\s+|@)?/, " at ");
+
+ }
+
+ return callStr;
+ }
+ };
+
+ callProto.invokeCallback = callProto.yield;
+
+ function createSpyCall(spy, thisValue, args, returnValue, exception, id, stack) {
+ if (typeof id !== "number") {
+ throw new TypeError("Call id is not a number");
+ }
+ var proxyCall = sinon.create(callProto);
+ proxyCall.proxy = spy;
+ proxyCall.thisValue = thisValue;
+ proxyCall.args = args;
+ proxyCall.returnValue = returnValue;
+ proxyCall.exception = exception;
+ proxyCall.callId = id;
+ proxyCall.stack = stack;
+
+ return proxyCall;
+ }
+ createSpyCall.toString = callProto.toString; // used by mocks
+
+ sinon.spyCall = createSpyCall;
+ return createSpyCall;
+ }
+
+ var isNode = typeof module !== "undefined" && module.exports && typeof require === "function";
+ var isAMD = typeof define === "function" && typeof define.amd === "object" && define.amd;
+
+ function loadDependencies(require, exports, module) {
+ var sinon = require("./util/core");
+ require("./match");
+ require("./format");
+ module.exports = makeApi(sinon);
+ }
+
+ if (isAMD) {
+ define(loadDependencies);
+ return;
+ }
+
+ if (isNode) {
+ loadDependencies(require, module.exports, module);
+ return;
+ }
+
+ if (sinonGlobal) {
+ makeApi(sinonGlobal);
+ }
+}(
+ typeof sinon === "object" && sinon // eslint-disable-line no-undef
+));
+
+/**
+ * @depend times_in_words.js
+ * @depend util/core.js
+ * @depend extend.js
+ * @depend call.js
+ * @depend format.js
+ */
+/**
+ * Spy functions
+ *
+ * @author Christian Johansen (christian@cjohansen.no)
+ * @license BSD
+ *
+ * Copyright (c) 2010-2013 Christian Johansen
+ */
+(function (sinonGlobal) {
+
+ function makeApi(sinon) {
+ var push = Array.prototype.push;
+ var slice = Array.prototype.slice;
+ var callId = 0;
+
+ function spy(object, property, types) {
+ if (!property && typeof object === "function") {
+ return spy.create(object);
+ }
+
+ if (!object && !property) {
+ return spy.create(function () { });
+ }
+
+ if (types) {
+ // A new descriptor is needed here because we can only wrap functions
+ // By passing the original descriptor we would end up trying to spy non-function properties
+ var descriptor = {};
+ var methodDesc = sinon.getPropertyDescriptor(object, property);
+
+ for (var i = 0; i < types.length; i++) {
+ descriptor[types[i]] = spy.create(methodDesc[types[i]]);
+ }
+ return sinon.wrapMethod(object, property, descriptor);
+ }
+
+ return sinon.wrapMethod(object, property, spy.create(object[property]));
+ }
+
+ function matchingFake(fakes, args, strict) {
+ if (!fakes) {
+ return undefined;
+ }
+
+ for (var i = 0, l = fakes.length; i < l; i++) {
+ if (fakes[i].matches(args, strict)) {
+ return fakes[i];
+ }
+ }
+ }
+
+ function incrementCallCount() {
+ this.called = true;
+ this.callCount += 1;
+ this.notCalled = false;
+ this.calledOnce = this.callCount === 1;
+ this.calledTwice = this.callCount === 2;
+ this.calledThrice = this.callCount === 3;
+ }
+
+ function createCallProperties() {
+ this.firstCall = this.getCall(0);
+ this.secondCall = this.getCall(1);
+ this.thirdCall = this.getCall(2);
+ this.lastCall = this.getCall(this.callCount - 1);
+ }
+
+ var vars = "a,b,c,d,e,f,g,h,i,j,k,l";
+ function createProxy(func, proxyLength) {
+ // Retain the function length:
+ var p;
+ if (proxyLength) {
+ eval("p = (function proxy(" + vars.substring(0, proxyLength * 2 - 1) + // eslint-disable-line no-eval
+ ") { return p.invoke(func, this, slice.call(arguments)); });");
+ } else {
+ p = function proxy() {
+ return p.invoke(func, this, slice.call(arguments));
+ };
+ }
+ p.isSinonProxy = true;
+ return p;
+ }
+
+ var uuid = 0;
+
+ // Public API
+ var spyApi = {
+ reset: function () {
+ if (this.invoking) {
+ var err = new Error("Cannot reset Sinon function while invoking it. " +
+ "Move the call to .reset outside of the callback.");
+ err.name = "InvalidResetException";
+ throw err;
+ }
+
+ this.called = false;
+ this.notCalled = true;
+ this.calledOnce = false;
+ this.calledTwice = false;
+ this.calledThrice = false;
+ this.callCount = 0;
+ this.firstCall = null;
+ this.secondCall = null;
+ this.thirdCall = null;
+ this.lastCall = null;
+ this.args = [];
+ this.returnValues = [];
+ this.thisValues = [];
+ this.exceptions = [];
+ this.callIds = [];
+ this.stacks = [];
+ if (this.fakes) {
+ for (var i = 0; i < this.fakes.length; i++) {
+ this.fakes[i].reset();
+ }
+ }
+
+ return this;
+ },
+
+ create: function create(func, spyLength) {
+ var name;
+
+ if (typeof func !== "function") {
+ func = function () { };
+ } else {
+ name = sinon.functionName(func);
+ }
+
+ if (!spyLength) {
+ spyLength = func.length;
+ }
+
+ var proxy = createProxy(func, spyLength);
+
+ sinon.extend(proxy, spy);
+ delete proxy.create;
+ sinon.extend(proxy, func);
+
+ proxy.reset();
+ proxy.prototype = func.prototype;
+ proxy.displayName = name || "spy";
+ proxy.toString = sinon.functionToString;
+ proxy.instantiateFake = sinon.spy.create;
+ proxy.id = "spy#" + uuid++;
+
+ return proxy;
+ },
+
+ invoke: function invoke(func, thisValue, args) {
+ var matching = matchingFake(this.fakes, args);
+ var exception, returnValue;
+
+ incrementCallCount.call(this);
+ push.call(this.thisValues, thisValue);
+ push.call(this.args, args);
+ push.call(this.callIds, callId++);
+
+ // Make call properties available from within the spied function:
+ createCallProperties.call(this);
+
+ try {
+ this.invoking = true;
+
+ if (matching) {
+ returnValue = matching.invoke(func, thisValue, args);
+ } else {
+ returnValue = (this.func || func).apply(thisValue, args);
+ }
+
+ var thisCall = this.getCall(this.callCount - 1);
+ if (thisCall.calledWithNew() && typeof returnValue !== "object") {
+ returnValue = thisValue;
+ }
+ } catch (e) {
+ exception = e;
+ } finally {
+ delete this.invoking;
+ }
+
+ push.call(this.exceptions, exception);
+ push.call(this.returnValues, returnValue);
+ push.call(this.stacks, new Error().stack);
+
+ // Make return value and exception available in the calls:
+ createCallProperties.call(this);
+
+ if (exception !== undefined) {
+ throw exception;
+ }
+
+ return returnValue;
+ },
+
+ named: function named(name) {
+ this.displayName = name;
+ return this;
+ },
+
+ getCall: function getCall(i) {
+ if (i < 0 || i >= this.callCount) {
+ return null;
+ }
+
+ return sinon.spyCall(this, this.thisValues[i], this.args[i],
+ this.returnValues[i], this.exceptions[i],
+ this.callIds[i], this.stacks[i]);
+ },
+
+ getCalls: function () {
+ var calls = [];
+ var i;
+
+ for (i = 0; i < this.callCount; i++) {
+ calls.push(this.getCall(i));
+ }
+
+ return calls;
+ },
+
+ calledBefore: function calledBefore(spyFn) {
+ if (!this.called) {
+ return false;
+ }
+
+ if (!spyFn.called) {
+ return true;
+ }
+
+ return this.callIds[0] < spyFn.callIds[spyFn.callIds.length - 1];
+ },
+
+ calledAfter: function calledAfter(spyFn) {
+ if (!this.called || !spyFn.called) {
+ return false;
+ }
+
+ return this.callIds[this.callCount - 1] > spyFn.callIds[spyFn.callCount - 1];
+ },
+
+ withArgs: function () {
+ var args = slice.call(arguments);
+
+ if (this.fakes) {
+ var match = matchingFake(this.fakes, args, true);
+
+ if (match) {
+ return match;
+ }
+ } else {
+ this.fakes = [];
+ }
+
+ var original = this;
+ var fake = this.instantiateFake();
+ fake.matchingAguments = args;
+ fake.parent = this;
+ push.call(this.fakes, fake);
+
+ fake.withArgs = function () {
+ return original.withArgs.apply(original, arguments);
+ };
+
+ for (var i = 0; i < this.args.length; i++) {
+ if (fake.matches(this.args[i])) {
+ incrementCallCount.call(fake);
+ push.call(fake.thisValues, this.thisValues[i]);
+ push.call(fake.args, this.args[i]);
+ push.call(fake.returnValues, this.returnValues[i]);
+ push.call(fake.exceptions, this.exceptions[i]);
+ push.call(fake.callIds, this.callIds[i]);
+ }
+ }
+ createCallProperties.call(fake);
+
+ return fake;
+ },
+
+ matches: function (args, strict) {
+ var margs = this.matchingAguments;
+
+ if (margs.length <= args.length &&
+ sinon.deepEqual(margs, args.slice(0, margs.length))) {
+ return !strict || margs.length === args.length;
+ }
+ },
+
+ printf: function (format) {
+ var spyInstance = this;
+ var args = slice.call(arguments, 1);
+ var formatter;
+
+ return (format || "").replace(/%(.)/g, function (match, specifyer) {
+ formatter = spyApi.formatters[specifyer];
+
+ if (typeof formatter === "function") {
+ return formatter.call(null, spyInstance, args);
+ } else if (!isNaN(parseInt(specifyer, 10))) {
+ return sinon.format(args[specifyer - 1]);
+ }
+
+ return "%" + specifyer;
+ });
+ }
+ };
+
+ function delegateToCalls(method, matchAny, actual, notCalled) {
+ spyApi[method] = function () {
+ if (!this.called) {
+ if (notCalled) {
+ return notCalled.apply(this, arguments);
+ }
+ return false;
+ }
+
+ var currentCall;
+ var matches = 0;
+
+ for (var i = 0, l = this.callCount; i < l; i += 1) {
+ currentCall = this.getCall(i);
+
+ if (currentCall[actual || method].apply(currentCall, arguments)) {
+ matches += 1;
+
+ if (matchAny) {
+ return true;
+ }
+ }
+ }
+
+ return matches === this.callCount;
+ };
+ }
+
+ delegateToCalls("calledOn", true);
+ delegateToCalls("alwaysCalledOn", false, "calledOn");
+ delegateToCalls("calledWith", true);
+ delegateToCalls("calledWithMatch", true);
+ delegateToCalls("alwaysCalledWith", false, "calledWith");
+ delegateToCalls("alwaysCalledWithMatch", false, "calledWithMatch");
+ delegateToCalls("calledWithExactly", true);
+ delegateToCalls("alwaysCalledWithExactly", false, "calledWithExactly");
+ delegateToCalls("neverCalledWith", false, "notCalledWith", function () {
+ return true;
+ });
+ delegateToCalls("neverCalledWithMatch", false, "notCalledWithMatch", function () {
+ return true;
+ });
+ delegateToCalls("threw", true);
+ delegateToCalls("alwaysThrew", false, "threw");
+ delegateToCalls("returned", true);
+ delegateToCalls("alwaysReturned", false, "returned");
+ delegateToCalls("calledWithNew", true);
+ delegateToCalls("alwaysCalledWithNew", false, "calledWithNew");
+ delegateToCalls("callArg", false, "callArgWith", function () {
+ throw new Error(this.toString() + " cannot call arg since it was not yet invoked.");
+ });
+ spyApi.callArgWith = spyApi.callArg;
+ delegateToCalls("callArgOn", false, "callArgOnWith", function () {
+ throw new Error(this.toString() + " cannot call arg since it was not yet invoked.");
+ });
+ spyApi.callArgOnWith = spyApi.callArgOn;
+ delegateToCalls("yield", false, "yield", function () {
+ throw new Error(this.toString() + " cannot yield since it was not yet invoked.");
+ });
+ // "invokeCallback" is an alias for "yield" since "yield" is invalid in strict mode.
+ spyApi.invokeCallback = spyApi.yield;
+ delegateToCalls("yieldOn", false, "yieldOn", function () {
+ throw new Error(this.toString() + " cannot yield since it was not yet invoked.");
+ });
+ delegateToCalls("yieldTo", false, "yieldTo", function (property) {
+ throw new Error(this.toString() + " cannot yield to '" + property +
+ "' since it was not yet invoked.");
+ });
+ delegateToCalls("yieldToOn", false, "yieldToOn", function (property) {
+ throw new Error(this.toString() + " cannot yield to '" + property +
+ "' since it was not yet invoked.");
+ });
+
+ spyApi.formatters = {
+ c: function (spyInstance) {
+ return sinon.timesInWords(spyInstance.callCount);
+ },
+
+ n: function (spyInstance) {
+ return spyInstance.toString();
+ },
+
+ C: function (spyInstance) {
+ var calls = [];
+
+ for (var i = 0, l = spyInstance.callCount; i < l; ++i) {
+ var stringifiedCall = " " + spyInstance.getCall(i).toString();
+ if (/\n/.test(calls[i - 1])) {
+ stringifiedCall = "\n" + stringifiedCall;
+ }
+ push.call(calls, stringifiedCall);
+ }
+
+ return calls.length > 0 ? "\n" + calls.join("\n") : "";
+ },
+
+ t: function (spyInstance) {
+ var objects = [];
+
+ for (var i = 0, l = spyInstance.callCount; i < l; ++i) {
+ push.call(objects, sinon.format(spyInstance.thisValues[i]));
+ }
+
+ return objects.join(", ");
+ },
+
+ "*": function (spyInstance, args) {
+ var formatted = [];
+
+ for (var i = 0, l = args.length; i < l; ++i) {
+ push.call(formatted, sinon.format(args[i]));
+ }
+
+ return formatted.join(", ");
+ }
+ };
+
+ sinon.extend(spy, spyApi);
+
+ spy.spyCall = sinon.spyCall;
+ sinon.spy = spy;
+
+ return spy;
+ }
+
+ var isNode = typeof module !== "undefined" && module.exports && typeof require === "function";
+ var isAMD = typeof define === "function" && typeof define.amd === "object" && define.amd;
+
+ function loadDependencies(require, exports, module) {
+ var core = require("./util/core");
+ require("./call");
+ require("./extend");
+ require("./times_in_words");
+ require("./format");
+ module.exports = makeApi(core);
+ }
+
+ if (isAMD) {
+ define(loadDependencies);
+ return;
+ }
+
+ if (isNode) {
+ loadDependencies(require, module.exports, module);
+ return;
+ }
+
+ if (sinonGlobal) {
+ makeApi(sinonGlobal);
+ }
+}(
+ typeof sinon === "object" && sinon // eslint-disable-line no-undef
+));
+
+/**
+ * @depend util/core.js
+ * @depend extend.js
+ */
+/**
+ * Stub behavior
+ *
+ * @author Christian Johansen (christian@cjohansen.no)
+ * @author Tim Fischbach (mail@timfischbach.de)
+ * @license BSD
+ *
+ * Copyright (c) 2010-2013 Christian Johansen
+ */
+(function (sinonGlobal) {
+
+ var slice = Array.prototype.slice;
+ var join = Array.prototype.join;
+ var useLeftMostCallback = -1;
+ var useRightMostCallback = -2;
+
+ var nextTick = (function () {
+ if (typeof process === "object" && typeof process.nextTick === "function") {
+ return process.nextTick;
+ }
+
+ if (typeof setImmediate === "function") {
+ return setImmediate;
+ }
+
+ return function (callback) {
+ setTimeout(callback, 0);
+ };
+ })();
+
+ function throwsException(error, message) {
+ if (typeof error === "string") {
+ this.exception = new Error(message || "");
+ this.exception.name = error;
+ } else if (!error) {
+ this.exception = new Error("Error");
+ } else {
+ this.exception = error;
+ }
+
+ return this;
+ }
+
+ function getCallback(behavior, args) {
+ var callArgAt = behavior.callArgAt;
+
+ if (callArgAt >= 0) {
+ return args[callArgAt];
+ }
+
+ var argumentList;
+
+ if (callArgAt === useLeftMostCallback) {
+ argumentList = args;
+ }
+
+ if (callArgAt === useRightMostCallback) {
+ argumentList = slice.call(args).reverse();
+ }
+
+ var callArgProp = behavior.callArgProp;
+
+ for (var i = 0, l = argumentList.length; i < l; ++i) {
+ if (!callArgProp && typeof argumentList[i] === "function") {
+ return argumentList[i];
+ }
+
+ if (callArgProp && argumentList[i] &&
+ typeof argumentList[i][callArgProp] === "function") {
+ return argumentList[i][callArgProp];
+ }
+ }
+
+ return null;
+ }
+
+ function makeApi(sinon) {
+ function getCallbackError(behavior, func, args) {
+ if (behavior.callArgAt < 0) {
+ var msg;
+
+ if (behavior.callArgProp) {
+ msg = sinon.functionName(behavior.stub) +
+ " expected to yield to '" + behavior.callArgProp +
+ "', but no object with such a property was passed.";
+ } else {
+ msg = sinon.functionName(behavior.stub) +
+ " expected to yield, but no callback was passed.";
+ }
+
+ if (args.length > 0) {
+ msg += " Received [" + join.call(args, ", ") + "]";
+ }
+
+ return msg;
+ }
+
+ return "argument at index " + behavior.callArgAt + " is not a function: " + func;
+ }
+
+ function callCallback(behavior, args) {
+ if (typeof behavior.callArgAt === "number") {
+ var func = getCallback(behavior, args);
+
+ if (typeof func !== "function") {
+ throw new TypeError(getCallbackError(behavior, func, args));
+ }
+
+ if (behavior.callbackAsync) {
+ nextTick(function () {
+ func.apply(behavior.callbackContext, behavior.callbackArguments);
+ });
+ } else {
+ func.apply(behavior.callbackContext, behavior.callbackArguments);
+ }
+ }
+ }
+
+ var proto = {
+ create: function create(stub) {
+ var behavior = sinon.extend({}, sinon.behavior);
+ delete behavior.create;
+ behavior.stub = stub;
+
+ return behavior;
+ },
+
+ isPresent: function isPresent() {
+ return (typeof this.callArgAt === "number" ||
+ this.exception ||
+ typeof this.returnArgAt === "number" ||
+ this.returnThis ||
+ this.returnValueDefined);
+ },
+
+ invoke: function invoke(context, args) {
+ callCallback(this, args);
+
+ if (this.exception) {
+ throw this.exception;
+ } else if (typeof this.returnArgAt === "number") {
+ return args[this.returnArgAt];
+ } else if (this.returnThis) {
+ return context;
+ }
+
+ return this.returnValue;
+ },
+
+ onCall: function onCall(index) {
+ return this.stub.onCall(index);
+ },
+
+ onFirstCall: function onFirstCall() {
+ return this.stub.onFirstCall();
+ },
+
+ onSecondCall: function onSecondCall() {
+ return this.stub.onSecondCall();
+ },
+
+ onThirdCall: function onThirdCall() {
+ return this.stub.onThirdCall();
+ },
+
+ withArgs: function withArgs(/* arguments */) {
+ throw new Error(
+ "Defining a stub by invoking \"stub.onCall(...).withArgs(...)\" " +
+ "is not supported. Use \"stub.withArgs(...).onCall(...)\" " +
+ "to define sequential behavior for calls with certain arguments."
+ );
+ },
+
+ callsArg: function callsArg(pos) {
+ if (typeof pos !== "number") {
+ throw new TypeError("argument index is not number");
+ }
+
+ this.callArgAt = pos;
+ this.callbackArguments = [];
+ this.callbackContext = undefined;
+ this.callArgProp = undefined;
+ this.callbackAsync = false;
+
+ return this;
+ },
+
+ callsArgOn: function callsArgOn(pos, context) {
+ if (typeof pos !== "number") {
+ throw new TypeError("argument index is not number");
+ }
+ if (typeof context !== "object") {
+ throw new TypeError("argument context is not an object");
+ }
+
+ this.callArgAt = pos;
+ this.callbackArguments = [];
+ this.callbackContext = context;
+ this.callArgProp = undefined;
+ this.callbackAsync = false;
+
+ return this;
+ },
+
+ callsArgWith: function callsArgWith(pos) {
+ if (typeof pos !== "number") {
+ throw new TypeError("argument index is not number");
+ }
+
+ this.callArgAt = pos;
+ this.callbackArguments = slice.call(arguments, 1);
+ this.callbackContext = undefined;
+ this.callArgProp = undefined;
+ this.callbackAsync = false;
+
+ return this;
+ },
+
+ callsArgOnWith: function callsArgWith(pos, context) {
+ if (typeof pos !== "number") {
+ throw new TypeError("argument index is not number");
+ }
+ if (typeof context !== "object") {
+ throw new TypeError("argument context is not an object");
+ }
+
+ this.callArgAt = pos;
+ this.callbackArguments = slice.call(arguments, 2);
+ this.callbackContext = context;
+ this.callArgProp = undefined;
+ this.callbackAsync = false;
+
+ return this;
+ },
+
+ yields: function () {
+ this.callArgAt = useLeftMostCallback;
+ this.callbackArguments = slice.call(arguments, 0);
+ this.callbackContext = undefined;
+ this.callArgProp = undefined;
+ this.callbackAsync = false;
+
+ return this;
+ },
+
+ yieldsRight: function () {
+ this.callArgAt = useRightMostCallback;
+ this.callbackArguments = slice.call(arguments, 0);
+ this.callbackContext = undefined;
+ this.callArgProp = undefined;
+ this.callbackAsync = false;
+
+ return this;
+ },
+
+ yieldsOn: function (context) {
+ if (typeof context !== "object") {
+ throw new TypeError("argument context is not an object");
+ }
+
+ this.callArgAt = useLeftMostCallback;
+ this.callbackArguments = slice.call(arguments, 1);
+ this.callbackContext = context;
+ this.callArgProp = undefined;
+ this.callbackAsync = false;
+
+ return this;
+ },
+
+ yieldsTo: function (prop) {
+ this.callArgAt = useLeftMostCallback;
+ this.callbackArguments = slice.call(arguments, 1);
+ this.callbackContext = undefined;
+ this.callArgProp = prop;
+ this.callbackAsync = false;
+
+ return this;
+ },
+
+ yieldsToOn: function (prop, context) {
+ if (typeof context !== "object") {
+ throw new TypeError("argument context is not an object");
+ }
+
+ this.callArgAt = useLeftMostCallback;
+ this.callbackArguments = slice.call(arguments, 2);
+ this.callbackContext = context;
+ this.callArgProp = prop;
+ this.callbackAsync = false;
+
+ return this;
+ },
+
+ throws: throwsException,
+ throwsException: throwsException,
+
+ returns: function returns(value) {
+ this.returnValue = value;
+ this.returnValueDefined = true;
+ this.exception = undefined;
+
+ return this;
+ },
+
+ returnsArg: function returnsArg(pos) {
+ if (typeof pos !== "number") {
+ throw new TypeError("argument index is not number");
+ }
+
+ this.returnArgAt = pos;
+
+ return this;
+ },
+
+ returnsThis: function returnsThis() {
+ this.returnThis = true;
+
+ return this;
+ }
+ };
+
+ function createAsyncVersion(syncFnName) {
+ return function () {
+ var result = this[syncFnName].apply(this, arguments);
+ this.callbackAsync = true;
+ return result;
+ };
+ }
+
+ // create asynchronous versions of callsArg* and yields* methods
+ for (var method in proto) {
+ // need to avoid creating anotherasync versions of the newly added async methods
+ if (proto.hasOwnProperty(method) && method.match(/^(callsArg|yields)/) && !method.match(/Async/)) {
+ proto[method + "Async"] = createAsyncVersion(method);
+ }
+ }
+
+ sinon.behavior = proto;
+ return proto;
+ }
+
+ var isNode = typeof module !== "undefined" && module.exports && typeof require === "function";
+ var isAMD = typeof define === "function" && typeof define.amd === "object" && define.amd;
+
+ function loadDependencies(require, exports, module) {
+ var sinon = require("./util/core");
+ require("./extend");
+ module.exports = makeApi(sinon);
+ }
+
+ if (isAMD) {
+ define(loadDependencies);
+ return;
+ }
+
+ if (isNode) {
+ loadDependencies(require, module.exports, module);
+ return;
+ }
+
+ if (sinonGlobal) {
+ makeApi(sinonGlobal);
+ }
+}(
+ typeof sinon === "object" && sinon // eslint-disable-line no-undef
+));
+
+/**
+ * @depend util/core.js
+ */
+(function (sinonGlobal) {
+
+ function makeApi(sinon) {
+ function walkInternal(obj, iterator, context, originalObj, seen) {
+ var proto, prop;
+
+ if (typeof Object.getOwnPropertyNames !== "function") {
+ // We explicitly want to enumerate through all of the prototype's properties
+ // in this case, therefore we deliberately leave out an own property check.
+ /* eslint-disable guard-for-in */
+ for (prop in obj) {
+ iterator.call(context, obj[prop], prop, obj);
+ }
+ /* eslint-enable guard-for-in */
+
+ return;
+ }
+
+ Object.getOwnPropertyNames(obj).forEach(function (k) {
+ if (seen[k] !== true) {
+ seen[k] = true;
+ var target = typeof Object.getOwnPropertyDescriptor(obj, k).get === "function" ?
+ originalObj : obj;
+ iterator.call(context, target[k], k, target);
+ }
+ });
+
+ proto = Object.getPrototypeOf(obj);
+ if (proto) {
+ walkInternal(proto, iterator, context, originalObj, seen);
+ }
+ }
+
+ /* Public: walks the prototype chain of an object and iterates over every own property
+ * name encountered. The iterator is called in the same fashion that Array.prototype.forEach
+ * works, where it is passed the value, key, and own object as the 1st, 2nd, and 3rd positional
+ * argument, respectively. In cases where Object.getOwnPropertyNames is not available, walk will
+ * default to using a simple for..in loop.
+ *
+ * obj - The object to walk the prototype chain for.
+ * iterator - The function to be called on each pass of the walk.
+ * context - (Optional) When given, the iterator will be called with this object as the receiver.
+ */
+ function walk(obj, iterator, context) {
+ return walkInternal(obj, iterator, context, obj, {});
+ }
+
+ sinon.walk = walk;
+ return sinon.walk;
+ }
+
+ function loadDependencies(require, exports, module) {
+ var sinon = require("./util/core");
+ module.exports = makeApi(sinon);
+ }
+
+ var isNode = typeof module !== "undefined" && module.exports && typeof require === "function";
+ var isAMD = typeof define === "function" && typeof define.amd === "object" && define.amd;
+
+ if (isAMD) {
+ define(loadDependencies);
+ return;
+ }
+
+ if (isNode) {
+ loadDependencies(require, module.exports, module);
+ return;
+ }
+
+ if (sinonGlobal) {
+ makeApi(sinonGlobal);
+ }
+}(
+ typeof sinon === "object" && sinon // eslint-disable-line no-undef
+));
+
+/**
+ * @depend util/core.js
+ * @depend extend.js
+ * @depend spy.js
+ * @depend behavior.js
+ * @depend walk.js
+ */
+/**
+ * Stub functions
+ *
+ * @author Christian Johansen (christian@cjohansen.no)
+ * @license BSD
+ *
+ * Copyright (c) 2010-2013 Christian Johansen
+ */
+(function (sinonGlobal) {
+
+ function makeApi(sinon) {
+ function stub(object, property, func) {
+ if (!!func && typeof func !== "function" && typeof func !== "object") {
+ throw new TypeError("Custom stub should be a function or a property descriptor");
+ }
+
+ var wrapper;
+
+ if (func) {
+ if (typeof func === "function") {
+ wrapper = sinon.spy && sinon.spy.create ? sinon.spy.create(func) : func;
+ } else {
+ wrapper = func;
+ if (sinon.spy && sinon.spy.create) {
+ var types = sinon.objectKeys(wrapper);
+ for (var i = 0; i < types.length; i++) {
+ wrapper[types[i]] = sinon.spy.create(wrapper[types[i]]);
+ }
+ }
+ }
+ } else {
+ var stubLength = 0;
+ if (typeof object === "object" && typeof object[property] === "function") {
+ stubLength = object[property].length;
+ }
+ wrapper = stub.create(stubLength);
+ }
+
+ if (!object && typeof property === "undefined") {
+ return sinon.stub.create();
+ }
+
+ if (typeof property === "undefined" && typeof object === "object") {
+ sinon.walk(object || {}, function (value, prop, propOwner) {
+ // we don't want to stub things like toString(), valueOf(), etc. so we only stub if the object
+ // is not Object.prototype
+ if (
+ propOwner !== Object.prototype &&
+ prop !== "constructor" &&
+ typeof sinon.getPropertyDescriptor(propOwner, prop).value === "function"
+ ) {
+ stub(object, prop);
+ }
+ });
+
+ return object;
+ }
+
+ return sinon.wrapMethod(object, property, wrapper);
+ }
+
+
+ /*eslint-disable no-use-before-define*/
+ function getParentBehaviour(stubInstance) {
+ return (stubInstance.parent && getCurrentBehavior(stubInstance.parent));
+ }
+
+ function getDefaultBehavior(stubInstance) {
+ return stubInstance.defaultBehavior ||
+ getParentBehaviour(stubInstance) ||
+ sinon.behavior.create(stubInstance);
+ }
+
+ function getCurrentBehavior(stubInstance) {
+ var behavior = stubInstance.behaviors[stubInstance.callCount - 1];
+ return behavior && behavior.isPresent() ? behavior : getDefaultBehavior(stubInstance);
+ }
+ /*eslint-enable no-use-before-define*/
+
+ var uuid = 0;
+
+ var proto = {
+ create: function create(stubLength) {
+ var functionStub = function () {
+ return getCurrentBehavior(functionStub).invoke(this, arguments);
+ };
+
+ functionStub.id = "stub#" + uuid++;
+ var orig = functionStub;
+ functionStub = sinon.spy.create(functionStub, stubLength);
+ functionStub.func = orig;
+
+ sinon.extend(functionStub, stub);
+ functionStub.instantiateFake = sinon.stub.create;
+ functionStub.displayName = "stub";
+ functionStub.toString = sinon.functionToString;
+
+ functionStub.defaultBehavior = null;
+ functionStub.behaviors = [];
+
+ return functionStub;
+ },
+
+ resetBehavior: function () {
+ var i;
+
+ this.defaultBehavior = null;
+ this.behaviors = [];
+
+ delete this.returnValue;
+ delete this.returnArgAt;
+ this.returnThis = false;
+
+ if (this.fakes) {
+ for (i = 0; i < this.fakes.length; i++) {
+ this.fakes[i].resetBehavior();
+ }
+ }
+ },
+
+ onCall: function onCall(index) {
+ if (!this.behaviors[index]) {
+ this.behaviors[index] = sinon.behavior.create(this);
+ }
+
+ return this.behaviors[index];
+ },
+
+ onFirstCall: function onFirstCall() {
+ return this.onCall(0);
+ },
+
+ onSecondCall: function onSecondCall() {
+ return this.onCall(1);
+ },
+
+ onThirdCall: function onThirdCall() {
+ return this.onCall(2);
+ }
+ };
+
+ function createBehavior(behaviorMethod) {
+ return function () {
+ this.defaultBehavior = this.defaultBehavior || sinon.behavior.create(this);
+ this.defaultBehavior[behaviorMethod].apply(this.defaultBehavior, arguments);
+ return this;
+ };
+ }
+
+ for (var method in sinon.behavior) {
+ if (sinon.behavior.hasOwnProperty(method) &&
+ !proto.hasOwnProperty(method) &&
+ method !== "create" &&
+ method !== "withArgs" &&
+ method !== "invoke") {
+ proto[method] = createBehavior(method);
+ }
+ }
+
+ sinon.extend(stub, proto);
+ sinon.stub = stub;
+
+ return stub;
+ }
+
+ var isNode = typeof module !== "undefined" && module.exports && typeof require === "function";
+ var isAMD = typeof define === "function" && typeof define.amd === "object" && define.amd;
+
+ function loadDependencies(require, exports, module) {
+ var core = require("./util/core");
+ require("./behavior");
+ require("./spy");
+ require("./extend");
+ module.exports = makeApi(core);
+ }
+
+ if (isAMD) {
+ define(loadDependencies);
+ return;
+ }
+
+ if (isNode) {
+ loadDependencies(require, module.exports, module);
+ return;
+ }
+
+ if (sinonGlobal) {
+ makeApi(sinonGlobal);
+ }
+}(
+ typeof sinon === "object" && sinon // eslint-disable-line no-undef
+));
+
+/**
+ * @depend times_in_words.js
+ * @depend util/core.js
+ * @depend call.js
+ * @depend extend.js
+ * @depend match.js
+ * @depend spy.js
+ * @depend stub.js
+ * @depend format.js
+ */
+/**
+ * Mock functions.
+ *
+ * @author Christian Johansen (christian@cjohansen.no)
+ * @license BSD
+ *
+ * Copyright (c) 2010-2013 Christian Johansen
+ */
+(function (sinonGlobal) {
+
+ function makeApi(sinon) {
+ var push = [].push;
+ var match = sinon.match;
+
+ function mock(object) {
+ // if (typeof console !== undefined && console.warn) {
+ // console.warn("mock will be removed from Sinon.JS v2.0");
+ // }
+
+ if (!object) {
+ return sinon.expectation.create("Anonymous mock");
+ }
+
+ return mock.create(object);
+ }
+
+ function each(collection, callback) {
+ if (!collection) {
+ return;
+ }
+
+ for (var i = 0, l = collection.length; i < l; i += 1) {
+ callback(collection[i]);
+ }
+ }
+
+ function arrayEquals(arr1, arr2, compareLength) {
+ if (compareLength && (arr1.length !== arr2.length)) {
+ return false;
+ }
+
+ for (var i = 0, l = arr1.length; i < l; i++) {
+ if (!sinon.deepEqual(arr1[i], arr2[i])) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ sinon.extend(mock, {
+ create: function create(object) {
+ if (!object) {
+ throw new TypeError("object is null");
+ }
+
+ var mockObject = sinon.extend({}, mock);
+ mockObject.object = object;
+ delete mockObject.create;
+
+ return mockObject;
+ },
+
+ expects: function expects(method) {
+ if (!method) {
+ throw new TypeError("method is falsy");
+ }
+
+ if (!this.expectations) {
+ this.expectations = {};
+ this.proxies = [];
+ }
+
+ if (!this.expectations[method]) {
+ this.expectations[method] = [];
+ var mockObject = this;
+
+ sinon.wrapMethod(this.object, method, function () {
+ return mockObject.invokeMethod(method, this, arguments);
+ });
+
+ push.call(this.proxies, method);
+ }
+
+ var expectation = sinon.expectation.create(method);
+ push.call(this.expectations[method], expectation);
+
+ return expectation;
+ },
+
+ restore: function restore() {
+ var object = this.object;
+
+ each(this.proxies, function (proxy) {
+ if (typeof object[proxy].restore === "function") {
+ object[proxy].restore();
+ }
+ });
+ },
+
+ verify: function verify() {
+ var expectations = this.expectations || {};
+ var messages = [];
+ var met = [];
+
+ each(this.proxies, function (proxy) {
+ each(expectations[proxy], function (expectation) {
+ if (!expectation.met()) {
+ push.call(messages, expectation.toString());
+ } else {
+ push.call(met, expectation.toString());
+ }
+ });
+ });
+
+ this.restore();
+
+ if (messages.length > 0) {
+ sinon.expectation.fail(messages.concat(met).join("\n"));
+ } else if (met.length > 0) {
+ sinon.expectation.pass(messages.concat(met).join("\n"));
+ }
+
+ return true;
+ },
+
+ invokeMethod: function invokeMethod(method, thisValue, args) {
+ var expectations = this.expectations && this.expectations[method] ? this.expectations[method] : [];
+ var expectationsWithMatchingArgs = [];
+ var currentArgs = args || [];
+ var i, available;
+
+ for (i = 0; i < expectations.length; i += 1) {
+ var expectedArgs = expectations[i].expectedArguments || [];
+ if (arrayEquals(expectedArgs, currentArgs, expectations[i].expectsExactArgCount)) {
+ expectationsWithMatchingArgs.push(expectations[i]);
+ }
+ }
+
+ for (i = 0; i < expectationsWithMatchingArgs.length; i += 1) {
+ if (!expectationsWithMatchingArgs[i].met() &&
+ expectationsWithMatchingArgs[i].allowsCall(thisValue, args)) {
+ return expectationsWithMatchingArgs[i].apply(thisValue, args);
+ }
+ }
+
+ var messages = [];
+ var exhausted = 0;
+
+ for (i = 0; i < expectationsWithMatchingArgs.length; i += 1) {
+ if (expectationsWithMatchingArgs[i].allowsCall(thisValue, args)) {
+ available = available || expectationsWithMatchingArgs[i];
+ } else {
+ exhausted += 1;
+ }
+ }
+
+ if (available && exhausted === 0) {
+ return available.apply(thisValue, args);
+ }
+
+ for (i = 0; i < expectations.length; i += 1) {
+ push.call(messages, " " + expectations[i].toString());
+ }
+
+ messages.unshift("Unexpected call: " + sinon.spyCall.toString.call({
+ proxy: method,
+ args: args
+ }));
+
+ sinon.expectation.fail(messages.join("\n"));
+ }
+ });
+
+ var times = sinon.timesInWords;
+ var slice = Array.prototype.slice;
+
+ function callCountInWords(callCount) {
+ if (callCount === 0) {
+ return "never called";
+ }
+
+ return "called " + times(callCount);
+ }
+
+ function expectedCallCountInWords(expectation) {
+ var min = expectation.minCalls;
+ var max = expectation.maxCalls;
+
+ if (typeof min === "number" && typeof max === "number") {
+ var str = times(min);
+
+ if (min !== max) {
+ str = "at least " + str + " and at most " + times(max);
+ }
+
+ return str;
+ }
+
+ if (typeof min === "number") {
+ return "at least " + times(min);
+ }
+
+ return "at most " + times(max);
+ }
+
+ function receivedMinCalls(expectation) {
+ var hasMinLimit = typeof expectation.minCalls === "number";
+ return !hasMinLimit || expectation.callCount >= expectation.minCalls;
+ }
+
+ function receivedMaxCalls(expectation) {
+ if (typeof expectation.maxCalls !== "number") {
+ return false;
+ }
+
+ return expectation.callCount === expectation.maxCalls;
+ }
+
+ function verifyMatcher(possibleMatcher, arg) {
+ var isMatcher = match && match.isMatcher(possibleMatcher);
+
+ return isMatcher && possibleMatcher.test(arg) || true;
+ }
+
+ sinon.expectation = {
+ minCalls: 1,
+ maxCalls: 1,
+
+ create: function create(methodName) {
+ var expectation = sinon.extend(sinon.stub.create(), sinon.expectation);
+ delete expectation.create;
+ expectation.method = methodName;
+
+ return expectation;
+ },
+
+ invoke: function invoke(func, thisValue, args) {
+ this.verifyCallAllowed(thisValue, args);
+
+ return sinon.spy.invoke.apply(this, arguments);
+ },
+
+ atLeast: function atLeast(num) {
+ if (typeof num !== "number") {
+ throw new TypeError("'" + num + "' is not number");
+ }
+
+ if (!this.limitsSet) {
+ this.maxCalls = null;
+ this.limitsSet = true;
+ }
+
+ this.minCalls = num;
+
+ return this;
+ },
+
+ atMost: function atMost(num) {
+ if (typeof num !== "number") {
+ throw new TypeError("'" + num + "' is not number");
+ }
+
+ if (!this.limitsSet) {
+ this.minCalls = null;
+ this.limitsSet = true;
+ }
+
+ this.maxCalls = num;
+
+ return this;
+ },
+
+ never: function never() {
+ return this.exactly(0);
+ },
+
+ once: function once() {
+ return this.exactly(1);
+ },
+
+ twice: function twice() {
+ return this.exactly(2);
+ },
+
+ thrice: function thrice() {
+ return this.exactly(3);
+ },
+
+ exactly: function exactly(num) {
+ if (typeof num !== "number") {
+ throw new TypeError("'" + num + "' is not a number");
+ }
+
+ this.atLeast(num);
+ return this.atMost(num);
+ },
+
+ met: function met() {
+ return !this.failed && receivedMinCalls(this);
+ },
+
+ verifyCallAllowed: function verifyCallAllowed(thisValue, args) {
+ if (receivedMaxCalls(this)) {
+ this.failed = true;
+ sinon.expectation.fail(this.method + " already called " + times(this.maxCalls));
+ }
+
+ if ("expectedThis" in this && this.expectedThis !== thisValue) {
+ sinon.expectation.fail(this.method + " called with " + thisValue + " as thisValue, expected " +
+ this.expectedThis);
+ }
+
+ if (!("expectedArguments" in this)) {
+ return;
+ }
+
+ if (!args) {
+ sinon.expectation.fail(this.method + " received no arguments, expected " +
+ sinon.format(this.expectedArguments));
+ }
+
+ if (args.length < this.expectedArguments.length) {
+ sinon.expectation.fail(this.method + " received too few arguments (" + sinon.format(args) +
+ "), expected " + sinon.format(this.expectedArguments));
+ }
+
+ if (this.expectsExactArgCount &&
+ args.length !== this.expectedArguments.length) {
+ sinon.expectation.fail(this.method + " received too many arguments (" + sinon.format(args) +
+ "), expected " + sinon.format(this.expectedArguments));
+ }
+
+ for (var i = 0, l = this.expectedArguments.length; i < l; i += 1) {
+
+ if (!verifyMatcher(this.expectedArguments[i], args[i])) {
+ sinon.expectation.fail(this.method + " received wrong arguments " + sinon.format(args) +
+ ", didn't match " + this.expectedArguments.toString());
+ }
+
+ if (!sinon.deepEqual(this.expectedArguments[i], args[i])) {
+ sinon.expectation.fail(this.method + " received wrong arguments " + sinon.format(args) +
+ ", expected " + sinon.format(this.expectedArguments));
+ }
+ }
+ },
+
+ allowsCall: function allowsCall(thisValue, args) {
+ if (this.met() && receivedMaxCalls(this)) {
+ return false;
+ }
+
+ if ("expectedThis" in this && this.expectedThis !== thisValue) {
+ return false;
+ }
+
+ if (!("expectedArguments" in this)) {
+ return true;
+ }
+
+ args = args || [];
+
+ if (args.length < this.expectedArguments.length) {
+ return false;
+ }
+
+ if (this.expectsExactArgCount &&
+ args.length !== this.expectedArguments.length) {
+ return false;
+ }
+
+ for (var i = 0, l = this.expectedArguments.length; i < l; i += 1) {
+ if (!verifyMatcher(this.expectedArguments[i], args[i])) {
+ return false;
+ }
+
+ if (!sinon.deepEqual(this.expectedArguments[i], args[i])) {
+ return false;
+ }
+ }
+
+ return true;
+ },
+
+ withArgs: function withArgs() {
+ this.expectedArguments = slice.call(arguments);
+ return this;
+ },
+
+ withExactArgs: function withExactArgs() {
+ this.withArgs.apply(this, arguments);
+ this.expectsExactArgCount = true;
+ return this;
+ },
+
+ on: function on(thisValue) {
+ this.expectedThis = thisValue;
+ return this;
+ },
+
+ toString: function () {
+ var args = (this.expectedArguments || []).slice();
+
+ if (!this.expectsExactArgCount) {
+ push.call(args, "[...]");
+ }
+
+ var callStr = sinon.spyCall.toString.call({
+ proxy: this.method || "anonymous mock expectation",
+ args: args
+ });
+
+ var message = callStr.replace(", [...", "[, ...") + " " +
+ expectedCallCountInWords(this);
+
+ if (this.met()) {
+ return "Expectation met: " + message;
+ }
+
+ return "Expected " + message + " (" +
+ callCountInWords(this.callCount) + ")";
+ },
+
+ verify: function verify() {
+ if (!this.met()) {
+ sinon.expectation.fail(this.toString());
+ } else {
+ sinon.expectation.pass(this.toString());
+ }
+
+ return true;
+ },
+
+ pass: function pass(message) {
+ sinon.assert.pass(message);
+ },
+
+ fail: function fail(message) {
+ var exception = new Error(message);
+ exception.name = "ExpectationError";
+
+ throw exception;
+ }
+ };
+
+ sinon.mock = mock;
+ return mock;
+ }
+
+ var isNode = typeof module !== "undefined" && module.exports && typeof require === "function";
+ var isAMD = typeof define === "function" && typeof define.amd === "object" && define.amd;
+
+ function loadDependencies(require, exports, module) {
+ var sinon = require("./util/core");
+ require("./times_in_words");
+ require("./call");
+ require("./extend");
+ require("./match");
+ require("./spy");
+ require("./stub");
+ require("./format");
+
+ module.exports = makeApi(sinon);
+ }
+
+ if (isAMD) {
+ define(loadDependencies);
+ return;
+ }
+
+ if (isNode) {
+ loadDependencies(require, module.exports, module);
+ return;
+ }
+
+ if (sinonGlobal) {
+ makeApi(sinonGlobal);
+ }
+}(
+ typeof sinon === "object" && sinon // eslint-disable-line no-undef
+));
+
+/**
+ * @depend util/core.js
+ * @depend spy.js
+ * @depend stub.js
+ * @depend mock.js
+ */
+/**
+ * Collections of stubs, spies and mocks.
+ *
+ * @author Christian Johansen (christian@cjohansen.no)
+ * @license BSD
+ *
+ * Copyright (c) 2010-2013 Christian Johansen
+ */
+(function (sinonGlobal) {
+
+ var push = [].push;
+ var hasOwnProperty = Object.prototype.hasOwnProperty;
+
+ function getFakes(fakeCollection) {
+ if (!fakeCollection.fakes) {
+ fakeCollection.fakes = [];
+ }
+
+ return fakeCollection.fakes;
+ }
+
+ function each(fakeCollection, method) {
+ var fakes = getFakes(fakeCollection);
+
+ for (var i = 0, l = fakes.length; i < l; i += 1) {
+ if (typeof fakes[i][method] === "function") {
+ fakes[i][method]();
+ }
+ }
+ }
+
+ function compact(fakeCollection) {
+ var fakes = getFakes(fakeCollection);
+ var i = 0;
+ while (i < fakes.length) {
+ fakes.splice(i, 1);
+ }
+ }
+
+ function makeApi(sinon) {
+ var collection = {
+ verify: function resolve() {
+ each(this, "verify");
+ },
+
+ restore: function restore() {
+ each(this, "restore");
+ compact(this);
+ },
+
+ reset: function restore() {
+ each(this, "reset");
+ },
+
+ verifyAndRestore: function verifyAndRestore() {
+ var exception;
+
+ try {
+ this.verify();
+ } catch (e) {
+ exception = e;
+ }
+
+ this.restore();
+
+ if (exception) {
+ throw exception;
+ }
+ },
+
+ add: function add(fake) {
+ push.call(getFakes(this), fake);
+ return fake;
+ },
+
+ spy: function spy() {
+ return this.add(sinon.spy.apply(sinon, arguments));
+ },
+
+ stub: function stub(object, property, value) {
+ if (property) {
+ var original = object[property];
+
+ if (typeof original !== "function") {
+ if (!hasOwnProperty.call(object, property)) {
+ throw new TypeError("Cannot stub non-existent own property " + property);
+ }
+
+ object[property] = value;
+
+ return this.add({
+ restore: function () {
+ object[property] = original;
+ }
+ });
+ }
+ }
+ if (!property && !!object && typeof object === "object") {
+ var stubbedObj = sinon.stub.apply(sinon, arguments);
+
+ for (var prop in stubbedObj) {
+ if (typeof stubbedObj[prop] === "function") {
+ this.add(stubbedObj[prop]);
+ }
+ }
+
+ return stubbedObj;
+ }
+
+ return this.add(sinon.stub.apply(sinon, arguments));
+ },
+
+ mock: function mock() {
+ return this.add(sinon.mock.apply(sinon, arguments));
+ },
+
+ inject: function inject(obj) {
+ var col = this;
+
+ obj.spy = function () {
+ return col.spy.apply(col, arguments);
+ };
+
+ obj.stub = function () {
+ return col.stub.apply(col, arguments);
+ };
+
+ obj.mock = function () {
+ return col.mock.apply(col, arguments);
+ };
+
+ return obj;
+ }
+ };
+
+ sinon.collection = collection;
+ return collection;
+ }
+
+ var isNode = typeof module !== "undefined" && module.exports && typeof require === "function";
+ var isAMD = typeof define === "function" && typeof define.amd === "object" && define.amd;
+
+ function loadDependencies(require, exports, module) {
+ var sinon = require("./util/core");
+ require("./mock");
+ require("./spy");
+ require("./stub");
+ module.exports = makeApi(sinon);
+ }
+
+ if (isAMD) {
+ define(loadDependencies);
+ return;
+ }
+
+ if (isNode) {
+ loadDependencies(require, module.exports, module);
+ return;
+ }
+
+ if (sinonGlobal) {
+ makeApi(sinonGlobal);
+ }
+}(
+ typeof sinon === "object" && sinon // eslint-disable-line no-undef
+));
+
+/**
+ * Fake timer API
+ * setTimeout
+ * setInterval
+ * clearTimeout
+ * clearInterval
+ * tick
+ * reset
+ * Date
+ *
+ * Inspired by jsUnitMockTimeOut from JsUnit
+ *
+ * @author Christian Johansen (christian@cjohansen.no)
+ * @license BSD
+ *
+ * Copyright (c) 2010-2013 Christian Johansen
+ */
+(function () {
+
+ function makeApi(s, lol) {
+ /*global lolex */
+ var llx = typeof lolex !== "undefined" ? lolex : lol;
+
+ s.useFakeTimers = function () {
+ var now;
+ var methods = Array.prototype.slice.call(arguments);
+
+ if (typeof methods[0] === "string") {
+ now = 0;
+ } else {
+ now = methods.shift();
+ }
+
+ var clock = llx.install(now || 0, methods);
+ clock.restore = clock.uninstall;
+ return clock;
+ };
+
+ s.clock = {
+ create: function (now) {
+ return llx.createClock(now);
+ }
+ };
+
+ s.timers = {
+ setTimeout: setTimeout,
+ clearTimeout: clearTimeout,
+ setImmediate: (typeof setImmediate !== "undefined" ? setImmediate : undefined),
+ clearImmediate: (typeof clearImmediate !== "undefined" ? clearImmediate : undefined),
+ setInterval: setInterval,
+ clearInterval: clearInterval,
+ Date: Date
+ };
+ }
+
+ var isNode = typeof module !== "undefined" && module.exports && typeof require === "function";
+ var isAMD = typeof define === "function" && typeof define.amd === "object" && define.amd;
+
+ function loadDependencies(require, epxorts, module, lolex) {
+ var core = require("./core");
+ makeApi(core, lolex);
+ module.exports = core;
+ }
+
+ if (isAMD) {
+ define(loadDependencies);
+ } else if (isNode) {
+ loadDependencies(require, module.exports, module, require("lolex"));
+ } else {
+ makeApi(sinon); // eslint-disable-line no-undef
+ }
+}());
+
+/**
+ * Minimal Event interface implementation
+ *
+ * Original implementation by Sven Fuchs: https://gist.github.com/995028
+ * Modifications and tests by Christian Johansen.
+ *
+ * @author Sven Fuchs (svenfuchs@artweb-design.de)
+ * @author Christian Johansen (christian@cjohansen.no)
+ * @license BSD
+ *
+ * Copyright (c) 2011 Sven Fuchs, Christian Johansen
+ */
+if (typeof sinon === "undefined") {
+ this.sinon = {};
+}
+
+(function () {
+
+ var push = [].push;
+
+ function makeApi(sinon) {
+ sinon.Event = function Event(type, bubbles, cancelable, target) {
+ this.initEvent(type, bubbles, cancelable, target);
+ };
+
+ sinon.Event.prototype = {
+ initEvent: function (type, bubbles, cancelable, target) {
+ this.type = type;
+ this.bubbles = bubbles;
+ this.cancelable = cancelable;
+ this.target = target;
+ },
+
+ stopPropagation: function () {},
+
+ preventDefault: function () {
+ this.defaultPrevented = true;
+ }
+ };
+
+ sinon.ProgressEvent = function ProgressEvent(type, progressEventRaw, target) {
+ this.initEvent(type, false, false, target);
+ this.loaded = typeof progressEventRaw.loaded === "number" ? progressEventRaw.loaded : null;
+ this.total = typeof progressEventRaw.total === "number" ? progressEventRaw.total : null;
+ this.lengthComputable = !!progressEventRaw.total;
+ };
+
+ sinon.ProgressEvent.prototype = new sinon.Event();
+
+ sinon.ProgressEvent.prototype.constructor = sinon.ProgressEvent;
+
+ sinon.CustomEvent = function CustomEvent(type, customData, target) {
+ this.initEvent(type, false, false, target);
+ this.detail = customData.detail || null;
+ };
+
+ sinon.CustomEvent.prototype = new sinon.Event();
+
+ sinon.CustomEvent.prototype.constructor = sinon.CustomEvent;
+
+ sinon.EventTarget = {
+ addEventListener: function addEventListener(event, listener) {
+ this.eventListeners = this.eventListeners || {};
+ this.eventListeners[event] = this.eventListeners[event] || [];
+ push.call(this.eventListeners[event], listener);
+ },
+
+ removeEventListener: function removeEventListener(event, listener) {
+ var listeners = this.eventListeners && this.eventListeners[event] || [];
+
+ for (var i = 0, l = listeners.length; i < l; ++i) {
+ if (listeners[i] === listener) {
+ return listeners.splice(i, 1);
+ }
+ }
+ },
+
+ dispatchEvent: function dispatchEvent(event) {
+ var type = event.type;
+ var listeners = this.eventListeners && this.eventListeners[type] || [];
+
+ for (var i = 0; i < listeners.length; i++) {
+ if (typeof listeners[i] === "function") {
+ listeners[i].call(this, event);
+ } else {
+ listeners[i].handleEvent(event);
+ }
+ }
+
+ return !!event.defaultPrevented;
+ }
+ };
+ }
+
+ var isNode = typeof module !== "undefined" && module.exports && typeof require === "function";
+ var isAMD = typeof define === "function" && typeof define.amd === "object" && define.amd;
+
+ function loadDependencies(require) {
+ var sinon = require("./core");
+ makeApi(sinon);
+ }
+
+ if (isAMD) {
+ define(loadDependencies);
+ } else if (isNode) {
+ loadDependencies(require);
+ } else {
+ makeApi(sinon); // eslint-disable-line no-undef
+ }
+}());
+
+/**
+ * @depend util/core.js
+ */
+/**
+ * Logs errors
+ *
+ * @author Christian Johansen (christian@cjohansen.no)
+ * @license BSD
+ *
+ * Copyright (c) 2010-2014 Christian Johansen
+ */
+(function (sinonGlobal) {
+
+ // cache a reference to setTimeout, so that our reference won't be stubbed out
+ // when using fake timers and errors will still get logged
+ // https://github.com/cjohansen/Sinon.JS/issues/381
+ var realSetTimeout = setTimeout;
+
+ function makeApi(sinon) {
+
+ function log() {}
+
+ function logError(label, err) {
+ var msg = label + " threw exception: ";
+
+ function throwLoggedError() {
+ err.message = msg + err.message;
+ throw err;
+ }
+
+ sinon.log(msg + "[" + err.name + "] " + err.message);
+
+ if (err.stack) {
+ sinon.log(err.stack);
+ }
+
+ if (logError.useImmediateExceptions) {
+ throwLoggedError();
+ } else {
+ logError.setTimeout(throwLoggedError, 0);
+ }
+ }
+
+ // When set to true, any errors logged will be thrown immediately;
+ // If set to false, the errors will be thrown in separate execution frame.
+ logError.useImmediateExceptions = false;
+
+ // wrap realSetTimeout with something we can stub in tests
+ logError.setTimeout = function (func, timeout) {
+ realSetTimeout(func, timeout);
+ };
+
+ var exports = {};
+ exports.log = sinon.log = log;
+ exports.logError = sinon.logError = logError;
+
+ return exports;
+ }
+
+ function loadDependencies(require, exports, module) {
+ var sinon = require("./util/core");
+ module.exports = makeApi(sinon);
+ }
+
+ var isNode = typeof module !== "undefined" && module.exports && typeof require === "function";
+ var isAMD = typeof define === "function" && typeof define.amd === "object" && define.amd;
+
+ if (isAMD) {
+ define(loadDependencies);
+ return;
+ }
+
+ if (isNode) {
+ loadDependencies(require, module.exports, module);
+ return;
+ }
+
+ if (sinonGlobal) {
+ makeApi(sinonGlobal);
+ }
+}(
+ typeof sinon === "object" && sinon // eslint-disable-line no-undef
+));
+
+/**
+ * @depend core.js
+ * @depend ../extend.js
+ * @depend event.js
+ * @depend ../log_error.js
+ */
+/**
+ * Fake XDomainRequest object
+ */
+
+/**
+ * Returns the global to prevent assigning values to 'this' when this is undefined.
+ * This can occur when files are interpreted by node in strict mode.
+ * @private
+ */
+function getGlobal() {
+
+ return typeof window !== "undefined" ? window : global;
+}
+
+if (typeof sinon === "undefined") {
+ if (typeof this === "undefined") {
+ getGlobal().sinon = {};
+ } else {
+ this.sinon = {};
+ }
+}
+
+// wrapper for global
+(function (global) {
+
+ var xdr = { XDomainRequest: global.XDomainRequest };
+ xdr.GlobalXDomainRequest = global.XDomainRequest;
+ xdr.supportsXDR = typeof xdr.GlobalXDomainRequest !== "undefined";
+ xdr.workingXDR = xdr.supportsXDR ? xdr.GlobalXDomainRequest : false;
+
+ function makeApi(sinon) {
+ sinon.xdr = xdr;
+
+ function FakeXDomainRequest() {
+ this.readyState = FakeXDomainRequest.UNSENT;
+ this.requestBody = null;
+ this.requestHeaders = {};
+ this.status = 0;
+ this.timeout = null;
+
+ if (typeof FakeXDomainRequest.onCreate === "function") {
+ FakeXDomainRequest.onCreate(this);
+ }
+ }
+
+ function verifyState(x) {
+ if (x.readyState !== FakeXDomainRequest.OPENED) {
+ throw new Error("INVALID_STATE_ERR");
+ }
+
+ if (x.sendFlag) {
+ throw new Error("INVALID_STATE_ERR");
+ }
+ }
+
+ function verifyRequestSent(x) {
+ if (x.readyState === FakeXDomainRequest.UNSENT) {
+ throw new Error("Request not sent");
+ }
+ if (x.readyState === FakeXDomainRequest.DONE) {
+ throw new Error("Request done");
+ }
+ }
+
+ function verifyResponseBodyType(body) {
+ if (typeof body !== "string") {
+ var error = new Error("Attempted to respond to fake XDomainRequest with " +
+ body + ", which is not a string.");
+ error.name = "InvalidBodyException";
+ throw error;
+ }
+ }
+
+ sinon.extend(FakeXDomainRequest.prototype, sinon.EventTarget, {
+ open: function open(method, url) {
+ this.method = method;
+ this.url = url;
+
+ this.responseText = null;
+ this.sendFlag = false;
+
+ this.readyStateChange(FakeXDomainRequest.OPENED);
+ },
+
+ readyStateChange: function readyStateChange(state) {
+ this.readyState = state;
+ var eventName = "";
+ switch (this.readyState) {
+ case FakeXDomainRequest.UNSENT:
+ break;
+ case FakeXDomainRequest.OPENED:
+ break;
+ case FakeXDomainRequest.LOADING:
+ if (this.sendFlag) {
+ //raise the progress event
+ eventName = "onprogress";
+ }
+ break;
+ case FakeXDomainRequest.DONE:
+ if (this.isTimeout) {
+ eventName = "ontimeout";
+ } else if (this.errorFlag || (this.status < 200 || this.status > 299)) {
+ eventName = "onerror";
+ } else {
+ eventName = "onload";
+ }
+ break;
+ }
+
+ // raising event (if defined)
+ if (eventName) {
+ if (typeof this[eventName] === "function") {
+ try {
+ this[eventName]();
+ } catch (e) {
+ sinon.logError("Fake XHR " + eventName + " handler", e);
+ }
+ }
+ }
+ },
+
+ send: function send(data) {
+ verifyState(this);
+
+ if (!/^(get|head)$/i.test(this.method)) {
+ this.requestBody = data;
+ }
+ this.requestHeaders["Content-Type"] = "text/plain;charset=utf-8";
+
+ this.errorFlag = false;
+ this.sendFlag = true;
+ this.readyStateChange(FakeXDomainRequest.OPENED);
+
+ if (typeof this.onSend === "function") {
+ this.onSend(this);
+ }
+ },
+
+ abort: function abort() {
+ this.aborted = true;
+ this.responseText = null;
+ this.errorFlag = true;
+
+ if (this.readyState > sinon.FakeXDomainRequest.UNSENT && this.sendFlag) {
+ this.readyStateChange(sinon.FakeXDomainRequest.DONE);
+ this.sendFlag = false;
+ }
+ },
+
+ setResponseBody: function setResponseBody(body) {
+ verifyRequestSent(this);
+ verifyResponseBodyType(body);
+
+ var chunkSize = this.chunkSize || 10;
+ var index = 0;
+ this.responseText = "";
+
+ do {
+ this.readyStateChange(FakeXDomainRequest.LOADING);
+ this.responseText += body.substring(index, index + chunkSize);
+ index += chunkSize;
+ } while (index < body.length);
+
+ this.readyStateChange(FakeXDomainRequest.DONE);
+ },
+
+ respond: function respond(status, contentType, body) {
+ // content-type ignored, since XDomainRequest does not carry this
+ // we keep the same syntax for respond(...) as for FakeXMLHttpRequest to ease
+ // test integration across browsers
+ this.status = typeof status === "number" ? status : 200;
+ this.setResponseBody(body || "");
+ },
+
+ simulatetimeout: function simulatetimeout() {
+ this.status = 0;
+ this.isTimeout = true;
+ // Access to this should actually throw an error
+ this.responseText = undefined;
+ this.readyStateChange(FakeXDomainRequest.DONE);
+ }
+ });
+
+ sinon.extend(FakeXDomainRequest, {
+ UNSENT: 0,
+ OPENED: 1,
+ LOADING: 3,
+ DONE: 4
+ });
+
+ sinon.useFakeXDomainRequest = function useFakeXDomainRequest() {
+ sinon.FakeXDomainRequest.restore = function restore(keepOnCreate) {
+ if (xdr.supportsXDR) {
+ global.XDomainRequest = xdr.GlobalXDomainRequest;
+ }
+
+ delete sinon.FakeXDomainRequest.restore;
+
+ if (keepOnCreate !== true) {
+ delete sinon.FakeXDomainRequest.onCreate;
+ }
+ };
+ if (xdr.supportsXDR) {
+ global.XDomainRequest = sinon.FakeXDomainRequest;
+ }
+ return sinon.FakeXDomainRequest;
+ };
+
+ sinon.FakeXDomainRequest = FakeXDomainRequest;
+ }
+
+ var isNode = typeof module !== "undefined" && module.exports && typeof require === "function";
+ var isAMD = typeof define === "function" && typeof define.amd === "object" && define.amd;
+
+ function loadDependencies(require, exports, module) {
+ var sinon = require("./core");
+ require("../extend");
+ require("./event");
+ require("../log_error");
+ makeApi(sinon);
+ module.exports = sinon;
+ }
+
+ if (isAMD) {
+ define(loadDependencies);
+ } else if (isNode) {
+ loadDependencies(require, module.exports, module);
+ } else {
+ makeApi(sinon); // eslint-disable-line no-undef
+ }
+})(typeof global !== "undefined" ? global : self);
+
+/**
+ * @depend core.js
+ * @depend ../extend.js
+ * @depend event.js
+ * @depend ../log_error.js
+ */
+/**
+ * Fake XMLHttpRequest object
+ *
+ * @author Christian Johansen (christian@cjohansen.no)
+ * @license BSD
+ *
+ * Copyright (c) 2010-2013 Christian Johansen
+ */
+(function (sinonGlobal, global) {
+
+ function getWorkingXHR(globalScope) {
+ var supportsXHR = typeof globalScope.XMLHttpRequest !== "undefined";
+ if (supportsXHR) {
+ return globalScope.XMLHttpRequest;
+ }
+
+ var supportsActiveX = typeof globalScope.ActiveXObject !== "undefined";
+ if (supportsActiveX) {
+ return function () {
+ return new globalScope.ActiveXObject("MSXML2.XMLHTTP.3.0");
+ };
+ }
+
+ return false;
+ }
+
+ var supportsProgress = typeof ProgressEvent !== "undefined";
+ var supportsCustomEvent = typeof CustomEvent !== "undefined";
+ var supportsFormData = typeof FormData !== "undefined";
+ var supportsArrayBuffer = typeof ArrayBuffer !== "undefined";
+ var supportsBlob = (function () {
+ try {
+ return !!new Blob();
+ } catch (e) {
+ return false;
+ }
+ })();
+ var sinonXhr = { XMLHttpRequest: global.XMLHttpRequest };
+ sinonXhr.GlobalXMLHttpRequest = global.XMLHttpRequest;
+ sinonXhr.GlobalActiveXObject = global.ActiveXObject;
+ sinonXhr.supportsActiveX = typeof sinonXhr.GlobalActiveXObject !== "undefined";
+ sinonXhr.supportsXHR = typeof sinonXhr.GlobalXMLHttpRequest !== "undefined";
+ sinonXhr.workingXHR = getWorkingXHR(global);
+ sinonXhr.supportsCORS = sinonXhr.supportsXHR && "withCredentials" in (new sinonXhr.GlobalXMLHttpRequest());
+
+ var unsafeHeaders = {
+ "Accept-Charset": true,
+ "Accept-Encoding": true,
+ Connection: true,
+ "Content-Length": true,
+ Cookie: true,
+ Cookie2: true,
+ "Content-Transfer-Encoding": true,
+ Date: true,
+ Expect: true,
+ Host: true,
+ "Keep-Alive": true,
+ Referer: true,
+ TE: true,
+ Trailer: true,
+ "Transfer-Encoding": true,
+ Upgrade: true,
+ "User-Agent": true,
+ Via: true
+ };
+
+ // An upload object is created for each
+ // FakeXMLHttpRequest and allows upload
+ // events to be simulated using uploadProgress
+ // and uploadError.
+ function UploadProgress() {
+ this.eventListeners = {
+ abort: [],
+ error: [],
+ load: [],
+ loadend: [],
+ progress: []
+ };
+ }
+
+ UploadProgress.prototype.addEventListener = function addEventListener(event, listener) {
+ this.eventListeners[event].push(listener);
+ };
+
+ UploadProgress.prototype.removeEventListener = function removeEventListener(event, listener) {
+ var listeners = this.eventListeners[event] || [];
+
+ for (var i = 0, l = listeners.length; i < l; ++i) {
+ if (listeners[i] === listener) {
+ return listeners.splice(i, 1);
+ }
+ }
+ };
+
+ UploadProgress.prototype.dispatchEvent = function dispatchEvent(event) {
+ var listeners = this.eventListeners[event.type] || [];
+
+ for (var i = 0, listener; (listener = listeners[i]) != null; i++) {
+ listener(event);
+ }
+ };
+
+ // Note that for FakeXMLHttpRequest to work pre ES5
+ // we lose some of the alignment with the spec.
+ // To ensure as close a match as possible,
+ // set responseType before calling open, send or respond;
+ function FakeXMLHttpRequest() {
+ this.readyState = FakeXMLHttpRequest.UNSENT;
+ this.requestHeaders = {};
+ this.requestBody = null;
+ this.status = 0;
+ this.statusText = "";
+ this.upload = new UploadProgress();
+ this.responseType = "";
+ this.response = "";
+ if (sinonXhr.supportsCORS) {
+ this.withCredentials = false;
+ }
+
+ var xhr = this;
+ var events = ["loadstart", "load", "abort", "error", "loadend"];
+
+ function addEventListener(eventName) {
+ xhr.addEventListener(eventName, function (event) {
+ var listener = xhr["on" + eventName];
+
+ if (listener && typeof listener === "function") {
+ listener.call(this, event);
+ }
+ });
+ }
+
+ for (var i = events.length - 1; i >= 0; i--) {
+ addEventListener(events[i]);
+ }
+
+ if (typeof FakeXMLHttpRequest.onCreate === "function") {
+ FakeXMLHttpRequest.onCreate(this);
+ }
+ }
+
+ function verifyState(xhr) {
+ if (xhr.readyState !== FakeXMLHttpRequest.OPENED) {
+ throw new Error("INVALID_STATE_ERR");
+ }
+
+ if (xhr.sendFlag) {
+ throw new Error("INVALID_STATE_ERR");
+ }
+ }
+
+ function getHeader(headers, header) {
+ header = header.toLowerCase();
+
+ for (var h in headers) {
+ if (h.toLowerCase() === header) {
+ return h;
+ }
+ }
+
+ return null;
+ }
+
+ // filtering to enable a white-list version of Sinon FakeXhr,
+ // where whitelisted requests are passed through to real XHR
+ function each(collection, callback) {
+ if (!collection) {
+ return;
+ }
+
+ for (var i = 0, l = collection.length; i < l; i += 1) {
+ callback(collection[i]);
+ }
+ }
+ function some(collection, callback) {
+ for (var index = 0; index < collection.length; index++) {
+ if (callback(collection[index]) === true) {
+ return true;
+ }
+ }
+ return false;
+ }
+ // largest arity in XHR is 5 - XHR#open
+ var apply = function (obj, method, args) {
+ switch (args.length) {
+ case 0: return obj[method]();
+ case 1: return obj[method](args[0]);
+ case 2: return obj[method](args[0], args[1]);
+ case 3: return obj[method](args[0], args[1], args[2]);
+ case 4: return obj[method](args[0], args[1], args[2], args[3]);
+ case 5: return obj[method](args[0], args[1], args[2], args[3], args[4]);
+ }
+ };
+
+ FakeXMLHttpRequest.filters = [];
+ FakeXMLHttpRequest.addFilter = function addFilter(fn) {
+ this.filters.push(fn);
+ };
+ var IE6Re = /MSIE 6/;
+ FakeXMLHttpRequest.defake = function defake(fakeXhr, xhrArgs) {
+ var xhr = new sinonXhr.workingXHR(); // eslint-disable-line new-cap
+
+ each([
+ "open",
+ "setRequestHeader",
+ "send",
+ "abort",
+ "getResponseHeader",
+ "getAllResponseHeaders",
+ "addEventListener",
+ "overrideMimeType",
+ "removeEventListener"
+ ], function (method) {
+ fakeXhr[method] = function () {
+ return apply(xhr, method, arguments);
+ };
+ });
+
+ var copyAttrs = function (args) {
+ each(args, function (attr) {
+ try {
+ fakeXhr[attr] = xhr[attr];
+ } catch (e) {
+ if (!IE6Re.test(navigator.userAgent)) {
+ throw e;
+ }
+ }
+ });
+ };
+
+ var stateChange = function stateChange() {
+ fakeXhr.readyState = xhr.readyState;
+ if (xhr.readyState >= FakeXMLHttpRequest.HEADERS_RECEIVED) {
+ copyAttrs(["status", "statusText"]);
+ }
+ if (xhr.readyState >= FakeXMLHttpRequest.LOADING) {
+ copyAttrs(["responseText", "response"]);
+ }
+ if (xhr.readyState === FakeXMLHttpRequest.DONE) {
+ copyAttrs(["responseXML"]);
+ }
+ if (fakeXhr.onreadystatechange) {
+ fakeXhr.onreadystatechange.call(fakeXhr, { target: fakeXhr });
+ }
+ };
+
+ if (xhr.addEventListener) {
+ for (var event in fakeXhr.eventListeners) {
+ if (fakeXhr.eventListeners.hasOwnProperty(event)) {
+
+ /*eslint-disable no-loop-func*/
+ each(fakeXhr.eventListeners[event], function (handler) {
+ xhr.addEventListener(event, handler);
+ });
+ /*eslint-enable no-loop-func*/
+ }
+ }
+ xhr.addEventListener("readystatechange", stateChange);
+ } else {
+ xhr.onreadystatechange = stateChange;
+ }
+ apply(xhr, "open", xhrArgs);
+ };
+ FakeXMLHttpRequest.useFilters = false;
+
+ function verifyRequestOpened(xhr) {
+ if (xhr.readyState !== FakeXMLHttpRequest.OPENED) {
+ throw new Error("INVALID_STATE_ERR - " + xhr.readyState);
+ }
+ }
+
+ function verifyRequestSent(xhr) {
+ if (xhr.readyState === FakeXMLHttpRequest.DONE) {
+ throw new Error("Request done");
+ }
+ }
+
+ function verifyHeadersReceived(xhr) {
+ if (xhr.async && xhr.readyState !== FakeXMLHttpRequest.HEADERS_RECEIVED) {
+ throw new Error("No headers received");
+ }
+ }
+
+ function verifyResponseBodyType(body) {
+ if (typeof body !== "string") {
+ var error = new Error("Attempted to respond to fake XMLHttpRequest with " +
+ body + ", which is not a string.");
+ error.name = "InvalidBodyException";
+ throw error;
+ }
+ }
+
+ function convertToArrayBuffer(body) {
+ var buffer = new ArrayBuffer(body.length);
+ var view = new Uint8Array(buffer);
+ for (var i = 0; i < body.length; i++) {
+ var charCode = body.charCodeAt(i);
+ if (charCode >= 256) {
+ throw new TypeError("arraybuffer or blob responseTypes require binary string, " +
+ "invalid character " + body[i] + " found.");
+ }
+ view[i] = charCode;
+ }
+ return buffer;
+ }
+
+ function isXmlContentType(contentType) {
+ return !contentType || /(text\/xml)|(application\/xml)|(\+xml)/.test(contentType);
+ }
+
+ function convertResponseBody(responseType, contentType, body) {
+ if (responseType === "" || responseType === "text") {
+ return body;
+ } else if (supportsArrayBuffer && responseType === "arraybuffer") {
+ return convertToArrayBuffer(body);
+ } else if (responseType === "json") {
+ try {
+ return JSON.parse(body);
+ } catch (e) {
+ // Return parsing failure as null
+ return null;
+ }
+ } else if (supportsBlob && responseType === "blob") {
+ var blobOptions = {};
+ if (contentType) {
+ blobOptions.type = contentType;
+ }
+ return new Blob([convertToArrayBuffer(body)], blobOptions);
+ } else if (responseType === "document") {
+ if (isXmlContentType(contentType)) {
+ return FakeXMLHttpRequest.parseXML(body);
+ }
+ return null;
+ }
+ throw new Error("Invalid responseType " + responseType);
+ }
+
+ function clearResponse(xhr) {
+ if (xhr.responseType === "" || xhr.responseType === "text") {
+ xhr.response = xhr.responseText = "";
+ } else {
+ xhr.response = xhr.responseText = null;
+ }
+ xhr.responseXML = null;
+ }
+
+ FakeXMLHttpRequest.parseXML = function parseXML(text) {
+ // Treat empty string as parsing failure
+ if (text !== "") {
+ try {
+ if (typeof DOMParser !== "undefined") {
+ var parser = new DOMParser();
+ return parser.parseFromString(text, "text/xml");
+ }
+ var xmlDoc = new window.ActiveXObject("Microsoft.XMLDOM");
+ xmlDoc.async = "false";
+ xmlDoc.loadXML(text);
+ return xmlDoc;
+ } catch (e) {
+ // Unable to parse XML - no biggie
+ }
+ }
+
+ return null;
+ };
+
+ FakeXMLHttpRequest.statusCodes = {
+ 100: "Continue",
+ 101: "Switching Protocols",
+ 200: "OK",
+ 201: "Created",
+ 202: "Accepted",
+ 203: "Non-Authoritative Information",
+ 204: "No Content",
+ 205: "Reset Content",
+ 206: "Partial Content",
+ 207: "Multi-Status",
+ 300: "Multiple Choice",
+ 301: "Moved Permanently",
+ 302: "Found",
+ 303: "See Other",
+ 304: "Not Modified",
+ 305: "Use Proxy",
+ 307: "Temporary Redirect",
+ 400: "Bad Request",
+ 401: "Unauthorized",
+ 402: "Payment Required",
+ 403: "Forbidden",
+ 404: "Not Found",
+ 405: "Method Not Allowed",
+ 406: "Not Acceptable",
+ 407: "Proxy Authentication Required",
+ 408: "Request Timeout",
+ 409: "Conflict",
+ 410: "Gone",
+ 411: "Length Required",
+ 412: "Precondition Failed",
+ 413: "Request Entity Too Large",
+ 414: "Request-URI Too Long",
+ 415: "Unsupported Media Type",
+ 416: "Requested Range Not Satisfiable",
+ 417: "Expectation Failed",
+ 422: "Unprocessable Entity",
+ 500: "Internal Server Error",
+ 501: "Not Implemented",
+ 502: "Bad Gateway",
+ 503: "Service Unavailable",
+ 504: "Gateway Timeout",
+ 505: "HTTP Version Not Supported"
+ };
+
+ function makeApi(sinon) {
+ sinon.xhr = sinonXhr;
+
+ sinon.extend(FakeXMLHttpRequest.prototype, sinon.EventTarget, {
+ async: true,
+
+ open: function open(method, url, async, username, password) {
+ this.method = method;
+ this.url = url;
+ this.async = typeof async === "boolean" ? async : true;
+ this.username = username;
+ this.password = password;
+ clearResponse(this);
+ this.requestHeaders = {};
+ this.sendFlag = false;
+
+ if (FakeXMLHttpRequest.useFilters === true) {
+ var xhrArgs = arguments;
+ var defake = some(FakeXMLHttpRequest.filters, function (filter) {
+ return filter.apply(this, xhrArgs);
+ });
+ if (defake) {
+ return FakeXMLHttpRequest.defake(this, arguments);
+ }
+ }
+ this.readyStateChange(FakeXMLHttpRequest.OPENED);
+ },
+
+ readyStateChange: function readyStateChange(state) {
+ this.readyState = state;
+
+ var readyStateChangeEvent = new sinon.Event("readystatechange", false, false, this);
+ var event, progress;
+
+ if (typeof this.onreadystatechange === "function") {
+ try {
+ this.onreadystatechange(readyStateChangeEvent);
+ } catch (e) {
+ sinon.logError("Fake XHR onreadystatechange handler", e);
+ }
+ }
+
+ if (this.readyState === FakeXMLHttpRequest.DONE) {
+ // ensure loaded and total are numbers
+ progress = {
+ loaded: this.progress || 0,
+ total: this.progress || 0
+ };
+
+ if (this.status === 0) {
+ event = this.aborted ? "abort" : "error";
+ }
+ else {
+ event = "load";
+ }
+
+ if (supportsProgress) {
+ this.upload.dispatchEvent(new sinon.ProgressEvent("progress", progress, this));
+ this.upload.dispatchEvent(new sinon.ProgressEvent(event, progress, this));
+ this.upload.dispatchEvent(new sinon.ProgressEvent("loadend", progress, this));
+ }
+
+ this.dispatchEvent(new sinon.ProgressEvent("progress", progress, this));
+ this.dispatchEvent(new sinon.ProgressEvent(event, progress, this));
+ this.dispatchEvent(new sinon.ProgressEvent("loadend", progress, this));
+ }
+
+ this.dispatchEvent(readyStateChangeEvent);
+ },
+
+ setRequestHeader: function setRequestHeader(header, value) {
+ verifyState(this);
+
+ if (unsafeHeaders[header] || /^(Sec-|Proxy-)/.test(header)) {
+ throw new Error("Refused to set unsafe header \"" + header + "\"");
+ }
+
+ if (this.requestHeaders[header]) {
+ this.requestHeaders[header] += "," + value;
+ } else {
+ this.requestHeaders[header] = value;
+ }
+ },
+
+ // Helps testing
+ setResponseHeaders: function setResponseHeaders(headers) {
+ verifyRequestOpened(this);
+ this.responseHeaders = {};
+
+ for (var header in headers) {
+ if (headers.hasOwnProperty(header)) {
+ this.responseHeaders[header] = headers[header];
+ }
+ }
+
+ if (this.async) {
+ this.readyStateChange(FakeXMLHttpRequest.HEADERS_RECEIVED);
+ } else {
+ this.readyState = FakeXMLHttpRequest.HEADERS_RECEIVED;
+ }
+ },
+
+ // Currently treats ALL data as a DOMString (i.e. no Document)
+ send: function send(data) {
+ verifyState(this);
+
+ if (!/^(get|head)$/i.test(this.method)) {
+ var contentType = getHeader(this.requestHeaders, "Content-Type");
+ if (this.requestHeaders[contentType]) {
+ var value = this.requestHeaders[contentType].split(";");
+ this.requestHeaders[contentType] = value[0] + ";charset=utf-8";
+ } else if (supportsFormData && !(data instanceof FormData)) {
+ this.requestHeaders["Content-Type"] = "text/plain;charset=utf-8";
+ }
+
+ this.requestBody = data;
+ }
+
+ this.errorFlag = false;
+ this.sendFlag = this.async;
+ clearResponse(this);
+ this.readyStateChange(FakeXMLHttpRequest.OPENED);
+
+ if (typeof this.onSend === "function") {
+ this.onSend(this);
+ }
+
+ this.dispatchEvent(new sinon.Event("loadstart", false, false, this));
+ },
+
+ abort: function abort() {
+ this.aborted = true;
+ clearResponse(this);
+ this.errorFlag = true;
+ this.requestHeaders = {};
+ this.responseHeaders = {};
+
+ if (this.readyState > FakeXMLHttpRequest.UNSENT && this.sendFlag) {
+ this.readyStateChange(FakeXMLHttpRequest.DONE);
+ this.sendFlag = false;
+ }
+
+ this.readyState = FakeXMLHttpRequest.UNSENT;
+ },
+
+ error: function error() {
+ clearResponse(this);
+ this.errorFlag = true;
+ this.requestHeaders = {};
+ this.responseHeaders = {};
+
+ this.readyStateChange(FakeXMLHttpRequest.DONE);
+ },
+
+ getResponseHeader: function getResponseHeader(header) {
+ if (this.readyState < FakeXMLHttpRequest.HEADERS_RECEIVED) {
+ return null;
+ }
+
+ if (/^Set-Cookie2?$/i.test(header)) {
+ return null;
+ }
+
+ header = getHeader(this.responseHeaders, header);
+
+ return this.responseHeaders[header] || null;
+ },
+
+ getAllResponseHeaders: function getAllResponseHeaders() {
+ if (this.readyState < FakeXMLHttpRequest.HEADERS_RECEIVED) {
+ return "";
+ }
+
+ var headers = "";
+
+ for (var header in this.responseHeaders) {
+ if (this.responseHeaders.hasOwnProperty(header) &&
+ !/^Set-Cookie2?$/i.test(header)) {
+ headers += header + ": " + this.responseHeaders[header] + "\r\n";
+ }
+ }
+
+ return headers;
+ },
+
+ setResponseBody: function setResponseBody(body) {
+ verifyRequestSent(this);
+ verifyHeadersReceived(this);
+ verifyResponseBodyType(body);
+ var contentType = this.getResponseHeader("Content-Type");
+
+ var isTextResponse = this.responseType === "" || this.responseType === "text";
+ clearResponse(this);
+ if (this.async) {
+ var chunkSize = this.chunkSize || 10;
+ var index = 0;
+
+ do {
+ this.readyStateChange(FakeXMLHttpRequest.LOADING);
+
+ if (isTextResponse) {
+ this.responseText = this.response += body.substring(index, index + chunkSize);
+ }
+ index += chunkSize;
+ } while (index < body.length);
+ }
+
+ this.response = convertResponseBody(this.responseType, contentType, body);
+ if (isTextResponse) {
+ this.responseText = this.response;
+ }
+
+ if (this.responseType === "document") {
+ this.responseXML = this.response;
+ } else if (this.responseType === "" && isXmlContentType(contentType)) {
+ this.responseXML = FakeXMLHttpRequest.parseXML(this.responseText);
+ }
+ this.progress = body.length;
+ this.readyStateChange(FakeXMLHttpRequest.DONE);
+ },
+
+ respond: function respond(status, headers, body) {
+ this.status = typeof status === "number" ? status : 200;
+ this.statusText = FakeXMLHttpRequest.statusCodes[this.status];
+ this.setResponseHeaders(headers || {});
+ this.setResponseBody(body || "");
+ },
+
+ uploadProgress: function uploadProgress(progressEventRaw) {
+ if (supportsProgress) {
+ this.upload.dispatchEvent(new sinon.ProgressEvent("progress", progressEventRaw));
+ }
+ },
+
+ downloadProgress: function downloadProgress(progressEventRaw) {
+ if (supportsProgress) {
+ this.dispatchEvent(new sinon.ProgressEvent("progress", progressEventRaw));
+ }
+ },
+
+ uploadError: function uploadError(error) {
+ if (supportsCustomEvent) {
+ this.upload.dispatchEvent(new sinon.CustomEvent("error", {detail: error}));
+ }
+ }
+ });
+
+ sinon.extend(FakeXMLHttpRequest, {
+ UNSENT: 0,
+ OPENED: 1,
+ HEADERS_RECEIVED: 2,
+ LOADING: 3,
+ DONE: 4
+ });
+
+ sinon.useFakeXMLHttpRequest = function () {
+ FakeXMLHttpRequest.restore = function restore(keepOnCreate) {
+ if (sinonXhr.supportsXHR) {
+ global.XMLHttpRequest = sinonXhr.GlobalXMLHttpRequest;
+ }
+
+ if (sinonXhr.supportsActiveX) {
+ global.ActiveXObject = sinonXhr.GlobalActiveXObject;
+ }
+
+ delete FakeXMLHttpRequest.restore;
+
+ if (keepOnCreate !== true) {
+ delete FakeXMLHttpRequest.onCreate;
+ }
+ };
+ if (sinonXhr.supportsXHR) {
+ global.XMLHttpRequest = FakeXMLHttpRequest;
+ }
+
+ if (sinonXhr.supportsActiveX) {
+ global.ActiveXObject = function ActiveXObject(objId) {
+ if (objId === "Microsoft.XMLHTTP" || /^Msxml2\.XMLHTTP/i.test(objId)) {
+
+ return new FakeXMLHttpRequest();
+ }
+
+ return new sinonXhr.GlobalActiveXObject(objId);
+ };
+ }
+
+ return FakeXMLHttpRequest;
+ };
+
+ sinon.FakeXMLHttpRequest = FakeXMLHttpRequest;
+ }
+
+ var isNode = typeof module !== "undefined" && module.exports && typeof require === "function";
+ var isAMD = typeof define === "function" && typeof define.amd === "object" && define.amd;
+
+ function loadDependencies(require, exports, module) {
+ var sinon = require("./core");
+ require("../extend");
+ require("./event");
+ require("../log_error");
+ makeApi(sinon);
+ module.exports = sinon;
+ }
+
+ if (isAMD) {
+ define(loadDependencies);
+ return;
+ }
+
+ if (isNode) {
+ loadDependencies(require, module.exports, module);
+ return;
+ }
+
+ if (sinonGlobal) {
+ makeApi(sinonGlobal);
+ }
+}(
+ typeof sinon === "object" && sinon, // eslint-disable-line no-undef
+ typeof global !== "undefined" ? global : self
+));
+
+/**
+ * @depend fake_xdomain_request.js
+ * @depend fake_xml_http_request.js
+ * @depend ../format.js
+ * @depend ../log_error.js
+ */
+/**
+ * The Sinon "server" mimics a web server that receives requests from
+ * sinon.FakeXMLHttpRequest and provides an API to respond to those requests,
+ * both synchronously and asynchronously. To respond synchronuously, canned
+ * answers have to be provided upfront.
+ *
+ * @author Christian Johansen (christian@cjohansen.no)
+ * @license BSD
+ *
+ * Copyright (c) 2010-2013 Christian Johansen
+ */
+(function () {
+
+ var push = [].push;
+
+ function responseArray(handler) {
+ var response = handler;
+
+ if (Object.prototype.toString.call(handler) !== "[object Array]") {
+ response = [200, {}, handler];
+ }
+
+ if (typeof response[2] !== "string") {
+ throw new TypeError("Fake server response body should be string, but was " +
+ typeof response[2]);
+ }
+
+ return response;
+ }
+
+ var wloc = typeof window !== "undefined" ? window.location : {};
+ var rCurrLoc = new RegExp("^" + wloc.protocol + "//" + wloc.host);
+
+ function matchOne(response, reqMethod, reqUrl) {
+ var rmeth = response.method;
+ var matchMethod = !rmeth || rmeth.toLowerCase() === reqMethod.toLowerCase();
+ var url = response.url;
+ var matchUrl = !url || url === reqUrl || (typeof url.test === "function" && url.test(reqUrl));
+
+ return matchMethod && matchUrl;
+ }
+
+ function match(response, request) {
+ var requestUrl = request.url;
+
+ if (!/^https?:\/\//.test(requestUrl) || rCurrLoc.test(requestUrl)) {
+ requestUrl = requestUrl.replace(rCurrLoc, "");
+ }
+
+ if (matchOne(response, this.getHTTPMethod(request), requestUrl)) {
+ if (typeof response.response === "function") {
+ var ru = response.url;
+ var args = [request].concat(ru && typeof ru.exec === "function" ? ru.exec(requestUrl).slice(1) : []);
+ return response.response.apply(response, args);
+ }
+
+ return true;
+ }
+
+ return false;
+ }
+
+ function makeApi(sinon) {
+ sinon.fakeServer = {
+ create: function (config) {
+ var server = sinon.create(this);
+ server.configure(config);
+ if (!sinon.xhr.supportsCORS) {
+ this.xhr = sinon.useFakeXDomainRequest();
+ } else {
+ this.xhr = sinon.useFakeXMLHttpRequest();
+ }
+ server.requests = [];
+
+ this.xhr.onCreate = function (xhrObj) {
+ server.addRequest(xhrObj);
+ };
+
+ return server;
+ },
+ configure: function (config) {
+ var whitelist = {
+ "autoRespond": true,
+ "autoRespondAfter": true,
+ "respondImmediately": true,
+ "fakeHTTPMethods": true
+ };
+ var setting;
+
+ config = config || {};
+ for (setting in config) {
+ if (whitelist.hasOwnProperty(setting) && config.hasOwnProperty(setting)) {
+ this[setting] = config[setting];
+ }
+ }
+ },
+ addRequest: function addRequest(xhrObj) {
+ var server = this;
+ push.call(this.requests, xhrObj);
+
+ xhrObj.onSend = function () {
+ server.handleRequest(this);
+
+ if (server.respondImmediately) {
+ server.respond();
+ } else if (server.autoRespond && !server.responding) {
+ setTimeout(function () {
+ server.responding = false;
+ server.respond();
+ }, server.autoRespondAfter || 10);
+
+ server.responding = true;
+ }
+ };
+ },
+
+ getHTTPMethod: function getHTTPMethod(request) {
+ if (this.fakeHTTPMethods && /post/i.test(request.method)) {
+ var matches = (request.requestBody || "").match(/_method=([^\b;]+)/);
+ return matches ? matches[1] : request.method;
+ }
+
+ return request.method;
+ },
+
+ handleRequest: function handleRequest(xhr) {
+ if (xhr.async) {
+ if (!this.queue) {
+ this.queue = [];
+ }
+
+ push.call(this.queue, xhr);
+ } else {
+ this.processRequest(xhr);
+ }
+ },
+
+ log: function log(response, request) {
+ var str;
+
+ str = "Request:\n" + sinon.format(request) + "\n\n";
+ str += "Response:\n" + sinon.format(response) + "\n\n";
+
+ sinon.log(str);
+ },
+
+ respondWith: function respondWith(method, url, body) {
+ if (arguments.length === 1 && typeof method !== "function") {
+ this.response = responseArray(method);
+ return;
+ }
+
+ if (!this.responses) {
+ this.responses = [];
+ }
+
+ if (arguments.length === 1) {
+ body = method;
+ url = method = null;
+ }
+
+ if (arguments.length === 2) {
+ body = url;
+ url = method;
+ method = null;
+ }
+
+ push.call(this.responses, {
+ method: method,
+ url: url,
+ response: typeof body === "function" ? body : responseArray(body)
+ });
+ },
+
+ respond: function respond() {
+ if (arguments.length > 0) {
+ this.respondWith.apply(this, arguments);
+ }
+
+ var queue = this.queue || [];
+ var requests = queue.splice(0, queue.length);
+
+ for (var i = 0; i < requests.length; i++) {
+ this.processRequest(requests[i]);
+ }
+ },
+
+ processRequest: function processRequest(request) {
+ try {
+ if (request.aborted) {
+ return;
+ }
+
+ var response = this.response || [404, {}, ""];
+
+ if (this.responses) {
+ for (var l = this.responses.length, i = l - 1; i >= 0; i--) {
+ if (match.call(this, this.responses[i], request)) {
+ response = this.responses[i].response;
+ break;
+ }
+ }
+ }
+
+ if (request.readyState !== 4) {
+ this.log(response, request);
+
+ request.respond(response[0], response[1], response[2]);
+ }
+ } catch (e) {
+ sinon.logError("Fake server request processing", e);
+ }
+ },
+
+ restore: function restore() {
+ return this.xhr.restore && this.xhr.restore.apply(this.xhr, arguments);
+ }
+ };
+ }
+
+ var isNode = typeof module !== "undefined" && module.exports && typeof require === "function";
+ var isAMD = typeof define === "function" && typeof define.amd === "object" && define.amd;
+
+ function loadDependencies(require, exports, module) {
+ var sinon = require("./core");
+ require("./fake_xdomain_request");
+ require("./fake_xml_http_request");
+ require("../format");
+ makeApi(sinon);
+ module.exports = sinon;
+ }
+
+ if (isAMD) {
+ define(loadDependencies);
+ } else if (isNode) {
+ loadDependencies(require, module.exports, module);
+ } else {
+ makeApi(sinon); // eslint-disable-line no-undef
+ }
+}());
+
+/**
+ * @depend fake_server.js
+ * @depend fake_timers.js
+ */
+/**
+ * Add-on for sinon.fakeServer that automatically handles a fake timer along with
+ * the FakeXMLHttpRequest. The direct inspiration for this add-on is jQuery
+ * 1.3.x, which does not use xhr object's onreadystatehandler at all - instead,
+ * it polls the object for completion with setInterval. Dispite the direct
+ * motivation, there is nothing jQuery-specific in this file, so it can be used
+ * in any environment where the ajax implementation depends on setInterval or
+ * setTimeout.
+ *
+ * @author Christian Johansen (christian@cjohansen.no)
+ * @license BSD
+ *
+ * Copyright (c) 2010-2013 Christian Johansen
+ */
+(function () {
+
+ function makeApi(sinon) {
+ function Server() {}
+ Server.prototype = sinon.fakeServer;
+
+ sinon.fakeServerWithClock = new Server();
+
+ sinon.fakeServerWithClock.addRequest = function addRequest(xhr) {
+ if (xhr.async) {
+ if (typeof setTimeout.clock === "object") {
+ this.clock = setTimeout.clock;
+ } else {
+ this.clock = sinon.useFakeTimers();
+ this.resetClock = true;
+ }
+
+ if (!this.longestTimeout) {
+ var clockSetTimeout = this.clock.setTimeout;
+ var clockSetInterval = this.clock.setInterval;
+ var server = this;
+
+ this.clock.setTimeout = function (fn, timeout) {
+ server.longestTimeout = Math.max(timeout, server.longestTimeout || 0);
+
+ return clockSetTimeout.apply(this, arguments);
+ };
+
+ this.clock.setInterval = function (fn, timeout) {
+ server.longestTimeout = Math.max(timeout, server.longestTimeout || 0);
+
+ return clockSetInterval.apply(this, arguments);
+ };
+ }
+ }
+
+ return sinon.fakeServer.addRequest.call(this, xhr);
+ };
+
+ sinon.fakeServerWithClock.respond = function respond() {
+ var returnVal = sinon.fakeServer.respond.apply(this, arguments);
+
+ if (this.clock) {
+ this.clock.tick(this.longestTimeout || 0);
+ this.longestTimeout = 0;
+
+ if (this.resetClock) {
+ this.clock.restore();
+ this.resetClock = false;
+ }
+ }
+
+ return returnVal;
+ };
+
+ sinon.fakeServerWithClock.restore = function restore() {
+ if (this.clock) {
+ this.clock.restore();
+ }
+
+ return sinon.fakeServer.restore.apply(this, arguments);
+ };
+ }
+
+ var isNode = typeof module !== "undefined" && module.exports && typeof require === "function";
+ var isAMD = typeof define === "function" && typeof define.amd === "object" && define.amd;
+
+ function loadDependencies(require) {
+ var sinon = require("./core");
+ require("./fake_server");
+ require("./fake_timers");
+ makeApi(sinon);
+ }
+
+ if (isAMD) {
+ define(loadDependencies);
+ } else if (isNode) {
+ loadDependencies(require);
+ } else {
+ makeApi(sinon); // eslint-disable-line no-undef
+ }
+}());
+
+/**
+ * @depend util/core.js
+ * @depend extend.js
+ * @depend collection.js
+ * @depend util/fake_timers.js
+ * @depend util/fake_server_with_clock.js
+ */
+/**
+ * Manages fake collections as well as fake utilities such as Sinon's
+ * timers and fake XHR implementation in one convenient object.
+ *
+ * @author Christian Johansen (christian@cjohansen.no)
+ * @license BSD
+ *
+ * Copyright (c) 2010-2013 Christian Johansen
+ */
+(function (sinonGlobal) {
+
+ function makeApi(sinon) {
+ var push = [].push;
+
+ function exposeValue(sandbox, config, key, value) {
+ if (!value) {
+ return;
+ }
+
+ if (config.injectInto && !(key in config.injectInto)) {
+ config.injectInto[key] = value;
+ sandbox.injectedKeys.push(key);
+ } else {
+ push.call(sandbox.args, value);
+ }
+ }
+
+ function prepareSandboxFromConfig(config) {
+ var sandbox = sinon.create(sinon.sandbox);
+
+ if (config.useFakeServer) {
+ if (typeof config.useFakeServer === "object") {
+ sandbox.serverPrototype = config.useFakeServer;
+ }
+
+ sandbox.useFakeServer();
+ }
+
+ if (config.useFakeTimers) {
+ if (typeof config.useFakeTimers === "object") {
+ sandbox.useFakeTimers.apply(sandbox, config.useFakeTimers);
+ } else {
+ sandbox.useFakeTimers();
+ }
+ }
+
+ return sandbox;
+ }
+
+ sinon.sandbox = sinon.extend(sinon.create(sinon.collection), {
+ useFakeTimers: function useFakeTimers() {
+ this.clock = sinon.useFakeTimers.apply(sinon, arguments);
+
+ return this.add(this.clock);
+ },
+
+ serverPrototype: sinon.fakeServer,
+
+ useFakeServer: function useFakeServer() {
+ var proto = this.serverPrototype || sinon.fakeServer;
+
+ if (!proto || !proto.create) {
+ return null;
+ }
+
+ this.server = proto.create();
+ return this.add(this.server);
+ },
+
+ inject: function (obj) {
+ sinon.collection.inject.call(this, obj);
+
+ if (this.clock) {
+ obj.clock = this.clock;
+ }
+
+ if (this.server) {
+ obj.server = this.server;
+ obj.requests = this.server.requests;
+ }
+
+ obj.match = sinon.match;
+
+ return obj;
+ },
+
+ restore: function () {
+ if (arguments.length) {
+ throw new Error("sandbox.restore() does not take any parameters. Perhaps you meant stub.restore()");
+ }
+
+ sinon.collection.restore.apply(this, arguments);
+ this.restoreContext();
+ },
+
+ restoreContext: function () {
+ if (this.injectedKeys) {
+ for (var i = 0, j = this.injectedKeys.length; i < j; i++) {
+ delete this.injectInto[this.injectedKeys[i]];
+ }
+ this.injectedKeys = [];
+ }
+ },
+
+ create: function (config) {
+ if (!config) {
+ return sinon.create(sinon.sandbox);
+ }
+
+ var sandbox = prepareSandboxFromConfig(config);
+ sandbox.args = sandbox.args || [];
+ sandbox.injectedKeys = [];
+ sandbox.injectInto = config.injectInto;
+ var prop,
+ value;
+ var exposed = sandbox.inject({});
+
+ if (config.properties) {
+ for (var i = 0, l = config.properties.length; i < l; i++) {
+ prop = config.properties[i];
+ value = exposed[prop] || prop === "sandbox" && sandbox;
+ exposeValue(sandbox, config, prop, value);
+ }
+ } else {
+ exposeValue(sandbox, config, "sandbox", value);
+ }
+
+ return sandbox;
+ },
+
+ match: sinon.match
+ });
+
+ sinon.sandbox.useFakeXMLHttpRequest = sinon.sandbox.useFakeServer;
+
+ return sinon.sandbox;
+ }
+
+ var isNode = typeof module !== "undefined" && module.exports && typeof require === "function";
+ var isAMD = typeof define === "function" && typeof define.amd === "object" && define.amd;
+
+ function loadDependencies(require, exports, module) {
+ var sinon = require("./util/core");
+ require("./extend");
+ require("./util/fake_server_with_clock");
+ require("./util/fake_timers");
+ require("./collection");
+ module.exports = makeApi(sinon);
+ }
+
+ if (isAMD) {
+ define(loadDependencies);
+ return;
+ }
+
+ if (isNode) {
+ loadDependencies(require, module.exports, module);
+ return;
+ }
+
+ if (sinonGlobal) {
+ makeApi(sinonGlobal);
+ }
+}(
+ typeof sinon === "object" && sinon // eslint-disable-line no-undef
+));
+
+/**
+ * @depend util/core.js
+ * @depend sandbox.js
+ */
+/**
+ * Test function, sandboxes fakes
+ *
+ * @author Christian Johansen (christian@cjohansen.no)
+ * @license BSD
+ *
+ * Copyright (c) 2010-2013 Christian Johansen
+ */
+(function (sinonGlobal) {
+
+ function makeApi(sinon) {
+ var slice = Array.prototype.slice;
+
+ function test(callback) {
+ var type = typeof callback;
+
+ if (type !== "function") {
+ throw new TypeError("sinon.test needs to wrap a test function, got " + type);
+ }
+
+ function sinonSandboxedTest() {
+ var config = sinon.getConfig(sinon.config);
+ config.injectInto = config.injectIntoThis && this || config.injectInto;
+ var sandbox = sinon.sandbox.create(config);
+ var args = slice.call(arguments);
+ var oldDone = args.length && args[args.length - 1];
+ var exception, result;
+
+ if (typeof oldDone === "function") {
+ args[args.length - 1] = function sinonDone(res) {
+ if (res) {
+ sandbox.restore();
+ } else {
+ sandbox.verifyAndRestore();
+ }
+ oldDone(res);
+ };
+ }
+
+ try {
+ result = callback.apply(this, args.concat(sandbox.args));
+ } catch (e) {
+ exception = e;
+ }
+
+ if (typeof exception !== "undefined") {
+ sandbox.restore();
+ throw exception;
+ } else if (typeof oldDone !== "function") {
+ sandbox.verifyAndRestore();
+ }
+
+ return result;
+ }
+
+ if (callback.length) {
+ return function sinonAsyncSandboxedTest(done) { // eslint-disable-line no-unused-vars
+ return sinonSandboxedTest.apply(this, arguments);
+ };
+ }
+
+ return sinonSandboxedTest;
+ }
+
+ test.config = {
+ injectIntoThis: true,
+ injectInto: null,
+ properties: ["spy", "stub", "mock", "clock", "server", "requests"],
+ useFakeTimers: true,
+ useFakeServer: true
+ };
+
+ sinon.test = test;
+ return test;
+ }
+
+ var isNode = typeof module !== "undefined" && module.exports && typeof require === "function";
+ var isAMD = typeof define === "function" && typeof define.amd === "object" && define.amd;
+
+ function loadDependencies(require, exports, module) {
+ var core = require("./util/core");
+ require("./sandbox");
+ module.exports = makeApi(core);
+ }
+
+ if (isAMD) {
+ define(loadDependencies);
+ } else if (isNode) {
+ loadDependencies(require, module.exports, module);
+ } else if (sinonGlobal) {
+ makeApi(sinonGlobal);
+ }
+}(typeof sinon === "object" && sinon || null)); // eslint-disable-line no-undef
+
+/**
+ * @depend util/core.js
+ * @depend test.js
+ */
+/**
+ * Test case, sandboxes all test functions
+ *
+ * @author Christian Johansen (christian@cjohansen.no)
+ * @license BSD
+ *
+ * Copyright (c) 2010-2013 Christian Johansen
+ */
+(function (sinonGlobal) {
+
+ function createTest(property, setUp, tearDown) {
+ return function () {
+ if (setUp) {
+ setUp.apply(this, arguments);
+ }
+
+ var exception, result;
+
+ try {
+ result = property.apply(this, arguments);
+ } catch (e) {
+ exception = e;
+ }
+
+ if (tearDown) {
+ tearDown.apply(this, arguments);
+ }
+
+ if (exception) {
+ throw exception;
+ }
+
+ return result;
+ };
+ }
+
+ function makeApi(sinon) {
+ function testCase(tests, prefix) {
+ if (!tests || typeof tests !== "object") {
+ throw new TypeError("sinon.testCase needs an object with test functions");
+ }
+
+ prefix = prefix || "test";
+ var rPrefix = new RegExp("^" + prefix);
+ var methods = {};
+ var setUp = tests.setUp;
+ var tearDown = tests.tearDown;
+ var testName,
+ property,
+ method;
+
+ for (testName in tests) {
+ if (tests.hasOwnProperty(testName) && !/^(setUp|tearDown)$/.test(testName)) {
+ property = tests[testName];
+
+ if (typeof property === "function" && rPrefix.test(testName)) {
+ method = property;
+
+ if (setUp || tearDown) {
+ method = createTest(property, setUp, tearDown);
+ }
+
+ methods[testName] = sinon.test(method);
+ } else {
+ methods[testName] = tests[testName];
+ }
+ }
+ }
+
+ return methods;
+ }
+
+ sinon.testCase = testCase;
+ return testCase;
+ }
+
+ var isNode = typeof module !== "undefined" && module.exports && typeof require === "function";
+ var isAMD = typeof define === "function" && typeof define.amd === "object" && define.amd;
+
+ function loadDependencies(require, exports, module) {
+ var core = require("./util/core");
+ require("./test");
+ module.exports = makeApi(core);
+ }
+
+ if (isAMD) {
+ define(loadDependencies);
+ return;
+ }
+
+ if (isNode) {
+ loadDependencies(require, module.exports, module);
+ return;
+ }
+
+ if (sinonGlobal) {
+ makeApi(sinonGlobal);
+ }
+}(
+ typeof sinon === "object" && sinon // eslint-disable-line no-undef
+));
+
+/**
+ * @depend times_in_words.js
+ * @depend util/core.js
+ * @depend match.js
+ * @depend format.js
+ */
+/**
+ * Assertions matching the test spy retrieval interface.
+ *
+ * @author Christian Johansen (christian@cjohansen.no)
+ * @license BSD
+ *
+ * Copyright (c) 2010-2013 Christian Johansen
+ */
+(function (sinonGlobal, global) {
+
+ var slice = Array.prototype.slice;
+
+ function makeApi(sinon) {
+ var assert;
+
+ function verifyIsStub() {
+ var method;
+
+ for (var i = 0, l = arguments.length; i < l; ++i) {
+ method = arguments[i];
+
+ if (!method) {
+ assert.fail("fake is not a spy");
+ }
+
+ if (method.proxy && method.proxy.isSinonProxy) {
+ verifyIsStub(method.proxy);
+ } else {
+ if (typeof method !== "function") {
+ assert.fail(method + " is not a function");
+ }
+
+ if (typeof method.getCall !== "function") {
+ assert.fail(method + " is not stubbed");
+ }
+ }
+
+ }
+ }
+
+ function verifyIsValidAssertion(assertionMethod, assertionArgs) {
+ switch (assertionMethod) {
+ case "notCalled":
+ case "called":
+ case "calledOnce":
+ case "calledTwice":
+ case "calledThrice":
+ if (assertionArgs.length !== 0) {
+ assert.fail(assertionMethod +
+ " takes 1 argument but was called with " +
+ (assertionArgs.length + 1) + " arguments");
+ }
+ break;
+ default:
+ break;
+ }
+ }
+
+ function failAssertion(object, msg) {
+ object = object || global;
+ var failMethod = object.fail || assert.fail;
+ failMethod.call(object, msg);
+ }
+
+ function mirrorPropAsAssertion(name, method, message) {
+ if (arguments.length === 2) {
+ message = method;
+ method = name;
+ }
+
+ assert[name] = function (fake) {
+ verifyIsStub(fake);
+
+ var args = slice.call(arguments, 1);
+ verifyIsValidAssertion(name, args);
+
+ var failed = false;
+
+ if (typeof method === "function") {
+ failed = !method(fake);
+ } else {
+ failed = typeof fake[method] === "function" ?
+ !fake[method].apply(fake, args) : !fake[method];
+ }
+
+ if (failed) {
+ failAssertion(this, (fake.printf || fake.proxy.printf).apply(fake, [message].concat(args)));
+ } else {
+ assert.pass(name);
+ }
+ };
+ }
+
+ function exposedName(prefix, prop) {
+ return !prefix || /^fail/.test(prop) ? prop :
+ prefix + prop.slice(0, 1).toUpperCase() + prop.slice(1);
+ }
+
+ assert = {
+ failException: "AssertError",
+
+ fail: function fail(message) {
+ var error = new Error(message);
+ error.name = this.failException || assert.failException;
+
+ throw error;
+ },
+
+ pass: function pass() {},
+
+ callOrder: function assertCallOrder() {
+ verifyIsStub.apply(null, arguments);
+ var expected = "";
+ var actual = "";
+
+ if (!sinon.calledInOrder(arguments)) {
+ try {
+ expected = [].join.call(arguments, ", ");
+ var calls = slice.call(arguments);
+ var i = calls.length;
+ while (i) {
+ if (!calls[--i].called) {
+ calls.splice(i, 1);
+ }
+ }
+ actual = sinon.orderByFirstCall(calls).join(", ");
+ } catch (e) {
+ // If this fails, we'll just fall back to the blank string
+ }
+
+ failAssertion(this, "expected " + expected + " to be " +
+ "called in order but were called as " + actual);
+ } else {
+ assert.pass("callOrder");
+ }
+ },
+
+ callCount: function assertCallCount(method, count) {
+ verifyIsStub(method);
+
+ if (method.callCount !== count) {
+ var msg = "expected %n to be called " + sinon.timesInWords(count) +
+ " but was called %c%C";
+ failAssertion(this, method.printf(msg));
+ } else {
+ assert.pass("callCount");
+ }
+ },
+
+ expose: function expose(target, options) {
+ if (!target) {
+ throw new TypeError("target is null or undefined");
+ }
+
+ var o = options || {};
+ var prefix = typeof o.prefix === "undefined" && "assert" || o.prefix;
+ var includeFail = typeof o.includeFail === "undefined" || !!o.includeFail;
+
+ for (var method in this) {
+ if (method !== "expose" && (includeFail || !/^(fail)/.test(method))) {
+ target[exposedName(prefix, method)] = this[method];
+ }
+ }
+
+ return target;
+ },
+
+ match: function match(actual, expectation) {
+ var matcher = sinon.match(expectation);
+ if (matcher.test(actual)) {
+ assert.pass("match");
+ } else {
+ var formatted = [
+ "expected value to match",
+ " expected = " + sinon.format(expectation),
+ " actual = " + sinon.format(actual)
+ ];
+
+ failAssertion(this, formatted.join("\n"));
+ }
+ }
+ };
+
+ mirrorPropAsAssertion("called", "expected %n to have been called at least once but was never called");
+ mirrorPropAsAssertion("notCalled", function (spy) {
+ return !spy.called;
+ }, "expected %n to not have been called but was called %c%C");
+ mirrorPropAsAssertion("calledOnce", "expected %n to be called once but was called %c%C");
+ mirrorPropAsAssertion("calledTwice", "expected %n to be called twice but was called %c%C");
+ mirrorPropAsAssertion("calledThrice", "expected %n to be called thrice but was called %c%C");
+ mirrorPropAsAssertion("calledOn", "expected %n to be called with %1 as this but was called with %t");
+ mirrorPropAsAssertion(
+ "alwaysCalledOn",
+ "expected %n to always be called with %1 as this but was called with %t"
+ );
+ mirrorPropAsAssertion("calledWithNew", "expected %n to be called with new");
+ mirrorPropAsAssertion("alwaysCalledWithNew", "expected %n to always be called with new");
+ mirrorPropAsAssertion("calledWith", "expected %n to be called with arguments %*%C");
+ mirrorPropAsAssertion("calledWithMatch", "expected %n to be called with match %*%C");
+ mirrorPropAsAssertion("alwaysCalledWith", "expected %n to always be called with arguments %*%C");
+ mirrorPropAsAssertion("alwaysCalledWithMatch", "expected %n to always be called with match %*%C");
+ mirrorPropAsAssertion("calledWithExactly", "expected %n to be called with exact arguments %*%C");
+ mirrorPropAsAssertion("alwaysCalledWithExactly", "expected %n to always be called with exact arguments %*%C");
+ mirrorPropAsAssertion("neverCalledWith", "expected %n to never be called with arguments %*%C");
+ mirrorPropAsAssertion("neverCalledWithMatch", "expected %n to never be called with match %*%C");
+ mirrorPropAsAssertion("threw", "%n did not throw exception%C");
+ mirrorPropAsAssertion("alwaysThrew", "%n did not always throw exception%C");
+
+ sinon.assert = assert;
+ return assert;
+ }
+
+ var isNode = typeof module !== "undefined" && module.exports && typeof require === "function";
+ var isAMD = typeof define === "function" && typeof define.amd === "object" && define.amd;
+
+ function loadDependencies(require, exports, module) {
+ var sinon = require("./util/core");
+ require("./match");
+ require("./format");
+ module.exports = makeApi(sinon);
+ }
+
+ if (isAMD) {
+ define(loadDependencies);
+ return;
+ }
+
+ if (isNode) {
+ loadDependencies(require, module.exports, module);
+ return;
+ }
+
+ if (sinonGlobal) {
+ makeApi(sinonGlobal);
+ }
+}(
+ typeof sinon === "object" && sinon, // eslint-disable-line no-undef
+ typeof global !== "undefined" ? global : self
+));
+
+ return sinon;
+}));
* - `pluralRules`
* - `digitGroupingPattern`
* - `fallbackLanguages`
+ * - `bcp47Map`
+ * - `languageNames`
*
* @property
*/
},
/**
- * Formats language tags according the BCP47 standard.
+ * Formats language tags according the BCP 47 standard.
* See LanguageCode::bcp47 for the PHP implementation.
*
* @param {string} languageTag Well-formed language tag
* @return {string}
*/
bcp47: function ( languageTag ) {
- var formatted,
+ var bcp47Map,
+ formatted,
+ segments,
isFirstSegment = true,
- isPrivate = false,
- segments = languageTag.split( '-' );
+ isPrivate = false;
+ languageTag = languageTag.toLowerCase();
+
+ bcp47Map = mw.language.getData( mw.config.get( 'wgUserLanguage' ), 'bcp47Map' );
+ if ( bcp47Map && Object.prototype.hasOwnProperty.call( bcp47Map, languageTag ) ) {
+ languageTag = bcp47Map[ languageTag ];
+ }
+
+ segments = languageTag.split( '-' );
formatted = segments.map( function ( segment ) {
var newSegment;
animation-delay: @arguments; // Chrome 43+, Firefox 16+, IE 10+, Edge 12+, Safari 9+, Opera 12.10 & 30+, iOS 9+, Android 47+
}
-// This is a general mixin for a color circle
-.mw-rcfilters-mixin-circle( @color: @color-base--inverted, @diameter: 2em, @padding: 0.5em, @border: false, @borderColor: @colorGray5, @emptyBackground: false ) {
+// Circle mixin
+.mw-rcfilters-circle( @min-size-diameter: @min-size-circle, @size-diameter: @size-circle, @margin: 0.5em ) {
.box-sizing( border-box );
- min-width: @diameter;
- width: @diameter;
- min-height: @diameter;
- height: @diameter;
- margin: @padding;
+ min-width: @min-size-diameter;
+ width: @size-diameter;
+ min-height: @min-size-diameter;
+ height: @size-diameter;
+ margin: @margin;
border-radius: 50%;
+}
+// Circle color mixin
+.mw-rcfilters-circle-color( @color: @color-base--inverted, @border: false, @borderColor: @colorGray5, @emptyBackground: false ) {
& when ( @emptyBackground = false ) {
background-color: @color;
}
+
& when ( @emptyBackground = true ) {
background-color: @highlight-none;
}
// a color class on its parent element
.result-circle( @colorName: 'none' ) {
&-@{colorName} {
- .mw-rcfilters-mixin-circle( ~'@{highlight-@{colorName}}', @result-circle-diameter, 0 );
display: none;
+ .mw-rcfilters-circle-color( ~'@{highlight-@{colorName}}' );
.mw-rcfilters-highlight-color-@{colorName} & {
display: inline-block;
// A mixin for changes list containers. Applies enough margin-left to fit the 5 highlight circles.
.result-circle-margin() {
- margin-left: ~'calc( ( @{result-circle-diameter} + @{result-circle-margin} ) * 5 + @{result-circle-general-margin} )';
+ margin-left: ~'calc( ( @{size-circle-result} + @{margin-circle-result} ) * 5 + @{margin-circle} )';
}
// A mixin just for changesListWrapperWidget page, to output the scope of the widget
list-style: none;
}
}
+
+ & > div {
+ margin-right: @margin-circle-result;
+ }
}
// Make more specific for the overrides
div.mw-rcfilters-ui-highlights {
body.mw-rcfilters-ui-initialized & {
display: inline-block;
+ .mw-rcfilters-circle( @size-circle-result, @size-circle-result, 0 );
}
&-color {
&-none {
display: inline-block;
+
.mw-changeslist-watchedseen & {
.mw-rcfilters-ui-changesListWrapperWidget.mw-rcfilters-ui-changesListWrapperWidget-highlighted & {
- .mw-rcfilters-mixin-circle( @highlight-none, @result-circle-diameter, 0, true, @highlight-grey, true );
+ .mw-rcfilters-circle-color( @highlight-none, true, @highlight-grey, true );
}
.mw-rcfilters-ui-changesListWrapperWidget:not( .mw-rcfilters-ui-changesListWrapperWidget-highlighted ) & {
- .mw-rcfilters-mixin-circle( @highlight-none, @result-circle-diameter, 0, true, @highlight-bluedot, true );
+ .mw-rcfilters-circle-color( @highlight-none, true, @highlight-bluedot, true );
}
}
.mw-changeslist-watchedunseen & {
.mw-rcfilters-ui-changesListWrapperWidget.mw-rcfilters-ui-changesListWrapperWidget-highlighted & {
- .mw-rcfilters-mixin-circle( @highlight-grey, @result-circle-diameter, 0, true, @highlight-grey );
+ .mw-rcfilters-circle-color( @highlight-grey, true, @highlight-grey );
}
.mw-rcfilters-ui-changesListWrapperWidget:not( .mw-rcfilters-ui-changesListWrapperWidget-highlighted ) & {
- .mw-rcfilters-mixin-circle( @highlight-bluedot, @result-circle-diameter, 0, true, @highlight-bluedot );
+ .mw-rcfilters-circle-color( @highlight-bluedot, true, @highlight-bluedot );
}
}
}
- // Watchlist unseen highlighted fixes
- // Seen (empty circle)
- // There's no need to correct 'unseen' because that would be
- // a filled colorful circle, which is the regular rendering
- .mw-changeslist-watchedseen &-c1 {
- .mw-rcfilters-mixin-circle( @highlight-c1, @result-circle-diameter, 0, true, @highlight-c1, true );
- }
+ }
- .mw-changeslist-watchedseen &-c2 {
- .mw-rcfilters-mixin-circle( @highlight-c2, @result-circle-diameter, 0, true, @highlight-c2, true );
- }
+ // Watchlist unseen highlighted fixes
+ // Seen (empty circle)
+ // There's no need to correct 'unseen' because that would be
+ // a filled colorful circle, which is the regular rendering
+ .mw-changeslist-watchedseen &-c1 {
+ .mw-rcfilters-circle-color( @highlight-c1, true, @highlight-c1, true );
+ }
- .mw-changeslist-watchedseen &-c3 {
- .mw-rcfilters-mixin-circle( @highlight-c3, @result-circle-diameter, 0, true, @highlight-c3, true );
- }
+ .mw-changeslist-watchedseen &-c2 {
+ .mw-rcfilters-circle-color( @highlight-c2, true, @highlight-c2, true );
+ }
- .mw-changeslist-watchedseen &-c4 {
- .mw-rcfilters-mixin-circle( @highlight-c4, @result-circle-diameter, 0, true, @highlight-c4, true );
- }
+ .mw-changeslist-watchedseen &-c3 {
+ .mw-rcfilters-circle-color( @highlight-c3, true, @highlight-c3, true );
+ }
- .mw-changeslist-watchedseen &-c5 {
- .mw-rcfilters-mixin-circle( @highlight-c5, @result-circle-diameter, 0, true, @highlight-c5, true );
- }
+ .mw-changeslist-watchedseen &-c4 {
+ .mw-rcfilters-circle-color( @highlight-c4, true, @highlight-c4, true );
}
- .mw-rcfilters-ui-changesListWrapperWidget & > div {
- margin-right: @result-circle-margin;
+ .mw-changeslist-watchedseen &-c5 {
+ .mw-rcfilters-circle-color( @highlight-c5, true, @highlight-c5, true );
}
}
width: 100%;
}
}
-}
-
-.mw-rcfilters-ui-highlights {
- display: none;
- padding: 0 @result-circle-general-margin 0 0;
- // The width is 5 circles times their diameter + individual margin
- // and then plus the general margin
- width: ~'calc( ( @{result-circle-diameter} + @{result-circle-margin} ) * 5 )';
- // And we want to shift the entire block to the left of the li
- position: relative;
- // Negative left margin of width + padding
- margin-left: ~'calc( ( @{result-circle-diameter} + @{result-circle-margin} ) * -5 - @{result-circle-general-margin} )';
- .mw-rcfilters-ui-changesListWrapperWidget-highlighted & {
- display: inline-block;
- }
-
- > div {
- .box-sizing( border-box );
- margin-right: @result-circle-margin;
- vertical-align: middle;
- // This is to make the dots appear at the center of the
- // text itself; it's a horrendous hack and blame JamesF for it.
- margin-top: -2px;
- float: right;
- }
-
- &-color {
- &-none {
- .mw-rcfilters-mixin-circle( @highlight-none, @result-circle-diameter, 0, true );
+ &-highlights {
+ display: none;
+ padding: 0 @margin-circle 0 0;
+ text-align: right;
+ // The width is 5 circles times their diameter + individual margin
+ // and then plus the general margin
+ width: ~'calc( ( @{size-circle-result} + @{margin-circle-result} ) * 5 )';
+ // And we want to shift the entire block to the left of the li
+ position: relative;
+ // Negative left margin of width + padding
+ margin-left: ~'calc( ( @{size-circle-result} + @{margin-circle-result} ) * -5 - @{margin-circle} )';
+
+ .mw-rcfilters-ui-changesListWrapperWidget-highlighted & {
display: inline-block;
+ }
+
+ // This needs to be very specific, since these are
+ // position rules that should apply to all overrides
+ .mw-rcfilters-ui-changesListWrapperWidget .mw-rcfilters-ui-changesListWrapperWidget-highlights > div&-circle {
+ vertical-align: middle;
+ .mw-rcfilters-circle( @size-circle-result, @size-circle-result, 0 );
+ // This is to make the dots appear at the center of the
+ // text itself; it's a horrendous hack and blame JamesF for it.
+ margin-top: -2px;
+ margin-right: @margin-circle-result;
+ }
- .mw-rcfilters-highlight-color-c1 &,
- .mw-rcfilters-highlight-color-c2 &,
- .mw-rcfilters-highlight-color-c3 &,
- .mw-rcfilters-highlight-color-c4 &,
- .mw-rcfilters-highlight-color-c5 & {
- display: none;
+ &-color {
+ &-none {
+ .mw-rcfilters-circle-color( @highlight-none, true );
+ display: inline-block;
+
+ .mw-rcfilters-highlight-color-c1 &,
+ .mw-rcfilters-highlight-color-c2 &,
+ .mw-rcfilters-highlight-color-c3 &,
+ .mw-rcfilters-highlight-color-c4 &,
+ .mw-rcfilters-highlight-color-c5 & {
+ display: none;
+ }
}
+ .result-circle( c1 );
+ .result-circle( c2 );
+ .result-circle( c3 );
+ .result-circle( c4 );
+ .result-circle( c5 );
}
- .result-circle( c1 );
- .result-circle( c2 );
- .result-circle( c3 );
- .result-circle( c4 );
- .result-circle( c5 );
}
}
.highlight-color-mix( c1, c3, c4, c5 );
.highlight-color-mix( c2, c3, c4, c5 );
-// Five colors:
+// Five colors
.mw-rcfilters-highlight-color-c1.mw-rcfilters-highlight-color-c2.mw-rcfilters-highlight-color-c3.mw-rcfilters-highlight-color-c4.mw-rcfilters-highlight-color-c5 {
.highlight-results( tint( mix( @highlight-c1, mix( @highlight-c2, mix( @highlight-c3, average( @highlight-c4, @highlight-c5 ), 20% ), 20% ), 20% ), 15% ) );
}
@import 'mw.rcfilters.mixins';
+@import 'mw.rcfilters.variables';
.mw-rcfilters-ui-filterItemHighlightButton {
.oo-ui-buttonWidget.oo-ui-popupButtonWidget .oo-ui-buttonElement-button > &-circle {
+ background-image: none;
display: inline-block;
vertical-align: middle;
- background-image: none;
- margin-right: 0.2em;
+ // Override OOUI rule on frameless icons
+ opacity: 1;
+ .mw-rcfilters-circle( @min-size-circle, @size-circle, @margin-circle 0 );
&-color {
&-c1 {
- // These values duplicate the sizing of the icon
- // width/height 1.875em
- .mw-rcfilters-mixin-circle( @highlight-c1, 1.875em, 0.2em 0 );
+ .mw-rcfilters-circle-color( @highlight-c1 );
}
&-c2 {
- .mw-rcfilters-mixin-circle( @highlight-c2, 1.875em, 0.2em 0 );
+ .mw-rcfilters-circle-color( @highlight-c2 );
}
&-c3 {
- .mw-rcfilters-mixin-circle( @highlight-c3, 1.875em, 0.2em 0 );
+ .mw-rcfilters-circle-color( @highlight-c3 );
}
&-c4 {
- .mw-rcfilters-mixin-circle( @highlight-c4, 1.875em, 0.2em 0 );
+ .mw-rcfilters-circle-color( @highlight-c4 );
}
&-c5 {
- .mw-rcfilters-mixin-circle( @highlight-c5, 1.875em, 0.2em 0 );
+ .mw-rcfilters-circle-color( @highlight-c5 );
}
}
}
@import 'mediawiki.ui/variables';
@import 'mw.rcfilters.mixins';
+@import 'mw.rcfilters.variables';
.mw-rcfilters-ui-highlightColorPickerWidget {
&-label {
display: block;
font-weight: bold;
- font-size: 1.2em;
+ font-size: 1.1425em;
}
&-buttonSelect {
&-color {
- // Override OOUI definition from padded popup; the definition
- // forces the first-child to be margin-top:0; which overrides
- // our definitions below where margin is 0.5em.
- // We set up the margin-top as 0.5em for all circles so we get
+ .mw-rcfilters-circle( @min-size-circle-colorpicker, @size-circle-colorpicker, @margin-circle );
+
+ // Override OOUI rule from padded popup;
+ // We set margin-top as ≈0.357em≈5px for all circles so we get
// a consistent result
- &.oo-ui-widget-enabled.oo-ui-optionWidget.oo-ui-buttonElement.oo-ui-buttonElement-frameless.oo-ui-buttonOptionWidget {
- margin-top: 0.5em;
+ &.oo-ui-buttonElement {
+ margin-top: @margin-circle;
+
+ // Override OOUI rule on frameless :first-child buttons
+ &:first-child {
+ margin-left: 0;
+ }
}
// Make the rule much more specific to override OOUI
- .oo-ui-iconElement-icon.oo-ui-icon-check {
- // Override OOUI icon dimensions
- // The parent is 2em with 0.5em margin
- // (see mw-rcfilters-mixin-circle below)
- // so here we want 2em - 0.5em = 1.5em
- width: 1.5em;
- height: 1.5em;
- // By eye, this is centered horizontally for the color circle
- margin-left: -0.1em;
+ &.oo-ui-buttonElement-frameless > .oo-ui-buttonElement-button > .oo-ui-iconElement-icon.oo-ui-icon-check {
+ // Align centered horizontally within the color circle
+ top: -2px;
+ left: 4 / @font-size-system-ui / @font-size-vector;
+ // Override OOUI rule on frameless icons
+ opacity: 1;
}
&-none {
- .oo-ui-iconElement-icon.oo-ui-icon-check {
- // By eye, this is centered horizontally for the white circle
- margin-left: -0.2em;
- }
+ .mw-rcfilters-circle-color( @highlight-none, true );
+ // Override `border-style` to `dashed`
+ border-style: dashed;
- .mw-rcfilters-mixin-circle( @highlight-none, 2em, 0.5em, true );
- // Override border to dashed
- border: 1px dashed @colorGray5;
+ &.oo-ui-buttonElement-frameless > .oo-ui-buttonElement-button > .oo-ui-iconElement-icon.oo-ui-icon-check {
+ // Align centered horizontally in the dashed white circle with 1px border-width
+ left: 3 / @font-size-system-ui / @font-size-vector;
+ }
&.oo-ui-buttonOptionWidget.oo-ui-buttonElement-active,
&.oo-ui-buttonOptionWidget.oo-ui-optionWidget-pressed,
}
}
&-c1 {
- .mw-rcfilters-mixin-circle( @highlight-c1 );
+ .mw-rcfilters-circle-color( @highlight-c1, false );
+ border-color: @highlight-c1;
&.oo-ui-buttonOptionWidget.oo-ui-buttonElement-active,
&.oo-ui-buttonOptionWidget.oo-ui-optionWidget-pressed,
}
}
&-c2 {
- .mw-rcfilters-mixin-circle( @highlight-c2 );
+ .mw-rcfilters-circle-color( @highlight-c2, true );
+ border-color: @highlight-c2;
&.oo-ui-buttonOptionWidget.oo-ui-buttonElement-active,
&.oo-ui-buttonOptionWidget.oo-ui-optionWidget-pressed,
}
}
&-c3 {
- .mw-rcfilters-mixin-circle( @highlight-c3 );
+ .mw-rcfilters-circle-color( @highlight-c3, true );
+ border-color: @highlight-c3;
&.oo-ui-buttonOptionWidget.oo-ui-buttonElement-active,
&.oo-ui-buttonOptionWidget.oo-ui-optionWidget-pressed,
}
}
&-c4 {
- .mw-rcfilters-mixin-circle( @highlight-c4 );
+ .mw-rcfilters-circle-color( @highlight-c4, true );
+ border-color: @highlight-c4;
&.oo-ui-buttonOptionWidget.oo-ui-buttonElement-active,
&.oo-ui-buttonOptionWidget.oo-ui-optionWidget-pressed,
}
}
&-c5 {
- .mw-rcfilters-mixin-circle( @highlight-c5 );
+ .mw-rcfilters-circle-color( @highlight-c5, true );
+ border-color: @highlight-c5;
&.oo-ui-buttonOptionWidget.oo-ui-buttonElement-active,
&.oo-ui-buttonOptionWidget.oo-ui-optionWidget-pressed,
@import 'mw.rcfilters.mixins';
@import 'mw.rcfilters.variables';
+@size-circle: 20 / @font-size-system-ui / @font-size-vector;
+@margin-circle: 5 / @font-size-system-ui / @font-size-vector;
+
.mw-rcfilters-ui-tagItemWidget {
// Background and color of the capsule widget need a bit
// more specificity to override OOUI internals
&-highlight {
display: none;
- margin-right: 0.5em;
width: 10px;
+ margin-right: @margin-circle;
&-highlighted {
display: inline-block;
&:before {
content: '';
- position: absolute;
display: block;
+ position: absolute;
top: 50%;
+ .mw-rcfilters-circle( 10px, 10px, ~'-5px 0.5em 0 0' );
}
&[ data-color='c1' ]:before {
- .mw-rcfilters-mixin-circle( @highlight-c1, 10px, ~'-5px 0.5em 0 0' );
+ .mw-rcfilters-circle-color( @highlight-c1 );
}
&[ data-color='c2' ]:before {
- .mw-rcfilters-mixin-circle( @highlight-c2, 10px, ~'-5px 0.5em 0 0' );
+ .mw-rcfilters-circle-color( @highlight-c2 );
}
&[ data-color='c3' ]:before {
- .mw-rcfilters-mixin-circle( @highlight-c3, 10px, ~'-5px 0.5em 0 0' );
+ .mw-rcfilters-circle-color( @highlight-c3 );
}
&[ data-color='c4' ]:before {
- .mw-rcfilters-mixin-circle( @highlight-c4, 10px, ~'-5px 0.5em 0 0' );
+ .mw-rcfilters-circle-color( @highlight-c4 );
}
&[ data-color='c5' ]:before {
- .mw-rcfilters-mixin-circle( @highlight-c5, 10px, ~'-5px 0.5em 0 0' );
+ .mw-rcfilters-circle-color( @highlight-c5 );
}
}
}
// Muted state
@muted-opacity: 0.5;
+// Circles
+@min-size-circle: 20px;
+@size-circle: 20 / @font-size-system-ui / @font-size-vector;
+@margin-circle: 5 / @font-size-system-ui / @font-size-vector;
+
// Result list circle indicators
// Defined and used in mw.rcfilters.ui.ChangesListWrapperWidget.less
-@result-circle-margin: 3px;
-@result-circle-general-margin: 0.5em;
+@margin-circle-result: 3px;
// In these small sizes, 'em' appears
// squished and inconsistent.
// Pixels are better for this use case:
-@result-circle-diameter: 6px;
+@size-circle-result: 6px;
+
+// Color picker circles
+@min-size-circle-colorpicker: 30px;
+@size-circle-colorpicker: 30 / @font-size-system-ui / @font-size-vector;
'SpecialPageTestBase' => "$testDir/phpunit/includes/specials/SpecialPageTestBase.php",
'SpecialPageExecutor' => "$testDir/phpunit/includes/specials/SpecialPageExecutor.php",
- # tests/phpunit/includes/Storage
- 'MediaWiki\Tests\Storage\McrSchemaDetection' => "$testDir/phpunit/includes/Storage/McrSchemaDetection.php",
- 'MediaWiki\Tests\Storage\McrSchemaOverride' => "$testDir/phpunit/includes/Storage/McrSchemaOverride.php",
- 'MediaWiki\Tests\Storage\McrWriteBothSchemaOverride' => "$testDir/phpunit/includes/Storage/McrWriteBothSchemaOverride.php",
- 'MediaWiki\Tests\Storage\McrReadNewSchemaOverride' => "$testDir/phpunit/includes/Storage/McrReadNewSchemaOverride.php",
- 'MediaWiki\Tests\Storage\RevisionSlotsTest' => "$testDir/phpunit/includes/Storage/RevisionSlotsTest.php",
- 'MediaWiki\Tests\Storage\RevisionRecordTests' => "$testDir/phpunit/includes/Storage/RevisionRecordTests.php",
- 'MediaWiki\Tests\Storage\RevisionStoreDbTestBase' => "$testDir/phpunit/includes/Storage/RevisionStoreDbTestBase.php",
- 'MediaWiki\Tests\Storage\PreMcrSchemaOverride' => "$testDir/phpunit/includes/Storage/PreMcrSchemaOverride.php",
+ # tests/phpunit/includes/Revision
+ 'MediaWiki\Tests\Revision\McrSchemaDetection' => "$testDir/phpunit/includes/Revision/McrSchemaDetection.php",
+ 'MediaWiki\Tests\Revision\McrSchemaOverride' => "$testDir/phpunit/includes/Revision/McrSchemaOverride.php",
+ 'MediaWiki\Tests\Revision\McrWriteBothSchemaOverride' => "$testDir/phpunit/includes/Revision/McrWriteBothSchemaOverride.php",
+ 'MediaWiki\Tests\Revision\McrReadNewSchemaOverride' => "$testDir/phpunit/includes/Revision/McrReadNewSchemaOverride.php",
+ 'MediaWiki\Tests\Revision\RevisionSlotsTest' => "$testDir/phpunit/includes/Revision/RevisionSlotsTest.php",
+ 'MediaWiki\Tests\Revision\RevisionRecordTests' => "$testDir/phpunit/includes/Revision/RevisionRecordTests.php",
+ 'MediaWiki\Tests\Revision\RevisionStoreDbTestBase' => "$testDir/phpunit/includes/Revision/RevisionStoreDbTestBase.php",
+ 'MediaWiki\Tests\Revision\PreMcrSchemaOverride' => "$testDir/phpunit/includes/Revision/PreMcrSchemaOverride.php",
# tests/phpunit/languages
'LanguageClassesTestCase' => "$testDir/phpunit/languages/LanguageClassesTestCase.php",
$tables[] = 'image_comment_temp';
}
- if ( $wgActorTableSchemaMigrationStage >= MIGRATION_WRITE_BOTH ) {
+ if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_WRITE_NEW ) {
// The new tables for actors are in use
$tables[] = 'actor';
$tables[] = 'revision_actor_temp';
*/
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[]
foreach ( $this->mwGlobalsToUnset as $value ) {
unset( $GLOBALS[$value] );
}
+ foreach ( $this->iniSettings as $name => $value ) {
+ ini_set( $name, $value );
+ }
if (
array_key_exists( 'wgExtraNamespaces', $this->mwGlobals ) ||
in_array( 'wgExtraNamespaces', $this->mwGlobalsToUnset )
}
}
+ /**
+ * 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.
return new ActorMigration( $stage );
}
+ /**
+ * @dataProvider provideConstructor
+ * @param int $stage
+ * @param string|null $exceptionMsg
+ */
+ public function testConstructor( $stage, $exceptionMsg ) {
+ try {
+ $m = new ActorMigration( $stage );
+ if ( $exceptionMsg !== null ) {
+ $this->fail( 'Expected exception not thrown' );
+ }
+ $this->assertInstanceOf( ActorMigration::class, $m );
+ } catch ( InvalidArgumentException $ex ) {
+ $this->assertSame( $exceptionMsg, $ex->getMessage() );
+ }
+ }
+
+ public static function provideConstructor() {
+ return [
+ [ 0, '$stage must include a write mode' ],
+ [ SCHEMA_COMPAT_READ_OLD, '$stage must include a write mode' ],
+ [ SCHEMA_COMPAT_READ_NEW, '$stage must include a write mode' ],
+ [ SCHEMA_COMPAT_READ_BOTH, '$stage must include a write mode' ],
+
+ [ SCHEMA_COMPAT_WRITE_OLD, '$stage must include a read mode' ],
+ [ SCHEMA_COMPAT_WRITE_OLD | SCHEMA_COMPAT_READ_OLD, null ],
+ [
+ SCHEMA_COMPAT_WRITE_OLD | SCHEMA_COMPAT_READ_NEW,
+ 'Cannot read the new schema without also writing it'
+ ],
+ [ SCHEMA_COMPAT_WRITE_OLD | SCHEMA_COMPAT_READ_BOTH, 'Cannot read both schemas' ],
+
+ [ SCHEMA_COMPAT_WRITE_NEW, '$stage must include a read mode' ],
+ [
+ SCHEMA_COMPAT_WRITE_NEW | SCHEMA_COMPAT_READ_OLD,
+ 'Cannot read the old schema without also writing it'
+ ],
+ [ SCHEMA_COMPAT_WRITE_NEW | SCHEMA_COMPAT_READ_NEW, null ],
+ [ SCHEMA_COMPAT_WRITE_NEW | SCHEMA_COMPAT_READ_BOTH, 'Cannot read both schemas' ],
+
+ [ SCHEMA_COMPAT_WRITE_BOTH, '$stage must include a read mode' ],
+ [ SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD, null ],
+ [ SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW, null ],
+ [ SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_BOTH, 'Cannot read both schemas' ],
+ ];
+ }
+
/**
* @dataProvider provideGetJoin
* @param int $stage
public static function provideGetJoin() {
return [
'Simple table, old' => [
- MIGRATION_OLD, 'rc_user', [
+ SCHEMA_COMPAT_OLD, 'rc_user', [
'tables' => [],
'fields' => [
'rc_user' => 'rc_user',
'joins' => [],
],
],
- 'Simple table, write-both' => [
- MIGRATION_WRITE_BOTH, 'rc_user', [
- 'tables' => [ 'actor_rc_user' => 'actor' ],
+ 'Simple table, read-old' => [
+ SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD, 'rc_user', [
+ 'tables' => [],
'fields' => [
- 'rc_user' => 'COALESCE( actor_rc_user.actor_user, rc_user )',
- 'rc_user_text' => 'COALESCE( actor_rc_user.actor_name, rc_user_text )',
- 'rc_actor' => 'rc_actor',
- ],
- 'joins' => [
- 'actor_rc_user' => [ 'LEFT JOIN', 'actor_rc_user.actor_id = rc_actor' ],
+ 'rc_user' => 'rc_user',
+ 'rc_user_text' => 'rc_user_text',
+ 'rc_actor' => 'NULL',
],
+ 'joins' => [],
],
],
- 'Simple table, write-new' => [
- MIGRATION_WRITE_NEW, 'rc_user', [
+ 'Simple table, read-new' => [
+ SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW, 'rc_user', [
'tables' => [ 'actor_rc_user' => 'actor' ],
'fields' => [
- 'rc_user' => 'COALESCE( actor_rc_user.actor_user, rc_user )',
- 'rc_user_text' => 'COALESCE( actor_rc_user.actor_name, rc_user_text )',
+ 'rc_user' => 'actor_rc_user.actor_user',
+ 'rc_user_text' => 'actor_rc_user.actor_name',
'rc_actor' => 'rc_actor',
],
'joins' => [
- 'actor_rc_user' => [ 'LEFT JOIN', 'actor_rc_user.actor_id = rc_actor' ],
+ 'actor_rc_user' => [ 'JOIN', 'actor_rc_user.actor_id = rc_actor' ],
],
],
],
'Simple table, new' => [
- MIGRATION_NEW, 'rc_user', [
+ SCHEMA_COMPAT_NEW, 'rc_user', [
'tables' => [ 'actor_rc_user' => 'actor' ],
'fields' => [
'rc_user' => 'actor_rc_user.actor_user',
],
'ipblocks, old' => [
- MIGRATION_OLD, 'ipb_by', [
+ SCHEMA_COMPAT_OLD, 'ipb_by', [
'tables' => [],
'fields' => [
'ipb_by' => 'ipb_by',
'joins' => [],
],
],
- 'ipblocks, write-both' => [
- MIGRATION_WRITE_BOTH, 'ipb_by', [
- 'tables' => [ 'actor_ipb_by' => 'actor' ],
+ 'ipblocks, read-old' => [
+ SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD, 'ipb_by', [
+ 'tables' => [],
'fields' => [
- 'ipb_by' => 'COALESCE( actor_ipb_by.actor_user, ipb_by )',
- 'ipb_by_text' => 'COALESCE( actor_ipb_by.actor_name, ipb_by_text )',
- 'ipb_by_actor' => 'ipb_by_actor',
- ],
- 'joins' => [
- 'actor_ipb_by' => [ 'LEFT JOIN', 'actor_ipb_by.actor_id = ipb_by_actor' ],
+ 'ipb_by' => 'ipb_by',
+ 'ipb_by_text' => 'ipb_by_text',
+ 'ipb_by_actor' => 'NULL',
],
+ 'joins' => [],
],
],
- 'ipblocks, write-new' => [
- MIGRATION_WRITE_NEW, 'ipb_by', [
+ 'ipblocks, read-new' => [
+ SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW, 'ipb_by', [
'tables' => [ 'actor_ipb_by' => 'actor' ],
'fields' => [
- 'ipb_by' => 'COALESCE( actor_ipb_by.actor_user, ipb_by )',
- 'ipb_by_text' => 'COALESCE( actor_ipb_by.actor_name, ipb_by_text )',
+ 'ipb_by' => 'actor_ipb_by.actor_user',
+ 'ipb_by_text' => 'actor_ipb_by.actor_name',
'ipb_by_actor' => 'ipb_by_actor',
],
'joins' => [
- 'actor_ipb_by' => [ 'LEFT JOIN', 'actor_ipb_by.actor_id = ipb_by_actor' ],
+ 'actor_ipb_by' => [ 'JOIN', 'actor_ipb_by.actor_id = ipb_by_actor' ],
],
],
],
'ipblocks, new' => [
- MIGRATION_NEW, 'ipb_by', [
+ SCHEMA_COMPAT_NEW, 'ipb_by', [
'tables' => [ 'actor_ipb_by' => 'actor' ],
'fields' => [
'ipb_by' => 'actor_ipb_by.actor_user',
],
'Revision, old' => [
- MIGRATION_OLD, 'rev_user', [
+ SCHEMA_COMPAT_OLD, 'rev_user', [
'tables' => [],
'fields' => [
'rev_user' => 'rev_user',
'joins' => [],
],
],
- 'Revision, write-both' => [
- MIGRATION_WRITE_BOTH, 'rev_user', [
- 'tables' => [
- 'temp_rev_user' => 'revision_actor_temp',
- 'actor_rev_user' => 'actor',
- ],
+ 'Revision, read-old' => [
+ SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD, 'rev_user', [
+ 'tables' => [],
'fields' => [
- 'rev_user' => 'COALESCE( actor_rev_user.actor_user, rev_user )',
- 'rev_user_text' => 'COALESCE( actor_rev_user.actor_name, rev_user_text )',
- 'rev_actor' => 'temp_rev_user.revactor_actor',
- ],
- 'joins' => [
- 'temp_rev_user' => [ 'LEFT JOIN', 'temp_rev_user.revactor_rev = rev_id' ],
- 'actor_rev_user' => [ 'LEFT JOIN', 'actor_rev_user.actor_id = temp_rev_user.revactor_actor' ],
+ 'rev_user' => 'rev_user',
+ 'rev_user_text' => 'rev_user_text',
+ 'rev_actor' => 'NULL',
],
+ 'joins' => [],
],
],
- 'Revision, write-new' => [
- MIGRATION_WRITE_NEW, 'rev_user', [
+ 'Revision, read-new' => [
+ SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW, 'rev_user', [
'tables' => [
'temp_rev_user' => 'revision_actor_temp',
'actor_rev_user' => 'actor',
],
'fields' => [
- 'rev_user' => 'COALESCE( actor_rev_user.actor_user, rev_user )',
- 'rev_user_text' => 'COALESCE( actor_rev_user.actor_name, rev_user_text )',
+ 'rev_user' => 'actor_rev_user.actor_user',
+ 'rev_user_text' => 'actor_rev_user.actor_name',
'rev_actor' => 'temp_rev_user.revactor_actor',
],
'joins' => [
- 'temp_rev_user' => [ 'LEFT JOIN', 'temp_rev_user.revactor_rev = rev_id' ],
- 'actor_rev_user' => [ 'LEFT JOIN', 'actor_rev_user.actor_id = temp_rev_user.revactor_actor' ],
+ 'temp_rev_user' => [ 'JOIN', 'temp_rev_user.revactor_rev = rev_id' ],
+ 'actor_rev_user' => [ 'JOIN', 'actor_rev_user.actor_id = temp_rev_user.revactor_actor' ],
],
],
],
'Revision, new' => [
- MIGRATION_NEW, 'rev_user', [
+ SCHEMA_COMPAT_NEW, 'rev_user', [
'tables' => [
'temp_rev_user' => 'revision_actor_temp',
'actor_rev_user' => 'actor',
return [
'Simple table, old' => [
- MIGRATION_OLD, 'rc_user', $genericUser, true, [
+ SCHEMA_COMPAT_OLD, 'rc_user', $genericUser, true, [
'tables' => [],
'orconds' => [ 'userid' => "rc_user = '1'" ],
'joins' => [],
],
],
- 'Simple table, write-both' => [
- MIGRATION_WRITE_BOTH, 'rc_user', $genericUser, true, [
+ 'Simple table, read-old' => [
+ SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD, 'rc_user', $genericUser, true, [
'tables' => [],
- 'orconds' => [
- 'actor' => "rc_actor = '11'",
- 'userid' => "rc_actor = '0' AND rc_user = '1'"
- ],
+ 'orconds' => [ 'userid' => "rc_user = '1'" ],
'joins' => [],
],
],
- 'Simple table, write-new' => [
- MIGRATION_WRITE_NEW, 'rc_user', $genericUser, true, [
+ 'Simple table, read-new' => [
+ SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW, 'rc_user', $genericUser, true, [
'tables' => [],
- 'orconds' => [
- 'actor' => "rc_actor = '11'",
- 'userid' => "rc_actor = '0' AND rc_user = '1'"
- ],
+ 'orconds' => [ 'actor' => "rc_actor = '11'" ],
'joins' => [],
],
],
'Simple table, new' => [
- MIGRATION_NEW, 'rc_user', $genericUser, true, [
+ SCHEMA_COMPAT_NEW, 'rc_user', $genericUser, true, [
'tables' => [],
'orconds' => [ 'actor' => "rc_actor = '11'" ],
'joins' => [],
],
'ipblocks, old' => [
- MIGRATION_OLD, 'ipb_by', $genericUser, true, [
+ SCHEMA_COMPAT_OLD, 'ipb_by', $genericUser, true, [
'tables' => [],
'orconds' => [ 'userid' => "ipb_by = '1'" ],
'joins' => [],
],
],
- 'ipblocks, write-both' => [
- MIGRATION_WRITE_BOTH, 'ipb_by', $genericUser, true, [
+ 'ipblocks, read-old' => [
+ SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD, 'ipb_by', $genericUser, true, [
'tables' => [],
- 'orconds' => [
- 'actor' => "ipb_by_actor = '11'",
- 'userid' => "ipb_by_actor = '0' AND ipb_by = '1'"
- ],
+ 'orconds' => [ 'userid' => "ipb_by = '1'" ],
'joins' => [],
],
],
- 'ipblocks, write-new' => [
- MIGRATION_WRITE_NEW, 'ipb_by', $genericUser, true, [
+ 'ipblocks, read-new' => [
+ SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW, 'ipb_by', $genericUser, true, [
'tables' => [],
- 'orconds' => [
- 'actor' => "ipb_by_actor = '11'",
- 'userid' => "ipb_by_actor = '0' AND ipb_by = '1'"
- ],
+ 'orconds' => [ 'actor' => "ipb_by_actor = '11'" ],
'joins' => [],
],
],
'ipblocks, new' => [
- MIGRATION_NEW, 'ipb_by', $genericUser, true, [
+ SCHEMA_COMPAT_NEW, 'ipb_by', $genericUser, true, [
'tables' => [],
'orconds' => [ 'actor' => "ipb_by_actor = '11'" ],
'joins' => [],
],
'Revision, old' => [
- MIGRATION_OLD, 'rev_user', $genericUser, true, [
+ SCHEMA_COMPAT_OLD, 'rev_user', $genericUser, true, [
'tables' => [],
'orconds' => [ 'userid' => "rev_user = '1'" ],
'joins' => [],
],
],
- 'Revision, write-both' => [
- MIGRATION_WRITE_BOTH, 'rev_user', $genericUser, true, [
- 'tables' => [
- 'temp_rev_user' => 'revision_actor_temp',
- ],
- 'orconds' => [
- 'actor' =>
- "(temp_rev_user.revactor_actor IS NOT NULL) AND temp_rev_user.revactor_actor = '11'",
- 'userid' => "temp_rev_user.revactor_actor IS NULL AND rev_user = '1'"
- ],
- 'joins' => [
- 'temp_rev_user' => [ 'LEFT JOIN', 'temp_rev_user.revactor_rev = rev_id' ],
- ],
+ 'Revision, read-old' => [
+ SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD, 'rev_user', $genericUser, true, [
+ 'tables' => [],
+ 'orconds' => [ 'userid' => "rev_user = '1'" ],
+ 'joins' => [],
],
],
- 'Revision, write-new' => [
- MIGRATION_WRITE_NEW, 'rev_user', $genericUser, true, [
+ 'Revision, read-new' => [
+ SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW, 'rev_user', $genericUser, true, [
'tables' => [
'temp_rev_user' => 'revision_actor_temp',
],
- 'orconds' => [
- 'actor' =>
- "(temp_rev_user.revactor_actor IS NOT NULL) AND temp_rev_user.revactor_actor = '11'",
- 'userid' => "temp_rev_user.revactor_actor IS NULL AND rev_user = '1'"
- ],
+ 'orconds' => [ 'actor' => "temp_rev_user.revactor_actor = '11'" ],
'joins' => [
- 'temp_rev_user' => [ 'LEFT JOIN', 'temp_rev_user.revactor_rev = rev_id' ],
+ 'temp_rev_user' => [ 'JOIN', 'temp_rev_user.revactor_rev = rev_id' ],
],
],
],
'Revision, new' => [
- MIGRATION_NEW, 'rev_user', $genericUser, true, [
+ SCHEMA_COMPAT_NEW, 'rev_user', $genericUser, true, [
'tables' => [
'temp_rev_user' => 'revision_actor_temp',
],
],
'Multiple users, old' => [
- MIGRATION_OLD, 'rc_user', $complicatedUsers, true, [
+ SCHEMA_COMPAT_OLD, 'rc_user', $complicatedUsers, true, [
'tables' => [],
'orconds' => [
'userid' => "rc_user IN ('1','2','3') ",
'joins' => [],
],
],
- 'Multiple users, write-both' => [
- MIGRATION_WRITE_BOTH, 'rc_user', $complicatedUsers, true, [
+ 'Multiple users, read-old' => [
+ SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD, 'rc_user', $complicatedUsers, true, [
'tables' => [],
'orconds' => [
- 'actor' => "rc_actor IN ('11','12','34') ",
- 'userid' => "rc_actor = '0' AND rc_user IN ('1','2','3') ",
- 'username' => "rc_actor = '0' AND rc_user_text IN ('192.168.12.34','192.168.12.35') "
+ 'userid' => "rc_user IN ('1','2','3') ",
+ 'username' => "rc_user_text IN ('192.168.12.34','192.168.12.35') "
],
'joins' => [],
],
],
- 'Multiple users, write-new' => [
- MIGRATION_WRITE_NEW, 'rc_user', $complicatedUsers, true, [
+ 'Multiple users, read-new' => [
+ SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW, 'rc_user', $complicatedUsers, true, [
'tables' => [],
- 'orconds' => [
- 'actor' => "rc_actor IN ('11','12','34') ",
- 'userid' => "rc_actor = '0' AND rc_user IN ('1','2','3') ",
- 'username' => "rc_actor = '0' AND rc_user_text IN ('192.168.12.34','192.168.12.35') "
- ],
+ 'orconds' => [ 'actor' => "rc_actor IN ('11','12','34') " ],
'joins' => [],
],
],
'Multiple users, new' => [
- MIGRATION_NEW, 'rc_user', $complicatedUsers, true, [
+ SCHEMA_COMPAT_NEW, 'rc_user', $complicatedUsers, true, [
'tables' => [],
'orconds' => [ 'actor' => "rc_actor IN ('11','12','34') " ],
'joins' => [],
],
'Multiple users, no use ID, old' => [
- MIGRATION_OLD, 'rc_user', $complicatedUsers, false, [
+ SCHEMA_COMPAT_OLD, 'rc_user', $complicatedUsers, false, [
'tables' => [],
'orconds' => [
'username' => "rc_user_text IN ('User1','User2','User3','192.168.12.34','192.168.12.35') "
'joins' => [],
],
],
- 'Multiple users, write-both' => [
- MIGRATION_WRITE_BOTH, 'rc_user', $complicatedUsers, false, [
+ 'Multiple users, read-old' => [
+ SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD, 'rc_user', $complicatedUsers, false, [
'tables' => [],
'orconds' => [
- 'actor' => "rc_actor IN ('11','12','34') ",
- 'username' => "rc_actor = '0' AND "
- . "rc_user_text IN ('User1','User2','User3','192.168.12.34','192.168.12.35') "
+ 'username' => "rc_user_text IN ('User1','User2','User3','192.168.12.34','192.168.12.35') "
],
'joins' => [],
],
],
- 'Multiple users, write-new' => [
- MIGRATION_WRITE_NEW, 'rc_user', $complicatedUsers, false, [
+ 'Multiple users, read-new' => [
+ SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW, 'rc_user', $complicatedUsers, false, [
'tables' => [],
- 'orconds' => [
- 'actor' => "rc_actor IN ('11','12','34') ",
- 'username' => "rc_actor = '0' AND "
- . "rc_user_text IN ('User1','User2','User3','192.168.12.34','192.168.12.35') "
- ],
+ 'orconds' => [ 'actor' => "rc_actor IN ('11','12','34') " ],
'joins' => [],
],
],
'Multiple users, new' => [
- MIGRATION_NEW, 'rc_user', $complicatedUsers, false, [
+ SCHEMA_COMPAT_NEW, 'rc_user', $complicatedUsers, false, [
'tables' => [],
'orconds' => [ 'actor' => "rc_actor IN ('11','12','34') " ],
'joins' => [],
$user->method( 'getActorId' )->willReturn( $this->db->insertId() );
}
+ $stageNames = [
+ SCHEMA_COMPAT_OLD => 'old',
+ SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD => 'write-both-read-old',
+ SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW => 'write-both-read-new',
+ SCHEMA_COMPAT_NEW => 'new',
+ ];
+
$stages = [
- MIGRATION_OLD => [ MIGRATION_OLD, MIGRATION_WRITE_BOTH, MIGRATION_WRITE_NEW ],
- MIGRATION_WRITE_BOTH => [ MIGRATION_OLD, MIGRATION_WRITE_BOTH, MIGRATION_WRITE_NEW,
- MIGRATION_NEW ],
- MIGRATION_WRITE_NEW => [ MIGRATION_WRITE_BOTH, MIGRATION_WRITE_NEW, MIGRATION_NEW ],
- MIGRATION_NEW => [ MIGRATION_WRITE_BOTH, MIGRATION_WRITE_NEW, MIGRATION_NEW ],
+ SCHEMA_COMPAT_OLD => [
+ SCHEMA_COMPAT_OLD,
+ SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD,
+ ],
+ SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD => [
+ SCHEMA_COMPAT_OLD,
+ SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD,
+ SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW,
+ SCHEMA_COMPAT_NEW
+ ],
+ SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW => [
+ SCHEMA_COMPAT_OLD,
+ SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD,
+ SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW,
+ SCHEMA_COMPAT_NEW
+ ],
+ SCHEMA_COMPAT_NEW => [
+ SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW,
+ SCHEMA_COMPAT_NEW
+ ],
];
$nameKey = $key . '_text';
foreach ( $stages as $writeStage => $possibleReadStages ) {
if ( $key === 'ipb_by' ) {
- $extraFields['ipb_address'] = __CLASS__ . "#$writeStage";
+ $extraFields['ipb_address'] = __CLASS__ . "#{$stageNames[$writeStage]}";
}
$w = $this->makeMigration( $writeStage );
$fields = $w->getInsertValues( $this->db, $key, $user );
}
- if ( $writeStage <= MIGRATION_WRITE_BOTH ) {
- $this->assertSame( $user->getId(), $fields[$key], "old field, stage=$writeStage" );
- $this->assertSame( $user->getName(), $fields[$nameKey], "old field, stage=$writeStage" );
+ if ( $writeStage & SCHEMA_COMPAT_WRITE_OLD ) {
+ $this->assertSame( $user->getId(), $fields[$key],
+ "old field, stage={$stageNames[$writeStage]}" );
+ $this->assertSame( $user->getName(), $fields[$nameKey],
+ "old field, stage={$stageNames[$writeStage]}" );
} else {
- $this->assertArrayNotHasKey( $key, $fields, "old field, stage=$writeStage" );
- $this->assertArrayNotHasKey( $nameKey, $fields, "old field, stage=$writeStage" );
+ $this->assertArrayNotHasKey( $key, $fields, "old field, stage={$stageNames[$writeStage]}" );
+ $this->assertArrayNotHasKey( $nameKey, $fields, "old field, stage={$stageNames[$writeStage]}" );
}
- if ( $writeStage >= MIGRATION_WRITE_BOTH && !$usesTemp ) {
- $this->assertSame( $user->getActorId(), $fields[$actorKey], "new field, stage=$writeStage" );
+ if ( ( $writeStage & SCHEMA_COMPAT_WRITE_NEW ) && !$usesTemp ) {
+ $this->assertSame( $user->getActorId(), $fields[$actorKey],
+ "new field, stage={$stageNames[$writeStage]}" );
} else {
- $this->assertArrayNotHasKey( $actorKey, $fields, "new field, stage=$writeStage" );
+ $this->assertArrayNotHasKey( $actorKey, $fields,
+ "new field, stage={$stageNames[$writeStage]}" );
}
$this->db->insert( $table, $extraFields + $fields, __METHOD__ );
$queryInfo['joins']
);
- $this->assertSame( $user->getId(), (int)$row->$key, "w=$writeStage, r=$readStage, id" );
- $this->assertSame( $user->getName(), $row->$nameKey, "w=$writeStage, r=$readStage, name" );
+ $this->assertSame( $user->getId(), (int)$row->$key,
+ "w={$stageNames[$writeStage]}, r={$stageNames[$readStage]}, id" );
+ $this->assertSame( $user->getName(), $row->$nameKey,
+ "w={$stageNames[$writeStage]}, r={$stageNames[$readStage]}, name" );
$this->assertSame(
- $readStage === MIGRATION_OLD || $writeStage === MIGRATION_OLD ? 0 : $user->getActorId(),
+ ( $readStage & SCHEMA_COMPAT_READ_OLD ) ? 0 : $user->getActorId(),
(int)$row->$actorKey,
- "w=$writeStage, r=$readStage, actor"
+ "w={$stageNames[$writeStage]}, r={$stageNames[$readStage]}, actor"
);
}
}
public static function provideStages() {
return [
- 'MIGRATION_OLD' => [ MIGRATION_OLD ],
- 'MIGRATION_WRITE_BOTH' => [ MIGRATION_WRITE_BOTH ],
- 'MIGRATION_WRITE_NEW' => [ MIGRATION_WRITE_NEW ],
- 'MIGRATION_NEW' => [ MIGRATION_NEW ],
+ 'old' => [ SCHEMA_COMPAT_OLD ],
+ 'read-old' => [ SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD ],
+ 'read-new' => [ SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW ],
+ 'new' => [ SCHEMA_COMPAT_NEW ],
];
}
}
public function testInsertUserIdentity() {
+ $this->setMwGlobals( [
+ // for User::getActorId()
+ 'wgActorTableSchemaMigrationStage' => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD
+ ] );
+ $this->overrideMwServices();
+
$user = $this->getTestUser()->getUser();
$userIdentity = $this->getMock( UserIdentity::class );
$userIdentity->method( 'getId' )->willReturn( $user->getId() );
list( $cFields, $cCallback ) = MediaWikiServices::getInstance()->getCommentStore()
->insertWithTempTable( $this->db, 'rev_comment', '' );
- $m = $this->makeMigration( MIGRATION_WRITE_BOTH );
+ $m = $this->makeMigration( SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW );
list( $fields, $callback ) =
$m->getInsertValuesWithTempTable( $this->db, 'rev_user', $userIdentity );
$extraFields = [
$callback( $id, $extraFields );
$cCallback( $id );
- $qi = Revision::getQueryInfo();
+ $qi = $m->getJoin( 'rev_user' );
$row = $this->db->selectRow(
- $qi['tables'], $qi['fields'], [ 'rev_id' => $id ], __METHOD__, [], $qi['joins']
+ [ 'revision' ] + $qi['tables'], $qi['fields'], [ 'rev_id' => $id ], __METHOD__, [], $qi['joins']
);
$this->assertSame( $user->getId(), (int)$row->rev_user );
$this->assertSame( $user->getName(), $row->rev_user_text );
$this->assertSame( $user->getActorId(), (int)$row->rev_actor );
- $m = $this->makeMigration( MIGRATION_WRITE_BOTH );
+ $m = $this->makeMigration( SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW );
$fields = $m->getInsertValues( $this->db, 'dummy_user', $userIdentity );
$this->assertSame( $user->getId(), $fields['dummy_user'] );
$this->assertSame( $user->getName(), $fields['dummy_user_text'] );
$this->assertSame( $user->getActorId(), $fields['dummy_actor'] );
}
- public function testConstructor() {
+ public function testNewMigration() {
$m = ActorMigration::newMigration();
$this->assertInstanceOf( ActorMigration::class, $m );
$this->assertSame( $m, ActorMigration::newMigration() );
public static function provideIsAnon() {
return [
- 'MIGRATION_OLD' => [ MIGRATION_OLD, 'foo = 0', 'foo != 0' ],
- 'MIGRATION_WRITE_BOTH' => [ MIGRATION_WRITE_BOTH, 'foo = 0', 'foo != 0' ],
- 'MIGRATION_WRITE_NEW' => [ MIGRATION_WRITE_NEW, 'foo = 0', 'foo != 0' ],
- 'MIGRATION_NEW' => [ MIGRATION_NEW, 'foo IS NULL', 'foo IS NOT NULL' ],
+ 'old' => [ SCHEMA_COMPAT_OLD, 'foo = 0', 'foo != 0' ],
+ 'read-old' => [ SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD, 'foo = 0', 'foo != 0' ],
+ 'read-new' => [
+ SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW, 'foo IS NULL', 'foo IS NOT NULL'
+ ],
+ 'new' => [ SCHEMA_COMPAT_NEW, 'foo IS NULL', 'foo IS NOT NULL' ],
];
}
];
}
- /**
- * @dataProvider provideHooks
- * @covers ::wfRunHooks
- */
- public function testOldStyleHooks( $msg, array $hook, $expectedFoo, $expectedBar ) {
- global $wgHooks;
-
- $this->hideDeprecated( 'wfRunHooks' );
- $foo = $bar = 'original';
-
- $wgHooks['MediaWikiHooksTest001'][] = $hook;
- wfRunHooks( 'MediaWikiHooksTest001', [ &$foo, &$bar ] );
-
- $this->assertSame( $expectedFoo, $foo, $msg );
- $this->assertSame( $expectedBar, $bar, $msg );
- }
-
/**
* @dataProvider provideHooks
* @covers Hooks::register
}
protected function tearDown() {
+ Language::factory( 'en' )->resetNamespaces();
+
if ( $this->restoreWarnings ) {
$this->restoreWarnings = false;
Wikimedia\restoreWarnings();
}
+
parent::tearDown();
}
/**
* @dataProvider provideAddWikiText
* @covers OutputPage::addWikiText
+ * @covers OutputPage::addWikiTextAsInterface
+ * @covers OutputPage::addWikiTextAsContent
* @covers OutputPage::addWikiTextWithTitle
* @covers OutputPage::addWikiTextTitle
* @covers OutputPage::addWikiTextTidy
$op = $this->newInstance();
$this->assertSame( '', $op->getHTML() );
+ $this->hideDeprecated( 'OutputPage::addWikiTextTitle' );
+ $this->hideDeprecated( 'OutputPage::addWikiTextWithTitle' );
+ $this->hideDeprecated( 'OutputPage::addWikiTextTidy' );
+ $this->hideDeprecated( 'OutputPage::addWikiTextTitleTidy' );
if ( in_array(
$method,
[ 'addWikiTextWithTitle', 'addWikiTextTitleTidy', 'addWikiTextTitle' ]
// Special placeholder because we can't get the actual title in the provider
$args[1] = $op->getTitle();
}
+ if ( in_array(
+ $method,
+ [ 'addWikiTextAsInterface', 'addWikiTextAsContent' ]
+ ) && count( $args ) >= 3 && $args[2] === null ) {
+ // Special placeholder because we can't get the actual title in the provider
+ $args[2] = $op->getTitle();
+ }
$op->$method( ...$args );
$this->assertSame( $expected, $op->getHTML() );
public function provideAddWikiText() {
$tests = [
'addWikiText' => [
+ // Not tidied; this API is deprecated.
'Simple wikitext' => [
[ "'''Bold'''" ],
"<p><b>Bold</b>\n</p>",
],
],
'addWikiTextWithTitle' => [
+ // Untidied; this API is deprecated
'With title at start' => [
[ '* {{PAGENAME}}', Title::newFromText( 'Talk:Some page' ) ],
"<ul><li>Some page</li></ul>\n",
"* Some page",
],
],
- 'addWikiTextTidy' => [
+ 'addWikiTextAsInterface' => [
+ // Preferred interface: output is tidied
+ 'Simple wikitext' => [
+ [ "'''Bold'''" ],
+ "<p><b>Bold</b>\n</p>",
+ ], 'Untidy wikitext' => [
+ [ "<b>Bold" ],
+ "<p><b>Bold\n</b></p>",
+ ], 'List at start' => [
+ [ '* List' ],
+ "<ul><li>List</li></ul>\n",
+ ], 'List not at start' => [
+ [ '* Not a list', false ],
+ '<p>* Not a list</p>',
+ ], 'No section edit links' => [
+ [ '== Title ==' ],
+ "<h2><span class=\"mw-headline\" id=\"Title\">Title</span></h2>\n",
+ ], 'With title at start' => [
+ [ '* {{PAGENAME}}', true, Title::newFromText( 'Talk:Some page' ) ],
+ "<ul><li>Some page</li></ul>\n",
+ ], 'With title at start' => [
+ [ '* {{PAGENAME}}', false, Title::newFromText( 'Talk:Some page' ), false ],
+ "<p>* Some page</p>",
+ ], 'Untidy input' => [
+ [ '<b>{{PAGENAME}}', true, Title::newFromText( 'Talk:Some page' ) ],
+ "<p><b>Some page\n</b></p>",
+ ],
+ ],
+ 'addWikiTextAsContent' => [
+ // Preferred interface: output is tidied
'SpecialNewimages' => [
[ "<p lang='en' dir='ltr'>\nMy message" ],
'<p lang="en" dir="ltr">' . "\nMy message\n</p>"
], 'List not at start' => [
[ '* <b>Not a list', false ],
'<p>* <b>Not a list</b></p>',
- ],
- ],
- 'addWikiTextTitleTidy' => [
- 'With title at start' => [
- [ '* {{PAGENAME}}', Title::newFromText( 'Talk:Some page' ) ],
+ ], 'With title at start' => [
+ [ '* {{PAGENAME}}', true, Title::newFromText( 'Talk:Some page' ) ],
"<ul><li>Some page</li></ul>\n",
], 'With title at start' => [
- [ '* {{PAGENAME}}', Title::newFromText( 'Talk:Some page' ), false ],
+ [ '* {{PAGENAME}}', false, Title::newFromText( 'Talk:Some page' ), false ],
"<p>* Some page</p>",
], 'EditPage' => [
- [ "<div class='mw-editintro'>{{PAGENAME}}", Title::newFromText( 'Talk:Some page' ) ],
+ [ "<div class='mw-editintro'>{{PAGENAME}}", true, Title::newFromText( 'Talk:Some page' ) ],
'<div class="mw-editintro">' . "Some page\n</div>"
],
],
$tests['addWikiTextTitle']["$key (addWikiTextTitle)"] =
array_merge( [ $args ], array_slice( $val, 1 ) );
}
- foreach ( $tests['addWikiTextTidy'] as $key => $val ) {
- $args = [ $val[0][0], null, $val[0][1] ?? true, true, false ];
+ foreach ( $tests['addWikiTextAsInterface'] as $key => $val ) {
+ $args = [ $val[0][0], $val[0][2] ?? null, $val[0][1] ?? true, true, true ];
$tests['addWikiTextTitle']["$key (addWikiTextTitle)"] =
array_merge( [ $args ], array_slice( $val, 1 ) );
}
- foreach ( $tests['addWikiTextTitleTidy'] as $key => $val ) {
- $args = [ $val[0][0], $val[0][1], $val[0][2] ?? true, true, false ];
+ foreach ( $tests['addWikiTextAsContent'] as $key => $val ) {
+ $args = [ $val[0][0], $val[0][2] ?? null, $val[0][1] ?? true, true, false ];
$tests['addWikiTextTitle']["$key (addWikiTextTitle)"] =
array_merge( [ $args ], array_slice( $val, 1 ) );
}
+ // addWikiTextTidy / addWikiTextTitleTidy were old aliases of
+ // addWikiTextAsContent
+ foreach ( $tests['addWikiTextAsContent'] as $key => $val ) {
+ if ( count( $val[0] ) > 2 ) {
+ $args = [ $val[0][0], $val[0][2], $val[0][1] ?? true ];
+ $tests['addWikiTextTitleTidy']["$key (addWikiTextTitleTidy)"] =
+ array_merge( [ $args ], array_slice( $val, 1 ) );
+ } else {
+ $args = [ $val[0][0], $val[0][1] ?? true ];
+ $tests['addWikiTextTidy']["$key (addWikiTextTidy)"] =
+ array_merge( [ $args ], array_slice( $val, 1 ) );
+ }
+ }
// We have to reformat our array to match what PHPUnit wants
$ret = [];
$op->addWikiText( 'a' );
}
+ /**
+ * @covers OutputPage::addWikiTextAsInterface
+ */
+ public function testAddWikiTextAsInterfaceNoTitle() {
+ $this->setExpectedException( MWException::class, 'Title is null' );
+
+ $op = $this->newInstance( [], null, 'notitle' );
+ $op->addWikiTextAsInterface( 'a' );
+ }
+
+ /**
+ * @covers OutputPage::addWikiTextAsContent
+ */
+ public function testAddWikiTextAsContentNoTitle() {
+ $this->setExpectedException( MWException::class, 'Title is null' );
+
+ $op = $this->newInstance( [], null, 'notitle' );
+ $op->addWikiTextAsContent( 'a' );
+ }
+
/**
* @covers OutputPage::addWikiMsg
*/
--- /dev/null
+<?php
+namespace MediaWiki\Tests\Revision;
+
+use CommentStoreComment;
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Revision\RevisionRecord;
+use MediaWiki\Revision\SlotRecord;
+use TextContent;
+use Title;
+use WikitextContent;
+
+/**
+ * Tests RevisionStore against the intermediate MCR DB schema for use during schema migration.
+ *
+ * @covers \MediaWiki\Revision\RevisionStore
+ *
+ * @group RevisionStore
+ * @group Storage
+ * @group Database
+ * @group medium
+ */
+class McrReadNewRevisionStoreDbTest extends RevisionStoreDbTestBase {
+
+ use McrReadNewSchemaOverride;
+
+ protected function assertRevisionExistsInDatabase( RevisionRecord $rev ) {
+ $numberOfSlots = count( $rev->getSlotRoles() );
+
+ // new schema is written
+ $this->assertSelect(
+ 'slots',
+ [ 'count(*)' ],
+ [ 'slot_revision_id' => $rev->getId() ],
+ [ [ (string)$numberOfSlots ] ]
+ );
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $revQuery = $store->getSlotsQueryInfo( [ 'content' ] );
+
+ $this->assertSelect(
+ $revQuery['tables'],
+ [ 'count(*)' ],
+ [
+ 'slot_revision_id' => $rev->getId(),
+ ],
+ [ [ (string)$numberOfSlots ] ],
+ [],
+ $revQuery['joins']
+ );
+
+ // Legacy schema is still being written
+ $this->assertSelect(
+ [ 'revision', 'text' ],
+ [ 'count(*)' ],
+ [ 'rev_id' => $rev->getId(), 'rev_text_id > 0' ],
+ [ [ 1 ] ],
+ [],
+ [ 'text' => [ 'INNER JOIN', [ 'rev_text_id = old_id' ] ] ]
+ );
+
+ parent::assertRevisionExistsInDatabase( $rev );
+ }
+
+ /**
+ * @param SlotRecord $a
+ * @param SlotRecord $b
+ */
+ protected function assertSameSlotContent( SlotRecord $a, SlotRecord $b ) {
+ parent::assertSameSlotContent( $a, $b );
+
+ // Assert that the same content ID has been used
+ $this->assertSame( $a->getContentId(), $b->getContentId() );
+ }
+
+ public function provideInsertRevisionOn_successes() {
+ foreach ( parent::provideInsertRevisionOn_successes() as $case ) {
+ yield $case;
+ }
+
+ yield 'Multi-slot revision insertion' => [
+ [
+ 'content' => [
+ 'main' => new WikitextContent( 'Chicken' ),
+ 'aux' => new TextContent( 'Egg' ),
+ ],
+ 'page' => true,
+ 'comment' => $this->getRandomCommentStoreComment(),
+ 'timestamp' => '20171117010101',
+ 'user' => true,
+ ],
+ ];
+ }
+
+ public function provideNewNullRevision() {
+ foreach ( parent::provideNewNullRevision() as $case ) {
+ yield $case;
+ }
+
+ yield [
+ Title::newFromText( 'UTPage_notAutoCreated' ),
+ [
+ 'content' => [
+ 'main' => new WikitextContent( 'Chicken' ),
+ 'aux' => new WikitextContent( 'Omelet' ),
+ ],
+ ],
+ CommentStoreComment::newUnsavedComment( __METHOD__ . ' comment multi' ),
+ ];
+ }
+
+ public function testGetQueryInfo_NoSlotDataJoin() {
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $queryInfo = $store->getQueryInfo();
+
+ // with the new schema enabled, query info should not join the main slot info
+ $this->assertFalse( array_key_exists( 'a_slot_data', $queryInfo['tables'] ) );
+ $this->assertFalse( array_key_exists( 'a_slot_data', $queryInfo['joins'] ) );
+ }
+
+ public function provideNewMutableRevisionFromArray() {
+ foreach ( parent::provideNewMutableRevisionFromArray() as $case ) {
+ yield $case;
+ }
+
+ yield 'Basic array, multiple roles' => [
+ [
+ 'id' => 2,
+ 'page' => 1,
+ 'timestamp' => '20171017114835',
+ 'user_text' => '111.0.1.2',
+ 'user' => 0,
+ 'minor_edit' => false,
+ 'deleted' => 0,
+ 'len' => 29,
+ 'parent_id' => 1,
+ 'sha1' => '89qs83keq9c9ccw9olvvm4oc9oq50ii',
+ 'comment' => 'Goat Comment!',
+ 'content' => [
+ 'main' => new WikitextContent( 'Söme Cöntent' ),
+ 'aux' => new TextContent( 'Öther Cöntent' ),
+ ]
+ ]
+ ];
+ }
+
+}
--- /dev/null
+<?php
+namespace MediaWiki\Tests\Revision;
+
+use Wikimedia\Rdbms\IMaintainableDatabase;
+use MediaWiki\DB\PatchFileLocation;
+
+/**
+ * Trait providing schema overrides that allow tests to run against the intermediate MCR database
+ * schema for use during schema migration.
+ */
+trait McrReadNewSchemaOverride {
+
+ use PatchFileLocation;
+ use McrSchemaDetection;
+
+ /**
+ * @return int
+ */
+ protected function getMcrMigrationStage() {
+ return SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW;
+ }
+
+ /**
+ * @return string[]
+ */
+ protected function getMcrTablesToReset() {
+ return [ 'content', 'content_models', 'slots', 'slot_roles' ];
+ }
+
+ /**
+ * @return array[]
+ */
+ protected function getSchemaOverrides( IMaintainableDatabase $db ) {
+ $overrides = [
+ 'scripts' => [],
+ 'drop' => [],
+ 'create' => [],
+ 'alter' => [],
+ ];
+
+ if ( !$this->hasMcrTables( $db ) ) {
+ $overrides['create'] = [ 'slots', 'content', 'slot_roles', 'content_models', ];
+ $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-slot_roles' );
+ $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-content_models' );
+ $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-content' );
+ $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-slots' );
+ }
+
+ if ( !$this->hasPreMcrFields( $db ) ) {
+ $overrides['alter'][] = 'revision';
+ $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'create-pre-mcr-fields', __DIR__ );
+ }
+
+ return $overrides;
+ }
+
+}
--- /dev/null
+<?php
+namespace MediaWiki\Tests\Revision;
+
+use CommentStoreComment;
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Revision\MutableRevisionRecord;
+use MediaWiki\Revision\RevisionRecord;
+use MediaWiki\Revision\SlotRecord;
+use TextContent;
+use Title;
+use WikitextContent;
+
+/**
+ * Tests RevisionStore against the post-migration MCR DB schema.
+ *
+ * @covers \MediaWiki\Revision\RevisionStore
+ *
+ * @group RevisionStore
+ * @group Storage
+ * @group Database
+ * @group medium
+ */
+class McrRevisionStoreDbTest extends RevisionStoreDbTestBase {
+
+ use McrSchemaOverride;
+
+ protected function assertRevisionExistsInDatabase( RevisionRecord $rev ) {
+ $numberOfSlots = count( $rev->getSlotRoles() );
+
+ // new schema is written
+ $this->assertSelect(
+ 'slots',
+ [ 'count(*)' ],
+ [ 'slot_revision_id' => $rev->getId() ],
+ [ [ (string)$numberOfSlots ] ]
+ );
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $revQuery = $store->getSlotsQueryInfo( [ 'content' ] );
+
+ $this->assertSelect(
+ $revQuery['tables'],
+ [ 'count(*)' ],
+ [
+ 'slot_revision_id' => $rev->getId(),
+ ],
+ [ [ (string)$numberOfSlots ] ],
+ [],
+ $revQuery['joins']
+ );
+
+ parent::assertRevisionExistsInDatabase( $rev );
+ }
+
+ /**
+ * @param SlotRecord $a
+ * @param SlotRecord $b
+ */
+ protected function assertSameSlotContent( SlotRecord $a, SlotRecord $b ) {
+ parent::assertSameSlotContent( $a, $b );
+
+ // Assert that the same content ID has been used
+ $this->assertSame( $a->getContentId(), $b->getContentId() );
+ }
+
+ public function provideInsertRevisionOn_successes() {
+ foreach ( parent::provideInsertRevisionOn_successes() as $case ) {
+ yield $case;
+ }
+
+ yield 'Multi-slot revision insertion' => [
+ [
+ 'content' => [
+ 'main' => new WikitextContent( 'Chicken' ),
+ 'aux' => new TextContent( 'Egg' ),
+ ],
+ 'page' => true,
+ 'comment' => $this->getRandomCommentStoreComment(),
+ 'timestamp' => '20171117010101',
+ 'user' => true,
+ ],
+ ];
+ }
+
+ public function provideNewNullRevision() {
+ foreach ( parent::provideNewNullRevision() as $case ) {
+ yield $case;
+ }
+
+ yield [
+ Title::newFromText( 'UTPage_notAutoCreated' ),
+ [
+ 'content' => [
+ 'main' => new WikitextContent( 'Chicken' ),
+ 'aux' => new WikitextContent( 'Omelet' ),
+ ],
+ ],
+ CommentStoreComment::newUnsavedComment( __METHOD__ . ' comment multi' ),
+ ];
+ }
+
+ public function provideNewMutableRevisionFromArray() {
+ foreach ( parent::provideNewMutableRevisionFromArray() as $case ) {
+ yield $case;
+ }
+
+ yield 'Basic array, multiple roles' => [
+ [
+ 'id' => 2,
+ 'page' => 1,
+ 'timestamp' => '20171017114835',
+ 'user_text' => '111.0.1.2',
+ 'user' => 0,
+ 'minor_edit' => false,
+ 'deleted' => 0,
+ 'len' => 29,
+ 'parent_id' => 1,
+ 'sha1' => '89qs83keq9c9ccw9olvvm4oc9oq50ii',
+ 'comment' => 'Goat Comment!',
+ 'content' => [
+ 'main' => new WikitextContent( 'Söme Cöntent' ),
+ 'aux' => new TextContent( 'Öther Cöntent' ),
+ ]
+ ]
+ ];
+ }
+
+ public function testGetQueryInfo_NoSlotDataJoin() {
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $queryInfo = $store->getQueryInfo();
+
+ // with the new schema enabled, query info should not join the main slot info
+ $this->assertFalse( array_key_exists( 'a_slot_data', $queryInfo['tables'] ) );
+ $this->assertFalse( array_key_exists( 'a_slot_data', $queryInfo['joins'] ) );
+ }
+
+ /**
+ * @covers \MediaWiki\Revision\RevisionStore::insertRevisionOn
+ * @covers \MediaWiki\Revision\RevisionStore::insertSlotRowOn
+ * @covers \MediaWiki\Revision\RevisionStore::insertContentRowOn
+ */
+ public function testInsertRevisionOn_T202032() {
+ // This test only makes sense for MySQL
+ if ( $this->db->getType() !== 'mysql' ) {
+ $this->assertTrue( true );
+ return;
+ }
+
+ // NOTE: must be done before checking MAX(rev_id)
+ $page = $this->getTestPage();
+
+ $maxRevId = $this->db->selectField( 'revision', 'MAX(rev_id)' );
+
+ // Construct a slot row that will conflict with the insertion of the next revision ID,
+ // to emulate the failure mode described in T202032. Nothing will ever read this row,
+ // we just need it to trigger a primary key conflict.
+ $this->db->insert( 'slots', [
+ 'slot_revision_id' => $maxRevId + 1,
+ 'slot_role_id' => 1,
+ 'slot_content_id' => 0,
+ 'slot_origin' => 0
+ ], __METHOD__ );
+
+ $rev = new MutableRevisionRecord( $page->getTitle() );
+ $rev->setTimestamp( '20180101000000' );
+ $rev->setComment( CommentStoreComment::newUnsavedComment( 'test' ) );
+ $rev->setUser( $this->getTestUser()->getUser() );
+ $rev->setContent( 'main', new WikitextContent( 'Text' ) );
+ $rev->setPageId( $page->getId() );
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $return = $store->insertRevisionOn( $rev, $this->db );
+
+ $this->assertSame( $maxRevId + 2, $return->getId() );
+
+ // is the new revision correct?
+ $this->assertRevisionCompleteness( $return );
+ $this->assertRevisionRecordsEqual( $rev, $return );
+
+ // can we find it directly in the database?
+ $this->assertRevisionExistsInDatabase( $return );
+
+ // can we load it from the store?
+ $loaded = $store->getRevisionById( $return->getId() );
+ $this->assertRevisionCompleteness( $loaded );
+ $this->assertRevisionRecordsEqual( $return, $loaded );
+ }
+
+}
--- /dev/null
+<?php
+namespace MediaWiki\Tests\Revision;
+
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * Trait providing methods for detecting which MCR schema migration phase the current schema
+ * is compatible with.
+ */
+trait McrSchemaDetection {
+
+ /**
+ * Returns true if MCR-related tables exist in the database.
+ * If yes, the database is compatible with with MIGRATION_NEW.
+ * If hasPreMcrFields() also returns true, the database supports MIGRATION_WRITE_BOTH mode.
+ *
+ * @param IDatabase $db
+ * @return bool
+ */
+ protected function hasMcrTables( IDatabase $db ) {
+ return $db->tableExists( 'slots', __METHOD__ );
+ }
+
+ /**
+ * Returns true if pre-MCR fields still exist in the database.
+ * If yes, the database is compatible with with MIGRATION_OLD mode.
+ * If hasMcrTables() also returns true, the database supports MIGRATION_WRITE_BOTH mode.
+ *
+ * Note that if the database has been updated in MIGRATION_NEW mode,
+ * the rev_text_id field will be 0 for new revisions. This means that
+ * in MIGRATION_OLD mode, reading such revisions will fail, even though
+ * all the necessary fields exist.
+ * This is not relevant for unit tests, since unit tests reset the database content anyway.
+ *
+ * @param IDatabase $db
+ * @return bool
+ */
+ protected function hasPreMcrFields( IDatabase $db ) {
+ return $db->fieldExists( 'revision', 'rev_content_model', __METHOD__ );
+ }
+
+}
--- /dev/null
+<?php
+namespace MediaWiki\Tests\Revision;
+
+use Wikimedia\Rdbms\IMaintainableDatabase;
+use MediaWiki\DB\PatchFileLocation;
+
+/**
+ * Trait providing schema overrides that allow tests to run against the post-migration
+ * MCR database schema.
+ */
+trait McrSchemaOverride {
+
+ use PatchFileLocation;
+ use McrSchemaDetection;
+
+ /**
+ * @return int
+ */
+ protected function getMcrMigrationStage() {
+ return MIGRATION_NEW;
+ }
+
+ /**
+ * @return string[]
+ */
+ protected function getMcrTablesToReset() {
+ return [
+ 'content',
+ 'content_models',
+ 'slots',
+ 'slot_roles',
+ ];
+ }
+
+ protected function getSchemaOverrides( IMaintainableDatabase $db ) {
+ $overrides = [
+ 'scripts' => [],
+ 'drop' => [],
+ 'create' => [],
+ 'alter' => [],
+ ];
+
+ if ( !$this->hasMcrTables( $db ) ) {
+ $overrides['create'] = [ 'slots', 'content', 'slot_roles', 'content_models', ];
+ $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-slot_roles' );
+ $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-content_models' );
+ $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-content' );
+ $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-slots.sql' );
+ }
+
+ if ( !$this->hasPreMcrFields( $db ) ) {
+ $overrides['alter'][] = 'revision';
+ $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'drop-pre-mcr-fields', __DIR__ );
+ }
+
+ return $overrides;
+ }
+
+}
--- /dev/null
+<?php
+namespace MediaWiki\Tests\Revision;
+
+use InvalidArgumentException;
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Revision\RevisionRecord;
+use MediaWiki\Revision\SlotRecord;
+use Revision;
+use WikitextContent;
+
+/**
+ * Tests RevisionStore against the intermediate MCR DB schema for use during schema migration.
+ *
+ * @covers \MediaWiki\Revision\RevisionStore
+ *
+ * @group RevisionStore
+ * @group Storage
+ * @group Database
+ * @group medium
+ */
+class McrWriteBothRevisionStoreDbTest extends RevisionStoreDbTestBase {
+
+ use McrWriteBothSchemaOverride;
+
+ protected function revisionToRow( Revision $rev, $options = [ 'page', 'user', 'comment' ] ) {
+ $row = parent::revisionToRow( $rev, $options );
+
+ $row->rev_text_id = (string)$rev->getTextId();
+ $row->rev_content_format = (string)$rev->getContentFormat();
+ $row->rev_content_model = (string)$rev->getContentModel();
+
+ return $row;
+ }
+
+ protected function assertRevisionExistsInDatabase( RevisionRecord $rev ) {
+ // New schema is being written
+ $this->assertSelect(
+ 'slots',
+ [ 'count(*)' ],
+ [ 'slot_revision_id' => $rev->getId() ],
+ [ [ '1' ] ]
+ );
+
+ $this->assertSelect(
+ 'content',
+ [ 'count(*)' ],
+ [ 'content_address' => $rev->getSlot( 'main' )->getAddress() ],
+ [ [ '1' ] ]
+ );
+
+ // Legacy schema is still being written
+ $this->assertSelect(
+ [ 'revision', 'text' ],
+ [ 'count(*)' ],
+ [ 'rev_id' => $rev->getId(), 'rev_text_id > 0' ],
+ [ [ 1 ] ],
+ [],
+ [ 'text' => [ 'INNER JOIN', [ 'rev_text_id = old_id' ] ] ]
+ );
+
+ parent::assertRevisionExistsInDatabase( $rev );
+ }
+
+ /**
+ * @param SlotRecord $a
+ * @param SlotRecord $b
+ */
+ protected function assertSameSlotContent( SlotRecord $a, SlotRecord $b ) {
+ parent::assertSameSlotContent( $a, $b );
+
+ // Assert that the same content ID has been used
+ if ( $a->hasContentId() && $b->hasContentId() ) {
+ $this->assertSame( $a->getContentId(), $b->getContentId() );
+ }
+ }
+
+ public function provideInsertRevisionOn_failures() {
+ foreach ( parent::provideInsertRevisionOn_failures() as $case ) {
+ yield $case;
+ }
+
+ yield 'slot that is not main slot' => [
+ [
+ 'content' => [
+ 'main' => new WikitextContent( 'Chicken' ),
+ 'lalala' => new WikitextContent( 'Duck' ),
+ ],
+ 'comment' => $this->getRandomCommentStoreComment(),
+ 'timestamp' => '20171117010101',
+ 'user' => true,
+ ],
+ new InvalidArgumentException( 'Only the main slot is supported' )
+ ];
+ }
+
+ public function provideNewMutableRevisionFromArray() {
+ foreach ( parent::provideNewMutableRevisionFromArray() as $case ) {
+ yield $case;
+ }
+
+ yield 'Basic array, with page & id' => [
+ [
+ 'id' => 2,
+ 'page' => 1,
+ 'text_id' => 2,
+ 'timestamp' => '20171017114835',
+ 'user_text' => '111.0.1.2',
+ 'user' => 0,
+ 'minor_edit' => false,
+ 'deleted' => 0,
+ 'len' => 46,
+ 'parent_id' => 1,
+ 'sha1' => 'rdqbbzs3pkhihgbs8qf2q9jsvheag5z',
+ 'comment' => 'Goat Comment!',
+ 'content_format' => 'text/x-wiki',
+ 'content_model' => 'wikitext',
+ ]
+ ];
+ }
+
+ /**
+ * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromArchiveRow
+ * @covers \MediaWiki\Revision\RevisionStore::insertRevisionOn
+ */
+ public function testInsertRevisionFromArchiveRow_unmigratedArchiveRow() {
+ // The main purpose of this test is to assert that after reading an archive
+ // row using the old schema it can be inserted into the revision table,
+ // and a slot row is created based on slot emulated from the old-style archive row,
+ // when none such slot row exists yet.
+
+ $title = $this->getTestPage()->getTitle();
+
+ $this->db->insert(
+ 'text',
+ [ 'old_text' => 'Just a test', 'old_flags' => 'utf-8' ],
+ __METHOD__
+ );
+
+ $textId = $this->db->insertId();
+
+ $row = (object)[
+ 'ar_minor_edit' => '0',
+ 'ar_user' => '0',
+ 'ar_user_text' => '127.0.0.1',
+ 'ar_actor' => null,
+ 'ar_len' => '11',
+ 'ar_deleted' => '0',
+ 'ar_rev_id' => 112277,
+ 'ar_timestamp' => $this->db->timestamp( '20180101000000' ),
+ 'ar_sha1' => 'deadbeef',
+ 'ar_page_id' => $title->getArticleID(),
+ 'ar_comment_text' => 'just a test',
+ 'ar_comment_data' => null,
+ 'ar_comment_cid' => null,
+ 'ar_content_format' => null,
+ 'ar_content_model' => null,
+ 'ts_tags' => null,
+ 'ar_id' => 17,
+ 'ar_namespace' => $title->getNamespace(),
+ 'ar_title' => $title->getDBkey(),
+ 'ar_text_id' => $textId,
+ 'ar_parent_id' => 112211,
+ ];
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $rev = $store->newRevisionFromArchiveRow( $row );
+
+ // re-insert archived revision
+ $return = $store->insertRevisionOn( $rev, $this->db );
+
+ // is the new revision correct?
+ $this->assertRevisionCompleteness( $return );
+ $this->assertRevisionRecordsEqual( $rev, $return );
+
+ // can we load it from the store?
+ $loaded = $store->getRevisionById( $return->getId() );
+ $this->assertNotNull( $loaded );
+ $this->assertRevisionCompleteness( $loaded );
+ $this->assertRevisionRecordsEqual( $return, $loaded );
+
+ // can we find it directly in the database?
+ $this->assertRevisionExistsInDatabase( $return );
+ }
+
+}
--- /dev/null
+<?php
+namespace MediaWiki\Tests\Revision;
+
+use Wikimedia\Rdbms\IMaintainableDatabase;
+use MediaWiki\DB\PatchFileLocation;
+
+/**
+ * Trait providing schema overrides that allow tests to run against the intermediate MCR database
+ * schema for use during schema migration.
+ */
+trait McrWriteBothSchemaOverride {
+
+ use PatchFileLocation;
+ use McrSchemaDetection;
+
+ /**
+ * @return int
+ */
+ protected function getMcrMigrationStage() {
+ return SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD;
+ }
+
+ /**
+ * @return string[]
+ */
+ protected function getMcrTablesToReset() {
+ return [ 'content', 'content_models', 'slots', 'slot_roles' ];
+ }
+
+ /**
+ * @return array[]
+ */
+ protected function getSchemaOverrides( IMaintainableDatabase $db ) {
+ $overrides = [
+ 'scripts' => [],
+ 'drop' => [],
+ 'create' => [],
+ 'alter' => [],
+ ];
+
+ if ( !$this->hasMcrTables( $db ) ) {
+ $overrides['create'] = [ 'slots', 'content', 'slot_roles', 'content_models', ];
+ $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-slot_roles' );
+ $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-content_models' );
+ $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-content' );
+ $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-slots' );
+ }
+
+ if ( !$this->hasPreMcrFields( $db ) ) {
+ $overrides['alter'][] = 'revision';
+ $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'create-pre-mcr-fields', __DIR__ );
+ }
+
+ return $overrides;
+ }
+
+}
--- /dev/null
+<?php
+
+namespace MediaWiki\Tests\Revision;
+
+use CommentStoreComment;
+use InvalidArgumentException;
+use MediaWiki\Revision\MutableRevisionRecord;
+use MediaWiki\Revision\MutableRevisionSlots;
+use MediaWiki\Revision\RevisionAccessException;
+use MediaWiki\Revision\RevisionRecord;
+use MediaWiki\Revision\SlotRecord;
+use MediaWiki\Storage\RevisionSlotsUpdate;
+use MediaWiki\User\UserIdentityValue;
+use MediaWikiTestCase;
+use TextContent;
+use Title;
+use User;
+use WikitextContent;
+
+/**
+ * @covers \MediaWiki\Revision\MutableRevisionRecord
+ * @covers \MediaWiki\Revision\RevisionRecord
+ */
+class MutableRevisionRecordTest extends MediaWikiTestCase {
+
+ use RevisionRecordTests;
+
+ /**
+ * @param array $rowOverrides
+ *
+ * @return MutableRevisionRecord
+ */
+ protected function newRevision( array $rowOverrides = [] ) {
+ $title = Title::newFromText( 'Dummy' );
+ $title->resetArticleID( 17 );
+
+ $user = new UserIdentityValue( 11, 'Tester', 0 );
+ $comment = CommentStoreComment::newUnsavedComment( 'Hello World' );
+
+ $record = new MutableRevisionRecord( $title );
+
+ if ( isset( $rowOverrides['rev_deleted'] ) ) {
+ $record->setVisibility( $rowOverrides['rev_deleted'] );
+ }
+
+ if ( isset( $rowOverrides['rev_id'] ) ) {
+ $record->setId( $rowOverrides['rev_id'] );
+ }
+
+ if ( isset( $rowOverrides['rev_page'] ) ) {
+ $record->setPageId( $rowOverrides['rev_page'] );
+ }
+
+ $record->setContent( SlotRecord::MAIN, new TextContent( 'Lorem Ipsum' ) );
+ $record->setComment( $comment );
+ $record->setUser( $user );
+ $record->setTimestamp( '20101010000000' );
+
+ return $record;
+ }
+
+ public function provideConstructor() {
+ $title = Title::newFromText( 'Dummy' );
+ $title->resetArticleID( 17 );
+
+ yield [
+ $title,
+ 'acmewiki'
+ ];
+ }
+
+ /**
+ * @dataProvider provideConstructor
+ *
+ * @param Title $title
+ * @param bool $wikiId
+ */
+ public function testConstructorAndGetters(
+ Title $title,
+ $wikiId = false
+ ) {
+ $rec = new MutableRevisionRecord( $title, $wikiId );
+
+ $this->assertSame( $title, $rec->getPageAsLinkTarget(), 'getPageAsLinkTarget' );
+ $this->assertSame( $wikiId, $rec->getWikiId(), 'getWikiId' );
+ }
+
+ public function provideConstructorFailure() {
+ $title = Title::newFromText( 'Dummy' );
+ $title->resetArticleID( 17 );
+
+ yield 'not a wiki id' => [
+ $title,
+ null
+ ];
+ }
+
+ /**
+ * @dataProvider provideConstructorFailure
+ *
+ * @param Title $title
+ * @param bool $wikiId
+ */
+ public function testConstructorFailure(
+ Title $title,
+ $wikiId = false
+ ) {
+ $this->setExpectedException( InvalidArgumentException::class );
+ new MutableRevisionRecord( $title, $wikiId );
+ }
+
+ public function testSetGetId() {
+ $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+ $this->assertNull( $record->getId() );
+ $record->setId( 888 );
+ $this->assertSame( 888, $record->getId() );
+ }
+
+ public function testSetGetUser() {
+ $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+ $user = $this->getTestSysop()->getUser();
+ $this->assertNull( $record->getUser() );
+ $record->setUser( $user );
+ $this->assertSame( $user, $record->getUser() );
+ }
+
+ public function testSetGetPageId() {
+ $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+ $this->assertSame( 0, $record->getPageId() );
+ $record->setPageId( 999 );
+ $this->assertSame( 999, $record->getPageId() );
+ }
+
+ public function testSetGetParentId() {
+ $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+ $this->assertNull( $record->getParentId() );
+ $record->setParentId( 100 );
+ $this->assertSame( 100, $record->getParentId() );
+ }
+
+ public function testGetMainContentWhenEmpty() {
+ $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+ $this->setExpectedException( RevisionAccessException::class );
+ $this->assertNull( $record->getContent( SlotRecord::MAIN ) );
+ }
+
+ public function testSetGetMainContent() {
+ $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+ $content = new WikitextContent( 'Badger' );
+ $record->setContent( SlotRecord::MAIN, $content );
+ $this->assertSame( $content, $record->getContent( SlotRecord::MAIN ) );
+ }
+
+ public function testGetSlotWhenEmpty() {
+ $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+ $this->assertFalse( $record->hasSlot( SlotRecord::MAIN ) );
+
+ $this->setExpectedException( RevisionAccessException::class );
+ $record->getSlot( SlotRecord::MAIN );
+ }
+
+ public function testSetGetSlot() {
+ $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+ $slot = SlotRecord::newUnsaved(
+ SlotRecord::MAIN,
+ new WikitextContent( 'x' )
+ );
+ $record->setSlot( $slot );
+ $this->assertTrue( $record->hasSlot( SlotRecord::MAIN ) );
+ $this->assertSame( $slot, $record->getSlot( SlotRecord::MAIN ) );
+ }
+
+ public function testSetGetMinor() {
+ $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+ $this->assertFalse( $record->isMinor() );
+ $record->setMinorEdit( true );
+ $this->assertSame( true, $record->isMinor() );
+ }
+
+ public function testSetGetTimestamp() {
+ $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+ $this->assertNull( $record->getTimestamp() );
+ $record->setTimestamp( '20180101010101' );
+ $this->assertSame( '20180101010101', $record->getTimestamp() );
+ }
+
+ public function testSetGetVisibility() {
+ $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+ $this->assertSame( 0, $record->getVisibility() );
+ $record->setVisibility( RevisionRecord::DELETED_USER );
+ $this->assertSame( RevisionRecord::DELETED_USER, $record->getVisibility() );
+ }
+
+ public function testSetGetSha1() {
+ $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+ $this->assertSame( 'phoiac9h4m842xq45sp7s6u21eteeq1', $record->getSha1() );
+ $record->setSha1( 'someHash' );
+ $this->assertSame( 'someHash', $record->getSha1() );
+ }
+
+ public function testGetSlots() {
+ $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+ $this->assertInstanceOf( MutableRevisionSlots::class, $record->getSlots() );
+ }
+
+ public function testSetGetSize() {
+ $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+ $this->assertSame( 0, $record->getSize() );
+ $record->setSize( 775 );
+ $this->assertSame( 775, $record->getSize() );
+ }
+
+ public function testSetGetComment() {
+ $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+ $comment = new CommentStoreComment( 1, 'foo' );
+ $this->assertNull( $record->getComment() );
+ $record->setComment( $comment );
+ $this->assertSame( $comment, $record->getComment() );
+ }
+
+ public function testSimpleGetOriginalAndInheritedSlots() {
+ $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+ $mainSlot = new SlotRecord(
+ (object)[
+ 'slot_id' => 1,
+ 'slot_revision_id' => null, // unsaved
+ 'slot_content_id' => 1,
+ 'content_address' => null, // touched
+ 'model_name' => 'x',
+ 'role_name' => 'main',
+ 'slot_origin' => null // touched
+ ],
+ new WikitextContent( 'main' )
+ );
+ $auxSlot = new SlotRecord(
+ (object)[
+ 'slot_id' => 2,
+ 'slot_revision_id' => null, // unsaved
+ 'slot_content_id' => 1,
+ 'content_address' => 'foo', // inherited
+ 'model_name' => 'x',
+ 'role_name' => 'aux',
+ 'slot_origin' => 1 // inherited
+ ],
+ new WikitextContent( 'aux' )
+ );
+
+ $record->setSlot( $mainSlot );
+ $record->setSlot( $auxSlot );
+
+ $this->assertSame( [ 'main' ], $record->getOriginalSlots()->getSlotRoles() );
+ $this->assertSame( $mainSlot, $record->getOriginalSlots()->getSlot( SlotRecord::MAIN ) );
+
+ $this->assertSame( [ 'aux' ], $record->getInheritedSlots()->getSlotRoles() );
+ $this->assertSame( $auxSlot, $record->getInheritedSlots()->getSlot( 'aux' ) );
+ }
+
+ public function testSimpleremoveSlot() {
+ $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+
+ $a = new WikitextContent( 'a' );
+ $b = new WikitextContent( 'b' );
+
+ $record->inheritSlot( SlotRecord::newSaved( 7, 3, 'a', SlotRecord::newUnsaved( 'a', $a ) ) );
+ $record->inheritSlot( SlotRecord::newSaved( 7, 4, 'b', SlotRecord::newUnsaved( 'b', $b ) ) );
+
+ $record->removeSlot( 'b' );
+
+ $this->assertTrue( $record->hasSlot( 'a' ) );
+ $this->assertFalse( $record->hasSlot( 'b' ) );
+ }
+
+ public function testApplyUpdate() {
+ $update = new RevisionSlotsUpdate();
+
+ $a = new WikitextContent( 'a' );
+ $b = new WikitextContent( 'b' );
+ $c = new WikitextContent( 'c' );
+ $x = new WikitextContent( 'x' );
+
+ $update->modifyContent( 'b', $x );
+ $update->modifyContent( 'c', $x );
+ $update->removeSlot( 'c' );
+ $update->removeSlot( 'd' );
+
+ $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+ $record->inheritSlot( SlotRecord::newSaved( 7, 3, 'a', SlotRecord::newUnsaved( 'a', $a ) ) );
+ $record->inheritSlot( SlotRecord::newSaved( 7, 4, 'b', SlotRecord::newUnsaved( 'b', $b ) ) );
+ $record->inheritSlot( SlotRecord::newSaved( 7, 5, 'c', SlotRecord::newUnsaved( 'c', $c ) ) );
+
+ $record->applyUpdate( $update );
+
+ $this->assertEquals( [ 'b' ], array_keys( $record->getOriginalSlots()->getSlots() ) );
+ $this->assertEquals( $a, $record->getSlot( 'a' )->getContent() );
+ $this->assertEquals( $x, $record->getSlot( 'b' )->getContent() );
+ $this->assertFalse( $record->hasSlot( 'c' ) );
+ }
+
+ public function provideNotReadyForInsertion() {
+ /** @var Title $title */
+ $title = $this->getMock( Title::class );
+
+ /** @var User $user */
+ $user = $this->getMock( User::class );
+
+ /** @var CommentStoreComment $comment */
+ $comment = $this->getMockBuilder( CommentStoreComment::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $content = new TextContent( 'Test' );
+
+ $rev = new MutableRevisionRecord( $title );
+ yield 'empty' => [ $rev ];
+
+ $rev = new MutableRevisionRecord( $title );
+ $rev->setContent( SlotRecord::MAIN, $content );
+ $rev->setUser( $user );
+ $rev->setComment( $comment );
+ yield 'no timestamp' => [ $rev ];
+
+ $rev = new MutableRevisionRecord( $title );
+ $rev->setUser( $user );
+ $rev->setComment( $comment );
+ $rev->setTimestamp( '20101010000000' );
+ yield 'no content' => [ $rev ];
+
+ $rev = new MutableRevisionRecord( $title );
+ $rev->setContent( SlotRecord::MAIN, $content );
+ $rev->setComment( $comment );
+ $rev->setTimestamp( '20101010000000' );
+ yield 'no user' => [ $rev ];
+
+ $rev = new MutableRevisionRecord( $title );
+ $rev->setUser( $user );
+ $rev->setContent( SlotRecord::MAIN, $content );
+ $rev->setTimestamp( '20101010000000' );
+ yield 'no comment' => [ $rev ];
+ }
+
+ /**
+ * @dataProvider provideNotReadyForInsertion
+ */
+ public function testNotReadyForInsertion( $rev ) {
+ $this->assertFalse( $rev->isReadyForInsertion() );
+ }
+}
--- /dev/null
+<?php
+
+namespace MediaWiki\Tests\Revision;
+
+use Content;
+use InvalidArgumentException;
+use MediaWiki\Revision\MutableRevisionSlots;
+use MediaWiki\Revision\RevisionAccessException;
+use MediaWiki\Revision\RevisionSlots;
+use MediaWiki\Revision\SlotRecord;
+use WikitextContent;
+
+/**
+ * @covers \MediaWiki\Revision\MutableRevisionSlots
+ */
+class MutableRevisionSlotsTest extends RevisionSlotsTest {
+
+ /**
+ * @param SlotRecord[] $slots
+ * @return RevisionSlots
+ */
+ protected function newRevisionSlots( $slots = [] ) {
+ return new MutableRevisionSlots( $slots );
+ }
+
+ public function provideConstructorFailue() {
+ yield 'array or the wrong thing' => [
+ [ 1, 2, 3 ]
+ ];
+ }
+
+ /**
+ * @dataProvider provideConstructorFailue
+ * @param $slots
+ *
+ * @covers \MediaWiki\Revision\RevisionSlots::__construct
+ * @covers \MediaWiki\Revision\RevisionSlots::setSlotsInternal
+ */
+ public function testConstructorFailue( $slots ) {
+ $this->setExpectedException( InvalidArgumentException::class );
+
+ new MutableRevisionSlots( $slots );
+ }
+
+ public function testSetMultipleSlots() {
+ $slots = new MutableRevisionSlots();
+
+ $this->assertSame( [], $slots->getSlots() );
+
+ $slotA = SlotRecord::newUnsaved( 'some', new WikitextContent( 'A' ) );
+ $slots->setSlot( $slotA );
+ $this->assertTrue( $slots->hasSlot( 'some' ) );
+ $this->assertSame( $slotA, $slots->getSlot( 'some' ) );
+ $this->assertSame( [ 'some' => $slotA ], $slots->getSlots() );
+
+ $slotB = SlotRecord::newUnsaved( 'other', new WikitextContent( 'B' ) );
+ $slots->setSlot( $slotB );
+ $this->assertTrue( $slots->hasSlot( 'other' ) );
+ $this->assertSame( $slotB, $slots->getSlot( 'other' ) );
+ $this->assertSame( [ 'some' => $slotA, 'other' => $slotB ], $slots->getSlots() );
+ }
+
+ public function testSetExistingSlotOverwritesSlot() {
+ $slots = new MutableRevisionSlots();
+
+ $this->assertSame( [], $slots->getSlots() );
+
+ $slotA = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) );
+ $slots->setSlot( $slotA );
+ $this->assertSame( $slotA, $slots->getSlot( SlotRecord::MAIN ) );
+ $this->assertSame( [ 'main' => $slotA ], $slots->getSlots() );
+
+ $slotB = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'B' ) );
+ $slots->setSlot( $slotB );
+ $this->assertSame( $slotB, $slots->getSlot( SlotRecord::MAIN ) );
+ $this->assertSame( [ 'main' => $slotB ], $slots->getSlots() );
+ }
+
+ /**
+ * @param string $role
+ * @param Content $content
+ * @return SlotRecord
+ */
+ private function newSavedSlot( $role, Content $content ) {
+ return SlotRecord::newSaved( 7, 7, 'xyz', SlotRecord::newUnsaved( $role, $content ) );
+ }
+
+ public function testInheritSlotOverwritesSlot() {
+ $slots = new MutableRevisionSlots();
+ $slotA = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) );
+ $slots->setSlot( $slotA );
+ $slotB = $this->newSavedSlot( SlotRecord::MAIN, new WikitextContent( 'B' ) );
+ $slotC = $this->newSavedSlot( 'foo', new WikitextContent( 'C' ) );
+ $slots->inheritSlot( $slotB );
+ $slots->inheritSlot( $slotC );
+ $this->assertSame( [ 'main', 'foo' ], $slots->getSlotRoles() );
+ $this->assertNotSame( $slotB, $slots->getSlot( SlotRecord::MAIN ) );
+ $this->assertNotSame( $slotC, $slots->getSlot( 'foo' ) );
+ $this->assertTrue( $slots->getSlot( SlotRecord::MAIN )->isInherited() );
+ $this->assertTrue( $slots->getSlot( 'foo' )->isInherited() );
+ $this->assertSame( $slotB->getContent(), $slots->getSlot( SlotRecord::MAIN )->getContent() );
+ $this->assertSame( $slotC->getContent(), $slots->getSlot( 'foo' )->getContent() );
+ }
+
+ public function testSetContentOfExistingSlotOverwritesContent() {
+ $slots = new MutableRevisionSlots();
+
+ $this->assertSame( [], $slots->getSlots() );
+
+ $slotA = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) );
+ $slots->setSlot( $slotA );
+ $this->assertSame( $slotA, $slots->getSlot( SlotRecord::MAIN ) );
+ $this->assertSame( [ 'main' => $slotA ], $slots->getSlots() );
+
+ $newContent = new WikitextContent( 'B' );
+ $slots->setContent( SlotRecord::MAIN, $newContent );
+ $this->assertSame( $newContent, $slots->getContent( SlotRecord::MAIN ) );
+ }
+
+ public function testRemoveExistingSlot() {
+ $slotA = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) );
+ $slots = new MutableRevisionSlots( [ $slotA ] );
+
+ $this->assertSame( [ 'main' => $slotA ], $slots->getSlots() );
+
+ $slots->removeSlot( SlotRecord::MAIN );
+ $this->assertSame( [], $slots->getSlots() );
+ $this->setExpectedException( RevisionAccessException::class );
+ $slots->getSlot( SlotRecord::MAIN );
+ }
+
+ public function testNewFromParentRevisionSlots() {
+ /** @var SlotRecord[] $parentSlots */
+ $parentSlots = [
+ 'some' => $this->newSavedSlot( 'some', new WikitextContent( 'X' ) ),
+ 'other' => $this->newSavedSlot( 'other', new WikitextContent( 'Y' ) ),
+ ];
+ $slots = MutableRevisionSlots::newFromParentRevisionSlots( $parentSlots );
+ $this->assertSame( [ 'some', 'other' ], $slots->getSlotRoles() );
+ $this->assertNotSame( $parentSlots['some'], $slots->getSlot( 'some' ) );
+ $this->assertNotSame( $parentSlots['other'], $slots->getSlot( 'other' ) );
+ $this->assertTrue( $slots->getSlot( 'some' )->isInherited() );
+ $this->assertTrue( $slots->getSlot( 'other' )->isInherited() );
+ $this->assertSame( $parentSlots['some']->getContent(), $slots->getContent( 'some' ) );
+ $this->assertSame( $parentSlots['other']->getContent(), $slots->getContent( 'other' ) );
+ }
+
+}
--- /dev/null
+<?php
+namespace MediaWiki\Tests\Revision;
+
+use Revision;
+
+/**
+ * Tests RevisionStore against the pre-MCR, pre-ContentHandler DB schema.
+ *
+ * @covers \MediaWiki\Revision\RevisionStore
+ *
+ * @group RevisionStore
+ * @group Storage
+ * @group Database
+ * @group medium
+ */
+class NoContentModelRevisionStoreDbTest extends RevisionStoreDbTestBase {
+
+ use PreMcrSchemaOverride;
+
+ protected function getContentHandlerUseDB() {
+ return false;
+ }
+
+ protected function revisionToRow( Revision $rev, $options = [ 'page', 'user', 'comment' ] ) {
+ $row = parent::revisionToRow( $rev, $options );
+
+ $row->rev_text_id = (string)$rev->getTextId();
+
+ return $row;
+ }
+
+ public function provideGetArchiveQueryInfo() {
+ yield [
+ [
+ 'tables' => [ 'archive' ],
+ 'fields' => array_merge(
+ $this->getDefaultArchiveFields(),
+ [
+ 'ar_comment_text' => 'ar_comment',
+ 'ar_comment_data' => 'NULL',
+ 'ar_comment_cid' => 'NULL',
+ 'ar_user_text' => 'ar_user_text',
+ 'ar_user' => 'ar_user',
+ 'ar_actor' => 'NULL',
+ ]
+ ),
+ 'joins' => [],
+ ]
+ ];
+ }
+
+ public function provideGetQueryInfo() {
+ yield [
+ [],
+ [
+ 'tables' => [ 'revision' ],
+ 'fields' => array_merge(
+ $this->getDefaultQueryFields(),
+ $this->getCommentQueryFields(),
+ $this->getActorQueryFields()
+ ),
+ 'joins' => [],
+ ]
+ ];
+ yield [
+ [ 'page' ],
+ [
+ 'tables' => [ 'revision', 'page' ],
+ 'fields' => array_merge(
+ $this->getDefaultQueryFields(),
+ $this->getCommentQueryFields(),
+ $this->getActorQueryFields(),
+ [
+ 'page_namespace',
+ 'page_title',
+ 'page_id',
+ 'page_latest',
+ 'page_is_redirect',
+ 'page_len',
+ ]
+ ),
+ 'joins' => [
+ 'page' => [ 'INNER JOIN', [ 'page_id = rev_page' ] ],
+ ],
+ ]
+ ];
+ yield [
+ [ 'user' ],
+ [
+ 'tables' => [ 'revision', 'user' ],
+ 'fields' => array_merge(
+ $this->getDefaultQueryFields(),
+ $this->getCommentQueryFields(),
+ $this->getActorQueryFields(),
+ [
+ 'user_name',
+ ]
+ ),
+ 'joins' => [
+ 'user' => [ 'LEFT JOIN', [ 'rev_user != 0', 'user_id = rev_user' ] ],
+ ],
+ ]
+ ];
+ yield [
+ [ 'text' ],
+ [
+ 'tables' => [ 'revision', 'text' ],
+ 'fields' => array_merge(
+ $this->getDefaultQueryFields(),
+ $this->getCommentQueryFields(),
+ $this->getActorQueryFields(),
+ [
+ 'old_text',
+ 'old_flags',
+ ]
+ ),
+ 'joins' => [
+ 'text' => [ 'INNER JOIN', [ 'rev_text_id=old_id' ] ],
+ ],
+ ]
+ ];
+ }
+
+ public function provideGetSlotsQueryInfo() {
+ $db = wfGetDB( DB_REPLICA );
+
+ yield [
+ [],
+ [
+ 'tables' => [
+ 'slots' => 'revision',
+ ],
+ 'fields' => array_merge(
+ [
+ 'slot_revision_id' => 'slots.rev_id',
+ 'slot_content_id' => 'NULL',
+ 'slot_origin' => 'slots.rev_id',
+ 'role_name' => $db->addQuotes( SlotRecord::MAIN ),
+ ]
+ ),
+ 'joins' => [],
+ ]
+ ];
+ yield [
+ [ 'content' ],
+ [
+ 'tables' => [
+ 'slots' => 'revision',
+ ],
+ 'fields' => array_merge(
+ [
+ 'slot_revision_id' => 'slots.rev_id',
+ 'slot_content_id' => 'NULL',
+ 'slot_origin' => 'slots.rev_id',
+ 'role_name' => $db->addQuotes( SlotRecord::MAIN ),
+ 'content_size' => 'slots.rev_len',
+ 'content_sha1' => 'slots.rev_sha1',
+ 'content_address' =>
+ $db->buildConcat( [ $db->addQuotes( 'tt:' ), 'slots.rev_text_id' ] ),
+ 'model_name' => 'NULL',
+ ]
+ ),
+ 'joins' => [],
+ ]
+ ];
+ }
+
+ public function provideNewMutableRevisionFromArray() {
+ foreach ( parent::provideNewMutableRevisionFromArray() as $case ) {
+ yield $case;
+ }
+
+ yield 'Basic array, with page & id' => [
+ [
+ 'id' => 2,
+ 'page' => 1,
+ 'text_id' => 2,
+ 'timestamp' => '20171017114835',
+ 'user_text' => '111.0.1.2',
+ 'user' => 0,
+ 'minor_edit' => false,
+ 'deleted' => 0,
+ 'len' => 46,
+ 'parent_id' => 1,
+ 'sha1' => 'rdqbbzs3pkhihgbs8qf2q9jsvheag5z',
+ 'comment' => 'Goat Comment!',
+ ]
+ ];
+ }
+
+}
--- /dev/null
+<?php
+namespace MediaWiki\Tests\Revision;
+
+use InvalidArgumentException;
+use MediaWiki\Revision\RevisionRecord;
+use Revision;
+use WikitextContent;
+
+/**
+ * Tests RevisionStore against the pre-MCR DB schema.
+ *
+ * @covers \MediaWiki\Revision\RevisionStore
+ *
+ * @group RevisionStore
+ * @group Storage
+ * @group Database
+ * @group medium
+ */
+class PreMcrRevisionStoreDbTest extends RevisionStoreDbTestBase {
+
+ use PreMcrSchemaOverride;
+
+ protected function revisionToRow( Revision $rev, $options = [ 'page', 'user', 'comment' ] ) {
+ $row = parent::revisionToRow( $rev, $options );
+
+ $row->rev_text_id = (string)$rev->getTextId();
+ $row->rev_content_format = (string)$rev->getContentFormat();
+ $row->rev_content_model = (string)$rev->getContentModel();
+
+ return $row;
+ }
+
+ protected function assertRevisionExistsInDatabase( RevisionRecord $rev ) {
+ // Legacy schema is still being written
+ $this->assertSelect(
+ [ 'revision', 'text' ],
+ [ 'count(*)' ],
+ [ 'rev_id' => $rev->getId(), 'rev_text_id > 0' ],
+ [ [ 1 ] ],
+ [],
+ [ 'text' => [ 'INNER JOIN', [ 'rev_text_id = old_id' ] ] ]
+ );
+
+ parent::assertRevisionExistsInDatabase( $rev );
+ }
+
+ public function provideInsertRevisionOn_failures() {
+ foreach ( parent::provideInsertRevisionOn_failures() as $case ) {
+ yield $case;
+ }
+
+ yield 'slot that is not main slot' => [
+ [
+ 'content' => [
+ 'main' => new WikitextContent( 'Chicken' ),
+ 'lalala' => new WikitextContent( 'Duck' ),
+ ],
+ 'comment' => $this->getRandomCommentStoreComment(),
+ 'timestamp' => '20171117010101',
+ 'user' => true,
+ ],
+ new InvalidArgumentException( 'Only the main slot is supported' )
+ ];
+ }
+
+ public function provideNewMutableRevisionFromArray() {
+ foreach ( parent::provideNewMutableRevisionFromArray() as $case ) {
+ yield $case;
+ }
+
+ yield 'Basic array, with page & id' => [
+ [
+ 'id' => 2,
+ 'page' => 1,
+ 'text_id' => 2,
+ 'timestamp' => '20171017114835',
+ 'user_text' => '111.0.1.2',
+ 'user' => 0,
+ 'minor_edit' => false,
+ 'deleted' => 0,
+ 'len' => 46,
+ 'parent_id' => 1,
+ 'sha1' => 'rdqbbzs3pkhihgbs8qf2q9jsvheag5z',
+ 'comment' => 'Goat Comment!',
+ 'content_format' => 'text/x-wiki',
+ 'content_model' => 'wikitext',
+ ]
+ ];
+ }
+
+}
--- /dev/null
+<?php
+namespace MediaWiki\Tests\Revision;
+
+use Wikimedia\Rdbms\IMaintainableDatabase;
+use MediaWiki\DB\PatchFileLocation;
+
+/**
+ * Trait providing schema overrides that allow tests to run against the pre-MCR database schema.
+ */
+trait PreMcrSchemaOverride {
+
+ use PatchFileLocation;
+ use McrSchemaDetection;
+
+ /**
+ * @return int
+ */
+ protected function getMcrMigrationStage() {
+ return MIGRATION_OLD;
+ }
+
+ /**
+ * @return string[]
+ */
+ protected function getMcrTablesToReset() {
+ return [];
+ }
+
+ /**
+ * @return array[]
+ */
+ protected function getSchemaOverrides( IMaintainableDatabase $db ) {
+ $overrides = [
+ 'scripts' => [],
+ 'drop' => [],
+ 'create' => [],
+ 'alter' => [],
+ ];
+
+ if ( $this->hasMcrTables( $db ) ) {
+ $overrides['drop'] = [ 'slots', 'content', 'slot_roles', 'content_models', ];
+ $overrides['scripts'][] = $this->getSqlPatchPath( $db, '/drop-mcr-tables', __DIR__ );
+ }
+
+ if ( !$this->hasPreMcrFields( $db ) ) {
+ $overrides['alter'][] = 'revision';
+ $overrides['scripts'][] = $this->getSqlPatchPath( $db, '/create-pre-mcr-fields', __DIR__ );
+ }
+
+ return $overrides;
+ }
+
+}
use Content;
use Language;
+use MediaWiki\Revision\MutableRevisionRecord;
+use MediaWiki\Revision\MutableRevisionSlots;
use MediaWiki\Revision\RenderedRevision;
-use MediaWiki\Storage\MutableRevisionRecord;
-use MediaWiki\Storage\MutableRevisionSlots;
-use MediaWiki\Storage\RevisionArchiveRecord;
-use MediaWiki\Storage\RevisionRecord;
-use MediaWiki\Storage\RevisionStore;
-use MediaWiki\Storage\RevisionStoreRecord;
-use MediaWiki\Storage\SlotRecord;
-use MediaWiki\Storage\SuppressedDataException;
-use MediaWiki\User\UserIdentityValue;
+use MediaWiki\Revision\RevisionArchiveRecord;
+use MediaWiki\Revision\RevisionRecord;
+use MediaWiki\Revision\RevisionStore;
+use MediaWiki\Revision\RevisionStoreRecord;
+use MediaWiki\Revision\SlotRecord;
+use MediaWiki\Revision\SuppressedDataException;
use MediaWikiTestCase;
+use MediaWiki\User\UserIdentityValue;
use ParserOptions;
use ParserOutput;
use PHPUnit\Framework\MockObject\MockObject;
--- /dev/null
+<?php
+
+namespace MediaWiki\Tests\Revision;
+
+use CommentStoreComment;
+use InvalidArgumentException;
+use MediaWiki\Revision\RevisionRecord;
+use MediaWiki\Revision\RevisionSlots;
+use MediaWiki\Revision\RevisionArchiveRecord;
+use MediaWiki\Revision\SlotRecord;
+use MediaWiki\User\UserIdentity;
+use MediaWiki\User\UserIdentityValue;
+use MediaWikiTestCase;
+use TextContent;
+use Title;
+
+/**
+ * @covers \MediaWiki\Revision\RevisionArchiveRecord
+ * @covers \MediaWiki\Revision\RevisionRecord
+ */
+class RevisionArchiveRecordTest extends MediaWikiTestCase {
+
+ use RevisionRecordTests;
+
+ /**
+ * @param array $rowOverrides
+ *
+ * @return RevisionArchiveRecord
+ */
+ protected function newRevision( array $rowOverrides = [] ) {
+ $title = Title::newFromText( 'Dummy' );
+ $title->resetArticleID( 17 );
+
+ $user = new UserIdentityValue( 11, 'Tester', 0 );
+ $comment = CommentStoreComment::newUnsavedComment( 'Hello World' );
+
+ $main = SlotRecord::newUnsaved( SlotRecord::MAIN, new TextContent( 'Lorem Ipsum' ) );
+ $aux = SlotRecord::newUnsaved( 'aux', new TextContent( 'Frumious Bandersnatch' ) );
+ $slots = new RevisionSlots( [ $main, $aux ] );
+
+ $row = [
+ 'ar_id' => '5',
+ 'ar_rev_id' => '7',
+ 'ar_page_id' => strval( $title->getArticleID() ),
+ 'ar_timestamp' => '20200101000000',
+ 'ar_deleted' => 0,
+ 'ar_minor_edit' => 0,
+ 'ar_parent_id' => '5',
+ 'ar_len' => $slots->computeSize(),
+ 'ar_sha1' => $slots->computeSha1(),
+ ];
+
+ foreach ( $rowOverrides as $field => $value ) {
+ $field = preg_replace( '/^rev_/', 'ar_', $field );
+ $row[$field] = $value;
+ }
+
+ return new RevisionArchiveRecord( $title, $user, $comment, (object)$row, $slots );
+ }
+
+ public function provideConstructor() {
+ $title = Title::newFromText( 'Dummy' );
+ $title->resetArticleID( 17 );
+
+ $user = new UserIdentityValue( 11, 'Tester', 0 );
+ $comment = CommentStoreComment::newUnsavedComment( 'Hello World' );
+
+ $main = SlotRecord::newUnsaved( SlotRecord::MAIN, new TextContent( 'Lorem Ipsum' ) );
+ $aux = SlotRecord::newUnsaved( 'aux', new TextContent( 'Frumious Bandersnatch' ) );
+ $slots = new RevisionSlots( [ $main, $aux ] );
+
+ $protoRow = [
+ 'ar_id' => '5',
+ 'ar_rev_id' => '7',
+ 'ar_page_id' => strval( $title->getArticleID() ),
+ 'ar_timestamp' => '20200101000000',
+ 'ar_deleted' => 0,
+ 'ar_minor_edit' => 0,
+ 'ar_parent_id' => '5',
+ 'ar_len' => $slots->computeSize(),
+ 'ar_sha1' => $slots->computeSha1(),
+ ];
+
+ $row = $protoRow;
+ yield 'all info' => [
+ $title,
+ $user,
+ $comment,
+ (object)$row,
+ $slots,
+ 'acmewiki'
+ ];
+
+ $row = $protoRow;
+ $row['ar_minor_edit'] = '1';
+ $row['ar_deleted'] = strval( RevisionRecord::DELETED_USER );
+
+ yield 'minor deleted' => [
+ $title,
+ $user,
+ $comment,
+ (object)$row,
+ $slots
+ ];
+
+ $row = $protoRow;
+ unset( $row['ar_parent'] );
+
+ yield 'no parent' => [
+ $title,
+ $user,
+ $comment,
+ (object)$row,
+ $slots
+ ];
+
+ $row = $protoRow;
+ $row['ar_len'] = null;
+ $row['ar_sha1'] = '';
+
+ yield 'ar_len is null, ar_sha1 is ""' => [
+ $title,
+ $user,
+ $comment,
+ (object)$row,
+ $slots
+ ];
+
+ $row = $protoRow;
+ yield 'no length, no hash' => [
+ Title::newFromText( 'DummyDoesNotExist' ),
+ $user,
+ $comment,
+ (object)$row,
+ $slots
+ ];
+ }
+
+ /**
+ * @dataProvider provideConstructor
+ *
+ * @param Title $title
+ * @param UserIdentity $user
+ * @param CommentStoreComment $comment
+ * @param object $row
+ * @param RevisionSlots $slots
+ * @param bool $wikiId
+ */
+ public function testConstructorAndGetters(
+ Title $title,
+ UserIdentity $user,
+ CommentStoreComment $comment,
+ $row,
+ RevisionSlots $slots,
+ $wikiId = false
+ ) {
+ $rec = new RevisionArchiveRecord( $title, $user, $comment, $row, $slots, $wikiId );
+
+ $this->assertSame( $title, $rec->getPageAsLinkTarget(), 'getPageAsLinkTarget' );
+ $this->assertSame( $user, $rec->getUser( RevisionRecord::RAW ), 'getUser' );
+ $this->assertSame( $comment, $rec->getComment(), 'getComment' );
+
+ $this->assertSame( $slots->getSlotRoles(), $rec->getSlotRoles(), 'getSlotRoles' );
+ $this->assertSame( $wikiId, $rec->getWikiId(), 'getWikiId' );
+
+ $this->assertSame( (int)$row->ar_id, $rec->getArchiveId(), 'getArchiveId' );
+ $this->assertSame( (int)$row->ar_rev_id, $rec->getId(), 'getId' );
+ $this->assertSame( (int)$row->ar_page_id, $rec->getPageId(), 'getId' );
+ $this->assertSame( $row->ar_timestamp, $rec->getTimestamp(), 'getTimestamp' );
+ $this->assertSame( (int)$row->ar_deleted, $rec->getVisibility(), 'getVisibility' );
+ $this->assertSame( (bool)$row->ar_minor_edit, $rec->isMinor(), 'getIsMinor' );
+
+ if ( isset( $row->ar_parent_id ) ) {
+ $this->assertSame( (int)$row->ar_parent_id, $rec->getParentId(), 'getParentId' );
+ } else {
+ $this->assertSame( 0, $rec->getParentId(), 'getParentId' );
+ }
+
+ if ( isset( $row->ar_len ) ) {
+ $this->assertSame( (int)$row->ar_len, $rec->getSize(), 'getSize' );
+ } else {
+ $this->assertSame( $slots->computeSize(), $rec->getSize(), 'getSize' );
+ }
+
+ if ( !empty( $row->ar_sha1 ) ) {
+ $this->assertSame( $row->ar_sha1, $rec->getSha1(), 'getSha1' );
+ } else {
+ $this->assertSame( $slots->computeSha1(), $rec->getSha1(), 'getSha1' );
+ }
+ }
+
+ public function provideConstructorFailure() {
+ $title = Title::newFromText( 'Dummy' );
+ $title->resetArticleID( 17 );
+
+ $user = new UserIdentityValue( 11, 'Tester', 0 );
+
+ $comment = CommentStoreComment::newUnsavedComment( 'Hello World' );
+
+ $main = SlotRecord::newUnsaved( SlotRecord::MAIN, new TextContent( 'Lorem Ipsum' ) );
+ $aux = SlotRecord::newUnsaved( 'aux', new TextContent( 'Frumious Bandersnatch' ) );
+ $slots = new RevisionSlots( [ $main, $aux ] );
+
+ $protoRow = [
+ 'ar_id' => '5',
+ 'ar_rev_id' => '7',
+ 'ar_page_id' => strval( $title->getArticleID() ),
+ 'ar_timestamp' => '20200101000000',
+ 'ar_deleted' => 0,
+ 'ar_minor_edit' => 0,
+ 'ar_parent_id' => '5',
+ 'ar_len' => $slots->computeSize(),
+ 'ar_sha1' => $slots->computeSha1(),
+ ];
+
+ yield 'not a row' => [
+ $title,
+ $user,
+ $comment,
+ 'not a row',
+ $slots,
+ 'acmewiki'
+ ];
+
+ $row = $protoRow;
+ $row['ar_timestamp'] = 'kittens';
+
+ yield 'bad timestamp' => [
+ $title,
+ $user,
+ $comment,
+ (object)$row,
+ $slots
+ ];
+
+ $row = $protoRow;
+
+ yield 'bad wiki' => [
+ $title,
+ $user,
+ $comment,
+ (object)$row,
+ $slots,
+ 12345
+ ];
+
+ // NOTE: $title->getArticleID does *not* have to match ar_page_id in all cases!
+ }
+
+ /**
+ * @dataProvider provideConstructorFailure
+ *
+ * @param Title $title
+ * @param UserIdentity $user
+ * @param CommentStoreComment $comment
+ * @param object $row
+ * @param RevisionSlots $slots
+ * @param bool $wikiId
+ */
+ public function testConstructorFailure(
+ Title $title,
+ UserIdentity $user,
+ CommentStoreComment $comment,
+ $row,
+ RevisionSlots $slots,
+ $wikiId = false
+ ) {
+ $this->setExpectedException( InvalidArgumentException::class );
+ new RevisionArchiveRecord( $title, $user, $comment, $row, $slots, $wikiId );
+ }
+
+}
--- /dev/null
+<?php
+namespace MediaWiki\Tests\Revision;
+
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Revision\SlotRecord;
+use MediaWikiTestCase;
+use Revision;
+
+/**
+ * Tests RevisionStore against the post-migration MCR DB schema.
+ *
+ * @group RevisionStore
+ * @group Storage
+ * @group Database
+ */
+class RevisionQueryInfoTest extends MediaWikiTestCase {
+
+ protected function getRevisionQueryFields( $returnTextIdField = true ) {
+ $fields = [
+ 'rev_id',
+ 'rev_page',
+ 'rev_timestamp',
+ 'rev_minor_edit',
+ 'rev_deleted',
+ 'rev_len',
+ 'rev_parent_id',
+ 'rev_sha1',
+ ];
+ if ( $returnTextIdField ) {
+ $fields[] = 'rev_text_id';
+ }
+ return $fields;
+ }
+
+ protected function getArchiveQueryFields( $returnTextFields = true ) {
+ $fields = [
+ 'ar_id',
+ 'ar_page_id',
+ 'ar_namespace',
+ 'ar_title',
+ 'ar_rev_id',
+ 'ar_timestamp',
+ 'ar_minor_edit',
+ 'ar_deleted',
+ 'ar_len',
+ 'ar_parent_id',
+ 'ar_sha1',
+ ];
+ if ( $returnTextFields ) {
+ $fields[] = 'ar_text_id';
+ }
+ return $fields;
+ }
+
+ protected function getOldCommentQueryFields( $prefix ) {
+ return [
+ "{$prefix}_comment_text" => "{$prefix}_comment",
+ "{$prefix}_comment_data" => 'NULL',
+ "{$prefix}_comment_cid" => 'NULL',
+ ];
+ }
+
+ protected function getCompatCommentQueryFields( $prefix ) {
+ return [
+ "{$prefix}_comment_text"
+ => "COALESCE( comment_{$prefix}_comment.comment_text, {$prefix}_comment )",
+ "{$prefix}_comment_data" => "comment_{$prefix}_comment.comment_data",
+ "{$prefix}_comment_cid" => "comment_{$prefix}_comment.comment_id",
+ ];
+ }
+
+ protected function getNewCommentQueryFields( $prefix ) {
+ return [
+ "{$prefix}_comment_text" => "comment_{$prefix}_comment.comment_text",
+ "{$prefix}_comment_data" => "comment_{$prefix}_comment.comment_data",
+ "{$prefix}_comment_cid" => "comment_{$prefix}_comment.comment_id",
+ ];
+ }
+
+ protected function getOldActorQueryFields( $prefix ) {
+ return [
+ "{$prefix}_user" => "{$prefix}_user",
+ "{$prefix}_user_text" => "{$prefix}_user_text",
+ "{$prefix}_actor" => 'NULL',
+ ];
+ }
+
+ protected function getNewActorQueryFields( $prefix, $tmp = false ) {
+ return [
+ "{$prefix}_user" => "actor_{$prefix}_user.actor_user",
+ "{$prefix}_user_text" => "actor_{$prefix}_user.actor_name",
+ "{$prefix}_actor" => $tmp ?: "{$prefix}_actor",
+ ];
+ }
+
+ protected function getNewActorJoins( $prefix ) {
+ return [
+ "temp_{$prefix}_user" => [
+ "JOIN",
+ "temp_{$prefix}_user.revactor_{$prefix} = {$prefix}_id",
+ ],
+ "actor_{$prefix}_user" => [
+ "JOIN",
+ "actor_{$prefix}_user.actor_id = temp_{$prefix}_user.revactor_actor",
+ ],
+ ];
+ }
+
+ protected function getCompatCommentJoins( $prefix ) {
+ return [
+ "temp_{$prefix}_comment" => [
+ "LEFT JOIN",
+ "temp_{$prefix}_comment.revcomment_{$prefix} = {$prefix}_id",
+ ],
+ "comment_{$prefix}_comment" => [
+ "LEFT JOIN",
+ "comment_{$prefix}_comment.comment_id = temp_{$prefix}_comment.revcomment_comment_id",
+ ],
+ ];
+ }
+
+ protected function getTextQueryFields() {
+ return [
+ 'old_text',
+ 'old_flags',
+ ];
+ }
+
+ protected function getPageQueryFields() {
+ return [
+ 'page_namespace',
+ 'page_title',
+ 'page_id',
+ 'page_latest',
+ 'page_is_redirect',
+ 'page_len',
+ ];
+ }
+
+ protected function getUserQueryFields() {
+ return [
+ 'user_name',
+ ];
+ }
+
+ protected function getContentHandlerQueryFields( $prefix ) {
+ return [
+ "{$prefix}_content_format",
+ "{$prefix}_content_model",
+ ];
+ }
+
+ public function provideArchiveQueryInfo() {
+ yield 'MCR, comment, actor' => [
+ [
+ 'wgMultiContentRevisionSchemaMigrationStage' => SCHEMA_COMPAT_NEW,
+ 'wgCommentTableSchemaMigrationStage' => MIGRATION_NEW,
+ 'wgActorTableSchemaMigrationStage' => SCHEMA_COMPAT_NEW,
+ ],
+ [
+ 'tables' => [
+ 'archive',
+ 'actor_ar_user' => 'actor',
+ 'comment_ar_comment' => 'comment',
+ ],
+ 'fields' => array_merge(
+ $this->getArchiveQueryFields( false ),
+ $this->getNewActorQueryFields( 'ar' ),
+ $this->getNewCommentQueryFields( 'ar' )
+ ),
+ 'joins' => [
+ 'comment_ar_comment'
+ => [ 'JOIN', 'comment_ar_comment.comment_id = ar_comment_id' ],
+ 'actor_ar_user' => [ 'JOIN', 'actor_ar_user.actor_id = ar_actor' ],
+ ],
+ ]
+ ];
+ yield 'read-new MCR, comment, actor' => [
+ [
+ 'wgContentHandlerUseDB' => true,
+ 'wgMultiContentRevisionSchemaMigrationStage'
+ => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW,
+ 'wgCommentTableSchemaMigrationStage' => MIGRATION_WRITE_NEW,
+ 'wgActorTableSchemaMigrationStage' => SCHEMA_COMPAT_NEW,
+ ],
+ [
+ 'tables' => [
+ 'archive',
+ 'actor_ar_user' => 'actor',
+ 'comment_ar_comment' => 'comment',
+ ],
+ 'fields' => array_merge(
+ $this->getArchiveQueryFields( false ),
+ $this->getNewActorQueryFields( 'ar' ),
+ $this->getCompatCommentQueryFields( 'ar' )
+ ),
+ 'joins' => [
+ 'comment_ar_comment'
+ => [ 'LEFT JOIN', 'comment_ar_comment.comment_id = ar_comment_id' ],
+ 'actor_ar_user' => [ 'JOIN', 'actor_ar_user.actor_id = ar_actor' ],
+ ],
+ ]
+ ];
+ yield 'MCR write-both/read-old' => [
+ [
+ 'wgContentHandlerUseDB' => true,
+ 'wgMultiContentRevisionSchemaMigrationStage'
+ => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD,
+ 'wgCommentTableSchemaMigrationStage' => MIGRATION_WRITE_BOTH,
+ 'wgActorTableSchemaMigrationStage' => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD,
+ ],
+ [
+ 'tables' => [
+ 'archive',
+ 'comment_ar_comment' => 'comment',
+ ],
+ 'fields' => array_merge(
+ $this->getArchiveQueryFields( true ),
+ $this->getContentHandlerQueryFields( 'ar' ),
+ $this->getOldActorQueryFields( 'ar' ),
+ $this->getCompatCommentQueryFields( 'ar' )
+ ),
+ 'joins' => [
+ 'comment_ar_comment'
+ => [ 'LEFT JOIN', 'comment_ar_comment.comment_id = ar_comment_id' ],
+ ],
+ ]
+ ];
+ yield 'pre-MCR, no model' => [
+ [
+ 'wgContentHandlerUseDB' => false,
+ 'wgMultiContentRevisionSchemaMigrationStage' => SCHEMA_COMPAT_OLD,
+ 'wgCommentTableSchemaMigrationStage' => MIGRATION_OLD,
+ 'wgActorTableSchemaMigrationStage' => SCHEMA_COMPAT_OLD,
+ ],
+ [
+ 'tables' => [
+ 'archive',
+ ],
+ 'fields' => array_merge(
+ $this->getArchiveQueryFields( true ),
+ $this->getOldActorQueryFields( 'ar' ),
+ $this->getOldCommentQueryFields( 'ar' )
+ ),
+ 'joins' => [],
+ ]
+ ];
+ }
+
+ public function provideQueryInfo() {
+ // TODO: more option variations
+ yield 'MCR, page, user, comment, actor' => [
+ [
+ 'wgContentHandlerUseDB' => true,
+ 'wgMultiContentRevisionSchemaMigrationStage' => SCHEMA_COMPAT_NEW,
+ 'wgCommentTableSchemaMigrationStage' => MIGRATION_NEW,
+ 'wgActorTableSchemaMigrationStage' => SCHEMA_COMPAT_NEW,
+ ],
+ [ 'page', 'user' ],
+ [
+ 'tables' => [
+ 'revision',
+ 'page',
+ 'user',
+ 'temp_rev_user' => 'revision_actor_temp',
+ 'temp_rev_comment' => 'revision_comment_temp',
+ 'actor_rev_user' => 'actor',
+ 'comment_rev_comment' => 'comment',
+ ],
+ 'fields' => array_merge(
+ $this->getRevisionQueryFields( false ),
+ $this->getPageQueryFields(),
+ $this->getUserQueryFields(),
+ $this->getNewActorQueryFields( 'rev', 'temp_rev_user.revactor_actor' ),
+ $this->getNewCommentQueryFields( 'rev' )
+ ),
+ 'joins' => [
+ 'page' => [ 'INNER JOIN', [ 'page_id = rev_page' ] ],
+ 'user' => [
+ 'LEFT JOIN',
+ [ 'actor_rev_user.actor_user != 0', 'user_id = actor_rev_user.actor_user' ],
+ ],
+ 'comment_rev_comment' => [
+ 'JOIN',
+ 'comment_rev_comment.comment_id = temp_rev_comment.revcomment_comment_id',
+ ],
+ 'actor_rev_user' => [
+ 'JOIN',
+ 'actor_rev_user.actor_id = temp_rev_user.revactor_actor',
+ ],
+ 'temp_rev_user' => [ 'JOIN', 'temp_rev_user.revactor_rev = rev_id' ],
+ 'temp_rev_comment' => [ 'JOIN', 'temp_rev_comment.revcomment_rev = rev_id' ],
+ ],
+ ]
+ ];
+ yield 'MCR read-new, page, user, comment, actor' => [
+ [
+ 'wgContentHandlerUseDB' => true,
+ 'wgMultiContentRevisionSchemaMigrationStage'
+ => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW,
+ 'wgCommentTableSchemaMigrationStage' => MIGRATION_WRITE_NEW,
+ 'wgActorTableSchemaMigrationStage' => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW,
+ ],
+ [ 'page', 'user' ],
+ [
+ 'tables' => [
+ 'revision',
+ 'page',
+ 'user',
+ 'temp_rev_user' => 'revision_actor_temp',
+ 'temp_rev_comment' => 'revision_comment_temp',
+ 'actor_rev_user' => 'actor',
+ 'comment_rev_comment' => 'comment',
+ ],
+ 'fields' => array_merge(
+ $this->getRevisionQueryFields( false ),
+ $this->getPageQueryFields(),
+ $this->getUserQueryFields(),
+ $this->getNewActorQueryFields( 'rev', 'temp_rev_user.revactor_actor' ),
+ $this->getCompatCommentQueryFields( 'rev' )
+ ),
+ 'joins' => array_merge(
+ [
+ 'page' => [ 'INNER JOIN', [ 'page_id = rev_page' ] ],
+ 'user' => [
+ 'LEFT JOIN',
+ [
+ 'actor_rev_user.actor_user != 0',
+ 'user_id = actor_rev_user.actor_user',
+ ]
+ ],
+ ],
+ $this->getNewActorJoins( 'rev' ),
+ $this->getCompatCommentJoins( 'rev' )
+ ),
+ ]
+ ];
+ yield 'MCR read-new' => [
+ [
+ 'wgContentHandlerUseDB' => true,
+ 'wgMultiContentRevisionSchemaMigrationStage'
+ => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW,
+ 'wgCommentTableSchemaMigrationStage' => MIGRATION_WRITE_NEW,
+ 'wgActorTableSchemaMigrationStage' => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW,
+ ],
+ [ 'page', 'user' ],
+ [
+ 'tables' => [
+ 'revision',
+ 'page',
+ 'user',
+ 'temp_rev_user' => 'revision_actor_temp',
+ 'temp_rev_comment' => 'revision_comment_temp',
+ 'actor_rev_user' => 'actor',
+ 'comment_rev_comment' => 'comment',
+ ],
+ 'fields' => array_merge(
+ $this->getRevisionQueryFields( false ),
+ $this->getPageQueryFields(),
+ $this->getUserQueryFields(),
+ $this->getNewActorQueryFields( 'rev', 'temp_rev_user.revactor_actor' ),
+ $this->getCompatCommentQueryFields( 'rev' )
+ ),
+ 'joins' => array_merge(
+ [
+ 'page' => [ 'INNER JOIN', [ 'page_id = rev_page' ] ],
+ 'user' => [
+ 'LEFT JOIN',
+ [
+ 'actor_rev_user.actor_user != 0',
+ 'user_id = actor_rev_user.actor_user'
+ ]
+ ],
+ ],
+ $this->getNewActorJoins( 'rev' ),
+ $this->getCompatCommentJoins( 'rev' )
+ ),
+ ]
+ ];
+ yield 'MCR write-both/read-old' => [
+ [
+ 'wgContentHandlerUseDB' => true,
+ 'wgMultiContentRevisionSchemaMigrationStage'
+ => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD,
+ 'wgCommentTableSchemaMigrationStage' => MIGRATION_WRITE_BOTH,
+ 'wgActorTableSchemaMigrationStage' => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD,
+ ],
+ [],
+ [
+ 'tables' => [
+ 'revision',
+ 'temp_rev_comment' => 'revision_comment_temp',
+ 'comment_rev_comment' => 'comment',
+ ],
+ 'fields' => array_merge(
+ $this->getRevisionQueryFields( true ),
+ $this->getContentHandlerQueryFields( 'rev' ),
+ $this->getOldActorQueryFields( 'rev', 'temp_rev_user.revactor_actor' ),
+ $this->getCompatCommentQueryFields( 'rev' )
+ ),
+ 'joins' => array_merge(
+ $this->getCompatCommentJoins( 'rev' )
+ ),
+ ]
+ ];
+ yield 'MCR write-both/read-old, page, user' => [
+ [
+ 'wgContentHandlerUseDB' => true,
+ 'wgMultiContentRevisionSchemaMigrationStage'
+ => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD,
+ 'wgCommentTableSchemaMigrationStage' => MIGRATION_WRITE_BOTH,
+ 'wgActorTableSchemaMigrationStage' => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD,
+ ],
+ [ 'page', 'user' ],
+ [
+ 'tables' => [
+ 'revision',
+ 'page',
+ 'user',
+ 'temp_rev_comment' => 'revision_comment_temp',
+ 'comment_rev_comment' => 'comment',
+ ],
+ 'fields' => array_merge(
+ $this->getRevisionQueryFields( true ),
+ $this->getContentHandlerQueryFields( 'rev' ),
+ $this->getUserQueryFields(),
+ $this->getPageQueryFields(),
+ $this->getOldActorQueryFields( 'rev', 'temp_rev_user.revactor_actor' ),
+ $this->getCompatCommentQueryFields( 'rev' )
+ ),
+ 'joins' => array_merge(
+ [
+ 'page' => [ 'INNER JOIN', [ 'page_id = rev_page' ] ],
+ 'user' => [
+ 'LEFT JOIN',
+ [
+ 'rev_user != 0',
+ 'user_id = rev_user'
+ ]
+ ],
+ ],
+ $this->getCompatCommentJoins( 'rev' )
+ ),
+ ]
+ ];
+ yield 'pre-MCR' => [
+ [
+ 'wgContentHandlerUseDB' => true,
+ 'wgMultiContentRevisionSchemaMigrationStage' => SCHEMA_COMPAT_OLD,
+ 'wgCommentTableSchemaMigrationStage' => MIGRATION_OLD,
+ 'wgActorTableSchemaMigrationStage' => SCHEMA_COMPAT_OLD,
+ ],
+ [],
+ [
+ 'tables' => [ 'revision' ],
+ 'fields' => array_merge(
+ $this->getRevisionQueryFields( true ),
+ $this->getContentHandlerQueryFields( 'rev' ),
+ $this->getOldActorQueryFields( 'rev' ),
+ $this->getOldCommentQueryFields( 'rev' )
+ ),
+ 'joins' => [],
+ ]
+ ];
+ yield 'pre-MCR, page, user' => [
+ [
+ 'wgContentHandlerUseDB' => true,
+ 'wgMultiContentRevisionSchemaMigrationStage' => SCHEMA_COMPAT_OLD,
+ 'wgCommentTableSchemaMigrationStage' => MIGRATION_OLD,
+ 'wgActorTableSchemaMigrationStage' => SCHEMA_COMPAT_OLD,
+ ],
+ [ 'page', 'user' ],
+ [
+ 'tables' => [ 'revision', 'page', 'user' ],
+ 'fields' => array_merge(
+ $this->getRevisionQueryFields( true ),
+ $this->getContentHandlerQueryFields( 'rev' ),
+ $this->getPageQueryFields(),
+ $this->getUserQueryFields(),
+ $this->getOldActorQueryFields( 'rev' ),
+ $this->getOldCommentQueryFields( 'rev' )
+ ),
+ 'joins' => [
+ 'page' => [ 'INNER JOIN', [ 'page_id = rev_page' ] ],
+ 'user' => [ 'LEFT JOIN', [ 'rev_user != 0', 'user_id = rev_user' ] ],
+ ],
+ ]
+ ];
+ yield 'pre-MCR, no model' => [
+ [
+ 'wgContentHandlerUseDB' => false,
+ 'wgMultiContentRevisionSchemaMigrationStage' => SCHEMA_COMPAT_OLD,
+ 'wgCommentTableSchemaMigrationStage' => MIGRATION_OLD,
+ 'wgActorTableSchemaMigrationStage' => SCHEMA_COMPAT_OLD,
+ ],
+ [],
+ [
+ 'tables' => [ 'revision' ],
+ 'fields' => array_merge(
+ $this->getRevisionQueryFields( true ),
+ $this->getOldActorQueryFields( 'rev' ),
+ $this->getOldCommentQueryFields( 'rev' )
+ ),
+ 'joins' => [],
+ ],
+ ];
+ yield 'pre-MCR, no model, page' => [
+ [
+ 'wgContentHandlerUseDB' => false,
+ 'wgMultiContentRevisionSchemaMigrationStage' => SCHEMA_COMPAT_OLD,
+ 'wgCommentTableSchemaMigrationStage' => MIGRATION_OLD,
+ 'wgActorTableSchemaMigrationStage' => SCHEMA_COMPAT_OLD,
+ ],
+ [ 'page' ],
+ [
+ 'tables' => [ 'revision', 'page' ],
+ 'fields' => array_merge(
+ $this->getRevisionQueryFields( true ),
+ $this->getPageQueryFields(),
+ $this->getOldActorQueryFields( 'rev' ),
+ $this->getOldCommentQueryFields( 'rev' )
+ ),
+ 'joins' => [
+ 'page' => [ 'INNER JOIN', [ 'page_id = rev_page' ], ],
+ ],
+ ],
+ ];
+ yield 'pre-MCR, no model, user' => [
+ [
+ 'wgContentHandlerUseDB' => false,
+ 'wgMultiContentRevisionSchemaMigrationStage' => SCHEMA_COMPAT_OLD,
+ 'wgCommentTableSchemaMigrationStage' => MIGRATION_OLD,
+ 'wgActorTableSchemaMigrationStage' => SCHEMA_COMPAT_OLD,
+ ],
+ [ 'user' ],
+ [
+ 'tables' => [ 'revision', 'user' ],
+ 'fields' => array_merge(
+ $this->getRevisionQueryFields( true ),
+ $this->getUserQueryFields(),
+ $this->getOldActorQueryFields( 'rev' ),
+ $this->getOldCommentQueryFields( 'rev' )
+ ),
+ 'joins' => [
+ 'user' => [ 'LEFT JOIN', [ 'rev_user != 0', 'user_id = rev_user' ] ],
+ ],
+ ],
+ ];
+ yield 'pre-MCR, no model, text' => [
+ [
+ 'wgContentHandlerUseDB' => false,
+ 'wgMultiContentRevisionSchemaMigrationStage' => SCHEMA_COMPAT_OLD,
+ 'wgCommentTableSchemaMigrationStage' => MIGRATION_OLD,
+ 'wgActorTableSchemaMigrationStage' => SCHEMA_COMPAT_OLD,
+ ],
+ [ 'text' ],
+ [
+ 'tables' => [ 'revision', 'text' ],
+ 'fields' => array_merge(
+ $this->getRevisionQueryFields( true ),
+ $this->getTextQueryFields(),
+ $this->getOldActorQueryFields( 'rev' ),
+ $this->getOldCommentQueryFields( 'rev' )
+ ),
+ 'joins' => [
+ 'text' => [ 'INNER JOIN', [ 'rev_text_id=old_id' ] ],
+ ],
+ ],
+ ];
+ yield 'pre-MCR, no model, text, page, user' => [
+ [
+ 'wgContentHandlerUseDB' => false,
+ 'wgMultiContentRevisionSchemaMigrationStage' => SCHEMA_COMPAT_OLD,
+ 'wgCommentTableSchemaMigrationStage' => MIGRATION_OLD,
+ 'wgActorTableSchemaMigrationStage' => SCHEMA_COMPAT_OLD,
+ ],
+ [ 'text', 'page', 'user' ],
+ [
+ 'tables' => [
+ 'revision', 'page', 'user', 'text'
+ ],
+ 'fields' => array_merge(
+ $this->getRevisionQueryFields( true ),
+ $this->getPageQueryFields(),
+ $this->getUserQueryFields(),
+ $this->getTextQueryFields(),
+ $this->getOldActorQueryFields( 'rev' ),
+ $this->getOldCommentQueryFields( 'rev' )
+ ),
+ 'joins' => [
+ 'page' => [
+ 'INNER JOIN',
+ [ 'page_id = rev_page' ],
+ ],
+ 'user' => [
+ 'LEFT JOIN',
+ [
+ 'rev_user != 0',
+ 'user_id = rev_user',
+ ],
+ ],
+ 'text' => [
+ 'INNER JOIN',
+ [ 'rev_text_id=old_id' ],
+ ],
+ ],
+ ],
+ ];
+ }
+
+ public function provideSlotsQueryInfo() {
+ yield 'MCR, no options' => [
+ [
+ 'wgMultiContentRevisionSchemaMigrationStage' => SCHEMA_COMPAT_NEW,
+ ],
+ [],
+ [
+ 'tables' => [
+ 'slots'
+ ],
+ 'fields' => [
+ 'slot_revision_id',
+ 'slot_content_id',
+ 'slot_origin',
+ 'slot_role_id',
+ ],
+ 'joins' => [],
+ ]
+ ];
+ yield 'MCR, role option' => [
+ [
+ 'wgMultiContentRevisionSchemaMigrationStage' => SCHEMA_COMPAT_NEW,
+ ],
+ [ 'role' ],
+ [
+ 'tables' => [
+ 'slots',
+ 'slot_roles',
+ ],
+ 'fields' => [
+ 'slot_revision_id',
+ 'slot_content_id',
+ 'slot_origin',
+ 'slot_role_id',
+ 'role_name',
+ ],
+ 'joins' => [
+ 'slot_roles' => [ 'LEFT JOIN', [ 'slot_role_id = role_id' ] ],
+ ],
+ ]
+ ];
+ yield 'MCR read-new, content option' => [
+ [
+ 'wgMultiContentRevisionSchemaMigrationStage'
+ => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW,
+ ],
+ [ 'content' ],
+ [
+ 'tables' => [
+ 'slots',
+ 'content',
+ ],
+ 'fields' => [
+ 'slot_revision_id',
+ 'slot_content_id',
+ 'slot_origin',
+ 'slot_role_id',
+ 'content_size',
+ 'content_sha1',
+ 'content_address',
+ 'content_model',
+ ],
+ 'joins' => [
+ 'content' => [ 'INNER JOIN', [ 'slot_content_id = content_id' ] ],
+ ],
+ ]
+ ];
+ yield 'MCR read-new, content and model options' => [
+ [
+ 'wgMultiContentRevisionSchemaMigrationStage'
+ => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW,
+ ],
+ [ 'content', 'model' ],
+ [
+ 'tables' => [
+ 'slots',
+ 'content',
+ 'content_models',
+ ],
+ 'fields' => [
+ 'slot_revision_id',
+ 'slot_content_id',
+ 'slot_origin',
+ 'slot_role_id',
+ 'content_size',
+ 'content_sha1',
+ 'content_address',
+ 'content_model',
+ 'model_name',
+ ],
+ 'joins' => [
+ 'content' => [ 'INNER JOIN', [ 'slot_content_id = content_id' ] ],
+ 'content_models' => [ 'LEFT JOIN', [ 'content_model = model_id' ] ],
+ ],
+ ]
+ ];
+
+ $db = wfGetDB( DB_REPLICA );
+
+ yield 'MCR write-both/read-old' => [
+ [
+ 'wgMultiContentRevisionSchemaMigrationStage'
+ => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD,
+ ],
+ [],
+ [
+ 'tables' => [
+ 'slots' => 'revision',
+ ],
+ 'fields' => array_merge(
+ [
+ 'slot_revision_id' => 'slots.rev_id',
+ 'slot_content_id' => 'NULL',
+ 'slot_origin' => 'slots.rev_id',
+ 'role_name' => $db->addQuotes( SlotRecord::MAIN ),
+ ]
+ ),
+ 'joins' => [],
+ ]
+ ];
+ yield 'MCR write-both/read-old, content' => [
+ [
+ 'wgMultiContentRevisionSchemaMigrationStage'
+ => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD,
+ ],
+ [ 'content' ],
+ [
+ 'tables' => [
+ 'slots' => 'revision',
+ ],
+ 'fields' => array_merge(
+ [
+ 'slot_revision_id' => 'slots.rev_id',
+ 'slot_content_id' => 'NULL',
+ 'slot_origin' => 'slots.rev_id',
+ 'role_name' => $db->addQuotes( SlotRecord::MAIN ),
+ 'content_size' => 'slots.rev_len',
+ 'content_sha1' => 'slots.rev_sha1',
+ 'content_address' => $db->buildConcat( [
+ $db->addQuotes( 'tt:' ), 'slots.rev_text_id' ] ),
+ 'model_name' => 'slots.rev_content_model',
+ ]
+ ),
+ 'joins' => [],
+ ]
+ ];
+ yield 'MCR write-both/read-old, content, model, role' => [
+ [
+ 'wgMultiContentRevisionSchemaMigrationStage'
+ => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD,
+ ],
+ [ 'content', 'model', 'role' ],
+ [
+ 'tables' => [
+ 'slots' => 'revision',
+ ],
+ 'fields' => array_merge(
+ [
+ 'slot_revision_id' => 'slots.rev_id',
+ 'slot_content_id' => 'NULL',
+ 'slot_origin' => 'slots.rev_id',
+ 'role_name' => $db->addQuotes( SlotRecord::MAIN ),
+ 'content_size' => 'slots.rev_len',
+ 'content_sha1' => 'slots.rev_sha1',
+ 'content_address' => $db->buildConcat( [
+ $db->addQuotes( 'tt:' ), 'slots.rev_text_id' ] ),
+ 'model_name' => 'slots.rev_content_model',
+ ]
+ ),
+ 'joins' => [],
+ ]
+ ];
+ yield 'pre-MCR' => [
+ [
+ 'wgMultiContentRevisionSchemaMigrationStage'
+ => SCHEMA_COMPAT_OLD,
+ ],
+ [],
+ [
+ 'tables' => [
+ 'slots' => 'revision',
+ ],
+ 'fields' => array_merge(
+ [
+ 'slot_revision_id' => 'slots.rev_id',
+ 'slot_content_id' => 'NULL',
+ 'slot_origin' => 'slots.rev_id',
+ 'role_name' => $db->addQuotes( SlotRecord::MAIN ),
+ ]
+ ),
+ 'joins' => [],
+ ]
+ ];
+ yield 'pre-MCR, content' => [
+ [
+ 'wgMultiContentRevisionSchemaMigrationStage'
+ => SCHEMA_COMPAT_OLD,
+ ],
+ [ 'content' ],
+ [
+ 'tables' => [
+ 'slots' => 'revision',
+ ],
+ 'fields' => array_merge(
+ [
+ 'slot_revision_id' => 'slots.rev_id',
+ 'slot_content_id' => 'NULL',
+ 'slot_origin' => 'slots.rev_id',
+ 'role_name' => $db->addQuotes( SlotRecord::MAIN ),
+ 'content_size' => 'slots.rev_len',
+ 'content_sha1' => 'slots.rev_sha1',
+ 'content_address' =>
+ $db->buildConcat( [ $db->addQuotes( 'tt:' ), 'slots.rev_text_id' ] ),
+ 'model_name' => 'slots.rev_content_model',
+ ]
+ ),
+ 'joins' => [],
+ ]
+ ];
+ }
+
+ public function provideSelectFields() {
+ yield 'with model, comment, and actor' => [
+ [
+ 'wgContentHandlerUseDB' => true,
+ 'wgCommentTableSchemaMigrationStage' => MIGRATION_WRITE_BOTH,
+ 'wgActorTableSchemaMigrationStage' => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD,
+ ],
+ 'fields' => array_merge(
+ [
+ 'rev_id',
+ 'rev_page',
+ 'rev_text_id',
+ 'rev_timestamp',
+ 'rev_user_text',
+ 'rev_user',
+ 'rev_actor' => 'NULL',
+ 'rev_minor_edit',
+ 'rev_deleted',
+ 'rev_len',
+ 'rev_parent_id',
+ 'rev_sha1',
+ ],
+ $this->getContentHandlerQueryFields( 'rev' ),
+ [
+ 'rev_comment_old' => 'rev_comment',
+ 'rev_comment_pk' => 'rev_id',
+ ]
+ ),
+ ];
+ yield 'no mode, no comment, no actor' => [
+ [
+ 'wgContentHandlerUseDB' => false,
+ 'wgCommentTableSchemaMigrationStage' => MIGRATION_OLD,
+ 'wgActorTableSchemaMigrationStage' => SCHEMA_COMPAT_OLD,
+ ],
+ 'fields' => array_merge(
+ [
+ 'rev_id',
+ 'rev_page',
+ 'rev_text_id',
+ 'rev_timestamp',
+ 'rev_user_text',
+ 'rev_user',
+ 'rev_actor' => 'NULL',
+ 'rev_minor_edit',
+ 'rev_deleted',
+ 'rev_len',
+ 'rev_parent_id',
+ 'rev_sha1',
+ ],
+ $this->getOldCommentQueryFields( 'rev' )
+ ),
+ ];
+ }
+
+ public function provideSelectArchiveFields() {
+ yield 'with model, comment, and actor' => [
+ [
+ 'wgContentHandlerUseDB' => true,
+ 'wgCommentTableSchemaMigrationStage' => MIGRATION_WRITE_BOTH,
+ 'wgActorTableSchemaMigrationStage' => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD,
+ ],
+ 'fields' => array_merge(
+ [
+ 'ar_id',
+ 'ar_page_id',
+ 'ar_rev_id',
+ 'ar_text_id',
+ 'ar_timestamp',
+ 'ar_user_text',
+ 'ar_user',
+ 'ar_actor' => 'NULL',
+ 'ar_minor_edit',
+ 'ar_deleted',
+ 'ar_len',
+ 'ar_parent_id',
+ 'ar_sha1',
+ ],
+ $this->getContentHandlerQueryFields( 'ar' ),
+ [
+ 'ar_comment_old' => 'ar_comment',
+ 'ar_comment_id' => 'ar_comment_id',
+ ]
+ ),
+ ];
+ yield 'no mode, no comment, no actor' => [
+ [
+ 'wgContentHandlerUseDB' => false,
+ 'wgCommentTableSchemaMigrationStage' => MIGRATION_OLD,
+ 'wgActorTableSchemaMigrationStage' => SCHEMA_COMPAT_OLD,
+ ],
+ 'fields' => array_merge(
+ [
+ 'ar_id',
+ 'ar_page_id',
+ 'ar_rev_id',
+ 'ar_text_id',
+ 'ar_timestamp',
+ 'ar_user_text',
+ 'ar_user',
+ 'ar_actor' => 'NULL',
+ 'ar_minor_edit',
+ 'ar_deleted',
+ 'ar_len',
+ 'ar_parent_id',
+ 'ar_sha1',
+ ],
+ $this->getOldCommentQueryFields( 'ar' )
+ ),
+ ];
+ }
+
+ /**
+ * @dataProvider provideSelectFields
+ * @covers Revision::selectFields
+ */
+ public function testRevisionSelectFields( $migrationStageSettings, $expected ) {
+ $this->setMwGlobals( $migrationStageSettings );
+ $this->overrideMwServices();
+
+ $this->hideDeprecated( 'Revision::selectFields' );
+ $this->assertArrayEqualsIgnoringIntKeyOrder( $expected, Revision::selectFields() );
+ }
+
+ /**
+ * @dataProvider provideSelectArchiveFields
+ * @covers Revision::selectArchiveFields
+ */
+ public function testRevisionSelectArchiveFields( $migrationStageSettings, $expected ) {
+ $this->setMwGlobals( $migrationStageSettings );
+ $this->overrideMwServices();
+
+ $this->hideDeprecated( 'Revision::selectArchiveFields' );
+ $this->assertArrayEqualsIgnoringIntKeyOrder( $expected, Revision::selectArchiveFields() );
+ }
+
+ /**
+ * @covers Revision::userJoinCond
+ */
+ public function testRevisionUserJoinCond() {
+ $this->hideDeprecated( 'Revision::userJoinCond' );
+ $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', SCHEMA_COMPAT_OLD );
+ $this->overrideMwServices();
+ $this->assertEquals(
+ [ 'LEFT JOIN', [ 'rev_user != 0', 'user_id = rev_user' ] ],
+ Revision::userJoinCond()
+ );
+ }
+
+ /**
+ * @covers Revision::pageJoinCond
+ */
+ public function testRevisionPageJoinCond() {
+ $this->hideDeprecated( 'Revision::pageJoinCond' );
+ $this->assertEquals(
+ [ 'INNER JOIN', [ 'page_id = rev_page' ] ],
+ Revision::pageJoinCond()
+ );
+ }
+
+ /**
+ * @covers Revision::selectTextFields
+ */
+ public function testRevisionSelectTextFields() {
+ $this->hideDeprecated( 'Revision::selectTextFields' );
+ $this->assertEquals(
+ $this->getTextQueryFields(),
+ Revision::selectTextFields()
+ );
+ }
+
+ /**
+ * @covers Revision::selectPageFields
+ */
+ public function testRevisionSelectPageFields() {
+ $this->hideDeprecated( 'Revision::selectPageFields' );
+ $this->assertEquals(
+ $this->getPageQueryFields(),
+ Revision::selectPageFields()
+ );
+ }
+
+ /**
+ * @covers Revision::selectUserFields
+ */
+ public function testRevisionSelectUserFields() {
+ $this->hideDeprecated( 'Revision::selectUserFields' );
+ $this->assertEquals(
+ $this->getUserQueryFields(),
+ Revision::selectUserFields()
+ );
+ }
+
+ /**
+ * @covers Revision::getArchiveQueryInfo
+ * @dataProvider provideArchiveQueryInfo
+ */
+ public function testRevisionGetArchiveQueryInfo( $migrationStageSettings, $expected ) {
+ $this->setMwGlobals( $migrationStageSettings );
+ $this->overrideMwServices();
+
+ $queryInfo = Revision::getArchiveQueryInfo();
+ $this->assertQueryInfoEquals( $expected, $queryInfo );
+ }
+
+ /**
+ * @covers Revision::getQueryInfo
+ * @dataProvider provideQueryInfo
+ */
+ public function testRevisionGetQueryInfo( $migrationStageSettings, $options, $expected ) {
+ $this->setMwGlobals( $migrationStageSettings );
+ $this->overrideMwServices();
+
+ $queryInfo = Revision::getQueryInfo( $options );
+ $this->assertQueryInfoEquals( $expected, $queryInfo );
+ }
+
+ /**
+ * @dataProvider provideQueryInfo
+ * @covers \MediaWiki\Revision\RevisionStore::getQueryInfo
+ */
+ public function testRevisionStoreGetQueryInfo( $migrationStageSettings, $options, $expected ) {
+ $this->setMwGlobals( $migrationStageSettings );
+ $this->overrideMwServices();
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+
+ $queryInfo = $store->getQueryInfo( $options );
+ $this->assertQueryInfoEquals( $expected, $queryInfo );
+ }
+
+ /**
+ * @dataProvider provideSlotsQueryInfo
+ * @covers \MediaWiki\Revision\RevisionStore::getSlotsQueryInfo
+ */
+ public function testRevisionStoreGetSlotsQueryInfo(
+ $migrationStageSettings,
+ $options,
+ $expected
+ ) {
+ $this->setMwGlobals( $migrationStageSettings );
+ $this->overrideMwServices();
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+
+ $queryInfo = $store->getSlotsQueryInfo( $options );
+ $this->assertQueryInfoEquals( $expected, $queryInfo );
+ }
+
+ /**
+ * @dataProvider provideArchiveQueryInfo
+ * @covers \MediaWiki\Revision\RevisionStore::getArchiveQueryInfo
+ */
+ public function testRevisionStoreGetArchiveQueryInfo( $migrationStageSettings, $expected ) {
+ $this->setMwGlobals( $migrationStageSettings );
+ $this->overrideMwServices();
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+
+ $queryInfo = $store->getArchiveQueryInfo();
+ $this->assertQueryInfoEquals( $expected, $queryInfo );
+ }
+
+ private function assertQueryInfoEquals( $expected, $queryInfo ) {
+ $this->assertArrayEqualsIgnoringIntKeyOrder(
+ $expected['tables'],
+ $queryInfo['tables'],
+ 'tables'
+ );
+ $this->assertArrayEqualsIgnoringIntKeyOrder(
+ $expected['fields'],
+ $queryInfo['fields'],
+ 'fields'
+ );
+ $this->assertArrayEqualsIgnoringIntKeyOrder(
+ $expected['joins'],
+ $queryInfo['joins'],
+ 'joins'
+ );
+ }
+
+ /**
+ * Assert that the two arrays passed are equal, ignoring the order of the values that integer
+ * keys.
+ *
+ * Note: Failures of this assertion can be slightly confusing as the arrays are actually
+ * split into a string key array and an int key array before assertions occur.
+ *
+ * @param array $expected
+ * @param array $actual
+ */
+ private function assertArrayEqualsIgnoringIntKeyOrder(
+ array $expected,
+ array $actual,
+ $message = null
+ ) {
+ $this->objectAssociativeSort( $expected );
+ $this->objectAssociativeSort( $actual );
+
+ // Separate the int key values from the string key values so that assertion failures are
+ // easier to understand.
+ $expectedIntKeyValues = [];
+ $actualIntKeyValues = [];
+
+ // Remove all int keys and re add them at the end after sorting by value
+ // This will result in all int keys being in the same order with same ints at the end of
+ // the array
+ foreach ( $expected as $key => $value ) {
+ if ( is_int( $key ) ) {
+ unset( $expected[$key] );
+ $expectedIntKeyValues[] = $value;
+ }
+ }
+ foreach ( $actual as $key => $value ) {
+ if ( is_int( $key ) ) {
+ unset( $actual[$key] );
+ $actualIntKeyValues[] = $value;
+ }
+ }
+
+ $this->objectAssociativeSort( $expected );
+ $this->objectAssociativeSort( $actual );
+
+ $this->objectAssociativeSort( $expectedIntKeyValues );
+ $this->objectAssociativeSort( $actualIntKeyValues );
+
+ $this->assertEquals( $expected, $actual, $message );
+ $this->assertEquals( $expectedIntKeyValues, $actualIntKeyValues, $message );
+ }
+
+}
--- /dev/null
+<?php
+
+// phpcs:disable MediaWiki.Commenting.PhpunitAnnotations.NotClassTrait
+
+namespace MediaWiki\Tests\Revision;
+
+use CommentStoreComment;
+use LogicException;
+use MediaWiki\Revision\RevisionRecord;
+use MediaWiki\Revision\RevisionSlots;
+use MediaWiki\Revision\RevisionStoreRecord;
+use MediaWiki\Revision\SlotRecord;
+use MediaWiki\Revision\SuppressedDataException;
+use MediaWiki\User\UserIdentityValue;
+use TextContent;
+use Title;
+
+/**
+ * @covers \MediaWiki\Revision\RevisionRecord
+ *
+ * @note Expects to be used in classes that extend MediaWikiTestCase.
+ */
+trait RevisionRecordTests {
+
+ /**
+ * @param array $rowOverrides
+ *
+ * @return RevisionRecord
+ */
+ protected abstract function newRevision( array $rowOverrides = [] );
+
+ private function provideAudienceCheckData( $field ) {
+ yield 'field accessible for oversighter (ALL)' => [
+ RevisionRecord::SUPPRESSED_ALL,
+ [ 'oversight' ],
+ true,
+ false
+ ];
+
+ yield 'field accessible for oversighter' => [
+ RevisionRecord::DELETED_RESTRICTED | $field,
+ [ 'oversight' ],
+ true,
+ false
+ ];
+
+ yield 'field not accessible for sysops (ALL)' => [
+ RevisionRecord::SUPPRESSED_ALL,
+ [ 'sysop' ],
+ false,
+ false
+ ];
+
+ yield 'field not accessible for sysops' => [
+ RevisionRecord::DELETED_RESTRICTED | $field,
+ [ 'sysop' ],
+ false,
+ false
+ ];
+
+ yield 'field accessible for sysops' => [
+ $field,
+ [ 'sysop' ],
+ true,
+ false
+ ];
+
+ yield 'field suppressed for logged in users' => [
+ $field,
+ [ 'user' ],
+ false,
+ false
+ ];
+
+ yield 'unrelated field suppressed' => [
+ $field === RevisionRecord::DELETED_COMMENT
+ ? RevisionRecord::DELETED_USER
+ : RevisionRecord::DELETED_COMMENT,
+ [ 'user' ],
+ true,
+ true
+ ];
+
+ yield 'nothing suppressed' => [
+ 0,
+ [ 'user' ],
+ true,
+ true
+ ];
+ }
+
+ public function testSerialization_fails() {
+ $this->setExpectedException( LogicException::class );
+ $rev = $this->newRevision();
+ serialize( $rev );
+ }
+
+ public function provideGetComment_audience() {
+ return $this->provideAudienceCheckData( RevisionRecord::DELETED_COMMENT );
+ }
+
+ private function forceStandardPermissions() {
+ $this->setMwGlobals(
+ 'wgGroupPermissions',
+ [
+ 'user' => [
+ 'viewsuppressed' => false,
+ 'suppressrevision' => false,
+ 'deletedtext' => false,
+ 'deletedhistory' => false,
+ ],
+ 'sysop' => [
+ 'viewsuppressed' => false,
+ 'suppressrevision' => false,
+ 'deletedtext' => true,
+ 'deletedhistory' => true,
+ ],
+ 'oversight' => [
+ 'deletedtext' => true,
+ 'deletedhistory' => true,
+ 'viewsuppressed' => true,
+ 'suppressrevision' => true,
+ ],
+ ]
+ );
+ }
+
+ /**
+ * @dataProvider provideGetComment_audience
+ */
+ public function testGetComment_audience( $visibility, $groups, $userCan, $publicCan ) {
+ $this->forceStandardPermissions();
+
+ $user = $this->getTestUser( $groups )->getUser();
+ $rev = $this->newRevision( [ 'rev_deleted' => $visibility ] );
+
+ $this->assertNotNull( $rev->getComment( RevisionRecord::RAW ), 'raw can' );
+
+ $this->assertSame(
+ $publicCan,
+ $rev->getComment( RevisionRecord::FOR_PUBLIC ) !== null,
+ 'public can'
+ );
+ $this->assertSame(
+ $userCan,
+ $rev->getComment( RevisionRecord::FOR_THIS_USER, $user ) !== null,
+ 'user can'
+ );
+ }
+
+ public function provideGetUser_audience() {
+ return $this->provideAudienceCheckData( RevisionRecord::DELETED_USER );
+ }
+
+ /**
+ * @dataProvider provideGetUser_audience
+ */
+ public function testGetUser_audience( $visibility, $groups, $userCan, $publicCan ) {
+ $this->forceStandardPermissions();
+
+ $user = $this->getTestUser( $groups )->getUser();
+ $rev = $this->newRevision( [ 'rev_deleted' => $visibility ] );
+
+ $this->assertNotNull( $rev->getUser( RevisionRecord::RAW ), 'raw can' );
+
+ $this->assertSame(
+ $publicCan,
+ $rev->getUser( RevisionRecord::FOR_PUBLIC ) !== null,
+ 'public can'
+ );
+ $this->assertSame(
+ $userCan,
+ $rev->getUser( RevisionRecord::FOR_THIS_USER, $user ) !== null,
+ 'user can'
+ );
+ }
+
+ public function provideGetSlot_audience() {
+ return $this->provideAudienceCheckData( RevisionRecord::DELETED_TEXT );
+ }
+
+ /**
+ * @dataProvider provideGetSlot_audience
+ */
+ public function testGetSlot_audience( $visibility, $groups, $userCan, $publicCan ) {
+ $this->forceStandardPermissions();
+
+ $user = $this->getTestUser( $groups )->getUser();
+ $rev = $this->newRevision( [ 'rev_deleted' => $visibility ] );
+
+ // NOTE: slot meta-data is never suppressed, just the content is!
+ $this->assertTrue( $rev->hasSlot( SlotRecord::MAIN ), 'hasSlot is never suppressed' );
+ $this->assertNotNull( $rev->getSlot( SlotRecord::MAIN, RevisionRecord::RAW ), 'raw meta' );
+ $this->assertNotNull( $rev->getSlot( SlotRecord::MAIN, RevisionRecord::FOR_PUBLIC ),
+ 'public meta' );
+
+ $this->assertNotNull(
+ $rev->getSlot( SlotRecord::MAIN, RevisionRecord::FOR_THIS_USER, $user ),
+ 'user can'
+ );
+
+ try {
+ $rev->getSlot( SlotRecord::MAIN, RevisionRecord::FOR_PUBLIC )->getContent();
+ $exception = null;
+ } catch ( SuppressedDataException $ex ) {
+ $exception = $ex;
+ }
+
+ $this->assertSame(
+ $publicCan,
+ $exception === null,
+ 'public can'
+ );
+
+ try {
+ $rev->getSlot( SlotRecord::MAIN, RevisionRecord::FOR_THIS_USER, $user )->getContent();
+ $exception = null;
+ } catch ( SuppressedDataException $ex ) {
+ $exception = $ex;
+ }
+
+ $this->assertSame(
+ $userCan,
+ $exception === null,
+ 'user can'
+ );
+ }
+
+ /**
+ * @dataProvider provideGetSlot_audience
+ */
+ public function testGetContent_audience( $visibility, $groups, $userCan, $publicCan ) {
+ $this->forceStandardPermissions();
+
+ $user = $this->getTestUser( $groups )->getUser();
+ $rev = $this->newRevision( [ 'rev_deleted' => $visibility ] );
+
+ $this->assertNotNull( $rev->getContent( SlotRecord::MAIN, RevisionRecord::RAW ), 'raw can' );
+
+ $this->assertSame(
+ $publicCan,
+ $rev->getContent( SlotRecord::MAIN, RevisionRecord::FOR_PUBLIC ) !== null,
+ 'public can'
+ );
+ $this->assertSame(
+ $userCan,
+ $rev->getContent( SlotRecord::MAIN, RevisionRecord::FOR_THIS_USER, $user ) !== null,
+ 'user can'
+ );
+ }
+
+ public function testGetSlot() {
+ $rev = $this->newRevision();
+
+ $slot = $rev->getSlot( SlotRecord::MAIN );
+ $this->assertNotNull( $slot, 'getSlot()' );
+ $this->assertSame( 'main', $slot->getRole(), 'getRole()' );
+ }
+
+ public function testHasSlot() {
+ $rev = $this->newRevision();
+
+ $this->assertTrue( $rev->hasSlot( SlotRecord::MAIN ) );
+ $this->assertFalse( $rev->hasSlot( 'xyz' ) );
+ }
+
+ public function testGetContent() {
+ $rev = $this->newRevision();
+
+ $content = $rev->getSlot( SlotRecord::MAIN );
+ $this->assertNotNull( $content, 'getContent()' );
+ $this->assertSame( CONTENT_MODEL_TEXT, $content->getModel(), 'getModel()' );
+ }
+
+ public function provideUserCanBitfield() {
+ yield [ 0, 0, [], null, true ];
+ // Bitfields match, user has no permissions
+ yield [
+ RevisionRecord::DELETED_TEXT,
+ RevisionRecord::DELETED_TEXT,
+ [],
+ null,
+ false
+ ];
+ yield [
+ RevisionRecord::DELETED_COMMENT,
+ RevisionRecord::DELETED_COMMENT,
+ [],
+ null,
+ false,
+ ];
+ yield [
+ RevisionRecord::DELETED_USER,
+ RevisionRecord::DELETED_USER,
+ [],
+ null,
+ false
+ ];
+ yield [
+ RevisionRecord::DELETED_RESTRICTED,
+ RevisionRecord::DELETED_RESTRICTED,
+ [],
+ null,
+ false,
+ ];
+ // Bitfields match, user (admin) does have permissions
+ yield [
+ RevisionRecord::DELETED_TEXT,
+ RevisionRecord::DELETED_TEXT,
+ [ 'sysop' ],
+ null,
+ true,
+ ];
+ yield [
+ RevisionRecord::DELETED_COMMENT,
+ RevisionRecord::DELETED_COMMENT,
+ [ 'sysop' ],
+ null,
+ true,
+ ];
+ yield [
+ RevisionRecord::DELETED_USER,
+ RevisionRecord::DELETED_USER,
+ [ 'sysop' ],
+ null,
+ true,
+ ];
+ // Bitfields match, user (admin) does not have permissions
+ yield [
+ RevisionRecord::DELETED_RESTRICTED,
+ RevisionRecord::DELETED_RESTRICTED,
+ [ 'sysop' ],
+ null,
+ false,
+ ];
+ // Bitfields match, user (oversight) does have permissions
+ yield [
+ RevisionRecord::DELETED_RESTRICTED,
+ RevisionRecord::DELETED_RESTRICTED,
+ [ 'oversight' ],
+ null,
+ true,
+ ];
+ // Check permissions using the title
+ yield [
+ RevisionRecord::DELETED_TEXT,
+ RevisionRecord::DELETED_TEXT,
+ [ 'sysop' ],
+ __METHOD__,
+ true,
+ ];
+ yield [
+ RevisionRecord::DELETED_TEXT,
+ RevisionRecord::DELETED_TEXT,
+ [],
+ __METHOD__,
+ false,
+ ];
+ }
+
+ /**
+ * @dataProvider provideUserCanBitfield
+ * @covers \MediaWiki\Revision\RevisionRecord::userCanBitfield
+ */
+ public function testUserCanBitfield( $bitField, $field, $userGroups, $title, $expected ) {
+ if ( is_string( $title ) ) {
+ // NOTE: Data providers cannot instantiate Title objects! See T202641.
+ $title = Title::newFromText( $title );
+ }
+
+ $this->forceStandardPermissions();
+
+ $user = $this->getTestUser( $userGroups )->getUser();
+
+ $this->assertSame(
+ $expected,
+ RevisionRecord::userCanBitfield( $bitField, $field, $user, $title )
+ );
+ }
+
+ public function provideHasSameContent() {
+ // Create some slots with content
+ $mainA = SlotRecord::newUnsaved( SlotRecord::MAIN, new TextContent( 'A' ) );
+ $mainB = SlotRecord::newUnsaved( SlotRecord::MAIN, new TextContent( 'B' ) );
+ $auxA = SlotRecord::newUnsaved( 'aux', new TextContent( 'A' ) );
+ $auxB = SlotRecord::newUnsaved( 'aux', new TextContent( 'A' ) );
+
+ $initialRecordSpec = [ [ $mainA ], 12 ];
+
+ return [
+ 'same record object' => [
+ true,
+ $initialRecordSpec,
+ $initialRecordSpec,
+ ],
+ 'same record content, different object' => [
+ true,
+ [ [ $mainA ], 12 ],
+ [ [ $mainA ], 13 ],
+ ],
+ 'same record content, aux slot, different object' => [
+ true,
+ [ [ $auxA ], 12 ],
+ [ [ $auxB ], 13 ],
+ ],
+ 'different content' => [
+ false,
+ [ [ $mainA ], 12 ],
+ [ [ $mainB ], 13 ],
+ ],
+ 'different content and number of slots' => [
+ false,
+ [ [ $mainA ], 12 ],
+ [ [ $mainA, $mainB ], 13 ],
+ ],
+ ];
+ }
+
+ /**
+ * @note Do not call directly from a data provider! Data providers cannot instantiate
+ * Title objects! See T202641.
+ *
+ * @param SlotRecord[] $slots
+ * @param int $revId
+ * @return RevisionStoreRecord
+ */
+ private function makeHasSameContentTestRecord( array $slots, $revId ) {
+ $title = Title::newFromText( 'provideHasSameContent' );
+ $title->resetArticleID( 19 );
+ $slots = new RevisionSlots( $slots );
+
+ return new RevisionStoreRecord(
+ $title,
+ new UserIdentityValue( 11, __METHOD__, 0 ),
+ CommentStoreComment::newUnsavedComment( __METHOD__ ),
+ (object)[
+ 'rev_id' => strval( $revId ),
+ 'rev_page' => strval( $title->getArticleID() ),
+ 'rev_timestamp' => '20200101000000',
+ 'rev_deleted' => 0,
+ 'rev_minor_edit' => 0,
+ 'rev_parent_id' => '5',
+ 'rev_len' => $slots->computeSize(),
+ 'rev_sha1' => $slots->computeSha1(),
+ 'page_latest' => '18',
+ ],
+ $slots
+ );
+ }
+
+ /**
+ * @dataProvider provideHasSameContent
+ * @covers \MediaWiki\Revision\RevisionRecord::hasSameContent
+ * @group Database
+ */
+ public function testHasSameContent(
+ $expected,
+ $recordSpec1,
+ $recordSpec2
+ ) {
+ $record1 = $this->makeHasSameContentTestRecord( ...$recordSpec1 );
+ $record2 = $this->makeHasSameContentTestRecord( ...$recordSpec2 );
+
+ $this->assertSame(
+ $expected,
+ $record1->hasSameContent( $record2 )
+ );
+ }
+
+ public function provideIsDeleted() {
+ yield 'no deletion' => [
+ 0,
+ [
+ RevisionRecord::DELETED_TEXT => false,
+ RevisionRecord::DELETED_COMMENT => false,
+ RevisionRecord::DELETED_USER => false,
+ RevisionRecord::DELETED_RESTRICTED => false,
+ ]
+ ];
+ yield 'text deleted' => [
+ RevisionRecord::DELETED_TEXT,
+ [
+ RevisionRecord::DELETED_TEXT => true,
+ RevisionRecord::DELETED_COMMENT => false,
+ RevisionRecord::DELETED_USER => false,
+ RevisionRecord::DELETED_RESTRICTED => false,
+ ]
+ ];
+ yield 'text and comment deleted' => [
+ RevisionRecord::DELETED_TEXT + RevisionRecord::DELETED_COMMENT,
+ [
+ RevisionRecord::DELETED_TEXT => true,
+ RevisionRecord::DELETED_COMMENT => true,
+ RevisionRecord::DELETED_USER => false,
+ RevisionRecord::DELETED_RESTRICTED => false,
+ ]
+ ];
+ yield 'all 4 deleted' => [
+ RevisionRecord::DELETED_TEXT +
+ RevisionRecord::DELETED_COMMENT +
+ RevisionRecord::DELETED_RESTRICTED +
+ RevisionRecord::DELETED_USER,
+ [
+ RevisionRecord::DELETED_TEXT => true,
+ RevisionRecord::DELETED_COMMENT => true,
+ RevisionRecord::DELETED_USER => true,
+ RevisionRecord::DELETED_RESTRICTED => true,
+ ]
+ ];
+ }
+
+ /**
+ * @dataProvider provideIsDeleted
+ * @covers \MediaWiki\Revision\RevisionRecord::isDeleted
+ */
+ public function testIsDeleted( $revDeleted, $assertionMap ) {
+ $rev = $this->newRevision( [ 'rev_deleted' => $revDeleted ] );
+ foreach ( $assertionMap as $deletionLevel => $expected ) {
+ $this->assertSame( $expected, $rev->isDeleted( $deletionLevel ) );
+ }
+ }
+
+ public function testIsReadyForInsertion() {
+ $rev = $this->newRevision();
+ $this->assertTrue( $rev->isReadyForInsertion() );
+ }
+
+}
use Content;
use Language;
use LogicException;
+use MediaWiki\Revision\MutableRevisionRecord;
+use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\RevisionRenderer;
-use MediaWiki\Storage\MutableRevisionRecord;
-use MediaWiki\Storage\RevisionRecord;
-use MediaWiki\Storage\SlotRecord;
-use MediaWiki\User\UserIdentityValue;
+use MediaWiki\Revision\SlotRecord;
use MediaWikiTestCase;
+use MediaWiki\User\UserIdentityValue;
use ParserOptions;
use ParserOutput;
use PHPUnit\Framework\MockObject\MockObject;
--- /dev/null
+<?php
+
+namespace MediaWiki\Tests\Revision;
+
+use InvalidArgumentException;
+use MediaWiki\Revision\RevisionAccessException;
+use MediaWiki\Revision\RevisionSlots;
+use MediaWiki\Revision\SlotRecord;
+use MediaWikiTestCase;
+use TextContent;
+use WikitextContent;
+
+class RevisionSlotsTest extends MediaWikiTestCase {
+
+ /**
+ * @param SlotRecord[] $slots
+ * @return RevisionSlots
+ */
+ protected function newRevisionSlots( $slots = [] ) {
+ return new RevisionSlots( $slots );
+ }
+
+ public function provideConstructorFailue() {
+ yield 'not an array or callable' => [
+ 'foo'
+ ];
+ yield 'array of the wrong thing' => [
+ [ 1, 2, 3 ]
+ ];
+ }
+
+ /**
+ * @dataProvider provideConstructorFailue
+ * @param $slots
+ *
+ * @covers \MediaWiki\Revision\RevisionSlots::__construct
+ * @covers \MediaWiki\Revision\RevisionSlots::setSlotsInternal
+ */
+ public function testConstructorFailue( $slots ) {
+ $this->setExpectedException( InvalidArgumentException::class );
+
+ new RevisionSlots( $slots );
+ }
+
+ /**
+ * @covers \MediaWiki\Revision\RevisionSlots::getSlot
+ */
+ public function testGetSlot() {
+ $mainSlot = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) );
+ $auxSlot = SlotRecord::newUnsaved( 'aux', new WikitextContent( 'B' ) );
+ $slots = $this->newRevisionSlots( [ $mainSlot, $auxSlot ] );
+
+ $this->assertSame( $mainSlot, $slots->getSlot( SlotRecord::MAIN ) );
+ $this->assertSame( $auxSlot, $slots->getSlot( 'aux' ) );
+ $this->setExpectedException( RevisionAccessException::class );
+ $slots->getSlot( 'nothere' );
+ }
+
+ /**
+ * @covers \MediaWiki\Revision\RevisionSlots::hasSlot
+ */
+ public function testHasSlot() {
+ $mainSlot = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) );
+ $auxSlot = SlotRecord::newUnsaved( 'aux', new WikitextContent( 'B' ) );
+ $slots = $this->newRevisionSlots( [ $mainSlot, $auxSlot ] );
+
+ $this->assertTrue( $slots->hasSlot( SlotRecord::MAIN ) );
+ $this->assertTrue( $slots->hasSlot( 'aux' ) );
+ $this->assertFalse( $slots->hasSlot( 'AUX' ) );
+ $this->assertFalse( $slots->hasSlot( 'xyz' ) );
+ }
+
+ /**
+ * @covers \MediaWiki\Revision\RevisionSlots::getContent
+ */
+ public function testGetContent() {
+ $mainContent = new WikitextContent( 'A' );
+ $auxContent = new WikitextContent( 'B' );
+ $mainSlot = SlotRecord::newUnsaved( SlotRecord::MAIN, $mainContent );
+ $auxSlot = SlotRecord::newUnsaved( 'aux', $auxContent );
+ $slots = $this->newRevisionSlots( [ $mainSlot, $auxSlot ] );
+
+ $this->assertSame( $mainContent, $slots->getContent( SlotRecord::MAIN ) );
+ $this->assertSame( $auxContent, $slots->getContent( 'aux' ) );
+ $this->setExpectedException( RevisionAccessException::class );
+ $slots->getContent( 'nothere' );
+ }
+
+ /**
+ * @covers \MediaWiki\Revision\RevisionSlots::getSlotRoles
+ */
+ public function testGetSlotRoles_someSlots() {
+ $mainSlot = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) );
+ $auxSlot = SlotRecord::newUnsaved( 'aux', new WikitextContent( 'B' ) );
+ $slots = $this->newRevisionSlots( [ $mainSlot, $auxSlot ] );
+
+ $this->assertSame( [ 'main', 'aux' ], $slots->getSlotRoles() );
+ }
+
+ /**
+ * @covers \MediaWiki\Revision\RevisionSlots::getSlotRoles
+ */
+ public function testGetSlotRoles_noSlots() {
+ $slots = $this->newRevisionSlots( [] );
+
+ $this->assertSame( [], $slots->getSlotRoles() );
+ }
+
+ /**
+ * @covers \MediaWiki\Revision\RevisionSlots::getSlots
+ */
+ public function testGetSlots() {
+ $mainSlot = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) );
+ $auxSlot = SlotRecord::newUnsaved( 'aux', new WikitextContent( 'B' ) );
+ $slotsArray = [ $mainSlot, $auxSlot ];
+ $slots = $this->newRevisionSlots( $slotsArray );
+
+ $this->assertEquals( [ 'main' => $mainSlot, 'aux' => $auxSlot ], $slots->getSlots() );
+ }
+
+ /**
+ * @covers \MediaWiki\Revision\RevisionSlots::getInheritedSlots
+ */
+ public function testGetInheritedSlots() {
+ $mainSlot = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) );
+ $auxSlot = SlotRecord::newInherited(
+ SlotRecord::newSaved(
+ 7, 7, 'foo',
+ SlotRecord::newUnsaved( 'aux', new WikitextContent( 'B' ) )
+ )
+ );
+ $slotsArray = [ $mainSlot, $auxSlot ];
+ $slots = $this->newRevisionSlots( $slotsArray );
+
+ $this->assertEquals( [ 'aux' => $auxSlot ], $slots->getInheritedSlots() );
+ }
+
+ /**
+ * @covers \MediaWiki\Revision\RevisionSlots::getOriginalSlots
+ */
+ public function testGetOriginalSlots() {
+ $mainSlot = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) );
+ $auxSlot = SlotRecord::newInherited(
+ SlotRecord::newSaved(
+ 7, 7, 'foo',
+ SlotRecord::newUnsaved( 'aux', new WikitextContent( 'B' ) )
+ )
+ );
+ $slotsArray = [ $mainSlot, $auxSlot ];
+ $slots = $this->newRevisionSlots( $slotsArray );
+
+ $this->assertEquals( [ 'main' => $mainSlot ], $slots->getOriginalSlots() );
+ }
+
+ public function provideComputeSize() {
+ yield [ 1, [ 'A' ] ];
+ yield [ 2, [ 'AA' ] ];
+ yield [ 4, [ 'AA', 'X', 'H' ] ];
+ }
+
+ /**
+ * @dataProvider provideComputeSize
+ * @covers \MediaWiki\Revision\RevisionSlots::computeSize
+ */
+ public function testComputeSize( $expected, $contentStrings ) {
+ $slotsArray = [];
+ foreach ( $contentStrings as $key => $contentString ) {
+ $slotsArray[] = SlotRecord::newUnsaved( strval( $key ), new WikitextContent( $contentString ) );
+ }
+ $slots = $this->newRevisionSlots( $slotsArray );
+
+ $this->assertSame( $expected, $slots->computeSize() );
+ }
+
+ public function provideComputeSha1() {
+ yield [ 'ctqm7794fr2dp1taki8a88ovwnvmnmj', [ 'A' ] ];
+ yield [ 'eyq8wiwlcofnaiy4eid97gyfy60uw51', [ 'AA' ] ];
+ yield [ 'lavctqfpxartyjr31f853drgfl4kj1g', [ 'AA', 'X', 'H' ] ];
+ }
+
+ /**
+ * @dataProvider provideComputeSha1
+ * @covers \MediaWiki\Revision\RevisionSlots::computeSha1
+ * @note this test is a bit brittle as the hashes are hardcoded, perhaps just check that strings
+ * are returned and different Slots objects return different strings?
+ */
+ public function testComputeSha1( $expected, $contentStrings ) {
+ $slotsArray = [];
+ foreach ( $contentStrings as $key => $contentString ) {
+ $slotsArray[] = SlotRecord::newUnsaved( strval( $key ), new WikitextContent( $contentString ) );
+ }
+ $slots = $this->newRevisionSlots( $slotsArray );
+
+ $this->assertSame( $expected, $slots->computeSha1() );
+ }
+
+ public function provideHasSameContent() {
+ $fooX = SlotRecord::newUnsaved( 'x', new TextContent( 'Foo' ) );
+ $barZ = SlotRecord::newUnsaved( 'z', new TextContent( 'Bar' ) );
+ $fooY = SlotRecord::newUnsaved( 'y', new TextContent( 'Foo' ) );
+ $barZS = SlotRecord::newSaved( 7, 7, 'xyz', $barZ );
+ $barZ2 = SlotRecord::newUnsaved( 'z', new TextContent( 'Baz' ) );
+
+ $a = $this->newRevisionSlots( [ 'x' => $fooX, 'z' => $barZ ] );
+ $a2 = $this->newRevisionSlots( [ 'x' => $fooX, 'z' => $barZ ] );
+ $a3 = $this->newRevisionSlots( [ 'x' => $fooX, 'z' => $barZS ] );
+ $b = $this->newRevisionSlots( [ 'y' => $fooY, 'z' => $barZ ] );
+ $c = $this->newRevisionSlots( [ 'x' => $fooX, 'z' => $barZ2 ] );
+
+ yield 'same instance' => [ $a, $a, true ];
+ yield 'same slots' => [ $a, $a2, true ];
+ yield 'same content' => [ $a, $a3, true ];
+
+ yield 'different roles' => [ $a, $b, false ];
+ yield 'different content' => [ $a, $c, false ];
+ }
+
+ /**
+ * @dataProvider provideHasSameContent
+ * @covers \MediaWiki\Revision\RevisionSlots::hasSameContent
+ */
+ public function testHasSameContent( RevisionSlots $a, RevisionSlots $b, $same ) {
+ $this->assertSame( $same, $a->hasSameContent( $b ) );
+ $this->assertSame( $same, $b->hasSameContent( $a ) );
+ }
+
+ public function provideGetRolesWithDifferentContent() {
+ $fooX = SlotRecord::newUnsaved( 'x', new TextContent( 'Foo' ) );
+ $barZ = SlotRecord::newUnsaved( 'z', new TextContent( 'Bar' ) );
+ $fooY = SlotRecord::newUnsaved( 'y', new TextContent( 'Foo' ) );
+ $barZS = SlotRecord::newSaved( 7, 7, 'xyz', $barZ );
+ $barZ2 = SlotRecord::newUnsaved( 'z', new TextContent( 'Baz' ) );
+
+ $a = $this->newRevisionSlots( [ 'x' => $fooX, 'z' => $barZ ] );
+ $a2 = $this->newRevisionSlots( [ 'x' => $fooX, 'z' => $barZ ] );
+ $a3 = $this->newRevisionSlots( [ 'x' => $fooX, 'z' => $barZS ] );
+ $b = $this->newRevisionSlots( [ 'y' => $fooY, 'z' => $barZ ] );
+ $c = $this->newRevisionSlots( [ 'x' => $fooX, 'z' => $barZ2 ] );
+
+ yield 'same instance' => [ $a, $a, [] ];
+ yield 'same slots' => [ $a, $a2, [] ];
+ yield 'same content' => [ $a, $a3, [] ];
+
+ yield 'different roles' => [ $a, $b, [ 'x', 'y' ] ];
+ yield 'different content' => [ $a, $c, [ 'z' ] ];
+ }
+
+ /**
+ * @dataProvider provideGetRolesWithDifferentContent
+ * @covers \MediaWiki\Revision\RevisionSlots::getRolesWithDifferentContent
+ */
+ public function testGetRolesWithDifferentContent( RevisionSlots $a, RevisionSlots $b, $roles ) {
+ $this->assertArrayEquals( $roles, $a->getRolesWithDifferentContent( $b ) );
+ $this->assertArrayEquals( $roles, $b->getRolesWithDifferentContent( $a ) );
+ }
+
+}
--- /dev/null
+<?php
+
+namespace MediaWiki\Tests\Revision;
+
+use CommentStoreComment;
+use Content;
+use Exception;
+use HashBagOStuff;
+use InvalidArgumentException;
+use Language;
+use MediaWiki\Linker\LinkTarget;
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Revision\IncompleteRevisionException;
+use MediaWiki\Revision\MutableRevisionRecord;
+use MediaWiki\Revision\RevisionRecord;
+use MediaWiki\Revision\RevisionStore;
+use MediaWiki\Revision\SlotRecord;
+use MediaWiki\Storage\BlobStoreFactory;
+use MediaWiki\Storage\SqlBlobStore;
+use MediaWiki\User\UserIdentityValue;
+use MediaWikiTestCase;
+use PHPUnit_Framework_MockObject_MockObject;
+use Revision;
+use TestUserRegistry;
+use Title;
+use WANObjectCache;
+use Wikimedia\Rdbms\Database;
+use Wikimedia\Rdbms\DatabaseDomain;
+use Wikimedia\Rdbms\DatabaseSqlite;
+use Wikimedia\Rdbms\FakeResultWrapper;
+use Wikimedia\Rdbms\LoadBalancer;
+use Wikimedia\Rdbms\TransactionProfiler;
+use WikiPage;
+use WikitextContent;
+
+/**
+ * @group Database
+ * @group RevisionStore
+ */
+abstract class RevisionStoreDbTestBase extends MediaWikiTestCase {
+
+ /**
+ * @var Title
+ */
+ private $testPageTitle;
+
+ /**
+ * @var WikiPage
+ */
+ private $testPage;
+
+ /**
+ * @return int
+ */
+ abstract protected function getMcrMigrationStage();
+
+ /**
+ * @return bool
+ */
+ protected function getContentHandlerUseDB() {
+ return true;
+ }
+
+ /**
+ * @return string[]
+ */
+ abstract protected function getMcrTablesToReset();
+
+ public function setUp() {
+ parent::setUp();
+ $this->tablesUsed[] = 'archive';
+ $this->tablesUsed[] = 'page';
+ $this->tablesUsed[] = 'revision';
+ $this->tablesUsed[] = 'comment';
+
+ $this->tablesUsed += $this->getMcrTablesToReset();
+
+ $this->setMwGlobals( [
+ 'wgMultiContentRevisionSchemaMigrationStage' => $this->getMcrMigrationStage(),
+ 'wgContentHandlerUseDB' => $this->getContentHandlerUseDB(),
+ 'wgCommentTableSchemaMigrationStage' => MIGRATION_OLD,
+ 'wgActorTableSchemaMigrationStage' => SCHEMA_COMPAT_OLD,
+ ] );
+
+ $this->overrideMwServices();
+ }
+
+ protected function addCoreDBData() {
+ // Blank out. This would fail with a modified schema, and we don't need it.
+ }
+
+ /**
+ * @return Title
+ */
+ protected function getTestPageTitle() {
+ if ( $this->testPageTitle ) {
+ return $this->testPageTitle;
+ }
+
+ $this->testPageTitle = Title::newFromText( 'UTPage-' . __CLASS__ );
+ return $this->testPageTitle;
+ }
+ /**
+ * @return WikiPage
+ */
+ protected function getTestPage() {
+ if ( $this->testPage ) {
+ return $this->testPage;
+ }
+
+ $title = $this->getTestPageTitle();
+ $this->testPage = WikiPage::factory( $title );
+
+ if ( !$this->testPage->exists() ) {
+ // Make sure we don't write to the live db.
+ $this->ensureMockDatabaseConnection( wfGetDB( DB_MASTER ) );
+
+ $user = static::getTestSysop()->getUser();
+
+ $this->testPage->doEditContent(
+ new WikitextContent( 'UTContent-' . __CLASS__ ),
+ 'UTPageSummary-' . __CLASS__,
+ EDIT_NEW | EDIT_SUPPRESS_RC,
+ false,
+ $user
+ );
+ }
+
+ return $this->testPage;
+ }
+
+ /**
+ * @return LoadBalancer|PHPUnit_Framework_MockObject_MockObject
+ */
+ private function getLoadBalancerMock( array $server ) {
+ $domain = new DatabaseDomain( $server['dbname'], null, $server['tablePrefix'] );
+
+ $lb = $this->getMockBuilder( LoadBalancer::class )
+ ->setMethods( [ 'reallyOpenConnection' ] )
+ ->setConstructorArgs( [
+ [ 'servers' => [ $server ], 'localDomain' => $domain ]
+ ] )
+ ->getMock();
+
+ $lb->method( 'reallyOpenConnection' )->willReturnCallback(
+ function ( array $server, $dbNameOverride ) {
+ return $this->getDatabaseMock( $server );
+ }
+ );
+
+ return $lb;
+ }
+
+ /**
+ * @return Database|PHPUnit_Framework_MockObject_MockObject
+ */
+ private function getDatabaseMock( array $params ) {
+ $db = $this->getMockBuilder( DatabaseSqlite::class )
+ ->setMethods( [ 'select', 'doQuery', 'open', 'closeConnection', 'isOpen' ] )
+ ->setConstructorArgs( [ $params ] )
+ ->getMock();
+
+ $db->method( 'select' )->willReturn( new FakeResultWrapper( [] ) );
+ $db->method( 'isOpen' )->willReturn( true );
+
+ return $db;
+ }
+
+ public function provideDomainCheck() {
+ yield [ false, 'test', '' ];
+ yield [ 'test', 'test', '' ];
+
+ yield [ false, 'test', 'foo_' ];
+ yield [ 'test-foo_', 'test', 'foo_' ];
+
+ yield [ false, 'dash-test', '' ];
+ yield [ 'dash-test', 'dash-test', '' ];
+
+ yield [ false, 'underscore_test', 'foo_' ];
+ yield [ 'underscore_test-foo_', 'underscore_test', 'foo_' ];
+ }
+
+ /**
+ * @dataProvider provideDomainCheck
+ * @covers \MediaWiki\Revision\RevisionStore::checkDatabaseWikiId
+ */
+ public function testDomainCheck( $wikiId, $dbName, $dbPrefix ) {
+ $this->setMwGlobals(
+ [
+ 'wgDBname' => $dbName,
+ 'wgDBprefix' => $dbPrefix,
+ ]
+ );
+
+ $loadBalancer = $this->getLoadBalancerMock(
+ [
+ 'host' => '*dummy*',
+ 'dbDirectory' => '*dummy*',
+ 'user' => 'test',
+ 'password' => 'test',
+ 'flags' => 0,
+ 'variables' => [],
+ 'schema' => '',
+ 'cliMode' => true,
+ 'agent' => '',
+ 'load' => 100,
+ 'profiler' => null,
+ 'trxProfiler' => new TransactionProfiler(),
+ 'connLogger' => new \Psr\Log\NullLogger(),
+ 'queryLogger' => new \Psr\Log\NullLogger(),
+ 'errorLogger' => function () {
+ },
+ 'deprecationLogger' => function () {
+ },
+ 'type' => 'test',
+ 'dbname' => $dbName,
+ 'tablePrefix' => $dbPrefix,
+ ]
+ );
+ $db = $loadBalancer->getConnection( DB_REPLICA );
+
+ /** @var SqlBlobStore $blobStore */
+ $blobStore = $this->getMockBuilder( SqlBlobStore::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $store = new RevisionStore(
+ $loadBalancer,
+ $blobStore,
+ new WANObjectCache( [ 'cache' => new HashBagOStuff() ] ),
+ MediaWikiServices::getInstance()->getCommentStore(),
+ MediaWikiServices::getInstance()->getContentModelStore(),
+ MediaWikiServices::getInstance()->getSlotRoleStore(),
+ $this->getMcrMigrationStage(),
+ MediaWikiServices::getInstance()->getActorMigration(),
+ $wikiId
+ );
+
+ $count = $store->countRevisionsByPageId( $db, 0 );
+
+ // Dummy check to make PhpUnit happy. We are really only interested in
+ // countRevisionsByPageId not failing due to the DB domain check.
+ $this->assertSame( 0, $count );
+ }
+
+ protected function assertLinkTargetsEqual( LinkTarget $l1, LinkTarget $l2 ) {
+ $this->assertEquals( $l1->getDBkey(), $l2->getDBkey() );
+ $this->assertEquals( $l1->getNamespace(), $l2->getNamespace() );
+ $this->assertEquals( $l1->getFragment(), $l2->getFragment() );
+ $this->assertEquals( $l1->getInterwiki(), $l2->getInterwiki() );
+ }
+
+ protected function assertRevisionRecordsEqual( RevisionRecord $r1, RevisionRecord $r2 ) {
+ $this->assertEquals(
+ $r1->getPageAsLinkTarget()->getNamespace(),
+ $r2->getPageAsLinkTarget()->getNamespace()
+ );
+
+ $this->assertEquals(
+ $r1->getPageAsLinkTarget()->getText(),
+ $r2->getPageAsLinkTarget()->getText()
+ );
+
+ if ( $r1->getParentId() ) {
+ $this->assertEquals( $r1->getParentId(), $r2->getParentId() );
+ }
+
+ $this->assertEquals( $r1->getUser()->getName(), $r2->getUser()->getName() );
+ $this->assertEquals( $r1->getUser()->getId(), $r2->getUser()->getId() );
+ $this->assertEquals( $r1->getComment(), $r2->getComment() );
+ $this->assertEquals( $r1->getTimestamp(), $r2->getTimestamp() );
+ $this->assertEquals( $r1->getVisibility(), $r2->getVisibility() );
+ $this->assertEquals( $r1->getSha1(), $r2->getSha1() );
+ $this->assertEquals( $r1->getSize(), $r2->getSize() );
+ $this->assertEquals( $r1->getPageId(), $r2->getPageId() );
+ $this->assertArrayEquals( $r1->getSlotRoles(), $r2->getSlotRoles() );
+ $this->assertEquals( $r1->getWikiId(), $r2->getWikiId() );
+ $this->assertEquals( $r1->isMinor(), $r2->isMinor() );
+ foreach ( $r1->getSlotRoles() as $role ) {
+ $this->assertSlotRecordsEqual( $r1->getSlot( $role ), $r2->getSlot( $role ) );
+ $this->assertTrue( $r1->getContent( $role )->equals( $r2->getContent( $role ) ) );
+ }
+ foreach ( [
+ RevisionRecord::DELETED_TEXT,
+ RevisionRecord::DELETED_COMMENT,
+ RevisionRecord::DELETED_USER,
+ RevisionRecord::DELETED_RESTRICTED,
+ ] as $field ) {
+ $this->assertEquals( $r1->isDeleted( $field ), $r2->isDeleted( $field ) );
+ }
+ }
+
+ protected function assertSlotRecordsEqual( SlotRecord $s1, SlotRecord $s2 ) {
+ $this->assertSame( $s1->getRole(), $s2->getRole() );
+ $this->assertSame( $s1->getModel(), $s2->getModel() );
+ $this->assertSame( $s1->getFormat(), $s2->getFormat() );
+ $this->assertSame( $s1->getSha1(), $s2->getSha1() );
+ $this->assertSame( $s1->getSize(), $s2->getSize() );
+ $this->assertTrue( $s1->getContent()->equals( $s2->getContent() ) );
+
+ $s1->hasRevision() ? $this->assertSame( $s1->getRevision(), $s2->getRevision() ) : null;
+ $s1->hasAddress() ? $this->assertSame( $s1->hasAddress(), $s2->hasAddress() ) : null;
+ }
+
+ protected function assertRevisionCompleteness( RevisionRecord $r ) {
+ $this->assertTrue( $r->hasSlot( SlotRecord::MAIN ) );
+ $this->assertInstanceOf( SlotRecord::class, $r->getSlot( SlotRecord::MAIN ) );
+ $this->assertInstanceOf( Content::class, $r->getContent( SlotRecord::MAIN ) );
+
+ foreach ( $r->getSlotRoles() as $role ) {
+ $this->assertSlotCompleteness( $r, $r->getSlot( $role ) );
+ }
+ }
+
+ protected function assertSlotCompleteness( RevisionRecord $r, SlotRecord $slot ) {
+ $this->assertTrue( $slot->hasAddress() );
+ $this->assertSame( $r->getId(), $slot->getRevision() );
+
+ $this->assertInstanceOf( Content::class, $slot->getContent() );
+ }
+
+ /**
+ * @param mixed[] $details
+ *
+ * @return RevisionRecord
+ */
+ private function getRevisionRecordFromDetailsArray( $details = [] ) {
+ // Convert some values that can't be provided by dataProviders
+ if ( isset( $details['user'] ) && $details['user'] === true ) {
+ $details['user'] = $this->getTestUser()->getUser();
+ }
+ if ( isset( $details['page'] ) && $details['page'] === true ) {
+ $details['page'] = $this->getTestPage()->getId();
+ }
+ if ( isset( $details['parent'] ) && $details['parent'] === true ) {
+ $details['parent'] = $this->getTestPage()->getLatest();
+ }
+
+ // Create the RevisionRecord with any available data
+ $rev = new MutableRevisionRecord( $this->getTestPageTitle() );
+ isset( $details['slot'] ) ? $rev->setSlot( $details['slot'] ) : null;
+ isset( $details['parent'] ) ? $rev->setParentId( $details['parent'] ) : null;
+ isset( $details['page'] ) ? $rev->setPageId( $details['page'] ) : null;
+ isset( $details['size'] ) ? $rev->setSize( $details['size'] ) : null;
+ isset( $details['sha1'] ) ? $rev->setSha1( $details['sha1'] ) : null;
+ isset( $details['comment'] ) ? $rev->setComment( $details['comment'] ) : null;
+ isset( $details['timestamp'] ) ? $rev->setTimestamp( $details['timestamp'] ) : null;
+ isset( $details['minor'] ) ? $rev->setMinorEdit( $details['minor'] ) : null;
+ isset( $details['user'] ) ? $rev->setUser( $details['user'] ) : null;
+ isset( $details['visibility'] ) ? $rev->setVisibility( $details['visibility'] ) : null;
+ isset( $details['id'] ) ? $rev->setId( $details['id'] ) : null;
+
+ if ( isset( $details['content'] ) ) {
+ foreach ( $details['content'] as $role => $content ) {
+ $rev->setContent( $role, $content );
+ }
+ }
+
+ return $rev;
+ }
+
+ public function provideInsertRevisionOn_successes() {
+ yield 'Bare minimum revision insertion' => [
+ [
+ 'slot' => SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'Chicken' ) ),
+ 'page' => true,
+ 'comment' => $this->getRandomCommentStoreComment(),
+ 'timestamp' => '20171117010101',
+ 'user' => true,
+ ],
+ ];
+ yield 'Detailed revision insertion' => [
+ [
+ 'slot' => SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'Chicken' ) ),
+ 'parent' => true,
+ 'page' => true,
+ 'comment' => $this->getRandomCommentStoreComment(),
+ 'timestamp' => '20171117010101',
+ 'user' => true,
+ 'minor' => true,
+ 'visibility' => RevisionRecord::DELETED_RESTRICTED,
+ ],
+ ];
+ }
+
+ protected function getRandomCommentStoreComment() {
+ return CommentStoreComment::newUnsavedComment( __METHOD__ . '.' . rand( 0, 1000 ) );
+ }
+
+ /**
+ * @dataProvider provideInsertRevisionOn_successes
+ * @covers \MediaWiki\Revision\RevisionStore::insertRevisionOn
+ * @covers \MediaWiki\Revision\RevisionStore::insertSlotRowOn
+ * @covers \MediaWiki\Revision\RevisionStore::insertContentRowOn
+ */
+ public function testInsertRevisionOn_successes(
+ array $revDetails = []
+ ) {
+ $title = $this->getTestPageTitle();
+ $rev = $this->getRevisionRecordFromDetailsArray( $revDetails );
+
+ $this->overrideMwServices();
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $return = $store->insertRevisionOn( $rev, wfGetDB( DB_MASTER ) );
+
+ // is the new revision correct?
+ $this->assertRevisionCompleteness( $return );
+ $this->assertLinkTargetsEqual( $title, $return->getPageAsLinkTarget() );
+ $this->assertRevisionRecordsEqual( $rev, $return );
+
+ // can we load it from the store?
+ $loaded = $store->getRevisionById( $return->getId() );
+ $this->assertRevisionCompleteness( $loaded );
+ $this->assertRevisionRecordsEqual( $return, $loaded );
+
+ // can we find it directly in the database?
+ $this->assertRevisionExistsInDatabase( $return );
+ }
+
+ protected function assertRevisionExistsInDatabase( RevisionRecord $rev ) {
+ $row = $this->revisionToRow( new Revision( $rev ), [] );
+
+ // unset nulled fields
+ unset( $row->rev_content_model );
+ unset( $row->rev_content_format );
+
+ // unset fake fields
+ unset( $row->rev_comment_text );
+ unset( $row->rev_comment_data );
+ unset( $row->rev_comment_cid );
+ unset( $row->rev_comment_id );
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $queryInfo = $store->getQueryInfo( [ 'user' ] );
+
+ $row = get_object_vars( $row );
+ $this->assertSelect(
+ $queryInfo['tables'],
+ array_keys( $row ),
+ [ 'rev_id' => $rev->getId() ],
+ [ array_values( $row ) ],
+ [],
+ $queryInfo['joins']
+ );
+ }
+
+ /**
+ * @param SlotRecord $a
+ * @param SlotRecord $b
+ */
+ protected function assertSameSlotContent( SlotRecord $a, SlotRecord $b ) {
+ // Assert that the same blob address has been used.
+ $this->assertSame( $a->getAddress(), $b->getAddress() );
+ }
+
+ /**
+ * @covers \MediaWiki\Revision\RevisionStore::insertRevisionOn
+ */
+ public function testInsertRevisionOn_blobAddressExists() {
+ $title = $this->getTestPageTitle();
+ $revDetails = [
+ 'slot' => SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'Chicken' ) ),
+ 'parent' => true,
+ 'comment' => $this->getRandomCommentStoreComment(),
+ 'timestamp' => '20171117010101',
+ 'user' => true,
+ ];
+
+ $this->overrideMwServices();
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+
+ // Insert the first revision
+ $revOne = $this->getRevisionRecordFromDetailsArray( $revDetails );
+ $firstReturn = $store->insertRevisionOn( $revOne, wfGetDB( DB_MASTER ) );
+ $this->assertLinkTargetsEqual( $title, $firstReturn->getPageAsLinkTarget() );
+ $this->assertRevisionRecordsEqual( $revOne, $firstReturn );
+
+ // Insert a second revision inheriting the same blob address
+ $revDetails['slot'] = SlotRecord::newInherited( $firstReturn->getSlot( SlotRecord::MAIN ) );
+ $revTwo = $this->getRevisionRecordFromDetailsArray( $revDetails );
+ $secondReturn = $store->insertRevisionOn( $revTwo, wfGetDB( DB_MASTER ) );
+ $this->assertLinkTargetsEqual( $title, $secondReturn->getPageAsLinkTarget() );
+ $this->assertRevisionRecordsEqual( $revTwo, $secondReturn );
+
+ $firstMainSlot = $firstReturn->getSlot( SlotRecord::MAIN );
+ $secondMainSlot = $secondReturn->getSlot( SlotRecord::MAIN );
+
+ $this->assertSameSlotContent( $firstMainSlot, $secondMainSlot );
+
+ // And that different revisions have been created.
+ $this->assertNotSame( $firstReturn->getId(), $secondReturn->getId() );
+
+ // Make sure the slot rows reference the correct revision
+ $this->assertSame( $firstReturn->getId(), $firstMainSlot->getRevision() );
+ $this->assertSame( $secondReturn->getId(), $secondMainSlot->getRevision() );
+ }
+
+ public function provideInsertRevisionOn_failures() {
+ yield 'no slot' => [
+ [
+ 'comment' => $this->getRandomCommentStoreComment(),
+ 'timestamp' => '20171117010101',
+ 'user' => true,
+ ],
+ new InvalidArgumentException( 'main slot must be provided' )
+ ];
+ yield 'no main slot' => [
+ [
+ 'slot' => SlotRecord::newUnsaved( 'aux', new WikitextContent( 'Turkey' ) ),
+ 'comment' => $this->getRandomCommentStoreComment(),
+ 'timestamp' => '20171117010101',
+ 'user' => true,
+ ],
+ new InvalidArgumentException( 'main slot must be provided' )
+ ];
+ yield 'no timestamp' => [
+ [
+ 'slot' => SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'Chicken' ) ),
+ 'comment' => $this->getRandomCommentStoreComment(),
+ 'user' => true,
+ ],
+ new IncompleteRevisionException( 'timestamp field must not be NULL!' )
+ ];
+ yield 'no comment' => [
+ [
+ 'slot' => SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'Chicken' ) ),
+ 'timestamp' => '20171117010101',
+ 'user' => true,
+ ],
+ new IncompleteRevisionException( 'comment must not be NULL!' )
+ ];
+ yield 'no user' => [
+ [
+ 'slot' => SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'Chicken' ) ),
+ 'comment' => $this->getRandomCommentStoreComment(),
+ 'timestamp' => '20171117010101',
+ ],
+ new IncompleteRevisionException( 'user must not be NULL!' )
+ ];
+ }
+
+ /**
+ * @dataProvider provideInsertRevisionOn_failures
+ * @covers \MediaWiki\Revision\RevisionStore::insertRevisionOn
+ */
+ public function testInsertRevisionOn_failures(
+ array $revDetails = [],
+ Exception $exception
+ ) {
+ $rev = $this->getRevisionRecordFromDetailsArray( $revDetails );
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+
+ $this->setExpectedException(
+ get_class( $exception ),
+ $exception->getMessage(),
+ $exception->getCode()
+ );
+ $store->insertRevisionOn( $rev, wfGetDB( DB_MASTER ) );
+ }
+
+ public function provideNewNullRevision() {
+ yield [
+ Title::newFromText( 'UTPage_notAutoCreated' ),
+ [ 'content' => [ 'main' => new WikitextContent( 'Flubber1' ) ] ],
+ CommentStoreComment::newUnsavedComment( __METHOD__ . ' comment1' ),
+ true,
+ ];
+ yield [
+ Title::newFromText( 'UTPage_notAutoCreated' ),
+ [ 'content' => [ 'main' => new WikitextContent( 'Flubber2' ) ] ],
+ CommentStoreComment::newUnsavedComment( __METHOD__ . ' comment2', [ 'a' => 1 ] ),
+ false,
+ ];
+ }
+
+ /**
+ * @dataProvider provideNewNullRevision
+ * @covers \MediaWiki\Revision\RevisionStore::newNullRevision
+ * @covers \MediaWiki\Revision\RevisionStore::findSlotContentId
+ */
+ public function testNewNullRevision( Title $title, $revDetails, $comment, $minor = false ) {
+ $this->overrideMwServices();
+
+ $user = TestUserRegistry::getMutableTestUser( __METHOD__ )->getUser();
+ $page = WikiPage::factory( $title );
+
+ if ( !$page->exists() ) {
+ $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__, EDIT_NEW );
+ }
+
+ $revDetails['page'] = $page->getId();
+ $revDetails['timestamp'] = wfTimestampNow();
+ $revDetails['comment'] = CommentStoreComment::newUnsavedComment( 'Base' );
+ $revDetails['user'] = $user;
+
+ $baseRev = $this->getRevisionRecordFromDetailsArray( $revDetails );
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+
+ $dbw = wfGetDB( DB_MASTER );
+ $baseRev = $store->insertRevisionOn( $baseRev, $dbw );
+ $page->updateRevisionOn( $dbw, new Revision( $baseRev ), $page->getLatest() );
+
+ $record = $store->newNullRevision(
+ wfGetDB( DB_MASTER ),
+ $title,
+ $comment,
+ $minor,
+ $user
+ );
+
+ $this->assertEquals( $title->getNamespace(), $record->getPageAsLinkTarget()->getNamespace() );
+ $this->assertEquals( $title->getDBkey(), $record->getPageAsLinkTarget()->getDBkey() );
+ $this->assertEquals( $comment, $record->getComment() );
+ $this->assertEquals( $minor, $record->isMinor() );
+ $this->assertEquals( $user->getName(), $record->getUser()->getName() );
+ $this->assertEquals( $baseRev->getId(), $record->getParentId() );
+
+ $this->assertArrayEquals(
+ $baseRev->getSlotRoles(),
+ $record->getSlotRoles()
+ );
+
+ foreach ( $baseRev->getSlotRoles() as $role ) {
+ $parentSlot = $baseRev->getSlot( $role );
+ $slot = $record->getSlot( $role );
+
+ $this->assertTrue( $slot->isInherited(), 'isInherited' );
+ $this->assertSame( $parentSlot->getOrigin(), $slot->getOrigin(), 'getOrigin' );
+ $this->assertSameSlotContent( $parentSlot, $slot );
+ }
+ }
+
+ /**
+ * @covers \MediaWiki\Revision\RevisionStore::newNullRevision
+ */
+ public function testNewNullRevision_nonExistingTitle() {
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $record = $store->newNullRevision(
+ wfGetDB( DB_MASTER ),
+ Title::newFromText( __METHOD__ . '.iDontExist!' ),
+ CommentStoreComment::newUnsavedComment( __METHOD__ . ' comment' ),
+ false,
+ TestUserRegistry::getMutableTestUser( __METHOD__ )->getUser()
+ );
+ $this->assertNull( $record );
+ }
+
+ /**
+ * @covers \MediaWiki\Revision\RevisionStore::getRcIdIfUnpatrolled
+ */
+ public function testGetRcIdIfUnpatrolled_returnsRecentChangesId() {
+ $page = $this->getTestPage();
+ $status = $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ );
+ /** @var Revision $rev */
+ $rev = $status->value['revision'];
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $revisionRecord = $store->getRevisionById( $rev->getId() );
+ $result = $store->getRcIdIfUnpatrolled( $revisionRecord );
+
+ $this->assertGreaterThan( 0, $result );
+ $this->assertSame(
+ $store->getRecentChange( $revisionRecord )->getAttribute( 'rc_id' ),
+ $result
+ );
+ }
+
+ /**
+ * @covers \MediaWiki\Revision\RevisionStore::getRcIdIfUnpatrolled
+ */
+ public function testGetRcIdIfUnpatrolled_returnsZeroIfPatrolled() {
+ // This assumes that sysops are auto patrolled
+ $sysop = $this->getTestSysop()->getUser();
+ $page = $this->getTestPage();
+ $status = $page->doEditContent(
+ new WikitextContent( __METHOD__ ),
+ __METHOD__,
+ 0,
+ false,
+ $sysop
+ );
+ /** @var Revision $rev */
+ $rev = $status->value['revision'];
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $revisionRecord = $store->getRevisionById( $rev->getId() );
+ $result = $store->getRcIdIfUnpatrolled( $revisionRecord );
+
+ $this->assertSame( 0, $result );
+ }
+
+ /**
+ * @covers \MediaWiki\Revision\RevisionStore::getRecentChange
+ */
+ public function testGetRecentChange() {
+ $page = $this->getTestPage();
+ $content = new WikitextContent( __METHOD__ );
+ $status = $page->doEditContent( $content, __METHOD__ );
+ /** @var Revision $rev */
+ $rev = $status->value['revision'];
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $revRecord = $store->getRevisionById( $rev->getId() );
+ $recentChange = $store->getRecentChange( $revRecord );
+
+ $this->assertEquals( $rev->getId(), $recentChange->getAttribute( 'rc_this_oldid' ) );
+ $this->assertEquals( $rev->getRecentChange(), $recentChange );
+ }
+
+ /**
+ * @covers \MediaWiki\Revision\RevisionStore::getRevisionById
+ */
+ public function testGetRevisionById() {
+ $page = $this->getTestPage();
+ $content = new WikitextContent( __METHOD__ );
+ $status = $page->doEditContent( $content, __METHOD__ );
+ /** @var Revision $rev */
+ $rev = $status->value['revision'];
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $revRecord = $store->getRevisionById( $rev->getId() );
+
+ $this->assertSame( $rev->getId(), $revRecord->getId() );
+ $this->assertTrue( $revRecord->getSlot( SlotRecord::MAIN )->getContent()->equals( $content ) );
+ $this->assertSame( __METHOD__, $revRecord->getComment()->text );
+ }
+
+ /**
+ * @covers \MediaWiki\Revision\RevisionStore::getRevisionByTitle
+ */
+ public function testGetRevisionByTitle() {
+ $page = $this->getTestPage();
+ $content = new WikitextContent( __METHOD__ );
+ $status = $page->doEditContent( $content, __METHOD__ );
+ /** @var Revision $rev */
+ $rev = $status->value['revision'];
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $revRecord = $store->getRevisionByTitle( $page->getTitle() );
+
+ $this->assertSame( $rev->getId(), $revRecord->getId() );
+ $this->assertTrue( $revRecord->getSlot( SlotRecord::MAIN )->getContent()->equals( $content ) );
+ $this->assertSame( __METHOD__, $revRecord->getComment()->text );
+ }
+
+ /**
+ * @covers \MediaWiki\Revision\RevisionStore::getRevisionByPageId
+ */
+ public function testGetRevisionByPageId() {
+ $page = $this->getTestPage();
+ $content = new WikitextContent( __METHOD__ );
+ $status = $page->doEditContent( $content, __METHOD__ );
+ /** @var Revision $rev */
+ $rev = $status->value['revision'];
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $revRecord = $store->getRevisionByPageId( $page->getId() );
+
+ $this->assertSame( $rev->getId(), $revRecord->getId() );
+ $this->assertTrue( $revRecord->getSlot( SlotRecord::MAIN )->getContent()->equals( $content ) );
+ $this->assertSame( __METHOD__, $revRecord->getComment()->text );
+ }
+
+ /**
+ * @covers \MediaWiki\Revision\RevisionStore::getRevisionByTimestamp
+ */
+ public function testGetRevisionByTimestamp() {
+ // Make sure there is 1 second between the last revision and the rev we create...
+ // Otherwise we might not get the correct revision and the test may fail...
+ // :(
+ $page = $this->getTestPage();
+ sleep( 1 );
+ $content = new WikitextContent( __METHOD__ );
+ $status = $page->doEditContent( $content, __METHOD__ );
+ /** @var Revision $rev */
+ $rev = $status->value['revision'];
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $revRecord = $store->getRevisionByTimestamp(
+ $page->getTitle(),
+ $rev->getTimestamp()
+ );
+
+ $this->assertSame( $rev->getId(), $revRecord->getId() );
+ $this->assertTrue( $revRecord->getSlot( SlotRecord::MAIN )->getContent()->equals( $content ) );
+ $this->assertSame( __METHOD__, $revRecord->getComment()->text );
+ }
+
+ protected function revisionToRow( Revision $rev, $options = [ 'page', 'user', 'comment' ] ) {
+ // XXX: the WikiPage object loads another RevisionRecord from the database. Not great.
+ $page = WikiPage::factory( $rev->getTitle() );
+
+ $fields = [
+ 'rev_id' => (string)$rev->getId(),
+ 'rev_page' => (string)$rev->getPage(),
+ 'rev_timestamp' => $this->db->timestamp( $rev->getTimestamp() ),
+ 'rev_user_text' => (string)$rev->getUserText(),
+ 'rev_user' => (string)$rev->getUser(),
+ 'rev_minor_edit' => $rev->isMinor() ? '1' : '0',
+ 'rev_deleted' => (string)$rev->getVisibility(),
+ 'rev_len' => (string)$rev->getSize(),
+ 'rev_parent_id' => (string)$rev->getParentId(),
+ 'rev_sha1' => (string)$rev->getSha1(),
+ ];
+
+ if ( in_array( 'page', $options ) ) {
+ $fields += [
+ 'page_namespace' => (string)$page->getTitle()->getNamespace(),
+ 'page_title' => $page->getTitle()->getDBkey(),
+ 'page_id' => (string)$page->getId(),
+ 'page_latest' => (string)$page->getLatest(),
+ 'page_is_redirect' => $page->isRedirect() ? '1' : '0',
+ 'page_len' => (string)$page->getContent()->getSize(),
+ ];
+ }
+
+ if ( in_array( 'user', $options ) ) {
+ $fields += [
+ 'user_name' => (string)$rev->getUserText(),
+ ];
+ }
+
+ if ( in_array( 'comment', $options ) ) {
+ $fields += [
+ 'rev_comment_text' => $rev->getComment(),
+ 'rev_comment_data' => null,
+ 'rev_comment_cid' => null,
+ ];
+ }
+
+ if ( $rev->getId() ) {
+ $fields += [
+ 'rev_id' => (string)$rev->getId(),
+ ];
+ }
+
+ return (object)$fields;
+ }
+
+ protected function assertRevisionRecordMatchesRevision(
+ Revision $rev,
+ RevisionRecord $record
+ ) {
+ $this->assertSame( $rev->getId(), $record->getId() );
+ $this->assertSame( $rev->getPage(), $record->getPageId() );
+ $this->assertSame( $rev->getTimestamp(), $record->getTimestamp() );
+ $this->assertSame( $rev->getUserText(), $record->getUser()->getName() );
+ $this->assertSame( $rev->getUser(), $record->getUser()->getId() );
+ $this->assertSame( $rev->isMinor(), $record->isMinor() );
+ $this->assertSame( $rev->getVisibility(), $record->getVisibility() );
+ $this->assertSame( $rev->getSize(), $record->getSize() );
+ /**
+ * @note As of MW 1.31, the database schema allows the parent ID to be
+ * NULL to indicate that it is unknown.
+ */
+ $expectedParent = $rev->getParentId();
+ if ( $expectedParent === null ) {
+ $expectedParent = 0;
+ }
+ $this->assertSame( $expectedParent, $record->getParentId() );
+ $this->assertSame( $rev->getSha1(), $record->getSha1() );
+ $this->assertSame( $rev->getComment(), $record->getComment()->text );
+ $this->assertSame( $rev->getContentFormat(),
+ $record->getContent( SlotRecord::MAIN )->getDefaultFormat() );
+ $this->assertSame( $rev->getContentModel(), $record->getContent( SlotRecord::MAIN )->getModel() );
+ $this->assertLinkTargetsEqual( $rev->getTitle(), $record->getPageAsLinkTarget() );
+
+ $revRec = $rev->getRevisionRecord();
+ $revMain = $revRec->getSlot( SlotRecord::MAIN );
+ $recMain = $record->getSlot( SlotRecord::MAIN );
+
+ $this->assertSame( $revMain->hasOrigin(), $recMain->hasOrigin(), 'hasOrigin' );
+ $this->assertSame( $revMain->hasAddress(), $recMain->hasAddress(), 'hasAddress' );
+ $this->assertSame( $revMain->hasContentId(), $recMain->hasContentId(), 'hasContentId' );
+
+ if ( $revMain->hasOrigin() ) {
+ $this->assertSame( $revMain->getOrigin(), $recMain->getOrigin(), 'getOrigin' );
+ }
+
+ if ( $revMain->hasAddress() ) {
+ $this->assertSame( $revMain->getAddress(), $recMain->getAddress(), 'getAddress' );
+ }
+
+ if ( $revMain->hasContentId() ) {
+ $this->assertSame( $revMain->getContentId(), $recMain->getContentId(), 'getContentId' );
+ }
+ }
+
+ /**
+ * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromRow
+ * @covers \MediaWiki\Revision\RevisionStore::getQueryInfo
+ */
+ public function testNewRevisionFromRow_getQueryInfo() {
+ $page = $this->getTestPage();
+ $text = __METHOD__ . 'a-ä';
+ /** @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']
+ );
+ $record = $store->newRevisionFromRow(
+ $row,
+ [],
+ $page->getTitle()
+ );
+ $this->assertRevisionRecordMatchesRevision( $rev, $record );
+ $this->assertSame( $text, $rev->getContent()->serialize() );
+ }
+
+ /**
+ * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromRow
+ */
+ public function testNewRevisionFromRow_anonEdit() {
+ $page = $this->getTestPage();
+ $text = __METHOD__ . 'a-ä';
+ /** @var Revision $rev */
+ $rev = $page->doEditContent(
+ new WikitextContent( $text ),
+ __METHOD__ . 'a'
+ )->value['revision'];
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $record = $store->newRevisionFromRow(
+ $this->revisionToRow( $rev ),
+ [],
+ $page->getTitle()
+ );
+ $this->assertRevisionRecordMatchesRevision( $rev, $record );
+ $this->assertSame( $text, $rev->getContent()->serialize() );
+ }
+
+ /**
+ * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromRow
+ */
+ public function testNewRevisionFromRow_anonEdit_legacyEncoding() {
+ $this->setMwGlobals( 'wgLegacyEncoding', 'windows-1252' );
+ $this->overrideMwServices();
+ $page = $this->getTestPage();
+ $text = __METHOD__ . 'a-ä';
+ /** @var Revision $rev */
+ $rev = $page->doEditContent(
+ new WikitextContent( $text ),
+ __METHOD__ . 'a'
+ )->value['revision'];
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $record = $store->newRevisionFromRow(
+ $this->revisionToRow( $rev ),
+ [],
+ $page->getTitle()
+ );
+ $this->assertRevisionRecordMatchesRevision( $rev, $record );
+ $this->assertSame( $text, $rev->getContent()->serialize() );
+ }
+
+ /**
+ * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromRow
+ */
+ public function testNewRevisionFromRow_userEdit() {
+ $page = $this->getTestPage();
+ $text = __METHOD__ . 'b-ä';
+ /** @var Revision $rev */
+ $rev = $page->doEditContent(
+ new WikitextContent( $text ),
+ __METHOD__ . 'b',
+ 0,
+ false,
+ $this->getTestUser()->getUser()
+ )->value['revision'];
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $record = $store->newRevisionFromRow(
+ $this->revisionToRow( $rev ),
+ [],
+ $page->getTitle()
+ );
+ $this->assertRevisionRecordMatchesRevision( $rev, $record );
+ $this->assertSame( $text, $rev->getContent()->serialize() );
+ }
+
+ /**
+ * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromArchiveRow
+ * @covers \MediaWiki\Revision\RevisionStore::getArchiveQueryInfo
+ */
+ public function testNewRevisionFromArchiveRow_getArchiveQueryInfo() {
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $title = Title::newFromText( __METHOD__ );
+ $text = __METHOD__ . '-bä';
+ $page = WikiPage::factory( $title );
+ /** @var Revision $orig */
+ $orig = $page->doEditContent( new WikitextContent( $text ), __METHOD__ )
+ ->value['revision'];
+ $page->doDeleteArticle( __METHOD__ );
+
+ $db = wfGetDB( DB_MASTER );
+ $arQuery = $store->getArchiveQueryInfo();
+ $res = $db->select(
+ $arQuery['tables'], $arQuery['fields'], [ 'ar_rev_id' => $orig->getId() ],
+ __METHOD__, [], $arQuery['joins']
+ );
+ $this->assertTrue( is_object( $res ), 'query failed' );
+
+ $row = $res->fetchObject();
+ $res->free();
+ $record = $store->newRevisionFromArchiveRow( $row );
+
+ $this->assertRevisionRecordMatchesRevision( $orig, $record );
+ $this->assertSame( $text, $record->getContent( SlotRecord::MAIN )->serialize() );
+ }
+
+ /**
+ * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromArchiveRow
+ */
+ public function testNewRevisionFromArchiveRow_legacyEncoding() {
+ $this->setMwGlobals( 'wgLegacyEncoding', 'windows-1252' );
+ $this->overrideMwServices();
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $title = Title::newFromText( __METHOD__ );
+ $text = __METHOD__ . '-bä';
+ $page = WikiPage::factory( $title );
+ /** @var Revision $orig */
+ $orig = $page->doEditContent( new WikitextContent( $text ), __METHOD__ )
+ ->value['revision'];
+ $page->doDeleteArticle( __METHOD__ );
+
+ $db = wfGetDB( DB_MASTER );
+ $arQuery = $store->getArchiveQueryInfo();
+ $res = $db->select(
+ $arQuery['tables'], $arQuery['fields'], [ 'ar_rev_id' => $orig->getId() ],
+ __METHOD__, [], $arQuery['joins']
+ );
+ $this->assertTrue( is_object( $res ), 'query failed' );
+
+ $row = $res->fetchObject();
+ $res->free();
+ $record = $store->newRevisionFromArchiveRow( $row );
+
+ $this->assertRevisionRecordMatchesRevision( $orig, $record );
+ $this->assertSame( $text, $record->getContent( SlotRecord::MAIN )->serialize() );
+ }
+
+ /**
+ * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromArchiveRow
+ */
+ public function testNewRevisionFromArchiveRow_no_user() {
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+
+ $row = (object)[
+ 'ar_id' => '1',
+ 'ar_page_id' => '2',
+ 'ar_namespace' => '0',
+ 'ar_title' => 'Something',
+ 'ar_rev_id' => '2',
+ 'ar_text_id' => '47',
+ 'ar_timestamp' => '20180528192356',
+ 'ar_minor_edit' => '0',
+ 'ar_deleted' => '0',
+ 'ar_len' => '78',
+ 'ar_parent_id' => '0',
+ 'ar_sha1' => 'deadbeef',
+ 'ar_comment_text' => 'whatever',
+ 'ar_comment_data' => null,
+ 'ar_comment_cid' => null,
+ 'ar_user' => '0',
+ 'ar_user_text' => '', // this is the important bit
+ 'ar_actor' => null,
+ 'ar_content_format' => null,
+ 'ar_content_model' => null,
+ ];
+
+ \Wikimedia\suppressWarnings();
+ $record = $store->newRevisionFromArchiveRow( $row );
+ \Wikimedia\suppressWarnings( true );
+
+ $this->assertInstanceOf( RevisionRecord::class, $record );
+ $this->assertInstanceOf( UserIdentityValue::class, $record->getUser() );
+ $this->assertSame( 'Unknown user', $record->getUser()->getName() );
+ }
+
+ /**
+ * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromRow
+ */
+ public function testNewRevisionFromRow_no_user() {
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $title = Title::newFromText( __METHOD__ );
+
+ $row = (object)[
+ 'rev_id' => '2',
+ 'rev_page' => '2',
+ 'page_namespace' => '0',
+ 'page_title' => $title->getText(),
+ 'rev_text_id' => '47',
+ 'rev_timestamp' => '20180528192356',
+ 'rev_minor_edit' => '0',
+ 'rev_deleted' => '0',
+ 'rev_len' => '78',
+ 'rev_parent_id' => '0',
+ 'rev_sha1' => 'deadbeef',
+ 'rev_comment_text' => 'whatever',
+ 'rev_comment_data' => null,
+ 'rev_comment_cid' => null,
+ 'rev_user' => '0',
+ 'rev_user_text' => '', // this is the important bit
+ 'rev_actor' => null,
+ 'rev_content_format' => null,
+ 'rev_content_model' => null,
+ ];
+
+ \Wikimedia\suppressWarnings();
+ $record = $store->newRevisionFromRow( $row, 0, $title );
+ \Wikimedia\suppressWarnings( true );
+
+ $this->assertNotNull( $record );
+ $this->assertNotNull( $record->getUser() );
+ $this->assertNotEmpty( $record->getUser()->getName() );
+ }
+
+ /**
+ * @covers \MediaWiki\Revision\RevisionStore::insertRevisionOn
+ */
+ public function testInsertRevisionOn_archive() {
+ // This is a round trip test for deletion and undeletion of a
+ // revision row via the archive table.
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $title = Title::newFromText( __METHOD__ );
+
+ $page = WikiPage::factory( $title );
+ /** @var Revision $origRev */
+ $page->doEditContent( new WikitextContent( "First" ), __METHOD__ . '-first' );
+ $origRev = $page->doEditContent( new WikitextContent( "Foo" ), __METHOD__ )
+ ->value['revision'];
+ $orig = $origRev->getRevisionRecord();
+ $page->doDeleteArticle( __METHOD__ );
+
+ // re-create page, so we can later load revisions for it
+ $page->doEditContent( new WikitextContent( 'Two' ), __METHOD__ );
+
+ $db = wfGetDB( DB_MASTER );
+ $arQuery = $store->getArchiveQueryInfo();
+ $row = $db->selectRow(
+ $arQuery['tables'], $arQuery['fields'], [ 'ar_rev_id' => $orig->getId() ],
+ __METHOD__, [], $arQuery['joins']
+ );
+
+ $this->assertNotFalse( $row, 'query failed' );
+
+ $record = $store->newRevisionFromArchiveRow(
+ $row,
+ 0,
+ $title,
+ [ 'page_id' => $title->getArticleID() ]
+ );
+
+ $restored = $store->insertRevisionOn( $record, $db );
+
+ // is the new revision correct?
+ $this->assertRevisionCompleteness( $restored );
+ $this->assertRevisionRecordsEqual( $record, $restored );
+
+ // does the new revision use the original slot?
+ $recMain = $record->getSlot( SlotRecord::MAIN );
+ $restMain = $restored->getSlot( SlotRecord::MAIN );
+ $this->assertSame( $recMain->getAddress(), $restMain->getAddress() );
+ $this->assertSame( $recMain->getContentId(), $restMain->getContentId() );
+ $this->assertSame( $recMain->getOrigin(), $restMain->getOrigin() );
+ $this->assertSame( 'Foo', $restMain->getContent()->serialize() );
+
+ // can we load it from the store?
+ $loaded = $store->getRevisionById( $restored->getId() );
+ $this->assertNotNull( $loaded );
+ $this->assertRevisionCompleteness( $loaded );
+ $this->assertRevisionRecordsEqual( $restored, $loaded );
+
+ // can we find it directly in the database?
+ $this->assertRevisionExistsInDatabase( $restored );
+ }
+
+ /**
+ * @covers \MediaWiki\Revision\RevisionStore::loadRevisionFromId
+ */
+ public function testLoadRevisionFromId() {
+ $title = Title::newFromText( __METHOD__ );
+ $page = WikiPage::factory( $title );
+ /** @var Revision $rev */
+ $rev = $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ )
+ ->value['revision'];
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $result = $store->loadRevisionFromId( wfGetDB( DB_MASTER ), $rev->getId() );
+ $this->assertRevisionRecordMatchesRevision( $rev, $result );
+ }
+
+ /**
+ * @covers \MediaWiki\Revision\RevisionStore::loadRevisionFromPageId
+ */
+ public function testLoadRevisionFromPageId() {
+ $title = Title::newFromText( __METHOD__ );
+ $page = WikiPage::factory( $title );
+ /** @var Revision $rev */
+ $rev = $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ )
+ ->value['revision'];
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $result = $store->loadRevisionFromPageId( wfGetDB( DB_MASTER ), $page->getId() );
+ $this->assertRevisionRecordMatchesRevision( $rev, $result );
+ }
+
+ /**
+ * @covers \MediaWiki\Revision\RevisionStore::loadRevisionFromTitle
+ */
+ public function testLoadRevisionFromTitle() {
+ $title = Title::newFromText( __METHOD__ );
+ $page = WikiPage::factory( $title );
+ /** @var Revision $rev */
+ $rev = $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ )
+ ->value['revision'];
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $result = $store->loadRevisionFromTitle( wfGetDB( DB_MASTER ), $title );
+ $this->assertRevisionRecordMatchesRevision( $rev, $result );
+ }
+
+ /**
+ * @covers \MediaWiki\Revision\RevisionStore::loadRevisionFromTimestamp
+ */
+ public function testLoadRevisionFromTimestamp() {
+ $title = Title::newFromText( __METHOD__ );
+ $page = WikiPage::factory( $title );
+ /** @var Revision $revOne */
+ $revOne = $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ )
+ ->value['revision'];
+ // Sleep to ensure different timestamps... )(evil)
+ sleep( 1 );
+ /** @var Revision $revTwo */
+ $revTwo = $page->doEditContent( new WikitextContent( __METHOD__ . 'a' ), '' )
+ ->value['revision'];
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $this->assertNull(
+ $store->loadRevisionFromTimestamp( wfGetDB( DB_MASTER ), $title, '20150101010101' )
+ );
+ $this->assertSame(
+ $revOne->getId(),
+ $store->loadRevisionFromTimestamp(
+ wfGetDB( DB_MASTER ),
+ $title,
+ $revOne->getTimestamp()
+ )->getId()
+ );
+ $this->assertSame(
+ $revTwo->getId(),
+ $store->loadRevisionFromTimestamp(
+ wfGetDB( DB_MASTER ),
+ $title,
+ $revTwo->getTimestamp()
+ )->getId()
+ );
+ }
+
+ /**
+ * @covers \MediaWiki\Revision\RevisionStore::listRevisionSizes
+ */
+ public function testGetParentLengths() {
+ $page = WikiPage::factory( Title::newFromText( __METHOD__ ) );
+ /** @var Revision $revOne */
+ $revOne = $page->doEditContent(
+ new WikitextContent( __METHOD__ ), __METHOD__
+ )->value['revision'];
+ /** @var Revision $revTwo */
+ $revTwo = $page->doEditContent(
+ new WikitextContent( __METHOD__ . '2' ), __METHOD__
+ )->value['revision'];
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $this->assertSame(
+ [
+ $revOne->getId() => strlen( __METHOD__ ),
+ ],
+ $store->listRevisionSizes(
+ wfGetDB( DB_MASTER ),
+ [ $revOne->getId() ]
+ )
+ );
+ $this->assertSame(
+ [
+ $revOne->getId() => strlen( __METHOD__ ),
+ $revTwo->getId() => strlen( __METHOD__ ) + 1,
+ ],
+ $store->listRevisionSizes(
+ wfGetDB( DB_MASTER ),
+ [ $revOne->getId(), $revTwo->getId() ]
+ )
+ );
+ }
+
+ /**
+ * @covers \MediaWiki\Revision\RevisionStore::getPreviousRevision
+ */
+ public function testGetPreviousRevision() {
+ $page = WikiPage::factory( Title::newFromText( __METHOD__ ) );
+ /** @var Revision $revOne */
+ $revOne = $page->doEditContent(
+ new WikitextContent( __METHOD__ ), __METHOD__
+ )->value['revision'];
+ /** @var Revision $revTwo */
+ $revTwo = $page->doEditContent(
+ new WikitextContent( __METHOD__ . '2' ), __METHOD__
+ )->value['revision'];
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $this->assertNull(
+ $store->getPreviousRevision( $store->getRevisionById( $revOne->getId() ) )
+ );
+ $this->assertSame(
+ $revOne->getId(),
+ $store->getPreviousRevision( $store->getRevisionById( $revTwo->getId() ) )->getId()
+ );
+ }
+
+ /**
+ * @covers \MediaWiki\Revision\RevisionStore::getNextRevision
+ */
+ public function testGetNextRevision() {
+ $page = WikiPage::factory( Title::newFromText( __METHOD__ ) );
+ /** @var Revision $revOne */
+ $revOne = $page->doEditContent(
+ new WikitextContent( __METHOD__ ), __METHOD__
+ )->value['revision'];
+ /** @var Revision $revTwo */
+ $revTwo = $page->doEditContent(
+ new WikitextContent( __METHOD__ . '2' ), __METHOD__
+ )->value['revision'];
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $this->assertSame(
+ $revTwo->getId(),
+ $store->getNextRevision( $store->getRevisionById( $revOne->getId() ) )->getId()
+ );
+ $this->assertNull(
+ $store->getNextRevision( $store->getRevisionById( $revTwo->getId() ) )
+ );
+ }
+
+ /**
+ * @covers \MediaWiki\Revision\RevisionStore::getTimestampFromId
+ */
+ public function testGetTimestampFromId_found() {
+ $page = $this->getTestPage();
+ /** @var Revision $rev */
+ $rev = $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ )
+ ->value['revision'];
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $result = $store->getTimestampFromId(
+ $page->getTitle(),
+ $rev->getId()
+ );
+
+ $this->assertSame( $rev->getTimestamp(), $result );
+ }
+
+ /**
+ * @covers \MediaWiki\Revision\RevisionStore::getTimestampFromId
+ */
+ public function testGetTimestampFromId_notFound() {
+ $page = $this->getTestPage();
+ /** @var Revision $rev */
+ $rev = $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ )
+ ->value['revision'];
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $result = $store->getTimestampFromId(
+ $page->getTitle(),
+ $rev->getId() + 1
+ );
+
+ $this->assertFalse( $result );
+ }
+
+ /**
+ * @covers \MediaWiki\Revision\RevisionStore::countRevisionsByPageId
+ */
+ public function testCountRevisionsByPageId() {
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $page = WikiPage::factory( Title::newFromText( __METHOD__ ) );
+
+ $this->assertSame(
+ 0,
+ $store->countRevisionsByPageId( wfGetDB( DB_MASTER ), $page->getId() )
+ );
+ $page->doEditContent( new WikitextContent( 'a' ), 'a' );
+ $this->assertSame(
+ 1,
+ $store->countRevisionsByPageId( wfGetDB( DB_MASTER ), $page->getId() )
+ );
+ $page->doEditContent( new WikitextContent( 'b' ), 'b' );
+ $this->assertSame(
+ 2,
+ $store->countRevisionsByPageId( wfGetDB( DB_MASTER ), $page->getId() )
+ );
+ }
+
+ /**
+ * @covers \MediaWiki\Revision\RevisionStore::countRevisionsByTitle
+ */
+ public function testCountRevisionsByTitle() {
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $page = WikiPage::factory( Title::newFromText( __METHOD__ ) );
+
+ $this->assertSame(
+ 0,
+ $store->countRevisionsByTitle( wfGetDB( DB_MASTER ), $page->getTitle() )
+ );
+ $page->doEditContent( new WikitextContent( 'a' ), 'a' );
+ $this->assertSame(
+ 1,
+ $store->countRevisionsByTitle( wfGetDB( DB_MASTER ), $page->getTitle() )
+ );
+ $page->doEditContent( new WikitextContent( 'b' ), 'b' );
+ $this->assertSame(
+ 2,
+ $store->countRevisionsByTitle( wfGetDB( DB_MASTER ), $page->getTitle() )
+ );
+ }
+
+ /**
+ * @covers \MediaWiki\Revision\RevisionStore::userWasLastToEdit
+ */
+ public function testUserWasLastToEdit_false() {
+ $sysop = $this->getTestSysop()->getUser();
+ $page = $this->getTestPage();
+ $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ );
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $result = $store->userWasLastToEdit(
+ wfGetDB( DB_MASTER ),
+ $page->getId(),
+ $sysop->getId(),
+ '20160101010101'
+ );
+ $this->assertFalse( $result );
+ }
+
+ /**
+ * @covers \MediaWiki\Revision\RevisionStore::userWasLastToEdit
+ */
+ public function testUserWasLastToEdit_true() {
+ $startTime = wfTimestampNow();
+ $sysop = $this->getTestSysop()->getUser();
+ $page = $this->getTestPage();
+ $page->doEditContent(
+ new WikitextContent( __METHOD__ ),
+ __METHOD__,
+ 0,
+ false,
+ $sysop
+ );
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $result = $store->userWasLastToEdit(
+ wfGetDB( DB_MASTER ),
+ $page->getId(),
+ $sysop->getId(),
+ $startTime
+ );
+ $this->assertTrue( $result );
+ }
+
+ /**
+ * @covers \MediaWiki\Revision\RevisionStore::getKnownCurrentRevision
+ */
+ public function testGetKnownCurrentRevision() {
+ $page = $this->getTestPage();
+ /** @var Revision $rev */
+ $rev = $page->doEditContent(
+ new WikitextContent( __METHOD__ . 'b' ),
+ __METHOD__ . 'b',
+ 0,
+ false,
+ $this->getTestUser()->getUser()
+ )->value['revision'];
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $record = $store->getKnownCurrentRevision(
+ $page->getTitle(),
+ $rev->getId()
+ );
+
+ $this->assertRevisionRecordMatchesRevision( $rev, $record );
+ }
+
+ public function provideNewMutableRevisionFromArray() {
+ yield 'Basic array, content object' => [
+ [
+ 'id' => 2,
+ 'page' => 1,
+ 'timestamp' => '20171017114835',
+ 'user_text' => '111.0.1.2',
+ 'user' => 0,
+ 'minor_edit' => false,
+ 'deleted' => 0,
+ 'len' => 46,
+ 'parent_id' => 1,
+ 'sha1' => 'rdqbbzs3pkhihgbs8qf2q9jsvheag5z',
+ 'comment' => 'Goat Comment!',
+ 'content' => new WikitextContent( 'Some Content' ),
+ ]
+ ];
+ yield 'Basic array, serialized text' => [
+ [
+ 'id' => 2,
+ 'page' => 1,
+ 'timestamp' => '20171017114835',
+ 'user_text' => '111.0.1.2',
+ 'user' => 0,
+ 'minor_edit' => false,
+ 'deleted' => 0,
+ 'len' => 46,
+ 'parent_id' => 1,
+ 'sha1' => 'rdqbbzs3pkhihgbs8qf2q9jsvheag5z',
+ 'comment' => 'Goat Comment!',
+ 'text' => ( new WikitextContent( 'Söme Content' ) )->serialize(),
+ ]
+ ];
+ yield 'Basic array, serialized text, utf-8 flags' => [
+ [
+ 'id' => 2,
+ 'page' => 1,
+ 'timestamp' => '20171017114835',
+ 'user_text' => '111.0.1.2',
+ 'user' => 0,
+ 'minor_edit' => false,
+ 'deleted' => 0,
+ 'len' => 46,
+ 'parent_id' => 1,
+ 'sha1' => 'rdqbbzs3pkhihgbs8qf2q9jsvheag5z',
+ 'comment' => 'Goat Comment!',
+ 'text' => ( new WikitextContent( 'Söme Content' ) )->serialize(),
+ 'flags' => 'utf-8',
+ ]
+ ];
+ yield 'Basic array, with title' => [
+ [
+ 'title' => Title::newFromText( 'SomeText' ),
+ 'timestamp' => '20171017114835',
+ 'user_text' => '111.0.1.2',
+ 'user' => 0,
+ 'minor_edit' => false,
+ 'deleted' => 0,
+ 'len' => 46,
+ 'parent_id' => 1,
+ 'sha1' => 'rdqbbzs3pkhihgbs8qf2q9jsvheag5z',
+ 'comment' => 'Goat Comment!',
+ 'content' => new WikitextContent( 'Some Content' ),
+ ]
+ ];
+ yield 'Basic array, no user field' => [
+ [
+ 'id' => 2,
+ 'page' => 1,
+ 'timestamp' => '20171017114835',
+ 'user_text' => '111.0.1.3',
+ 'minor_edit' => false,
+ 'deleted' => 0,
+ 'len' => 46,
+ 'parent_id' => 1,
+ 'sha1' => 'rdqbbzs3pkhihgbs8qf2q9jsvheag5z',
+ 'comment' => 'Goat Comment!',
+ 'content' => new WikitextContent( 'Some Content' ),
+ ]
+ ];
+ }
+
+ /**
+ * @dataProvider provideNewMutableRevisionFromArray
+ * @covers \MediaWiki\Revision\RevisionStore::newMutableRevisionFromArray
+ */
+ public function testNewMutableRevisionFromArray( array $array ) {
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+
+ // HACK: if $array['page'] is given, make sure that the page exists
+ if ( isset( $array['page'] ) ) {
+ $t = Title::newFromID( $array['page'] );
+ if ( !$t || !$t->exists() ) {
+ $t = Title::makeTitle( NS_MAIN, __METHOD__ );
+ $info = $this->insertPage( $t );
+ $array['page'] = $info['id'];
+ }
+ }
+
+ $result = $store->newMutableRevisionFromArray( $array );
+
+ if ( isset( $array['id'] ) ) {
+ $this->assertSame( $array['id'], $result->getId() );
+ }
+ if ( isset( $array['page'] ) ) {
+ $this->assertSame( $array['page'], $result->getPageId() );
+ }
+ $this->assertSame( $array['timestamp'], $result->getTimestamp() );
+ $this->assertSame( $array['user_text'], $result->getUser()->getName() );
+ if ( isset( $array['user'] ) ) {
+ $this->assertSame( $array['user'], $result->getUser()->getId() );
+ }
+ $this->assertSame( (bool)$array['minor_edit'], $result->isMinor() );
+ $this->assertSame( $array['deleted'], $result->getVisibility() );
+ $this->assertSame( $array['len'], $result->getSize() );
+ $this->assertSame( $array['parent_id'], $result->getParentId() );
+ $this->assertSame( $array['sha1'], $result->getSha1() );
+ $this->assertSame( $array['comment'], $result->getComment()->text );
+ if ( isset( $array['content'] ) ) {
+ foreach ( $array['content'] as $role => $content ) {
+ $this->assertTrue(
+ $result->getContent( $role )->equals( $content )
+ );
+ }
+ } elseif ( isset( $array['text'] ) ) {
+ $this->assertSame( $array['text'],
+ $result->getSlot( SlotRecord::MAIN )->getContent()->serialize() );
+ } elseif ( isset( $array['content_format'] ) ) {
+ $this->assertSame(
+ $array['content_format'],
+ $result->getSlot( SlotRecord::MAIN )->getContent()->getDefaultFormat()
+ );
+ $this->assertSame( $array['content_model'], $result->getSlot( SlotRecord::MAIN )->getModel() );
+ }
+ }
+
+ /**
+ * @dataProvider provideNewMutableRevisionFromArray
+ * @covers \MediaWiki\Revision\RevisionStore::newMutableRevisionFromArray
+ */
+ public function testNewMutableRevisionFromArray_legacyEncoding( array $array ) {
+ $cache = new WANObjectCache( [ 'cache' => new HashBagOStuff() ] );
+ $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
+ $blobStore = new SqlBlobStore( $lb, $cache );
+ $blobStore->setLegacyEncoding( 'windows-1252', Language::factory( 'en' ) );
+
+ $factory = $this->getMockBuilder( BlobStoreFactory::class )
+ ->setMethods( [ 'newBlobStore', 'newSqlBlobStore' ] )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $factory->expects( $this->any() )
+ ->method( 'newBlobStore' )
+ ->willReturn( $blobStore );
+ $factory->expects( $this->any() )
+ ->method( 'newSqlBlobStore' )
+ ->willReturn( $blobStore );
+
+ $this->setService( 'BlobStoreFactory', $factory );
+
+ $this->testNewMutableRevisionFromArray( $array );
+ }
+
+}
--- /dev/null
+<?php
+
+namespace MediaWiki\Tests\Revision;
+
+use ActorMigration;
+use CommentStore;
+use MediaWiki\Logger\Spi as LoggerSpi;
+use MediaWiki\Revision\RevisionStore;
+use MediaWiki\Revision\RevisionStoreFactory;
+use MediaWiki\Storage\BlobStore;
+use MediaWiki\Storage\BlobStoreFactory;
+use MediaWiki\Storage\NameTableStore;
+use MediaWiki\Storage\NameTableStoreFactory;
+use MediaWiki\Storage\SqlBlobStore;
+use MediaWikiTestCase;
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+use WANObjectCache;
+use Wikimedia\Rdbms\ILBFactory;
+use Wikimedia\Rdbms\ILoadBalancer;
+use Wikimedia\TestingAccessWrapper;
+
+class RevisionStoreFactoryTest extends MediaWikiTestCase {
+
+ public function testValidConstruction_doesntCauseErrors() {
+ new RevisionStoreFactory(
+ $this->getMockLoadBalancerFactory(),
+ $this->getMockBlobStoreFactory(),
+ $this->getNameTableStoreFactory(),
+ $this->getHashWANObjectCache(),
+ $this->getMockCommentStore(),
+ ActorMigration::newMigration(),
+ MIGRATION_OLD,
+ $this->getMockLoggerSpi(),
+ true
+ );
+ $this->assertTrue( true );
+ }
+
+ public function provideWikiIds() {
+ yield [ true ];
+ yield [ false ];
+ yield [ 'somewiki' ];
+ yield [ 'somewiki', MIGRATION_OLD , false ];
+ yield [ 'somewiki', MIGRATION_NEW , true ];
+ }
+
+ /**
+ * @dataProvider provideWikiIds
+ */
+ public function testGetRevisionStore(
+ $wikiId,
+ $mcrMigrationStage = MIGRATION_OLD,
+ $contentHandlerUseDb = true
+ ) {
+ $lbFactory = $this->getMockLoadBalancerFactory();
+ $blobStoreFactory = $this->getMockBlobStoreFactory();
+ $nameTableStoreFactory = $this->getNameTableStoreFactory();
+ $cache = $this->getHashWANObjectCache();
+ $commentStore = $this->getMockCommentStore();
+ $actorMigration = ActorMigration::newMigration();
+ $loggerProvider = $this->getMockLoggerSpi();
+
+ $factory = new RevisionStoreFactory(
+ $lbFactory,
+ $blobStoreFactory,
+ $nameTableStoreFactory,
+ $cache,
+ $commentStore,
+ $actorMigration,
+ $mcrMigrationStage,
+ $loggerProvider,
+ $contentHandlerUseDb
+ );
+
+ $store = $factory->getRevisionStore( $wikiId );
+ $wrapper = TestingAccessWrapper::newFromObject( $store );
+
+ // ensure the correct object type is returned
+ $this->assertInstanceOf( RevisionStore::class, $store );
+
+ // ensure the RevisionStore is for the given wikiId
+ $this->assertSame( $wikiId, $wrapper->wikiId );
+
+ // ensure all other required services are correctly set
+ $this->assertSame( $cache, $wrapper->cache );
+ $this->assertSame( $commentStore, $wrapper->commentStore );
+ $this->assertSame( $mcrMigrationStage, $wrapper->mcrMigrationStage );
+ $this->assertSame( $actorMigration, $wrapper->actorMigration );
+ $this->assertSame( $contentHandlerUseDb, $store->getContentHandlerUseDB() );
+
+ $this->assertInstanceOf( ILoadBalancer::class, $wrapper->loadBalancer );
+ $this->assertInstanceOf( BlobStore::class, $wrapper->blobStore );
+ $this->assertInstanceOf( NameTableStore::class, $wrapper->contentModelStore );
+ $this->assertInstanceOf( NameTableStore::class, $wrapper->slotRoleStore );
+ $this->assertInstanceOf( LoggerInterface::class, $wrapper->logger );
+ }
+
+ /**
+ * @return \PHPUnit_Framework_MockObject_MockObject|ILoadBalancer
+ */
+ private function getMockLoadBalancer() {
+ return $this->getMockBuilder( ILoadBalancer::class )
+ ->disableOriginalConstructor()->getMock();
+ }
+
+ /**
+ * @return \PHPUnit_Framework_MockObject_MockObject|ILBFactory
+ */
+ private function getMockLoadBalancerFactory() {
+ $mock = $this->getMockBuilder( ILBFactory::class )
+ ->disableOriginalConstructor()->getMock();
+
+ $mock->method( 'getMainLB' )
+ ->willReturnCallback( function () {
+ return $this->getMockLoadBalancer();
+ } );
+
+ return $mock;
+ }
+
+ /**
+ * @return \PHPUnit_Framework_MockObject_MockObject|SqlBlobStore
+ */
+ private function getMockSqlBlobStore() {
+ return $this->getMockBuilder( SqlBlobStore::class )
+ ->disableOriginalConstructor()->getMock();
+ }
+
+ /**
+ * @return \PHPUnit_Framework_MockObject_MockObject|BlobStoreFactory
+ */
+ private function getMockBlobStoreFactory() {
+ $mock = $this->getMockBuilder( BlobStoreFactory::class )
+ ->disableOriginalConstructor()->getMock();
+
+ $mock->method( 'newSqlBlobStore' )
+ ->willReturnCallback( function () {
+ return $this->getMockSqlBlobStore();
+ } );
+
+ return $mock;
+ }
+
+ /**
+ * @return NameTableStoreFactory
+ */
+ private function getNameTableStoreFactory() {
+ return new NameTableStoreFactory(
+ $this->getMockLoadBalancerFactory(),
+ $this->getHashWANObjectCache(),
+ new NullLogger() );
+ }
+
+ /**
+ * @return \PHPUnit_Framework_MockObject_MockObject|CommentStore
+ */
+ private function getMockCommentStore() {
+ return $this->getMockBuilder( CommentStore::class )
+ ->disableOriginalConstructor()->getMock();
+ }
+
+ private function getHashWANObjectCache() {
+ return new WANObjectCache( [ 'cache' => new \HashBagOStuff() ] );
+ }
+
+ /**
+ * @return \PHPUnit_Framework_MockObject_MockObject|LoggerSpi
+ */
+ private function getMockLoggerSpi() {
+ $mock = $this->getMock( LoggerSpi::class );
+
+ $mock->method( 'getLogger' )
+ ->willReturn( new NullLogger() );
+
+ return $mock;
+ }
+
+}
--- /dev/null
+<?php
+
+namespace MediaWiki\Tests\Revision;
+
+use CommentStoreComment;
+use InvalidArgumentException;
+use MediaWiki\Revision\RevisionRecord;
+use MediaWiki\Revision\RevisionSlots;
+use MediaWiki\Revision\RevisionStoreRecord;
+use MediaWiki\Revision\SlotRecord;
+use MediaWiki\User\UserIdentity;
+use MediaWiki\User\UserIdentityValue;
+use MediaWikiTestCase;
+use TextContent;
+use Title;
+
+/**
+ * @covers \MediaWiki\Revision\RevisionStoreRecord
+ * @covers \MediaWiki\Revision\RevisionRecord
+ */
+class RevisionStoreRecordTest extends MediaWikiTestCase {
+
+ use RevisionRecordTests;
+
+ /**
+ * @param array $rowOverrides
+ *
+ * @return RevisionStoreRecord
+ */
+ protected function newRevision( array $rowOverrides = [] ) {
+ $title = Title::newFromText( 'Dummy' );
+ $title->resetArticleID( 17 );
+
+ $user = new UserIdentityValue( 11, 'Tester', 0 );
+ $comment = CommentStoreComment::newUnsavedComment( 'Hello World' );
+
+ $main = SlotRecord::newUnsaved( SlotRecord::MAIN, new TextContent( 'Lorem Ipsum' ) );
+ $aux = SlotRecord::newUnsaved( 'aux', new TextContent( 'Frumious Bandersnatch' ) );
+ $slots = new RevisionSlots( [ $main, $aux ] );
+
+ $row = [
+ 'rev_id' => '7',
+ 'rev_page' => strval( $title->getArticleID() ),
+ 'rev_timestamp' => '20200101000000',
+ 'rev_deleted' => 0,
+ 'rev_minor_edit' => 0,
+ 'rev_parent_id' => '5',
+ 'rev_len' => $slots->computeSize(),
+ 'rev_sha1' => $slots->computeSha1(),
+ 'page_latest' => '18',
+ ];
+
+ $row = array_merge( $row, $rowOverrides );
+
+ return new RevisionStoreRecord( $title, $user, $comment, (object)$row, $slots );
+ }
+
+ public function provideConstructor() {
+ $title = Title::newFromText( 'Dummy' );
+ $title->resetArticleID( 17 );
+
+ $user = new UserIdentityValue( 11, 'Tester', 0 );
+ $comment = CommentStoreComment::newUnsavedComment( 'Hello World' );
+
+ $main = SlotRecord::newUnsaved( SlotRecord::MAIN, new TextContent( 'Lorem Ipsum' ) );
+ $aux = SlotRecord::newUnsaved( 'aux', new TextContent( 'Frumious Bandersnatch' ) );
+ $slots = new RevisionSlots( [ $main, $aux ] );
+
+ $protoRow = [
+ 'rev_id' => '7',
+ 'rev_page' => strval( $title->getArticleID() ),
+ 'rev_timestamp' => '20200101000000',
+ 'rev_deleted' => 0,
+ 'rev_minor_edit' => 0,
+ 'rev_parent_id' => '5',
+ 'rev_len' => $slots->computeSize(),
+ 'rev_sha1' => $slots->computeSha1(),
+ 'page_latest' => '18',
+ ];
+
+ $row = $protoRow;
+ yield 'all info' => [
+ $title,
+ $user,
+ $comment,
+ (object)$row,
+ $slots,
+ 'acmewiki'
+ ];
+
+ $row = $protoRow;
+ $row['rev_minor_edit'] = '1';
+ $row['rev_deleted'] = strval( RevisionRecord::DELETED_USER );
+
+ yield 'minor deleted' => [
+ $title,
+ $user,
+ $comment,
+ (object)$row,
+ $slots
+ ];
+
+ $row = $protoRow;
+ $row['page_latest'] = $row['rev_id'];
+
+ yield 'latest' => [
+ $title,
+ $user,
+ $comment,
+ (object)$row,
+ $slots
+ ];
+
+ $row = $protoRow;
+ unset( $row['rev_parent'] );
+
+ yield 'no parent' => [
+ $title,
+ $user,
+ $comment,
+ (object)$row,
+ $slots
+ ];
+
+ $row = $protoRow;
+ $row['rev_len'] = null;
+ $row['rev_sha1'] = '';
+
+ yield 'rev_len is null, rev_sha1 is ""' => [
+ $title,
+ $user,
+ $comment,
+ (object)$row,
+ $slots
+ ];
+
+ $row = $protoRow;
+ yield 'no length, no hash' => [
+ Title::newFromText( 'DummyDoesNotExist' ),
+ $user,
+ $comment,
+ (object)$row,
+ $slots
+ ];
+ }
+
+ /**
+ * @dataProvider provideConstructor
+ *
+ * @param Title $title
+ * @param UserIdentity $user
+ * @param CommentStoreComment $comment
+ * @param object $row
+ * @param RevisionSlots $slots
+ * @param bool $wikiId
+ */
+ public function testConstructorAndGetters(
+ Title $title,
+ UserIdentity $user,
+ CommentStoreComment $comment,
+ $row,
+ RevisionSlots $slots,
+ $wikiId = false
+ ) {
+ $rec = new RevisionStoreRecord( $title, $user, $comment, $row, $slots, $wikiId );
+
+ $this->assertSame( $title, $rec->getPageAsLinkTarget(), 'getPageAsLinkTarget' );
+ $this->assertSame( $user, $rec->getUser( RevisionRecord::RAW ), 'getUser' );
+ $this->assertSame( $comment, $rec->getComment(), 'getComment' );
+
+ $this->assertSame( $slots, $rec->getSlots(), 'getSlots' );
+ $this->assertSame( $slots->getSlotRoles(), $rec->getSlotRoles(), 'getSlotRoles' );
+ $this->assertSame( $slots->getSlots(), $rec->getSlots()->getSlots(), 'getSlots' );
+ $this->assertSame( $wikiId, $rec->getWikiId(), 'getWikiId' );
+
+ $this->assertSame( (int)$row->rev_id, $rec->getId(), 'getId' );
+ $this->assertSame( (int)$row->rev_page, $rec->getPageId(), 'getId' );
+ $this->assertSame( $row->rev_timestamp, $rec->getTimestamp(), 'getTimestamp' );
+ $this->assertSame( (int)$row->rev_deleted, $rec->getVisibility(), 'getVisibility' );
+ $this->assertSame( (bool)$row->rev_minor_edit, $rec->isMinor(), 'getIsMinor' );
+
+ if ( isset( $row->rev_parent_id ) ) {
+ $this->assertSame( (int)$row->rev_parent_id, $rec->getParentId(), 'getParentId' );
+ } else {
+ $this->assertSame( 0, $rec->getParentId(), 'getParentId' );
+ }
+
+ if ( isset( $row->rev_len ) ) {
+ $this->assertSame( (int)$row->rev_len, $rec->getSize(), 'getSize' );
+ } else {
+ $this->assertSame( $slots->computeSize(), $rec->getSize(), 'getSize' );
+ }
+
+ if ( !empty( $row->rev_sha1 ) ) {
+ $this->assertSame( $row->rev_sha1, $rec->getSha1(), 'getSha1' );
+ } else {
+ $this->assertSame( $slots->computeSha1(), $rec->getSha1(), 'getSha1' );
+ }
+
+ if ( isset( $row->page_latest ) ) {
+ $this->assertSame(
+ (int)$row->rev_id === (int)$row->page_latest,
+ $rec->isCurrent(),
+ 'isCurrent'
+ );
+ } else {
+ $this->assertSame(
+ false,
+ $rec->isCurrent(),
+ 'isCurrent'
+ );
+ }
+ }
+
+ public function provideConstructorFailure() {
+ $title = Title::newFromText( 'Dummy' );
+ $title->resetArticleID( 17 );
+
+ $user = new UserIdentityValue( 11, 'Tester', 0 );
+
+ $comment = CommentStoreComment::newUnsavedComment( 'Hello World' );
+
+ $main = SlotRecord::newUnsaved( SlotRecord::MAIN, new TextContent( 'Lorem Ipsum' ) );
+ $aux = SlotRecord::newUnsaved( 'aux', new TextContent( 'Frumious Bandersnatch' ) );
+ $slots = new RevisionSlots( [ $main, $aux ] );
+
+ $protoRow = [
+ 'rev_id' => '7',
+ 'rev_page' => strval( $title->getArticleID() ),
+ 'rev_timestamp' => '20200101000000',
+ 'rev_deleted' => 0,
+ 'rev_minor_edit' => 0,
+ 'rev_parent_id' => '5',
+ 'rev_len' => $slots->computeSize(),
+ 'rev_sha1' => $slots->computeSha1(),
+ 'page_latest' => '18',
+ ];
+
+ yield 'not a row' => [
+ $title,
+ $user,
+ $comment,
+ 'not a row',
+ $slots,
+ 'acmewiki'
+ ];
+
+ $row = $protoRow;
+ $row['rev_timestamp'] = 'kittens';
+
+ yield 'bad timestamp' => [
+ $title,
+ $user,
+ $comment,
+ (object)$row,
+ $slots
+ ];
+
+ $row = $protoRow;
+ $row['rev_page'] = 99;
+
+ yield 'page ID mismatch' => [
+ $title,
+ $user,
+ $comment,
+ (object)$row,
+ $slots
+ ];
+
+ $row = $protoRow;
+
+ yield 'bad wiki' => [
+ $title,
+ $user,
+ $comment,
+ (object)$row,
+ $slots,
+ 12345
+ ];
+ }
+
+ /**
+ * @dataProvider provideConstructorFailure
+ *
+ * @param Title $title
+ * @param UserIdentity $user
+ * @param CommentStoreComment $comment
+ * @param object $row
+ * @param RevisionSlots $slots
+ * @param bool $wikiId
+ */
+ public function testConstructorFailure(
+ Title $title,
+ UserIdentity $user,
+ CommentStoreComment $comment,
+ $row,
+ RevisionSlots $slots,
+ $wikiId = false
+ ) {
+ $this->setExpectedException( InvalidArgumentException::class );
+ new RevisionStoreRecord( $title, $user, $comment, $row, $slots, $wikiId );
+ }
+
+ public function provideIsCurrent() {
+ yield [
+ [
+ 'rev_id' => 11,
+ 'page_latest' => 11,
+ ],
+ true,
+ ];
+ yield [
+ [
+ 'rev_id' => 11,
+ 'page_latest' => 10,
+ ],
+ false,
+ ];
+ }
+
+ /**
+ * @dataProvider provideIsCurrent
+ */
+ public function testIsCurrent( $row, $current ) {
+ $rev = $this->newRevision( $row );
+
+ $this->assertSame( $current, $rev->isCurrent(), 'isCurrent()' );
+ }
+
+ public function provideGetSlot_audience_latest() {
+ return $this->provideAudienceCheckData( RevisionRecord::DELETED_TEXT );
+ }
+
+ /**
+ * @dataProvider provideGetSlot_audience_latest
+ */
+ public function testGetSlot_audience_latest( $visibility, $groups, $userCan, $publicCan ) {
+ $this->forceStandardPermissions();
+
+ $user = $this->getTestUser( $groups )->getUser();
+ $rev = $this->newRevision(
+ [
+ 'rev_deleted' => $visibility,
+ 'rev_id' => 11,
+ 'page_latest' => 11, // revision is current
+ ]
+ );
+
+ // NOTE: slot meta-data is never suppressed, just the content is!
+ $this->assertNotNull( $rev->getSlot( SlotRecord::MAIN, RevisionRecord::RAW ), 'raw can' );
+ $this->assertNotNull( $rev->getSlot( SlotRecord::MAIN, RevisionRecord::FOR_PUBLIC ),
+ 'public can' );
+
+ $this->assertNotNull(
+ $rev->getSlot( SlotRecord::MAIN, RevisionRecord::FOR_THIS_USER, $user ),
+ 'user can'
+ );
+
+ $rev->getSlot( SlotRecord::MAIN, RevisionRecord::RAW )->getContent();
+ // NOTE: the content of the current revision is never suppressed!
+ // Check that getContent() doesn't throw SuppressedDataException
+ $rev->getSlot( SlotRecord::MAIN, RevisionRecord::FOR_PUBLIC )->getContent();
+ $rev->getSlot( SlotRecord::MAIN, RevisionRecord::FOR_THIS_USER, $user )->getContent();
+ }
+
+}
--- /dev/null
+<?php
+
+namespace MediaWiki\Tests\Revision;
+
+use CommentStore;
+use HashBagOStuff;
+use InvalidArgumentException;
+use Language;
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Revision\RevisionAccessException;
+use MediaWiki\Revision\RevisionStore;
+use MediaWiki\Revision\SlotRecord;
+use MediaWiki\Storage\SqlBlobStore;
+use MediaWikiTestCase;
+use MWException;
+use Title;
+use WANObjectCache;
+use Wikimedia\Rdbms\Database;
+use Wikimedia\Rdbms\LoadBalancer;
+use Wikimedia\TestingAccessWrapper;
+use WikitextContent;
+
+class RevisionStoreTest extends MediaWikiTestCase {
+
+ private function useTextId() {
+ global $wgMultiContentRevisionSchemaMigrationStage;
+
+ return (bool)( $wgMultiContentRevisionSchemaMigrationStage & SCHEMA_COMPAT_READ_OLD );
+ }
+
+ /**
+ * @param LoadBalancer $loadBalancer
+ * @param SqlBlobStore $blobStore
+ * @param WANObjectCache $WANObjectCache
+ *
+ * @return RevisionStore
+ */
+ private function getRevisionStore(
+ $loadBalancer = null,
+ $blobStore = null,
+ $WANObjectCache = null
+ ) {
+ global $wgMultiContentRevisionSchemaMigrationStage;
+ // the migration stage should be irrelevant, since all the tests that interact with
+ // the database are in RevisionStoreDbTest, not here.
+
+ return new RevisionStore(
+ $loadBalancer ?: $this->getMockLoadBalancer(),
+ $blobStore ?: $this->getMockSqlBlobStore(),
+ $WANObjectCache ?: $this->getHashWANObjectCache(),
+ MediaWikiServices::getInstance()->getCommentStore(),
+ MediaWikiServices::getInstance()->getContentModelStore(),
+ MediaWikiServices::getInstance()->getSlotRoleStore(),
+ $wgMultiContentRevisionSchemaMigrationStage,
+ MediaWikiServices::getInstance()->getActorMigration()
+ );
+ }
+
+ /**
+ * @return \PHPUnit_Framework_MockObject_MockObject|LoadBalancer
+ */
+ private function getMockLoadBalancer() {
+ return $this->getMockBuilder( LoadBalancer::class )
+ ->disableOriginalConstructor()->getMock();
+ }
+
+ /**
+ * @return \PHPUnit_Framework_MockObject_MockObject|Database
+ */
+ private function getMockDatabase() {
+ return $this->getMockBuilder( Database::class )
+ ->disableOriginalConstructor()->getMock();
+ }
+
+ /**
+ * @return \PHPUnit_Framework_MockObject_MockObject|SqlBlobStore
+ */
+ private function getMockSqlBlobStore() {
+ return $this->getMockBuilder( SqlBlobStore::class )
+ ->disableOriginalConstructor()->getMock();
+ }
+
+ /**
+ * @return \PHPUnit_Framework_MockObject_MockObject|CommentStore
+ */
+ private function getMockCommentStore() {
+ return $this->getMockBuilder( CommentStore::class )
+ ->disableOriginalConstructor()->getMock();
+ }
+
+ private function getHashWANObjectCache() {
+ return new WANObjectCache( [ 'cache' => new \HashBagOStuff() ] );
+ }
+
+ public function provideSetContentHandlerUseDB() {
+ return [
+ // ContentHandlerUseDB can be true of false pre migration.
+ [ false, SCHEMA_COMPAT_OLD, false ],
+ [ true, SCHEMA_COMPAT_OLD, false ],
+ // During and after migration it can not be false...
+ [ false, SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD, true ],
+ [ false, SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW, true ],
+ [ false, SCHEMA_COMPAT_NEW, true ],
+ // ...but it can be true.
+ [ true, SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD, false ],
+ [ true, SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW, false ],
+ [ true, SCHEMA_COMPAT_NEW, false ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideSetContentHandlerUseDB
+ * @covers \MediaWiki\Revision\RevisionStore::getContentHandlerUseDB
+ * @covers \MediaWiki\Revision\RevisionStore::setContentHandlerUseDB
+ */
+ public function testSetContentHandlerUseDB( $contentHandlerDb, $migrationMode, $expectedFail ) {
+ if ( $expectedFail ) {
+ $this->setExpectedException( MWException::class );
+ }
+
+ $nameTables = MediaWikiServices::getInstance()->getNameTableStoreFactory();
+
+ $store = new RevisionStore(
+ $this->getMockLoadBalancer(),
+ $this->getMockSqlBlobStore(),
+ $this->getHashWANObjectCache(),
+ $this->getMockCommentStore(),
+ $nameTables->getContentModels(),
+ $nameTables->getSlotRoles(),
+ $migrationMode,
+ MediaWikiServices::getInstance()->getActorMigration()
+ );
+
+ $store->setContentHandlerUseDB( $contentHandlerDb );
+ $this->assertSame( $contentHandlerDb, $store->getContentHandlerUseDB() );
+ }
+
+ public function testGetTitle_successFromPageId() {
+ $mockLoadBalancer = $this->getMockLoadBalancer();
+ // Title calls wfGetDB() so we have to set the main service
+ $this->setService( 'DBLoadBalancer', $mockLoadBalancer );
+
+ $db = $this->getMockDatabase();
+ // Title calls wfGetDB() which uses a regular Connection
+ $mockLoadBalancer->expects( $this->atLeastOnce() )
+ ->method( 'getConnection' )
+ ->willReturn( $db );
+
+ // First call to Title::newFromID, faking no result (db lag?)
+ $db->expects( $this->at( 0 ) )
+ ->method( 'selectRow' )
+ ->with(
+ 'page',
+ $this->anything(),
+ [ 'page_id' => 1 ]
+ )
+ ->willReturn( (object)[
+ 'page_namespace' => '1',
+ 'page_title' => 'Food',
+ ] );
+
+ $store = $this->getRevisionStore( $mockLoadBalancer );
+ $title = $store->getTitle( 1, 2, RevisionStore::READ_NORMAL );
+
+ $this->assertSame( 1, $title->getNamespace() );
+ $this->assertSame( 'Food', $title->getDBkey() );
+ }
+
+ public function testGetTitle_successFromPageIdOnFallback() {
+ $mockLoadBalancer = $this->getMockLoadBalancer();
+ // Title calls wfGetDB() so we have to set the main service
+ $this->setService( 'DBLoadBalancer', $mockLoadBalancer );
+
+ $db = $this->getMockDatabase();
+ // Title calls wfGetDB() which uses a regular Connection
+ // Assert that the first call uses a REPLICA and the second falls back to master
+ $mockLoadBalancer->expects( $this->exactly( 2 ) )
+ ->method( 'getConnection' )
+ ->willReturn( $db );
+ // RevisionStore getTitle uses a ConnectionRef
+ $mockLoadBalancer->expects( $this->atLeastOnce() )
+ ->method( 'getConnectionRef' )
+ ->willReturn( $db );
+
+ // First call to Title::newFromID, faking no result (db lag?)
+ $db->expects( $this->at( 0 ) )
+ ->method( 'selectRow' )
+ ->with(
+ 'page',
+ $this->anything(),
+ [ 'page_id' => 1 ]
+ )
+ ->willReturn( false );
+
+ // First select using rev_id, faking no result (db lag?)
+ $db->expects( $this->at( 1 ) )
+ ->method( 'selectRow' )
+ ->with(
+ [ 'revision', 'page' ],
+ $this->anything(),
+ [ 'rev_id' => 2 ]
+ )
+ ->willReturn( false );
+
+ // Second call to Title::newFromID, no result
+ $db->expects( $this->at( 2 ) )
+ ->method( 'selectRow' )
+ ->with(
+ 'page',
+ $this->anything(),
+ [ 'page_id' => 1 ]
+ )
+ ->willReturn( (object)[
+ 'page_namespace' => '2',
+ 'page_title' => 'Foodey',
+ ] );
+
+ $store = $this->getRevisionStore( $mockLoadBalancer );
+ $title = $store->getTitle( 1, 2, RevisionStore::READ_NORMAL );
+
+ $this->assertSame( 2, $title->getNamespace() );
+ $this->assertSame( 'Foodey', $title->getDBkey() );
+ }
+
+ public function testGetTitle_successFromRevId() {
+ $mockLoadBalancer = $this->getMockLoadBalancer();
+ // Title calls wfGetDB() so we have to set the main service
+ $this->setService( 'DBLoadBalancer', $mockLoadBalancer );
+
+ $db = $this->getMockDatabase();
+ // Title calls wfGetDB() which uses a regular Connection
+ $mockLoadBalancer->expects( $this->atLeastOnce() )
+ ->method( 'getConnection' )
+ ->willReturn( $db );
+ // RevisionStore getTitle uses a ConnectionRef
+ $mockLoadBalancer->expects( $this->atLeastOnce() )
+ ->method( 'getConnectionRef' )
+ ->willReturn( $db );
+
+ // First call to Title::newFromID, faking no result (db lag?)
+ $db->expects( $this->at( 0 ) )
+ ->method( 'selectRow' )
+ ->with(
+ 'page',
+ $this->anything(),
+ [ 'page_id' => 1 ]
+ )
+ ->willReturn( false );
+
+ // First select using rev_id, faking no result (db lag?)
+ $db->expects( $this->at( 1 ) )
+ ->method( 'selectRow' )
+ ->with(
+ [ 'revision', 'page' ],
+ $this->anything(),
+ [ 'rev_id' => 2 ]
+ )
+ ->willReturn( (object)[
+ 'page_namespace' => '1',
+ 'page_title' => 'Food2',
+ ] );
+
+ $store = $this->getRevisionStore( $mockLoadBalancer );
+ $title = $store->getTitle( 1, 2, RevisionStore::READ_NORMAL );
+
+ $this->assertSame( 1, $title->getNamespace() );
+ $this->assertSame( 'Food2', $title->getDBkey() );
+ }
+
+ public function testGetTitle_successFromRevIdOnFallback() {
+ $mockLoadBalancer = $this->getMockLoadBalancer();
+ // Title calls wfGetDB() so we have to set the main service
+ $this->setService( 'DBLoadBalancer', $mockLoadBalancer );
+
+ $db = $this->getMockDatabase();
+ // Title calls wfGetDB() which uses a regular Connection
+ // Assert that the first call uses a REPLICA and the second falls back to master
+ $mockLoadBalancer->expects( $this->exactly( 2 ) )
+ ->method( 'getConnection' )
+ ->willReturn( $db );
+ // RevisionStore getTitle uses a ConnectionRef
+ $mockLoadBalancer->expects( $this->atLeastOnce() )
+ ->method( 'getConnectionRef' )
+ ->willReturn( $db );
+
+ // First call to Title::newFromID, faking no result (db lag?)
+ $db->expects( $this->at( 0 ) )
+ ->method( 'selectRow' )
+ ->with(
+ 'page',
+ $this->anything(),
+ [ 'page_id' => 1 ]
+ )
+ ->willReturn( false );
+
+ // First select using rev_id, faking no result (db lag?)
+ $db->expects( $this->at( 1 ) )
+ ->method( 'selectRow' )
+ ->with(
+ [ 'revision', 'page' ],
+ $this->anything(),
+ [ 'rev_id' => 2 ]
+ )
+ ->willReturn( false );
+
+ // Second call to Title::newFromID, no result
+ $db->expects( $this->at( 2 ) )
+ ->method( 'selectRow' )
+ ->with(
+ 'page',
+ $this->anything(),
+ [ 'page_id' => 1 ]
+ )
+ ->willReturn( false );
+
+ // Second select using rev_id, result
+ $db->expects( $this->at( 3 ) )
+ ->method( 'selectRow' )
+ ->with(
+ [ 'revision', 'page' ],
+ $this->anything(),
+ [ 'rev_id' => 2 ]
+ )
+ ->willReturn( (object)[
+ 'page_namespace' => '2',
+ 'page_title' => 'Foodey',
+ ] );
+
+ $store = $this->getRevisionStore( $mockLoadBalancer );
+ $title = $store->getTitle( 1, 2, RevisionStore::READ_NORMAL );
+
+ $this->assertSame( 2, $title->getNamespace() );
+ $this->assertSame( 'Foodey', $title->getDBkey() );
+ }
+
+ /**
+ * @covers \MediaWiki\Revision\RevisionStore::getTitle
+ */
+ public function testGetTitle_correctFallbackAndthrowsExceptionAfterFallbacks() {
+ $mockLoadBalancer = $this->getMockLoadBalancer();
+ // Title calls wfGetDB() so we have to set the main service
+ $this->setService( 'DBLoadBalancer', $mockLoadBalancer );
+
+ $db = $this->getMockDatabase();
+ // Title calls wfGetDB() which uses a regular Connection
+ // Assert that the first call uses a REPLICA and the second falls back to master
+
+ // RevisionStore getTitle uses getConnectionRef
+ // Title::newFromID uses getConnection
+ foreach ( [ 'getConnection', 'getConnectionRef' ] as $method ) {
+ $mockLoadBalancer->expects( $this->exactly( 2 ) )
+ ->method( $method )
+ ->willReturnCallback( function ( $masterOrReplica ) use ( $db ) {
+ static $callCounter = 0;
+ $callCounter++;
+ // The first call should be to a REPLICA, and the second a MASTER.
+ if ( $callCounter === 1 ) {
+ $this->assertSame( DB_REPLICA, $masterOrReplica );
+ } elseif ( $callCounter === 2 ) {
+ $this->assertSame( DB_MASTER, $masterOrReplica );
+ }
+ return $db;
+ } );
+ }
+ // First and third call to Title::newFromID, faking no result
+ foreach ( [ 0, 2 ] as $counter ) {
+ $db->expects( $this->at( $counter ) )
+ ->method( 'selectRow' )
+ ->with(
+ 'page',
+ $this->anything(),
+ [ 'page_id' => 1 ]
+ )
+ ->willReturn( false );
+ }
+
+ foreach ( [ 1, 3 ] as $counter ) {
+ $db->expects( $this->at( $counter ) )
+ ->method( 'selectRow' )
+ ->with(
+ [ 'revision', 'page' ],
+ $this->anything(),
+ [ 'rev_id' => 2 ]
+ )
+ ->willReturn( false );
+ }
+
+ $store = $this->getRevisionStore( $mockLoadBalancer );
+
+ $this->setExpectedException( RevisionAccessException::class );
+ $store->getTitle( 1, 2, RevisionStore::READ_NORMAL );
+ }
+
+ public function provideNewRevisionFromRow_legacyEncoding_applied() {
+ yield 'windows-1252, old_flags is empty' => [
+ 'windows-1252',
+ 'en',
+ [
+ 'old_flags' => '',
+ 'old_text' => "S\xF6me Content",
+ ],
+ 'Söme Content'
+ ];
+
+ yield 'windows-1252, old_flags is null' => [
+ 'windows-1252',
+ 'en',
+ [
+ 'old_flags' => null,
+ 'old_text' => "S\xF6me Content",
+ ],
+ 'Söme Content'
+ ];
+ }
+
+ /**
+ * @dataProvider provideNewRevisionFromRow_legacyEncoding_applied
+ *
+ * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromRow
+ */
+ public function testNewRevisionFromRow_legacyEncoding_applied( $encoding, $locale, $row, $text ) {
+ if ( !$this->useTextId() ) {
+ $this->markTestSkipped( 'No longer applicable with MCR schema' );
+ }
+
+ $cache = new WANObjectCache( [ 'cache' => new HashBagOStuff() ] );
+ $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
+
+ $blobStore = new SqlBlobStore( $lb, $cache );
+ $blobStore->setLegacyEncoding( $encoding, Language::factory( $locale ) );
+
+ $store = $this->getRevisionStore( $lb, $blobStore, $cache );
+
+ $record = $store->newRevisionFromRow(
+ $this->makeRow( $row ),
+ 0,
+ Title::newFromText( __METHOD__ . '-UTPage' )
+ );
+
+ $this->assertSame( $text, $record->getContent( SlotRecord::MAIN )->serialize() );
+ }
+
+ /**
+ * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromRow
+ */
+ public function testNewRevisionFromRow_legacyEncoding_ignored() {
+ if ( !$this->useTextId() ) {
+ $this->markTestSkipped( 'No longer applicable with MCR schema' );
+ }
+
+ $row = [
+ 'old_flags' => 'utf-8',
+ 'old_text' => 'Söme Content',
+ ];
+
+ $cache = new WANObjectCache( [ 'cache' => new HashBagOStuff() ] );
+ $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
+
+ $blobStore = new SqlBlobStore( $lb, $cache );
+ $blobStore->setLegacyEncoding( 'windows-1252', Language::factory( 'en' ) );
+
+ $store = $this->getRevisionStore( $lb, $blobStore, $cache );
+
+ $record = $store->newRevisionFromRow(
+ $this->makeRow( $row ),
+ 0,
+ Title::newFromText( __METHOD__ . '-UTPage' )
+ );
+ $this->assertSame( 'Söme Content', $record->getContent( SlotRecord::MAIN )->serialize() );
+ }
+
+ private function makeRow( array $array ) {
+ $row = $array + [
+ 'rev_id' => 7,
+ 'rev_page' => 5,
+ 'rev_timestamp' => '20110101000000',
+ 'rev_user_text' => 'Tester',
+ 'rev_user' => 17,
+ 'rev_minor_edit' => 0,
+ 'rev_deleted' => 0,
+ 'rev_len' => 100,
+ 'rev_parent_id' => 0,
+ 'rev_sha1' => 'deadbeef',
+ 'rev_comment_text' => 'Testing',
+ 'rev_comment_data' => '{}',
+ 'rev_comment_cid' => 111,
+ 'page_namespace' => 0,
+ 'page_title' => 'TEST',
+ 'page_id' => 5,
+ 'page_latest' => 7,
+ 'page_is_redirect' => 0,
+ 'page_len' => 100,
+ 'user_name' => 'Tester',
+ ];
+
+ if ( $this->useTextId() ) {
+ $row += [
+ 'rev_content_format' => CONTENT_FORMAT_TEXT,
+ 'rev_content_model' => CONTENT_MODEL_TEXT,
+ 'rev_text_id' => 11,
+ 'old_id' => 11,
+ 'old_text' => 'Hello World',
+ 'old_flags' => 'utf-8',
+ ];
+ } else {
+ if ( !isset( $row['content'] ) && isset( $array['old_text'] ) ) {
+ $row['content'] = [
+ 'main' => new WikitextContent( $array['old_text'] ),
+ ];
+ }
+ }
+
+ return (object)$row;
+ }
+
+ public function provideMigrationConstruction() {
+ return [
+ [ SCHEMA_COMPAT_OLD, false ],
+ [ SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD, false ],
+ [ SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW, false ],
+ [ SCHEMA_COMPAT_NEW, false ],
+ [ SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_BOTH, true ],
+ [ SCHEMA_COMPAT_WRITE_OLD | SCHEMA_COMPAT_READ_BOTH, true ],
+ [ SCHEMA_COMPAT_WRITE_NEW | SCHEMA_COMPAT_READ_BOTH, true ],
+ ];
+ }
+
+ /**
+ * @covers \MediaWiki\Revision\RevisionStore::__construct
+ * @dataProvider provideMigrationConstruction
+ */
+ public function testMigrationConstruction( $migration, $expectException ) {
+ if ( $expectException ) {
+ $this->setExpectedException( InvalidArgumentException::class );
+ }
+ $loadBalancer = $this->getMockLoadBalancer();
+ $blobStore = $this->getMockSqlBlobStore();
+ $cache = $this->getHashWANObjectCache();
+ $commentStore = $this->getMockCommentStore();
+ $services = MediaWikiServices::getInstance();
+ $nameTables = $services->getNameTableStoreFactory();
+ $contentModelStore = $nameTables->getContentModels();
+ $slotRoleStore = $nameTables->getSlotRoles();
+ $store = new RevisionStore(
+ $loadBalancer,
+ $blobStore,
+ $cache,
+ $commentStore,
+ $nameTables->getContentModels(),
+ $nameTables->getSlotRoles(),
+ $migration,
+ $services->getActorMigration()
+ );
+ if ( !$expectException ) {
+ $store = TestingAccessWrapper::newFromObject( $store );
+ $this->assertSame( $loadBalancer, $store->loadBalancer );
+ $this->assertSame( $blobStore, $store->blobStore );
+ $this->assertSame( $cache, $store->cache );
+ $this->assertSame( $commentStore, $store->commentStore );
+ $this->assertSame( $contentModelStore, $store->contentModelStore );
+ $this->assertSame( $slotRoleStore, $store->slotRoleStore );
+ $this->assertSame( $migration, $store->mcrMigrationStage );
+ }
+ }
+
+}
--- /dev/null
+<?php
+
+namespace MediaWiki\Tests\Revision;
+
+use InvalidArgumentException;
+use LogicException;
+use MediaWiki\Revision\IncompleteRevisionException;
+use MediaWiki\Revision\SlotRecord;
+use MediaWiki\Revision\SuppressedDataException;
+use MediaWikiTestCase;
+use WikitextContent;
+
+/**
+ * @covers \MediaWiki\Revision\SlotRecord
+ */
+class SlotRecordTest extends MediaWikiTestCase {
+
+ private function makeRow( $data = [] ) {
+ $data = $data + [
+ 'slot_id' => 1234,
+ 'slot_content_id' => 33,
+ 'content_size' => '5',
+ 'content_sha1' => 'someHash',
+ 'content_address' => 'tt:456',
+ 'model_name' => CONTENT_MODEL_WIKITEXT,
+ 'format_name' => CONTENT_FORMAT_WIKITEXT,
+ 'slot_revision_id' => '2',
+ 'slot_origin' => '1',
+ 'role_name' => 'myRole',
+ ];
+ return (object)$data;
+ }
+
+ public function testCompleteConstruction() {
+ $row = $this->makeRow();
+ $record = new SlotRecord( $row, new WikitextContent( 'A' ) );
+
+ $this->assertTrue( $record->hasAddress() );
+ $this->assertTrue( $record->hasContentId() );
+ $this->assertTrue( $record->hasRevision() );
+ $this->assertTrue( $record->isInherited() );
+ $this->assertSame( 'A', $record->getContent()->getNativeData() );
+ $this->assertSame( 5, $record->getSize() );
+ $this->assertSame( 'someHash', $record->getSha1() );
+ $this->assertSame( CONTENT_MODEL_WIKITEXT, $record->getModel() );
+ $this->assertSame( 2, $record->getRevision() );
+ $this->assertSame( 1, $record->getOrigin() );
+ $this->assertSame( 'tt:456', $record->getAddress() );
+ $this->assertSame( 33, $record->getContentId() );
+ $this->assertSame( CONTENT_FORMAT_WIKITEXT, $record->getFormat() );
+ $this->assertSame( 'myRole', $record->getRole() );
+ }
+
+ public function testConstructionDeferred() {
+ $row = $this->makeRow( [
+ 'content_size' => null, // to be computed
+ 'content_sha1' => null, // to be computed
+ 'format_name' => function () {
+ return CONTENT_FORMAT_WIKITEXT;
+ },
+ 'slot_revision_id' => '2',
+ 'slot_origin' => '2',
+ 'slot_content_id' => function () {
+ return null;
+ },
+ ] );
+
+ $content = function () {
+ return new WikitextContent( 'A' );
+ };
+
+ $record = new SlotRecord( $row, $content );
+
+ $this->assertTrue( $record->hasAddress() );
+ $this->assertTrue( $record->hasRevision() );
+ $this->assertFalse( $record->hasContentId() );
+ $this->assertFalse( $record->isInherited() );
+ $this->assertSame( 'A', $record->getContent()->getNativeData() );
+ $this->assertSame( 1, $record->getSize() );
+ $this->assertNotNull( $record->getSha1() );
+ $this->assertSame( CONTENT_MODEL_WIKITEXT, $record->getModel() );
+ $this->assertSame( 2, $record->getRevision() );
+ $this->assertSame( 2, $record->getRevision() );
+ $this->assertSame( 'tt:456', $record->getAddress() );
+ $this->assertSame( CONTENT_FORMAT_WIKITEXT, $record->getFormat() );
+ $this->assertSame( 'myRole', $record->getRole() );
+ }
+
+ public function testNewUnsaved() {
+ $record = SlotRecord::newUnsaved( 'myRole', new WikitextContent( 'A' ) );
+
+ $this->assertFalse( $record->hasAddress() );
+ $this->assertFalse( $record->hasContentId() );
+ $this->assertFalse( $record->hasRevision() );
+ $this->assertFalse( $record->isInherited() );
+ $this->assertFalse( $record->hasOrigin() );
+ $this->assertSame( 'A', $record->getContent()->getNativeData() );
+ $this->assertSame( 1, $record->getSize() );
+ $this->assertNotNull( $record->getSha1() );
+ $this->assertSame( CONTENT_MODEL_WIKITEXT, $record->getModel() );
+ $this->assertSame( 'myRole', $record->getRole() );
+ }
+
+ public function provideInvalidConstruction() {
+ yield 'both null' => [ null, null ];
+ yield 'null row' => [ null, new WikitextContent( 'A' ) ];
+ yield 'array row' => [ [], new WikitextContent( 'A' ) ];
+ yield 'empty row' => [ (object)[], new WikitextContent( 'A' ) ];
+ yield 'null content' => [ (object)[], null ];
+ }
+
+ /**
+ * @dataProvider provideInvalidConstruction
+ */
+ public function testInvalidConstruction( $row, $content ) {
+ $this->setExpectedException( InvalidArgumentException::class );
+ new SlotRecord( $row, $content );
+ }
+
+ public function testGetContentId_fails() {
+ $record = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) );
+ $this->setExpectedException( IncompleteRevisionException::class );
+
+ $record->getContentId();
+ }
+
+ public function testGetAddress_fails() {
+ $record = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) );
+ $this->setExpectedException( IncompleteRevisionException::class );
+
+ $record->getAddress();
+ }
+
+ public function provideIncomplete() {
+ $unsaved = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) );
+ yield 'unsaved' => [ $unsaved ];
+
+ $parent = new SlotRecord( $this->makeRow(), new WikitextContent( 'A' ) );
+ $inherited = SlotRecord::newInherited( $parent );
+ yield 'inherited' => [ $inherited ];
+ }
+
+ /**
+ * @dataProvider provideIncomplete
+ */
+ public function testGetRevision_fails( SlotRecord $record ) {
+ $record = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) );
+ $this->setExpectedException( IncompleteRevisionException::class );
+
+ $record->getRevision();
+ }
+
+ /**
+ * @dataProvider provideIncomplete
+ */
+ public function testGetOrigin_fails( SlotRecord $record ) {
+ $record = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) );
+ $this->setExpectedException( IncompleteRevisionException::class );
+
+ $record->getOrigin();
+ }
+
+ public function provideHashStability() {
+ yield [ '', 'phoiac9h4m842xq45sp7s6u21eteeq1' ];
+ yield [ 'Lorem ipsum', 'hcr5u40uxr81d3nx89nvwzclfz6r9c5' ];
+ }
+
+ /**
+ * @dataProvider provideHashStability
+ */
+ public function testHashStability( $text, $hash ) {
+ // Changing the output of the hash function will break things horribly!
+
+ $this->assertSame( $hash, SlotRecord::base36Sha1( $text ) );
+
+ $record = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( $text ) );
+ $this->assertSame( $hash, $record->getSha1() );
+ }
+
+ public function testNewWithSuppressedContent() {
+ $input = new SlotRecord( $this->makeRow(), new WikitextContent( 'A' ) );
+ $output = SlotRecord::newWithSuppressedContent( $input );
+
+ $this->setExpectedException( SuppressedDataException::class );
+ $output->getContent();
+ }
+
+ public function testNewInherited() {
+ $row = $this->makeRow( [ 'slot_revision_id' => 7, 'slot_origin' => 7 ] );
+ $parent = new SlotRecord( $row, new WikitextContent( 'A' ) );
+
+ // This would happen while doing an edit, before saving revision meta-data.
+ $inherited = SlotRecord::newInherited( $parent );
+
+ $this->assertSame( $parent->getContentId(), $inherited->getContentId() );
+ $this->assertSame( $parent->getAddress(), $inherited->getAddress() );
+ $this->assertSame( $parent->getContent(), $inherited->getContent() );
+ $this->assertTrue( $inherited->isInherited() );
+ $this->assertTrue( $inherited->hasOrigin() );
+ $this->assertFalse( $inherited->hasRevision() );
+
+ // make sure we didn't mess with the internal state of $parent
+ $this->assertFalse( $parent->isInherited() );
+ $this->assertSame( 7, $parent->getRevision() );
+
+ // This would happen while doing an edit, after saving the revision meta-data
+ // and content meta-data.
+ $saved = SlotRecord::newSaved(
+ 10,
+ $inherited->getContentId(),
+ $inherited->getAddress(),
+ $inherited
+ );
+ $this->assertSame( $parent->getContentId(), $saved->getContentId() );
+ $this->assertSame( $parent->getAddress(), $saved->getAddress() );
+ $this->assertSame( $parent->getContent(), $saved->getContent() );
+ $this->assertTrue( $saved->isInherited() );
+ $this->assertTrue( $saved->hasRevision() );
+ $this->assertSame( 10, $saved->getRevision() );
+
+ // make sure we didn't mess with the internal state of $parent or $inherited
+ $this->assertSame( 7, $parent->getRevision() );
+ $this->assertFalse( $inherited->hasRevision() );
+ }
+
+ public function testNewSaved() {
+ // This would happen while doing an edit, before saving revision meta-data.
+ $unsaved = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) );
+
+ // This would happen while doing an edit, after saving the revision meta-data
+ // and content meta-data.
+ $saved = SlotRecord::newSaved( 10, 20, 'theNewAddress', $unsaved );
+ $this->assertFalse( $saved->isInherited() );
+ $this->assertTrue( $saved->hasOrigin() );
+ $this->assertTrue( $saved->hasRevision() );
+ $this->assertTrue( $saved->hasAddress() );
+ $this->assertTrue( $saved->hasContentId() );
+ $this->assertSame( 'theNewAddress', $saved->getAddress() );
+ $this->assertSame( 20, $saved->getContentId() );
+ $this->assertSame( 'A', $saved->getContent()->getNativeData() );
+ $this->assertSame( 10, $saved->getRevision() );
+ $this->assertSame( 10, $saved->getOrigin() );
+
+ // make sure we didn't mess with the internal state of $unsaved
+ $this->assertFalse( $unsaved->hasAddress() );
+ $this->assertFalse( $unsaved->hasContentId() );
+ $this->assertFalse( $unsaved->hasRevision() );
+ }
+
+ public function provideNewSaved_LogicException() {
+ $freshRow = $this->makeRow( [
+ 'content_id' => 10,
+ 'content_address' => 'address:1',
+ 'slot_origin' => 1,
+ 'slot_revision_id' => 1,
+ ] );
+
+ $freshSlot = new SlotRecord( $freshRow, new WikitextContent( 'A' ) );
+ yield 'mismatching address' => [ 1, 10, 'address:BAD', $freshSlot ];
+ yield 'mismatching revision' => [ 5, 10, 'address:1', $freshSlot ];
+ yield 'mismatching content ID' => [ 1, 17, 'address:1', $freshSlot ];
+
+ $inheritedRow = $this->makeRow( [
+ 'content_id' => null,
+ 'content_address' => null,
+ 'slot_origin' => 0,
+ 'slot_revision_id' => 1,
+ ] );
+
+ $inheritedSlot = new SlotRecord( $inheritedRow, new WikitextContent( 'A' ) );
+ yield 'inherited, but no address' => [ 1, 10, 'address:2', $inheritedSlot ];
+ }
+
+ /**
+ * @dataProvider provideNewSaved_LogicException
+ */
+ public function testNewSaved_LogicException(
+ $revisionId,
+ $contentId,
+ $contentAddress,
+ SlotRecord $protoSlot
+ ) {
+ $this->setExpectedException( LogicException::class );
+ SlotRecord::newSaved( $revisionId, $contentId, $contentAddress, $protoSlot );
+ }
+
+ public function provideNewSaved_InvalidArgumentException() {
+ $unsaved = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) );
+
+ yield 'bad revision id' => [ 'xyzzy', 5, 'address', $unsaved ];
+ yield 'bad content id' => [ 7, 'xyzzy', 'address', $unsaved ];
+ yield 'bad content address' => [ 7, 5, 77, $unsaved ];
+ }
+
+ /**
+ * @dataProvider provideNewSaved_InvalidArgumentException
+ */
+ public function testNewSaved_InvalidArgumentException(
+ $revisionId,
+ $contentId,
+ $contentAddress,
+ SlotRecord $protoSlot
+ ) {
+ $this->setExpectedException( InvalidArgumentException::class );
+ SlotRecord::newSaved( $revisionId, $contentId, $contentAddress, $protoSlot );
+ }
+
+ public function provideHasSameContent() {
+ $fail = function () {
+ self::fail( 'There should be no need to actually load the content.' );
+ };
+
+ $a100a1 = new SlotRecord(
+ $this->makeRow(
+ [
+ 'model_name' => 'A',
+ 'content_size' => 100,
+ 'content_sha1' => 'hash-a',
+ 'content_address' => 'xxx:a1',
+ ]
+ ),
+ $fail
+ );
+ $a100a1b = new SlotRecord(
+ $this->makeRow(
+ [
+ 'model_name' => 'A',
+ 'content_size' => 100,
+ 'content_sha1' => 'hash-a',
+ 'content_address' => 'xxx:a1',
+ ]
+ ),
+ $fail
+ );
+ $a100null = new SlotRecord(
+ $this->makeRow(
+ [
+ 'model_name' => 'A',
+ 'content_size' => 100,
+ 'content_sha1' => 'hash-a',
+ 'content_address' => null,
+ ]
+ ),
+ $fail
+ );
+ $a100a2 = new SlotRecord(
+ $this->makeRow(
+ [
+ 'model_name' => 'A',
+ 'content_size' => 100,
+ 'content_sha1' => 'hash-a',
+ 'content_address' => 'xxx:a2',
+ ]
+ ),
+ $fail
+ );
+ $b100a1 = new SlotRecord(
+ $this->makeRow(
+ [
+ 'model_name' => 'B',
+ 'content_size' => 100,
+ 'content_sha1' => 'hash-a',
+ 'content_address' => 'xxx:a1',
+ ]
+ ),
+ $fail
+ );
+ $a200a1 = new SlotRecord(
+ $this->makeRow(
+ [
+ 'model_name' => 'A',
+ 'content_size' => 200,
+ 'content_sha1' => 'hash-a',
+ 'content_address' => 'xxx:a2',
+ ]
+ ),
+ $fail
+ );
+ $a100x1 = new SlotRecord(
+ $this->makeRow(
+ [
+ 'model_name' => 'A',
+ 'content_size' => 100,
+ 'content_sha1' => 'hash-x',
+ 'content_address' => 'xxx:x1',
+ ]
+ ),
+ $fail
+ );
+
+ yield 'same instance' => [ $a100a1, $a100a1, true ];
+ yield 'no address' => [ $a100a1, $a100null, true ];
+ yield 'same address' => [ $a100a1, $a100a1b, true ];
+ yield 'different address' => [ $a100a1, $a100a2, true ];
+ yield 'different model' => [ $a100a1, $b100a1, false ];
+ yield 'different size' => [ $a100a1, $a200a1, false ];
+ yield 'different hash' => [ $a100a1, $a100x1, false ];
+ }
+
+ /**
+ * @dataProvider provideHasSameContent
+ */
+ public function testHasSameContent( SlotRecord $a, SlotRecord $b, $sameContent ) {
+ $this->assertSame( $sameContent, $a->hasSameContent( $b ) );
+ $this->assertSame( $sameContent, $b->hasSameContent( $a ) );
+ }
+
+}
--- /dev/null
+ALTER TABLE /*_*/revision ADD rev_text_id INTEGER DEFAULT 0;
+ALTER TABLE /*_*/revision ADD rev_content_model VARBINARY(32) DEFAULT NULL;
+ALTER TABLE /*_*/revision ADD rev_content_format VARBINARY(64) DEFAULT NULL;
--- /dev/null
+DROP TABLE /*_*/slots;
+DROP TABLE /*_*/content;
+DROP TABLE /*_*/content_models;
+DROP TABLE /*_*/slot_roles;
--- /dev/null
+ALTER TABLE /*_*/revision DROP COLUMN rev_text_id;
+ALTER TABLE /*_*/revision DROP COLUMN rev_content_model;
+ALTER TABLE /*_*/revision DROP COLUMN rev_content_format;
--- /dev/null
+DROP TABLE /*_*/revision;
+
+CREATE TABLE /*_*/revision (
+ rev_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ rev_page INTEGER NOT NULL,
+ rev_comment BLOB NOT NULL,
+ rev_user INTEGER NOT NULL default 0,
+ rev_user_text varchar(255) NOT NULL default '',
+ rev_timestamp blob(14) NOT NULL default '',
+ rev_minor_edit INTEGER NOT NULL default 0,
+ rev_deleted INTEGER NOT NULL default 0,
+ rev_len INTEGER unsigned,
+ rev_parent_id INTEGER default NULL,
+ rev_sha1 varbinary(32) NOT NULL default ''
+) /*$wgDBTableOptions*/;
<?php
use MediaWiki\MediaWikiServices;
-use MediaWiki\Storage\MutableRevisionRecord;
-use MediaWiki\Storage\RevisionStore;
-use MediaWiki\Storage\IncompleteRevisionException;
-use MediaWiki\Storage\RevisionRecord;
-use MediaWiki\Storage\SlotRecord;
+use MediaWiki\Revision\MutableRevisionRecord;
+use MediaWiki\Revision\RevisionStore;
+use MediaWiki\Revision\IncompleteRevisionException;
+use MediaWiki\Revision\RevisionRecord;
+use MediaWiki\Revision\SlotRecord;
/**
* RevisionDbTestBase contains test cases for the Revision class that have Database interactions.
'wgMultiContentRevisionSchemaMigrationStage' => $this->getMcrMigrationStage(),
'wgContentHandlerUseDB' => $this->getContentHandlerUseDB(),
'wgCommentTableSchemaMigrationStage' => MIGRATION_OLD,
- 'wgActorTableSchemaMigrationStage' => MIGRATION_OLD,
+ 'wgActorTableSchemaMigrationStage' => SCHEMA_COMPAT_OLD,
] );
$this->overrideMwServices();
<?php
-use MediaWiki\Storage\MutableRevisionRecord;
-use MediaWiki\Storage\SlotRecord;
-use MediaWiki\Tests\Storage\McrSchemaOverride;
+use MediaWiki\Revision\MutableRevisionRecord;
+use MediaWiki\Revision\SlotRecord;
+use MediaWiki\Tests\Revision\McrSchemaOverride;
/**
* Tests Revision against the MCR DB schema after schema migration.
<?php
-use MediaWiki\Storage\MutableRevisionRecord;
-use MediaWiki\Storage\SlotRecord;
-use MediaWiki\Tests\Storage\McrReadNewSchemaOverride;
+use MediaWiki\Revision\MutableRevisionRecord;
+use MediaWiki\Revision\SlotRecord;
+use MediaWiki\Tests\Revision\McrReadNewSchemaOverride;
/**
* Tests Revision against the intermediate MCR DB schema for use during schema migration.
<?php
-use MediaWiki\Tests\Storage\McrWriteBothSchemaOverride;
+use MediaWiki\Tests\Revision\McrWriteBothSchemaOverride;
/**
* Tests Revision against the intermediate MCR DB schema for use during schema migration.
<?php
-use MediaWiki\Tests\Storage\PreMcrSchemaOverride;
+use MediaWiki\Tests\Revision\PreMcrSchemaOverride;
/**
* Tests Revision against the pre-MCR, pre ContentHandler DB schema.
<?php
-use MediaWiki\Tests\Storage\PreMcrSchemaOverride;
+use MediaWiki\Tests\Revision\PreMcrSchemaOverride;
/**
* Tests Revision against the pre-MCR DB schema.
<?php
use MediaWiki\MediaWikiServices;
+use MediaWiki\Revision\MutableRevisionRecord;
+use MediaWiki\Revision\RevisionAccessException;
+use MediaWiki\Revision\RevisionRecord;
+use MediaWiki\Revision\RevisionStore;
+use MediaWiki\Revision\SlotRecord;
use MediaWiki\Storage\BlobStoreFactory;
-use MediaWiki\Storage\MutableRevisionRecord;
-use MediaWiki\Storage\RevisionAccessException;
-use MediaWiki\Storage\RevisionRecord;
-use MediaWiki\Storage\RevisionStore;
-use MediaWiki\Storage\SlotRecord;
use MediaWiki\Storage\SqlBlobStore;
use Wikimedia\Rdbms\IDatabase;
use Wikimedia\Rdbms\LoadBalancer;
/**
* @dataProvider provideConstructFromArray
* @covers Revision::__construct
- * @covers \MediaWiki\Storage\RevisionStore::newMutableRevisionFromArray
+ * @covers \MediaWiki\Revision\RevisionStore::newMutableRevisionFromArray
*/
public function testConstructFromArray( $rowArray ) {
$rev = new Revision( $rowArray, 0, $this->getMockTitle() );
/**
* @covers Revision::__construct
- * @covers \MediaWiki\Storage\RevisionStore::newMutableRevisionFromArray
+ * @covers \MediaWiki\Revision\RevisionStore::newMutableRevisionFromArray
*/
public function testConstructFromEmptyArray() {
$rev = new Revision( [], 0, $this->getMockTitle() );
/**
* @covers Revision::__construct
- * @covers \MediaWiki\Storage\RevisionStore::newMutableRevisionFromArray
+ * @covers \MediaWiki\Revision\RevisionStore::newMutableRevisionFromArray
*/
public function testConstructFromArrayWithBadPageId() {
Wikimedia\suppressWarnings();
/**
* @dataProvider provideConstructFromArray_userSetAsExpected
* @covers Revision::__construct
- * @covers \MediaWiki\Storage\RevisionStore::newMutableRevisionFromArray
+ * @covers \MediaWiki\Revision\RevisionStore::newMutableRevisionFromArray
*
* @param array $rowArray
* @param mixed $expectedUserId null to expect the current wgUser ID
/**
* @dataProvider provideConstructFromArrayThrowsExceptions
* @covers Revision::__construct
- * @covers \MediaWiki\Storage\RevisionStore::newMutableRevisionFromArray
+ * @covers \MediaWiki\Revision\RevisionStore::newMutableRevisionFromArray
*/
public function testConstructFromArrayThrowsExceptions( $rowArray, Exception $expectedException ) {
$this->setExpectedException(
/**
* @covers Revision::__construct
- * @covers \MediaWiki\Storage\RevisionStore::newMutableRevisionFromArray
+ * @covers \MediaWiki\Revision\RevisionStore::newMutableRevisionFromArray
*/
public function testConstructFromNothing() {
$this->setExpectedException(
/**
* @dataProvider provideConstructFromRow
* @covers Revision::__construct
- * @covers \MediaWiki\Storage\RevisionStore::newMutableRevisionFromArray
+ * @covers \MediaWiki\Revision\RevisionStore::newMutableRevisionFromArray
*/
public function testConstructFromRow( array $arrayData, callable $assertions ) {
$row = (object)$arrayData;
/**
* @covers Revision::__construct
- * @covers \MediaWiki\Storage\RevisionStore::newMutableRevisionFromArray
+ * @covers \MediaWiki\Revision\RevisionStore::newMutableRevisionFromArray
*/
public function testConstructFromRowWithBadPageId() {
$this->setMwGlobals( 'wgCommentTableSchemaMigrationStage', MIGRATION_OLD );
*/
public function testLoadFromTitle() {
$this->setMwGlobals( 'wgCommentTableSchemaMigrationStage', MIGRATION_OLD );
- $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', MIGRATION_OLD );
+ $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', SCHEMA_COMPAT_OLD );
$this->overrideMwServices();
$title = $this->getMockTitle();
'rev_content_model' => 'GOATMODEL',
];
+ $domain = MediaWikiServices::getInstance()->getDBLoadBalancer()->getLocalDomainID();
$db = $this->getMock( IDatabase::class );
$db->expects( $this->any() )
->method( 'getDomainId' )
- ->will( $this->returnValue( wfWikiID() ) );
+ ->will( $this->returnValue( $domain ) );
$db->expects( $this->once() )
->method( 'selectRow' )
->with(
use ContentHandler;
use LinksUpdate;
use MediaWiki\MediaWikiServices;
+use MediaWiki\Revision\MutableRevisionRecord;
+use MediaWiki\Revision\MutableRevisionSlots;
+use MediaWiki\Revision\RevisionRecord;
+use MediaWiki\Revision\SlotRecord;
use MediaWiki\Storage\DerivedPageDataUpdater;
-use MediaWiki\Storage\MutableRevisionRecord;
-use MediaWiki\Storage\MutableRevisionSlots;
-use MediaWiki\Storage\RevisionRecord;
use MediaWiki\Storage\RevisionSlotsUpdate;
-use MediaWiki\Storage\SlotRecord;
use MediaWikiTestCase;
use MWCallableUpdate;
use MWTimestamp;
+++ /dev/null
-<?php
-namespace MediaWiki\Tests\Storage;
-
-use CommentStoreComment;
-use MediaWiki\MediaWikiServices;
-use MediaWiki\Storage\RevisionRecord;
-use MediaWiki\Storage\SlotRecord;
-use TextContent;
-use Title;
-use WikitextContent;
-
-/**
- * Tests RevisionStore against the intermediate MCR DB schema for use during schema migration.
- *
- * @covers \MediaWiki\Storage\RevisionStore
- *
- * @group RevisionStore
- * @group Storage
- * @group Database
- * @group medium
- */
-class McrReadNewRevisionStoreDbTest extends RevisionStoreDbTestBase {
-
- use McrReadNewSchemaOverride;
-
- protected function assertRevisionExistsInDatabase( RevisionRecord $rev ) {
- $numberOfSlots = count( $rev->getSlotRoles() );
-
- // new schema is written
- $this->assertSelect(
- 'slots',
- [ 'count(*)' ],
- [ 'slot_revision_id' => $rev->getId() ],
- [ [ (string)$numberOfSlots ] ]
- );
-
- $store = MediaWikiServices::getInstance()->getRevisionStore();
- $revQuery = $store->getSlotsQueryInfo( [ 'content' ] );
-
- $this->assertSelect(
- $revQuery['tables'],
- [ 'count(*)' ],
- [
- 'slot_revision_id' => $rev->getId(),
- ],
- [ [ (string)$numberOfSlots ] ],
- [],
- $revQuery['joins']
- );
-
- // Legacy schema is still being written
- $this->assertSelect(
- [ 'revision', 'text' ],
- [ 'count(*)' ],
- [ 'rev_id' => $rev->getId(), 'rev_text_id > 0' ],
- [ [ 1 ] ],
- [],
- [ 'text' => [ 'INNER JOIN', [ 'rev_text_id = old_id' ] ] ]
- );
-
- parent::assertRevisionExistsInDatabase( $rev );
- }
-
- /**
- * @param SlotRecord $a
- * @param SlotRecord $b
- */
- protected function assertSameSlotContent( SlotRecord $a, SlotRecord $b ) {
- parent::assertSameSlotContent( $a, $b );
-
- // Assert that the same content ID has been used
- $this->assertSame( $a->getContentId(), $b->getContentId() );
- }
-
- public function provideInsertRevisionOn_successes() {
- foreach ( parent::provideInsertRevisionOn_successes() as $case ) {
- yield $case;
- }
-
- yield 'Multi-slot revision insertion' => [
- [
- 'content' => [
- 'main' => new WikitextContent( 'Chicken' ),
- 'aux' => new TextContent( 'Egg' ),
- ],
- 'page' => true,
- 'comment' => $this->getRandomCommentStoreComment(),
- 'timestamp' => '20171117010101',
- 'user' => true,
- ],
- ];
- }
-
- public function provideNewNullRevision() {
- foreach ( parent::provideNewNullRevision() as $case ) {
- yield $case;
- }
-
- yield [
- Title::newFromText( 'UTPage_notAutoCreated' ),
- [
- 'content' => [
- 'main' => new WikitextContent( 'Chicken' ),
- 'aux' => new WikitextContent( 'Omelet' ),
- ],
- ],
- CommentStoreComment::newUnsavedComment( __METHOD__ . ' comment multi' ),
- ];
- }
-
- public function testGetQueryInfo_NoSlotDataJoin() {
- $store = MediaWikiServices::getInstance()->getRevisionStore();
- $queryInfo = $store->getQueryInfo();
-
- // with the new schema enabled, query info should not join the main slot info
- $this->assertFalse( array_key_exists( 'a_slot_data', $queryInfo['tables'] ) );
- $this->assertFalse( array_key_exists( 'a_slot_data', $queryInfo['joins'] ) );
- }
-
- public function provideNewMutableRevisionFromArray() {
- foreach ( parent::provideNewMutableRevisionFromArray() as $case ) {
- yield $case;
- }
-
- yield 'Basic array, multiple roles' => [
- [
- 'id' => 2,
- 'page' => 1,
- 'timestamp' => '20171017114835',
- 'user_text' => '111.0.1.2',
- 'user' => 0,
- 'minor_edit' => false,
- 'deleted' => 0,
- 'len' => 29,
- 'parent_id' => 1,
- 'sha1' => '89qs83keq9c9ccw9olvvm4oc9oq50ii',
- 'comment' => 'Goat Comment!',
- 'content' => [
- 'main' => new WikitextContent( 'Söme Cöntent' ),
- 'aux' => new TextContent( 'Öther Cöntent' ),
- ]
- ]
- ];
- }
-
-}
+++ /dev/null
-<?php
-namespace MediaWiki\Tests\Storage;
-
-use Wikimedia\Rdbms\IMaintainableDatabase;
-use MediaWiki\DB\PatchFileLocation;
-
-/**
- * Trait providing schema overrides that allow tests to run against the intermediate MCR database
- * schema for use during schema migration.
- */
-trait McrReadNewSchemaOverride {
-
- use PatchFileLocation;
- use McrSchemaDetection;
-
- /**
- * @return int
- */
- protected function getMcrMigrationStage() {
- return SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW;
- }
-
- /**
- * @return string[]
- */
- protected function getMcrTablesToReset() {
- return [ 'content', 'content_models', 'slots', 'slot_roles' ];
- }
-
- /**
- * @return array[]
- */
- protected function getSchemaOverrides( IMaintainableDatabase $db ) {
- $overrides = [
- 'scripts' => [],
- 'drop' => [],
- 'create' => [],
- 'alter' => [],
- ];
-
- if ( !$this->hasMcrTables( $db ) ) {
- $overrides['create'] = [ 'slots', 'content', 'slot_roles', 'content_models', ];
- $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-slot_roles' );
- $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-content_models' );
- $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-content' );
- $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-slots' );
- }
-
- if ( !$this->hasPreMcrFields( $db ) ) {
- $overrides['alter'][] = 'revision';
- $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'create-pre-mcr-fields', __DIR__ );
- }
-
- return $overrides;
- }
-
-}
+++ /dev/null
-<?php
-namespace MediaWiki\Tests\Storage;
-
-use CommentStoreComment;
-use MediaWiki\MediaWikiServices;
-use MediaWiki\Storage\MutableRevisionRecord;
-use MediaWiki\Storage\RevisionRecord;
-use MediaWiki\Storage\SlotRecord;
-use TextContent;
-use Title;
-use WikitextContent;
-
-/**
- * Tests RevisionStore against the post-migration MCR DB schema.
- *
- * @covers \MediaWiki\Storage\RevisionStore
- *
- * @group RevisionStore
- * @group Storage
- * @group Database
- * @group medium
- */
-class McrRevisionStoreDbTest extends RevisionStoreDbTestBase {
-
- use McrSchemaOverride;
-
- protected function assertRevisionExistsInDatabase( RevisionRecord $rev ) {
- $numberOfSlots = count( $rev->getSlotRoles() );
-
- // new schema is written
- $this->assertSelect(
- 'slots',
- [ 'count(*)' ],
- [ 'slot_revision_id' => $rev->getId() ],
- [ [ (string)$numberOfSlots ] ]
- );
-
- $store = MediaWikiServices::getInstance()->getRevisionStore();
- $revQuery = $store->getSlotsQueryInfo( [ 'content' ] );
-
- $this->assertSelect(
- $revQuery['tables'],
- [ 'count(*)' ],
- [
- 'slot_revision_id' => $rev->getId(),
- ],
- [ [ (string)$numberOfSlots ] ],
- [],
- $revQuery['joins']
- );
-
- parent::assertRevisionExistsInDatabase( $rev );
- }
-
- /**
- * @param SlotRecord $a
- * @param SlotRecord $b
- */
- protected function assertSameSlotContent( SlotRecord $a, SlotRecord $b ) {
- parent::assertSameSlotContent( $a, $b );
-
- // Assert that the same content ID has been used
- $this->assertSame( $a->getContentId(), $b->getContentId() );
- }
-
- public function provideInsertRevisionOn_successes() {
- foreach ( parent::provideInsertRevisionOn_successes() as $case ) {
- yield $case;
- }
-
- yield 'Multi-slot revision insertion' => [
- [
- 'content' => [
- 'main' => new WikitextContent( 'Chicken' ),
- 'aux' => new TextContent( 'Egg' ),
- ],
- 'page' => true,
- 'comment' => $this->getRandomCommentStoreComment(),
- 'timestamp' => '20171117010101',
- 'user' => true,
- ],
- ];
- }
-
- public function provideNewNullRevision() {
- foreach ( parent::provideNewNullRevision() as $case ) {
- yield $case;
- }
-
- yield [
- Title::newFromText( 'UTPage_notAutoCreated' ),
- [
- 'content' => [
- 'main' => new WikitextContent( 'Chicken' ),
- 'aux' => new WikitextContent( 'Omelet' ),
- ],
- ],
- CommentStoreComment::newUnsavedComment( __METHOD__ . ' comment multi' ),
- ];
- }
-
- public function provideNewMutableRevisionFromArray() {
- foreach ( parent::provideNewMutableRevisionFromArray() as $case ) {
- yield $case;
- }
-
- yield 'Basic array, multiple roles' => [
- [
- 'id' => 2,
- 'page' => 1,
- 'timestamp' => '20171017114835',
- 'user_text' => '111.0.1.2',
- 'user' => 0,
- 'minor_edit' => false,
- 'deleted' => 0,
- 'len' => 29,
- 'parent_id' => 1,
- 'sha1' => '89qs83keq9c9ccw9olvvm4oc9oq50ii',
- 'comment' => 'Goat Comment!',
- 'content' => [
- 'main' => new WikitextContent( 'Söme Cöntent' ),
- 'aux' => new TextContent( 'Öther Cöntent' ),
- ]
- ]
- ];
- }
-
- public function testGetQueryInfo_NoSlotDataJoin() {
- $store = MediaWikiServices::getInstance()->getRevisionStore();
- $queryInfo = $store->getQueryInfo();
-
- // with the new schema enabled, query info should not join the main slot info
- $this->assertFalse( array_key_exists( 'a_slot_data', $queryInfo['tables'] ) );
- $this->assertFalse( array_key_exists( 'a_slot_data', $queryInfo['joins'] ) );
- }
-
- /**
- * @covers \MediaWiki\Storage\RevisionStore::insertRevisionOn
- * @covers \MediaWiki\Storage\RevisionStore::insertSlotRowOn
- * @covers \MediaWiki\Storage\RevisionStore::insertContentRowOn
- */
- public function testInsertRevisionOn_T202032() {
- // This test only makes sense for MySQL
- if ( $this->db->getType() !== 'mysql' ) {
- $this->assertTrue( true );
- return;
- }
-
- // NOTE: must be done before checking MAX(rev_id)
- $page = $this->getTestPage();
-
- $maxRevId = $this->db->selectField( 'revision', 'MAX(rev_id)' );
-
- // Construct a slot row that will conflict with the insertion of the next revision ID,
- // to emulate the failure mode described in T202032. Nothing will ever read this row,
- // we just need it to trigger a primary key conflict.
- $this->db->insert( 'slots', [
- 'slot_revision_id' => $maxRevId + 1,
- 'slot_role_id' => 1,
- 'slot_content_id' => 0,
- 'slot_origin' => 0
- ], __METHOD__ );
-
- $rev = new MutableRevisionRecord( $page->getTitle() );
- $rev->setTimestamp( '20180101000000' );
- $rev->setComment( CommentStoreComment::newUnsavedComment( 'test' ) );
- $rev->setUser( $this->getTestUser()->getUser() );
- $rev->setContent( 'main', new WikitextContent( 'Text' ) );
- $rev->setPageId( $page->getId() );
-
- $store = MediaWikiServices::getInstance()->getRevisionStore();
- $return = $store->insertRevisionOn( $rev, $this->db );
-
- $this->assertSame( $maxRevId + 2, $return->getId() );
-
- // is the new revision correct?
- $this->assertRevisionCompleteness( $return );
- $this->assertRevisionRecordsEqual( $rev, $return );
-
- // can we find it directly in the database?
- $this->assertRevisionExistsInDatabase( $return );
-
- // can we load it from the store?
- $loaded = $store->getRevisionById( $return->getId() );
- $this->assertRevisionCompleteness( $loaded );
- $this->assertRevisionRecordsEqual( $return, $loaded );
- }
-
-}
+++ /dev/null
-<?php
-namespace MediaWiki\Tests\Storage;
-
-use Wikimedia\Rdbms\IDatabase;
-
-/**
- * Trait providing methods for detecting which MCR schema migration phase the current schema
- * is compatible with.
- */
-trait McrSchemaDetection {
-
- /**
- * Returns true if MCR-related tables exist in the database.
- * If yes, the database is compatible with with MIGRATION_NEW.
- * If hasPreMcrFields() also returns true, the database supports MIGRATION_WRITE_BOTH mode.
- *
- * @param IDatabase $db
- * @return bool
- */
- protected function hasMcrTables( IDatabase $db ) {
- return $db->tableExists( 'slots', __METHOD__ );
- }
-
- /**
- * Returns true if pre-MCR fields still exist in the database.
- * If yes, the database is compatible with with MIGRATION_OLD mode.
- * If hasMcrTables() also returns true, the database supports MIGRATION_WRITE_BOTH mode.
- *
- * Note that if the database has been updated in MIGRATION_NEW mode,
- * the rev_text_id field will be 0 for new revisions. This means that
- * in MIGRATION_OLD mode, reading such revisions will fail, even though
- * all the necessary fields exist.
- * This is not relevant for unit tests, since unit tests reset the database content anyway.
- *
- * @param IDatabase $db
- * @return bool
- */
- protected function hasPreMcrFields( IDatabase $db ) {
- return $db->fieldExists( 'revision', 'rev_content_model', __METHOD__ );
- }
-
-}
+++ /dev/null
-<?php
-namespace MediaWiki\Tests\Storage;
-
-use Wikimedia\Rdbms\IMaintainableDatabase;
-use MediaWiki\DB\PatchFileLocation;
-
-/**
- * Trait providing schema overrides that allow tests to run against the post-migration
- * MCR database schema.
- */
-trait McrSchemaOverride {
-
- use PatchFileLocation;
- use McrSchemaDetection;
-
- /**
- * @return int
- */
- protected function getMcrMigrationStage() {
- return MIGRATION_NEW;
- }
-
- /**
- * @return string[]
- */
- protected function getMcrTablesToReset() {
- return [
- 'content',
- 'content_models',
- 'slots',
- 'slot_roles',
- ];
- }
-
- protected function getSchemaOverrides( IMaintainableDatabase $db ) {
- $overrides = [
- 'scripts' => [],
- 'drop' => [],
- 'create' => [],
- 'alter' => [],
- ];
-
- if ( !$this->hasMcrTables( $db ) ) {
- $overrides['create'] = [ 'slots', 'content', 'slot_roles', 'content_models', ];
- $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-slot_roles' );
- $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-content_models' );
- $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-content' );
- $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-slots.sql' );
- }
-
- if ( !$this->hasPreMcrFields( $db ) ) {
- $overrides['alter'][] = 'revision';
- $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'drop-pre-mcr-fields', __DIR__ );
- }
-
- return $overrides;
- }
-
-}
+++ /dev/null
-<?php
-namespace MediaWiki\Tests\Storage;
-
-use InvalidArgumentException;
-use MediaWiki\MediaWikiServices;
-use MediaWiki\Storage\RevisionRecord;
-use MediaWiki\Storage\SlotRecord;
-use Revision;
-use WikitextContent;
-
-/**
- * Tests RevisionStore against the intermediate MCR DB schema for use during schema migration.
- *
- * @covers \MediaWiki\Storage\RevisionStore
- *
- * @group RevisionStore
- * @group Storage
- * @group Database
- * @group medium
- */
-class McrWriteBothRevisionStoreDbTest extends RevisionStoreDbTestBase {
-
- use McrWriteBothSchemaOverride;
-
- protected function revisionToRow( Revision $rev, $options = [ 'page', 'user', 'comment' ] ) {
- $row = parent::revisionToRow( $rev, $options );
-
- $row->rev_text_id = (string)$rev->getTextId();
- $row->rev_content_format = (string)$rev->getContentFormat();
- $row->rev_content_model = (string)$rev->getContentModel();
-
- return $row;
- }
-
- protected function assertRevisionExistsInDatabase( RevisionRecord $rev ) {
- // New schema is being written
- $this->assertSelect(
- 'slots',
- [ 'count(*)' ],
- [ 'slot_revision_id' => $rev->getId() ],
- [ [ '1' ] ]
- );
-
- $this->assertSelect(
- 'content',
- [ 'count(*)' ],
- [ 'content_address' => $rev->getSlot( 'main' )->getAddress() ],
- [ [ '1' ] ]
- );
-
- // Legacy schema is still being written
- $this->assertSelect(
- [ 'revision', 'text' ],
- [ 'count(*)' ],
- [ 'rev_id' => $rev->getId(), 'rev_text_id > 0' ],
- [ [ 1 ] ],
- [],
- [ 'text' => [ 'INNER JOIN', [ 'rev_text_id = old_id' ] ] ]
- );
-
- parent::assertRevisionExistsInDatabase( $rev );
- }
-
- /**
- * @param SlotRecord $a
- * @param SlotRecord $b
- */
- protected function assertSameSlotContent( SlotRecord $a, SlotRecord $b ) {
- parent::assertSameSlotContent( $a, $b );
-
- // Assert that the same content ID has been used
- if ( $a->hasContentId() && $b->hasContentId() ) {
- $this->assertSame( $a->getContentId(), $b->getContentId() );
- }
- }
-
- public function provideInsertRevisionOn_failures() {
- foreach ( parent::provideInsertRevisionOn_failures() as $case ) {
- yield $case;
- }
-
- yield 'slot that is not main slot' => [
- [
- 'content' => [
- 'main' => new WikitextContent( 'Chicken' ),
- 'lalala' => new WikitextContent( 'Duck' ),
- ],
- 'comment' => $this->getRandomCommentStoreComment(),
- 'timestamp' => '20171117010101',
- 'user' => true,
- ],
- new InvalidArgumentException( 'Only the main slot is supported' )
- ];
- }
-
- public function provideNewMutableRevisionFromArray() {
- foreach ( parent::provideNewMutableRevisionFromArray() as $case ) {
- yield $case;
- }
-
- yield 'Basic array, with page & id' => [
- [
- 'id' => 2,
- 'page' => 1,
- 'text_id' => 2,
- 'timestamp' => '20171017114835',
- 'user_text' => '111.0.1.2',
- 'user' => 0,
- 'minor_edit' => false,
- 'deleted' => 0,
- 'len' => 46,
- 'parent_id' => 1,
- 'sha1' => 'rdqbbzs3pkhihgbs8qf2q9jsvheag5z',
- 'comment' => 'Goat Comment!',
- 'content_format' => 'text/x-wiki',
- 'content_model' => 'wikitext',
- ]
- ];
- }
-
- /**
- * @covers \MediaWiki\Storage\RevisionStore::newRevisionFromArchiveRow
- * @covers \MediaWiki\Storage\RevisionStore::insertRevisionOn
- */
- public function testInsertRevisionFromArchiveRow_unmigratedArchiveRow() {
- // The main purpose of this test is to assert that after reading an archive
- // row using the old schema it can be inserted into the revision table,
- // and a slot row is created based on slot emulated from the old-style archive row,
- // when none such slot row exists yet.
-
- $title = $this->getTestPage()->getTitle();
-
- $this->db->insert(
- 'text',
- [ 'old_text' => 'Just a test', 'old_flags' => 'utf-8' ],
- __METHOD__
- );
-
- $textId = $this->db->insertId();
-
- $row = (object)[
- 'ar_minor_edit' => '0',
- 'ar_user' => '0',
- 'ar_user_text' => '127.0.0.1',
- 'ar_actor' => null,
- 'ar_len' => '11',
- 'ar_deleted' => '0',
- 'ar_rev_id' => 112277,
- 'ar_timestamp' => $this->db->timestamp( '20180101000000' ),
- 'ar_sha1' => 'deadbeef',
- 'ar_page_id' => $title->getArticleID(),
- 'ar_comment_text' => 'just a test',
- 'ar_comment_data' => null,
- 'ar_comment_cid' => null,
- 'ar_content_format' => null,
- 'ar_content_model' => null,
- 'ts_tags' => null,
- 'ar_id' => 17,
- 'ar_namespace' => $title->getNamespace(),
- 'ar_title' => $title->getDBkey(),
- 'ar_text_id' => $textId,
- 'ar_parent_id' => 112211,
- ];
-
- $store = MediaWikiServices::getInstance()->getRevisionStore();
- $rev = $store->newRevisionFromArchiveRow( $row );
-
- // re-insert archived revision
- $return = $store->insertRevisionOn( $rev, $this->db );
-
- // is the new revision correct?
- $this->assertRevisionCompleteness( $return );
- $this->assertRevisionRecordsEqual( $rev, $return );
-
- // can we load it from the store?
- $loaded = $store->getRevisionById( $return->getId() );
- $this->assertNotNull( $loaded );
- $this->assertRevisionCompleteness( $loaded );
- $this->assertRevisionRecordsEqual( $return, $loaded );
-
- // can we find it directly in the database?
- $this->assertRevisionExistsInDatabase( $return );
- }
-
-}
+++ /dev/null
-<?php
-namespace MediaWiki\Tests\Storage;
-
-use Wikimedia\Rdbms\IMaintainableDatabase;
-use MediaWiki\DB\PatchFileLocation;
-
-/**
- * Trait providing schema overrides that allow tests to run against the intermediate MCR database
- * schema for use during schema migration.
- */
-trait McrWriteBothSchemaOverride {
-
- use PatchFileLocation;
- use McrSchemaDetection;
-
- /**
- * @return int
- */
- protected function getMcrMigrationStage() {
- return SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD;
- }
-
- /**
- * @return string[]
- */
- protected function getMcrTablesToReset() {
- return [ 'content', 'content_models', 'slots', 'slot_roles' ];
- }
-
- /**
- * @return array[]
- */
- protected function getSchemaOverrides( IMaintainableDatabase $db ) {
- $overrides = [
- 'scripts' => [],
- 'drop' => [],
- 'create' => [],
- 'alter' => [],
- ];
-
- if ( !$this->hasMcrTables( $db ) ) {
- $overrides['create'] = [ 'slots', 'content', 'slot_roles', 'content_models', ];
- $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-slot_roles' );
- $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-content_models' );
- $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-content' );
- $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-slots' );
- }
-
- if ( !$this->hasPreMcrFields( $db ) ) {
- $overrides['alter'][] = 'revision';
- $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'create-pre-mcr-fields', __DIR__ );
- }
-
- return $overrides;
- }
-
-}
+++ /dev/null
-<?php
-
-namespace MediaWiki\Tests\Storage;
-
-use CommentStoreComment;
-use InvalidArgumentException;
-use MediaWiki\Storage\MutableRevisionRecord;
-use MediaWiki\Storage\MutableRevisionSlots;
-use MediaWiki\Storage\RevisionAccessException;
-use MediaWiki\Storage\RevisionRecord;
-use MediaWiki\Storage\RevisionSlotsUpdate;
-use MediaWiki\Storage\SlotRecord;
-use MediaWiki\User\UserIdentityValue;
-use MediaWikiTestCase;
-use TextContent;
-use Title;
-use User;
-use WikitextContent;
-
-/**
- * @covers \MediaWiki\Storage\MutableRevisionRecord
- * @covers \MediaWiki\Storage\RevisionRecord
- */
-class MutableRevisionRecordTest extends MediaWikiTestCase {
-
- use RevisionRecordTests;
-
- /**
- * @param array $rowOverrides
- *
- * @return MutableRevisionRecord
- */
- protected function newRevision( array $rowOverrides = [] ) {
- $title = Title::newFromText( 'Dummy' );
- $title->resetArticleID( 17 );
-
- $user = new UserIdentityValue( 11, 'Tester', 0 );
- $comment = CommentStoreComment::newUnsavedComment( 'Hello World' );
-
- $record = new MutableRevisionRecord( $title );
-
- if ( isset( $rowOverrides['rev_deleted'] ) ) {
- $record->setVisibility( $rowOverrides['rev_deleted'] );
- }
-
- if ( isset( $rowOverrides['rev_id'] ) ) {
- $record->setId( $rowOverrides['rev_id'] );
- }
-
- if ( isset( $rowOverrides['rev_page'] ) ) {
- $record->setPageId( $rowOverrides['rev_page'] );
- }
-
- $record->setContent( SlotRecord::MAIN, new TextContent( 'Lorem Ipsum' ) );
- $record->setComment( $comment );
- $record->setUser( $user );
- $record->setTimestamp( '20101010000000' );
-
- return $record;
- }
-
- public function provideConstructor() {
- $title = Title::newFromText( 'Dummy' );
- $title->resetArticleID( 17 );
-
- yield [
- $title,
- 'acmewiki'
- ];
- }
-
- /**
- * @dataProvider provideConstructor
- *
- * @param Title $title
- * @param bool $wikiId
- */
- public function testConstructorAndGetters(
- Title $title,
- $wikiId = false
- ) {
- $rec = new MutableRevisionRecord( $title, $wikiId );
-
- $this->assertSame( $title, $rec->getPageAsLinkTarget(), 'getPageAsLinkTarget' );
- $this->assertSame( $wikiId, $rec->getWikiId(), 'getWikiId' );
- }
-
- public function provideConstructorFailure() {
- $title = Title::newFromText( 'Dummy' );
- $title->resetArticleID( 17 );
-
- yield 'not a wiki id' => [
- $title,
- null
- ];
- }
-
- /**
- * @dataProvider provideConstructorFailure
- *
- * @param Title $title
- * @param bool $wikiId
- */
- public function testConstructorFailure(
- Title $title,
- $wikiId = false
- ) {
- $this->setExpectedException( InvalidArgumentException::class );
- new MutableRevisionRecord( $title, $wikiId );
- }
-
- public function testSetGetId() {
- $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
- $this->assertNull( $record->getId() );
- $record->setId( 888 );
- $this->assertSame( 888, $record->getId() );
- }
-
- public function testSetGetUser() {
- $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
- $user = $this->getTestSysop()->getUser();
- $this->assertNull( $record->getUser() );
- $record->setUser( $user );
- $this->assertSame( $user, $record->getUser() );
- }
-
- public function testSetGetPageId() {
- $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
- $this->assertSame( 0, $record->getPageId() );
- $record->setPageId( 999 );
- $this->assertSame( 999, $record->getPageId() );
- }
-
- public function testSetGetParentId() {
- $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
- $this->assertNull( $record->getParentId() );
- $record->setParentId( 100 );
- $this->assertSame( 100, $record->getParentId() );
- }
-
- public function testGetMainContentWhenEmpty() {
- $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
- $this->setExpectedException( RevisionAccessException::class );
- $this->assertNull( $record->getContent( SlotRecord::MAIN ) );
- }
-
- public function testSetGetMainContent() {
- $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
- $content = new WikitextContent( 'Badger' );
- $record->setContent( SlotRecord::MAIN, $content );
- $this->assertSame( $content, $record->getContent( SlotRecord::MAIN ) );
- }
-
- public function testGetSlotWhenEmpty() {
- $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
- $this->assertFalse( $record->hasSlot( SlotRecord::MAIN ) );
-
- $this->setExpectedException( RevisionAccessException::class );
- $record->getSlot( SlotRecord::MAIN );
- }
-
- public function testSetGetSlot() {
- $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
- $slot = SlotRecord::newUnsaved(
- SlotRecord::MAIN,
- new WikitextContent( 'x' )
- );
- $record->setSlot( $slot );
- $this->assertTrue( $record->hasSlot( SlotRecord::MAIN ) );
- $this->assertSame( $slot, $record->getSlot( SlotRecord::MAIN ) );
- }
-
- public function testSetGetMinor() {
- $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
- $this->assertFalse( $record->isMinor() );
- $record->setMinorEdit( true );
- $this->assertSame( true, $record->isMinor() );
- }
-
- public function testSetGetTimestamp() {
- $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
- $this->assertNull( $record->getTimestamp() );
- $record->setTimestamp( '20180101010101' );
- $this->assertSame( '20180101010101', $record->getTimestamp() );
- }
-
- public function testSetGetVisibility() {
- $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
- $this->assertSame( 0, $record->getVisibility() );
- $record->setVisibility( RevisionRecord::DELETED_USER );
- $this->assertSame( RevisionRecord::DELETED_USER, $record->getVisibility() );
- }
-
- public function testSetGetSha1() {
- $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
- $this->assertSame( 'phoiac9h4m842xq45sp7s6u21eteeq1', $record->getSha1() );
- $record->setSha1( 'someHash' );
- $this->assertSame( 'someHash', $record->getSha1() );
- }
-
- public function testGetSlots() {
- $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
- $this->assertInstanceOf( MutableRevisionSlots::class, $record->getSlots() );
- }
-
- public function testSetGetSize() {
- $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
- $this->assertSame( 0, $record->getSize() );
- $record->setSize( 775 );
- $this->assertSame( 775, $record->getSize() );
- }
-
- public function testSetGetComment() {
- $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
- $comment = new CommentStoreComment( 1, 'foo' );
- $this->assertNull( $record->getComment() );
- $record->setComment( $comment );
- $this->assertSame( $comment, $record->getComment() );
- }
-
- public function testSimpleGetOriginalAndInheritedSlots() {
- $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
- $mainSlot = new SlotRecord(
- (object)[
- 'slot_id' => 1,
- 'slot_revision_id' => null, // unsaved
- 'slot_content_id' => 1,
- 'content_address' => null, // touched
- 'model_name' => 'x',
- 'role_name' => 'main',
- 'slot_origin' => null // touched
- ],
- new WikitextContent( 'main' )
- );
- $auxSlot = new SlotRecord(
- (object)[
- 'slot_id' => 2,
- 'slot_revision_id' => null, // unsaved
- 'slot_content_id' => 1,
- 'content_address' => 'foo', // inherited
- 'model_name' => 'x',
- 'role_name' => 'aux',
- 'slot_origin' => 1 // inherited
- ],
- new WikitextContent( 'aux' )
- );
-
- $record->setSlot( $mainSlot );
- $record->setSlot( $auxSlot );
-
- $this->assertSame( [ 'main' ], $record->getOriginalSlots()->getSlotRoles() );
- $this->assertSame( $mainSlot, $record->getOriginalSlots()->getSlot( SlotRecord::MAIN ) );
-
- $this->assertSame( [ 'aux' ], $record->getInheritedSlots()->getSlotRoles() );
- $this->assertSame( $auxSlot, $record->getInheritedSlots()->getSlot( 'aux' ) );
- }
-
- public function testSimpleremoveSlot() {
- $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
-
- $a = new WikitextContent( 'a' );
- $b = new WikitextContent( 'b' );
-
- $record->inheritSlot( SlotRecord::newSaved( 7, 3, 'a', SlotRecord::newUnsaved( 'a', $a ) ) );
- $record->inheritSlot( SlotRecord::newSaved( 7, 4, 'b', SlotRecord::newUnsaved( 'b', $b ) ) );
-
- $record->removeSlot( 'b' );
-
- $this->assertTrue( $record->hasSlot( 'a' ) );
- $this->assertFalse( $record->hasSlot( 'b' ) );
- }
-
- public function testApplyUpdate() {
- $update = new RevisionSlotsUpdate();
-
- $a = new WikitextContent( 'a' );
- $b = new WikitextContent( 'b' );
- $c = new WikitextContent( 'c' );
- $x = new WikitextContent( 'x' );
-
- $update->modifyContent( 'b', $x );
- $update->modifyContent( 'c', $x );
- $update->removeSlot( 'c' );
- $update->removeSlot( 'd' );
-
- $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
- $record->inheritSlot( SlotRecord::newSaved( 7, 3, 'a', SlotRecord::newUnsaved( 'a', $a ) ) );
- $record->inheritSlot( SlotRecord::newSaved( 7, 4, 'b', SlotRecord::newUnsaved( 'b', $b ) ) );
- $record->inheritSlot( SlotRecord::newSaved( 7, 5, 'c', SlotRecord::newUnsaved( 'c', $c ) ) );
-
- $record->applyUpdate( $update );
-
- $this->assertEquals( [ 'b' ], array_keys( $record->getOriginalSlots()->getSlots() ) );
- $this->assertEquals( $a, $record->getSlot( 'a' )->getContent() );
- $this->assertEquals( $x, $record->getSlot( 'b' )->getContent() );
- $this->assertFalse( $record->hasSlot( 'c' ) );
- }
-
- public function provideNotReadyForInsertion() {
- /** @var Title $title */
- $title = $this->getMock( Title::class );
-
- /** @var User $user */
- $user = $this->getMock( User::class );
-
- /** @var CommentStoreComment $comment */
- $comment = $this->getMockBuilder( CommentStoreComment::class )
- ->disableOriginalConstructor()
- ->getMock();
-
- $content = new TextContent( 'Test' );
-
- $rev = new MutableRevisionRecord( $title );
- yield 'empty' => [ $rev ];
-
- $rev = new MutableRevisionRecord( $title );
- $rev->setContent( SlotRecord::MAIN, $content );
- $rev->setUser( $user );
- $rev->setComment( $comment );
- yield 'no timestamp' => [ $rev ];
-
- $rev = new MutableRevisionRecord( $title );
- $rev->setUser( $user );
- $rev->setComment( $comment );
- $rev->setTimestamp( '20101010000000' );
- yield 'no content' => [ $rev ];
-
- $rev = new MutableRevisionRecord( $title );
- $rev->setContent( SlotRecord::MAIN, $content );
- $rev->setComment( $comment );
- $rev->setTimestamp( '20101010000000' );
- yield 'no user' => [ $rev ];
-
- $rev = new MutableRevisionRecord( $title );
- $rev->setUser( $user );
- $rev->setContent( SlotRecord::MAIN, $content );
- $rev->setTimestamp( '20101010000000' );
- yield 'no comment' => [ $rev ];
- }
-
- /**
- * @dataProvider provideNotReadyForInsertion
- */
- public function testNotReadyForInsertion( $rev ) {
- $this->assertFalse( $rev->isReadyForInsertion() );
- }
-}
+++ /dev/null
-<?php
-
-namespace MediaWiki\Tests\Storage;
-
-use Content;
-use InvalidArgumentException;
-use MediaWiki\Storage\MutableRevisionSlots;
-use MediaWiki\Storage\RevisionAccessException;
-use MediaWiki\Storage\RevisionSlots;
-use MediaWiki\Storage\SlotRecord;
-use WikitextContent;
-
-/**
- * @covers \MediaWiki\Storage\MutableRevisionSlots
- */
-class MutableRevisionSlotsTest extends RevisionSlotsTest {
-
- /**
- * @param SlotRecord[] $slots
- * @return RevisionSlots
- */
- protected function newRevisionSlots( $slots = [] ) {
- return new MutableRevisionSlots( $slots );
- }
-
- public function provideConstructorFailue() {
- yield 'array or the wrong thing' => [
- [ 1, 2, 3 ]
- ];
- }
-
- /**
- * @dataProvider provideConstructorFailue
- * @param $slots
- *
- * @covers \MediaWiki\Storage\RevisionSlots::__construct
- * @covers \MediaWiki\Storage\RevisionSlots::setSlotsInternal
- */
- public function testConstructorFailue( $slots ) {
- $this->setExpectedException( InvalidArgumentException::class );
-
- new MutableRevisionSlots( $slots );
- }
-
- public function testSetMultipleSlots() {
- $slots = new MutableRevisionSlots();
-
- $this->assertSame( [], $slots->getSlots() );
-
- $slotA = SlotRecord::newUnsaved( 'some', new WikitextContent( 'A' ) );
- $slots->setSlot( $slotA );
- $this->assertTrue( $slots->hasSlot( 'some' ) );
- $this->assertSame( $slotA, $slots->getSlot( 'some' ) );
- $this->assertSame( [ 'some' => $slotA ], $slots->getSlots() );
-
- $slotB = SlotRecord::newUnsaved( 'other', new WikitextContent( 'B' ) );
- $slots->setSlot( $slotB );
- $this->assertTrue( $slots->hasSlot( 'other' ) );
- $this->assertSame( $slotB, $slots->getSlot( 'other' ) );
- $this->assertSame( [ 'some' => $slotA, 'other' => $slotB ], $slots->getSlots() );
- }
-
- public function testSetExistingSlotOverwritesSlot() {
- $slots = new MutableRevisionSlots();
-
- $this->assertSame( [], $slots->getSlots() );
-
- $slotA = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) );
- $slots->setSlot( $slotA );
- $this->assertSame( $slotA, $slots->getSlot( SlotRecord::MAIN ) );
- $this->assertSame( [ 'main' => $slotA ], $slots->getSlots() );
-
- $slotB = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'B' ) );
- $slots->setSlot( $slotB );
- $this->assertSame( $slotB, $slots->getSlot( SlotRecord::MAIN ) );
- $this->assertSame( [ 'main' => $slotB ], $slots->getSlots() );
- }
-
- /**
- * @param string $role
- * @param Content $content
- * @return SlotRecord
- */
- private function newSavedSlot( $role, Content $content ) {
- return SlotRecord::newSaved( 7, 7, 'xyz', SlotRecord::newUnsaved( $role, $content ) );
- }
-
- public function testInheritSlotOverwritesSlot() {
- $slots = new MutableRevisionSlots();
- $slotA = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) );
- $slots->setSlot( $slotA );
- $slotB = $this->newSavedSlot( SlotRecord::MAIN, new WikitextContent( 'B' ) );
- $slotC = $this->newSavedSlot( 'foo', new WikitextContent( 'C' ) );
- $slots->inheritSlot( $slotB );
- $slots->inheritSlot( $slotC );
- $this->assertSame( [ 'main', 'foo' ], $slots->getSlotRoles() );
- $this->assertNotSame( $slotB, $slots->getSlot( SlotRecord::MAIN ) );
- $this->assertNotSame( $slotC, $slots->getSlot( 'foo' ) );
- $this->assertTrue( $slots->getSlot( SlotRecord::MAIN )->isInherited() );
- $this->assertTrue( $slots->getSlot( 'foo' )->isInherited() );
- $this->assertSame( $slotB->getContent(), $slots->getSlot( SlotRecord::MAIN )->getContent() );
- $this->assertSame( $slotC->getContent(), $slots->getSlot( 'foo' )->getContent() );
- }
-
- public function testSetContentOfExistingSlotOverwritesContent() {
- $slots = new MutableRevisionSlots();
-
- $this->assertSame( [], $slots->getSlots() );
-
- $slotA = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) );
- $slots->setSlot( $slotA );
- $this->assertSame( $slotA, $slots->getSlot( SlotRecord::MAIN ) );
- $this->assertSame( [ 'main' => $slotA ], $slots->getSlots() );
-
- $newContent = new WikitextContent( 'B' );
- $slots->setContent( SlotRecord::MAIN, $newContent );
- $this->assertSame( $newContent, $slots->getContent( SlotRecord::MAIN ) );
- }
-
- public function testRemoveExistingSlot() {
- $slotA = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) );
- $slots = new MutableRevisionSlots( [ $slotA ] );
-
- $this->assertSame( [ 'main' => $slotA ], $slots->getSlots() );
-
- $slots->removeSlot( SlotRecord::MAIN );
- $this->assertSame( [], $slots->getSlots() );
- $this->setExpectedException( RevisionAccessException::class );
- $slots->getSlot( SlotRecord::MAIN );
- }
-
- public function testNewFromParentRevisionSlots() {
- /** @var SlotRecord[] $parentSlots */
- $parentSlots = [
- 'some' => $this->newSavedSlot( 'some', new WikitextContent( 'X' ) ),
- 'other' => $this->newSavedSlot( 'other', new WikitextContent( 'Y' ) ),
- ];
- $slots = MutableRevisionSlots::newFromParentRevisionSlots( $parentSlots );
- $this->assertSame( [ 'some', 'other' ], $slots->getSlotRoles() );
- $this->assertNotSame( $parentSlots['some'], $slots->getSlot( 'some' ) );
- $this->assertNotSame( $parentSlots['other'], $slots->getSlot( 'other' ) );
- $this->assertTrue( $slots->getSlot( 'some' )->isInherited() );
- $this->assertTrue( $slots->getSlot( 'other' )->isInherited() );
- $this->assertSame( $parentSlots['some']->getContent(), $slots->getContent( 'some' ) );
- $this->assertSame( $parentSlots['other']->getContent(), $slots->getContent( 'other' ) );
- }
-
-}
/**
* @return \PHPUnit_Framework_MockObject_MockObject|ILoadBalancer
*/
- private function getMockLoadBalancer() {
- return $this->getMockBuilder( ILoadBalancer::class )
+ private function getMockLoadBalancer( $localDomain ) {
+ $mock = $this->getMockBuilder( ILoadBalancer::class )
->disableOriginalConstructor()->getMock();
+
+ $mock->expects( $this->any() )
+ ->method( 'getLocalDomainID' )
+ ->willReturn( $localDomain );
+
+ return $mock;
}
/**
$mock = $this->getMockBuilder( ILBFactory::class )
->disableOriginalConstructor()->getMock();
+ $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
+ $localDomain = $lbFactory->getLocalDomainID();
+
+ $mock->expects( $this->any() )->method( 'getLocalDomainID' )->willReturn( $localDomain );
+
$mock->expects( $this->once() )
->method( 'getMainLB' )
->with( $this->equalTo( $expectedWiki ) )
- ->willReturnCallback( function ( $domain ) use ( $expectedWiki ) {
- return $this->getMockLoadBalancer();
+ ->willReturnCallback( function ( $domain ) use ( $localDomain ) {
+ return $this->getMockLoadBalancer( $localDomain );
} );
return $mock;
/** @dataProvider provideTestGet */
public function testGet( $tableName, $wiki, $expectedWiki ) {
$services = MediaWikiServices::getInstance();
- $db = wfGetDB( DB_MASTER );
- if ( $wiki === false ) {
- $wiki2 = $db->getWikiID();
- } else {
- $wiki2 = $wiki;
- }
+ $wiki2 = ( $wiki === false )
+ ? $services->getDBLoadBalancerFactory()->getLocalDomainID()
+ : $wiki;
$names = new NameTableStoreFactory(
$this->getMockLoadBalancerFactory( $expectedWiki ),
$services->getMainWANObjectCache(),
+++ /dev/null
-<?php
-namespace MediaWiki\Tests\Storage;
-
-use Revision;
-
-/**
- * Tests RevisionStore against the pre-MCR, pre-ContentHandler DB schema.
- *
- * @covers \MediaWiki\Storage\RevisionStore
- *
- * @group RevisionStore
- * @group Storage
- * @group Database
- * @group medium
- */
-class NoContentModelRevisionStoreDbTest extends RevisionStoreDbTestBase {
-
- use PreMcrSchemaOverride;
-
- protected function getContentHandlerUseDB() {
- return false;
- }
-
- protected function revisionToRow( Revision $rev, $options = [ 'page', 'user', 'comment' ] ) {
- $row = parent::revisionToRow( $rev, $options );
-
- $row->rev_text_id = (string)$rev->getTextId();
-
- return $row;
- }
-
- public function provideGetArchiveQueryInfo() {
- yield [
- [
- 'tables' => [ 'archive' ],
- 'fields' => array_merge(
- $this->getDefaultArchiveFields(),
- [
- 'ar_comment_text' => 'ar_comment',
- 'ar_comment_data' => 'NULL',
- 'ar_comment_cid' => 'NULL',
- 'ar_user_text' => 'ar_user_text',
- 'ar_user' => 'ar_user',
- 'ar_actor' => 'NULL',
- ]
- ),
- 'joins' => [],
- ]
- ];
- }
-
- public function provideGetQueryInfo() {
- yield [
- [],
- [
- 'tables' => [ 'revision' ],
- 'fields' => array_merge(
- $this->getDefaultQueryFields(),
- $this->getCommentQueryFields(),
- $this->getActorQueryFields()
- ),
- 'joins' => [],
- ]
- ];
- yield [
- [ 'page' ],
- [
- 'tables' => [ 'revision', 'page' ],
- 'fields' => array_merge(
- $this->getDefaultQueryFields(),
- $this->getCommentQueryFields(),
- $this->getActorQueryFields(),
- [
- 'page_namespace',
- 'page_title',
- 'page_id',
- 'page_latest',
- 'page_is_redirect',
- 'page_len',
- ]
- ),
- 'joins' => [
- 'page' => [ 'INNER JOIN', [ 'page_id = rev_page' ] ],
- ],
- ]
- ];
- yield [
- [ 'user' ],
- [
- 'tables' => [ 'revision', 'user' ],
- 'fields' => array_merge(
- $this->getDefaultQueryFields(),
- $this->getCommentQueryFields(),
- $this->getActorQueryFields(),
- [
- 'user_name',
- ]
- ),
- 'joins' => [
- 'user' => [ 'LEFT JOIN', [ 'rev_user != 0', 'user_id = rev_user' ] ],
- ],
- ]
- ];
- yield [
- [ 'text' ],
- [
- 'tables' => [ 'revision', 'text' ],
- 'fields' => array_merge(
- $this->getDefaultQueryFields(),
- $this->getCommentQueryFields(),
- $this->getActorQueryFields(),
- [
- 'old_text',
- 'old_flags',
- ]
- ),
- 'joins' => [
- 'text' => [ 'INNER JOIN', [ 'rev_text_id=old_id' ] ],
- ],
- ]
- ];
- }
-
- public function provideGetSlotsQueryInfo() {
- $db = wfGetDB( DB_REPLICA );
-
- yield [
- [],
- [
- 'tables' => [
- 'slots' => 'revision',
- ],
- 'fields' => array_merge(
- [
- 'slot_revision_id' => 'slots.rev_id',
- 'slot_content_id' => 'NULL',
- 'slot_origin' => 'slots.rev_id',
- 'role_name' => $db->addQuotes( SlotRecord::MAIN ),
- ]
- ),
- 'joins' => [],
- ]
- ];
- yield [
- [ 'content' ],
- [
- 'tables' => [
- 'slots' => 'revision',
- ],
- 'fields' => array_merge(
- [
- 'slot_revision_id' => 'slots.rev_id',
- 'slot_content_id' => 'NULL',
- 'slot_origin' => 'slots.rev_id',
- 'role_name' => $db->addQuotes( SlotRecord::MAIN ),
- 'content_size' => 'slots.rev_len',
- 'content_sha1' => 'slots.rev_sha1',
- 'content_address' =>
- $db->buildConcat( [ $db->addQuotes( 'tt:' ), 'slots.rev_text_id' ] ),
- 'model_name' => 'NULL',
- ]
- ),
- 'joins' => [],
- ]
- ];
- }
-
- public function provideNewMutableRevisionFromArray() {
- foreach ( parent::provideNewMutableRevisionFromArray() as $case ) {
- yield $case;
- }
-
- yield 'Basic array, with page & id' => [
- [
- 'id' => 2,
- 'page' => 1,
- 'text_id' => 2,
- 'timestamp' => '20171017114835',
- 'user_text' => '111.0.1.2',
- 'user' => 0,
- 'minor_edit' => false,
- 'deleted' => 0,
- 'len' => 46,
- 'parent_id' => 1,
- 'sha1' => 'rdqbbzs3pkhihgbs8qf2q9jsvheag5z',
- 'comment' => 'Goat Comment!',
- ]
- ];
- }
-
-}
use CommentStoreComment;
use Content;
use MediaWiki\MediaWikiServices;
-use MediaWiki\Storage\RevisionRecord;
-use MediaWiki\Storage\SlotRecord;
+use MediaWiki\Revision\RevisionRecord;
+use MediaWiki\Revision\SlotRecord;
use MediaWikiTestCase;
use ParserOptions;
use RecentChange;
+++ /dev/null
-<?php
-namespace MediaWiki\Tests\Storage;
-
-use InvalidArgumentException;
-use MediaWiki\Storage\RevisionRecord;
-use Revision;
-use WikitextContent;
-
-/**
- * Tests RevisionStore against the pre-MCR DB schema.
- *
- * @covers \MediaWiki\Storage\RevisionStore
- *
- * @group RevisionStore
- * @group Storage
- * @group Database
- * @group medium
- */
-class PreMcrRevisionStoreDbTest extends RevisionStoreDbTestBase {
-
- use PreMcrSchemaOverride;
-
- protected function revisionToRow( Revision $rev, $options = [ 'page', 'user', 'comment' ] ) {
- $row = parent::revisionToRow( $rev, $options );
-
- $row->rev_text_id = (string)$rev->getTextId();
- $row->rev_content_format = (string)$rev->getContentFormat();
- $row->rev_content_model = (string)$rev->getContentModel();
-
- return $row;
- }
-
- protected function assertRevisionExistsInDatabase( RevisionRecord $rev ) {
- // Legacy schema is still being written
- $this->assertSelect(
- [ 'revision', 'text' ],
- [ 'count(*)' ],
- [ 'rev_id' => $rev->getId(), 'rev_text_id > 0' ],
- [ [ 1 ] ],
- [],
- [ 'text' => [ 'INNER JOIN', [ 'rev_text_id = old_id' ] ] ]
- );
-
- parent::assertRevisionExistsInDatabase( $rev );
- }
-
- public function provideInsertRevisionOn_failures() {
- foreach ( parent::provideInsertRevisionOn_failures() as $case ) {
- yield $case;
- }
-
- yield 'slot that is not main slot' => [
- [
- 'content' => [
- 'main' => new WikitextContent( 'Chicken' ),
- 'lalala' => new WikitextContent( 'Duck' ),
- ],
- 'comment' => $this->getRandomCommentStoreComment(),
- 'timestamp' => '20171117010101',
- 'user' => true,
- ],
- new InvalidArgumentException( 'Only the main slot is supported' )
- ];
- }
-
- public function provideNewMutableRevisionFromArray() {
- foreach ( parent::provideNewMutableRevisionFromArray() as $case ) {
- yield $case;
- }
-
- yield 'Basic array, with page & id' => [
- [
- 'id' => 2,
- 'page' => 1,
- 'text_id' => 2,
- 'timestamp' => '20171017114835',
- 'user_text' => '111.0.1.2',
- 'user' => 0,
- 'minor_edit' => false,
- 'deleted' => 0,
- 'len' => 46,
- 'parent_id' => 1,
- 'sha1' => 'rdqbbzs3pkhihgbs8qf2q9jsvheag5z',
- 'comment' => 'Goat Comment!',
- 'content_format' => 'text/x-wiki',
- 'content_model' => 'wikitext',
- ]
- ];
- }
-
-}
+++ /dev/null
-<?php
-namespace MediaWiki\Tests\Storage;
-
-use Wikimedia\Rdbms\IMaintainableDatabase;
-use MediaWiki\DB\PatchFileLocation;
-
-/**
- * Trait providing schema overrides that allow tests to run against the pre-MCR database schema.
- */
-trait PreMcrSchemaOverride {
-
- use PatchFileLocation;
- use McrSchemaDetection;
-
- /**
- * @return int
- */
- protected function getMcrMigrationStage() {
- return MIGRATION_OLD;
- }
-
- /**
- * @return string[]
- */
- protected function getMcrTablesToReset() {
- return [];
- }
-
- /**
- * @return array[]
- */
- protected function getSchemaOverrides( IMaintainableDatabase $db ) {
- $overrides = [
- 'scripts' => [],
- 'drop' => [],
- 'create' => [],
- 'alter' => [],
- ];
-
- if ( $this->hasMcrTables( $db ) ) {
- $overrides['drop'] = [ 'slots', 'content', 'slot_roles', 'content_models', ];
- $overrides['scripts'][] = $this->getSqlPatchPath( $db, '/drop-mcr-tables', __DIR__ );
- }
-
- if ( !$this->hasPreMcrFields( $db ) ) {
- $overrides['alter'][] = 'revision';
- $overrides['scripts'][] = $this->getSqlPatchPath( $db, '/create-pre-mcr-fields', __DIR__ );
- }
-
- return $overrides;
- }
-
-}
+++ /dev/null
-<?php
-
-namespace MediaWiki\Tests\Storage;
-
-use CommentStoreComment;
-use InvalidArgumentException;
-use MediaWiki\Storage\RevisionRecord;
-use MediaWiki\Storage\RevisionSlots;
-use MediaWiki\Storage\RevisionArchiveRecord;
-use MediaWiki\Storage\SlotRecord;
-use MediaWiki\User\UserIdentity;
-use MediaWiki\User\UserIdentityValue;
-use MediaWikiTestCase;
-use TextContent;
-use Title;
-
-/**
- * @covers \MediaWiki\Storage\RevisionArchiveRecord
- * @covers \MediaWiki\Storage\RevisionRecord
- */
-class RevisionArchiveRecordTest extends MediaWikiTestCase {
-
- use RevisionRecordTests;
-
- /**
- * @param array $rowOverrides
- *
- * @return RevisionArchiveRecord
- */
- protected function newRevision( array $rowOverrides = [] ) {
- $title = Title::newFromText( 'Dummy' );
- $title->resetArticleID( 17 );
-
- $user = new UserIdentityValue( 11, 'Tester', 0 );
- $comment = CommentStoreComment::newUnsavedComment( 'Hello World' );
-
- $main = SlotRecord::newUnsaved( SlotRecord::MAIN, new TextContent( 'Lorem Ipsum' ) );
- $aux = SlotRecord::newUnsaved( 'aux', new TextContent( 'Frumious Bandersnatch' ) );
- $slots = new RevisionSlots( [ $main, $aux ] );
-
- $row = [
- 'ar_id' => '5',
- 'ar_rev_id' => '7',
- 'ar_page_id' => strval( $title->getArticleID() ),
- 'ar_timestamp' => '20200101000000',
- 'ar_deleted' => 0,
- 'ar_minor_edit' => 0,
- 'ar_parent_id' => '5',
- 'ar_len' => $slots->computeSize(),
- 'ar_sha1' => $slots->computeSha1(),
- ];
-
- foreach ( $rowOverrides as $field => $value ) {
- $field = preg_replace( '/^rev_/', 'ar_', $field );
- $row[$field] = $value;
- }
-
- return new RevisionArchiveRecord( $title, $user, $comment, (object)$row, $slots );
- }
-
- public function provideConstructor() {
- $title = Title::newFromText( 'Dummy' );
- $title->resetArticleID( 17 );
-
- $user = new UserIdentityValue( 11, 'Tester', 0 );
- $comment = CommentStoreComment::newUnsavedComment( 'Hello World' );
-
- $main = SlotRecord::newUnsaved( SlotRecord::MAIN, new TextContent( 'Lorem Ipsum' ) );
- $aux = SlotRecord::newUnsaved( 'aux', new TextContent( 'Frumious Bandersnatch' ) );
- $slots = new RevisionSlots( [ $main, $aux ] );
-
- $protoRow = [
- 'ar_id' => '5',
- 'ar_rev_id' => '7',
- 'ar_page_id' => strval( $title->getArticleID() ),
- 'ar_timestamp' => '20200101000000',
- 'ar_deleted' => 0,
- 'ar_minor_edit' => 0,
- 'ar_parent_id' => '5',
- 'ar_len' => $slots->computeSize(),
- 'ar_sha1' => $slots->computeSha1(),
- ];
-
- $row = $protoRow;
- yield 'all info' => [
- $title,
- $user,
- $comment,
- (object)$row,
- $slots,
- 'acmewiki'
- ];
-
- $row = $protoRow;
- $row['ar_minor_edit'] = '1';
- $row['ar_deleted'] = strval( RevisionRecord::DELETED_USER );
-
- yield 'minor deleted' => [
- $title,
- $user,
- $comment,
- (object)$row,
- $slots
- ];
-
- $row = $protoRow;
- unset( $row['ar_parent'] );
-
- yield 'no parent' => [
- $title,
- $user,
- $comment,
- (object)$row,
- $slots
- ];
-
- $row = $protoRow;
- $row['ar_len'] = null;
- $row['ar_sha1'] = '';
-
- yield 'ar_len is null, ar_sha1 is ""' => [
- $title,
- $user,
- $comment,
- (object)$row,
- $slots
- ];
-
- $row = $protoRow;
- yield 'no length, no hash' => [
- Title::newFromText( 'DummyDoesNotExist' ),
- $user,
- $comment,
- (object)$row,
- $slots
- ];
- }
-
- /**
- * @dataProvider provideConstructor
- *
- * @param Title $title
- * @param UserIdentity $user
- * @param CommentStoreComment $comment
- * @param object $row
- * @param RevisionSlots $slots
- * @param bool $wikiId
- */
- public function testConstructorAndGetters(
- Title $title,
- UserIdentity $user,
- CommentStoreComment $comment,
- $row,
- RevisionSlots $slots,
- $wikiId = false
- ) {
- $rec = new RevisionArchiveRecord( $title, $user, $comment, $row, $slots, $wikiId );
-
- $this->assertSame( $title, $rec->getPageAsLinkTarget(), 'getPageAsLinkTarget' );
- $this->assertSame( $user, $rec->getUser( RevisionRecord::RAW ), 'getUser' );
- $this->assertSame( $comment, $rec->getComment(), 'getComment' );
-
- $this->assertSame( $slots->getSlotRoles(), $rec->getSlotRoles(), 'getSlotRoles' );
- $this->assertSame( $wikiId, $rec->getWikiId(), 'getWikiId' );
-
- $this->assertSame( (int)$row->ar_id, $rec->getArchiveId(), 'getArchiveId' );
- $this->assertSame( (int)$row->ar_rev_id, $rec->getId(), 'getId' );
- $this->assertSame( (int)$row->ar_page_id, $rec->getPageId(), 'getId' );
- $this->assertSame( $row->ar_timestamp, $rec->getTimestamp(), 'getTimestamp' );
- $this->assertSame( (int)$row->ar_deleted, $rec->getVisibility(), 'getVisibility' );
- $this->assertSame( (bool)$row->ar_minor_edit, $rec->isMinor(), 'getIsMinor' );
-
- if ( isset( $row->ar_parent_id ) ) {
- $this->assertSame( (int)$row->ar_parent_id, $rec->getParentId(), 'getParentId' );
- } else {
- $this->assertSame( 0, $rec->getParentId(), 'getParentId' );
- }
-
- if ( isset( $row->ar_len ) ) {
- $this->assertSame( (int)$row->ar_len, $rec->getSize(), 'getSize' );
- } else {
- $this->assertSame( $slots->computeSize(), $rec->getSize(), 'getSize' );
- }
-
- if ( !empty( $row->ar_sha1 ) ) {
- $this->assertSame( $row->ar_sha1, $rec->getSha1(), 'getSha1' );
- } else {
- $this->assertSame( $slots->computeSha1(), $rec->getSha1(), 'getSha1' );
- }
- }
-
- public function provideConstructorFailure() {
- $title = Title::newFromText( 'Dummy' );
- $title->resetArticleID( 17 );
-
- $user = new UserIdentityValue( 11, 'Tester', 0 );
-
- $comment = CommentStoreComment::newUnsavedComment( 'Hello World' );
-
- $main = SlotRecord::newUnsaved( SlotRecord::MAIN, new TextContent( 'Lorem Ipsum' ) );
- $aux = SlotRecord::newUnsaved( 'aux', new TextContent( 'Frumious Bandersnatch' ) );
- $slots = new RevisionSlots( [ $main, $aux ] );
-
- $protoRow = [
- 'ar_id' => '5',
- 'ar_rev_id' => '7',
- 'ar_page_id' => strval( $title->getArticleID() ),
- 'ar_timestamp' => '20200101000000',
- 'ar_deleted' => 0,
- 'ar_minor_edit' => 0,
- 'ar_parent_id' => '5',
- 'ar_len' => $slots->computeSize(),
- 'ar_sha1' => $slots->computeSha1(),
- ];
-
- yield 'not a row' => [
- $title,
- $user,
- $comment,
- 'not a row',
- $slots,
- 'acmewiki'
- ];
-
- $row = $protoRow;
- $row['ar_timestamp'] = 'kittens';
-
- yield 'bad timestamp' => [
- $title,
- $user,
- $comment,
- (object)$row,
- $slots
- ];
-
- $row = $protoRow;
-
- yield 'bad wiki' => [
- $title,
- $user,
- $comment,
- (object)$row,
- $slots,
- 12345
- ];
-
- // NOTE: $title->getArticleID does *not* have to match ar_page_id in all cases!
- }
-
- /**
- * @dataProvider provideConstructorFailure
- *
- * @param Title $title
- * @param UserIdentity $user
- * @param CommentStoreComment $comment
- * @param object $row
- * @param RevisionSlots $slots
- * @param bool $wikiId
- */
- public function testConstructorFailure(
- Title $title,
- UserIdentity $user,
- CommentStoreComment $comment,
- $row,
- RevisionSlots $slots,
- $wikiId = false
- ) {
- $this->setExpectedException( InvalidArgumentException::class );
- new RevisionArchiveRecord( $title, $user, $comment, $row, $slots, $wikiId );
- }
-
-}
+++ /dev/null
-<?php
-namespace MediaWiki\Tests\Storage;
-
-use MediaWiki\MediaWikiServices;
-use MediaWiki\Storage\SlotRecord;
-use MediaWikiTestCase;
-use Revision;
-
-/**
- * Tests RevisionStore against the post-migration MCR DB schema.
- *
- * @group RevisionStore
- * @group Storage
- * @group Database
- */
-class RevisionQueryInfoTest extends MediaWikiTestCase {
-
- protected function getRevisionQueryFields( $returnTextIdField = true ) {
- $fields = [
- 'rev_id',
- 'rev_page',
- 'rev_timestamp',
- 'rev_minor_edit',
- 'rev_deleted',
- 'rev_len',
- 'rev_parent_id',
- 'rev_sha1',
- ];
- if ( $returnTextIdField ) {
- $fields[] = 'rev_text_id';
- }
- return $fields;
- }
-
- protected function getArchiveQueryFields( $returnTextFields = true ) {
- $fields = [
- 'ar_id',
- 'ar_page_id',
- 'ar_namespace',
- 'ar_title',
- 'ar_rev_id',
- 'ar_timestamp',
- 'ar_minor_edit',
- 'ar_deleted',
- 'ar_len',
- 'ar_parent_id',
- 'ar_sha1',
- ];
- if ( $returnTextFields ) {
- $fields[] = 'ar_text_id';
- }
- return $fields;
- }
-
- protected function getOldCommentQueryFields( $prefix ) {
- return [
- "{$prefix}_comment_text" => "{$prefix}_comment",
- "{$prefix}_comment_data" => 'NULL',
- "{$prefix}_comment_cid" => 'NULL',
- ];
- }
-
- protected function getCompatCommentQueryFields( $prefix ) {
- return [
- "{$prefix}_comment_text"
- => "COALESCE( comment_{$prefix}_comment.comment_text, {$prefix}_comment )",
- "{$prefix}_comment_data" => "comment_{$prefix}_comment.comment_data",
- "{$prefix}_comment_cid" => "comment_{$prefix}_comment.comment_id",
- ];
- }
-
- protected function getNewCommentQueryFields( $prefix ) {
- return [
- "{$prefix}_comment_text" => "comment_{$prefix}_comment.comment_text",
- "{$prefix}_comment_data" => "comment_{$prefix}_comment.comment_data",
- "{$prefix}_comment_cid" => "comment_{$prefix}_comment.comment_id",
- ];
- }
-
- protected function getOldActorQueryFields( $prefix ) {
- return [
- "{$prefix}_user" => "{$prefix}_user",
- "{$prefix}_user_text" => "{$prefix}_user_text",
- "{$prefix}_actor" => 'NULL',
- ];
- }
-
- protected function getNewActorQueryFields( $prefix, $tmp = false ) {
- return [
- "{$prefix}_user" => "actor_{$prefix}_user.actor_user",
- "{$prefix}_user_text" => "actor_{$prefix}_user.actor_name",
- "{$prefix}_actor" => $tmp ?: "{$prefix}_actor",
- ];
- }
-
- protected function getCompatActorQueryFields( $prefix, $tmp = false ) {
- return [
- "{$prefix}_user" => "COALESCE( actor_{$prefix}_user.actor_user, {$prefix}_user )",
- "{$prefix}_user_text" => "COALESCE( actor_{$prefix}_user.actor_name, {$prefix}_user_text )",
- "{$prefix}_actor" => $tmp ?: "{$prefix}_actor",
- ];
- }
-
- protected function getCompatActorJoins( $prefix ) {
- return [
- "temp_{$prefix}_user" => [
- "LEFT JOIN",
- "temp_{$prefix}_user.revactor_{$prefix} = {$prefix}_id",
- ],
- "actor_{$prefix}_user" => [
- "LEFT JOIN",
- "actor_{$prefix}_user.actor_id = temp_{$prefix}_user.revactor_actor",
- ],
- ];
- }
-
- protected function getCompatCommentJoins( $prefix ) {
- return [
- "temp_{$prefix}_comment" => [
- "LEFT JOIN",
- "temp_{$prefix}_comment.revcomment_{$prefix} = {$prefix}_id",
- ],
- "comment_{$prefix}_comment" => [
- "LEFT JOIN",
- "comment_{$prefix}_comment.comment_id = temp_{$prefix}_comment.revcomment_comment_id",
- ],
- ];
- }
-
- protected function getTextQueryFields() {
- return [
- 'old_text',
- 'old_flags',
- ];
- }
-
- protected function getPageQueryFields() {
- return [
- 'page_namespace',
- 'page_title',
- 'page_id',
- 'page_latest',
- 'page_is_redirect',
- 'page_len',
- ];
- }
-
- protected function getUserQueryFields() {
- return [
- 'user_name',
- ];
- }
-
- protected function getContentHandlerQueryFields( $prefix ) {
- return [
- "{$prefix}_content_format",
- "{$prefix}_content_model",
- ];
- }
-
- public function provideArchiveQueryInfo() {
- yield 'MCR, comment, actor' => [
- [
- 'wgMultiContentRevisionSchemaMigrationStage' => SCHEMA_COMPAT_NEW,
- 'wgCommentTableSchemaMigrationStage' => MIGRATION_NEW,
- 'wgActorTableSchemaMigrationStage' => MIGRATION_NEW,
- ],
- [
- 'tables' => [
- 'archive',
- 'actor_ar_user' => 'actor',
- 'comment_ar_comment' => 'comment',
- ],
- 'fields' => array_merge(
- $this->getArchiveQueryFields( false ),
- $this->getNewActorQueryFields( 'ar' ),
- $this->getNewCommentQueryFields( 'ar' )
- ),
- 'joins' => [
- 'comment_ar_comment'
- => [ 'JOIN', 'comment_ar_comment.comment_id = ar_comment_id' ],
- 'actor_ar_user' => [ 'JOIN', 'actor_ar_user.actor_id = ar_actor' ],
- ],
- ]
- ];
- yield 'read-new MCR, comment, actor' => [
- [
- 'wgContentHandlerUseDB' => true,
- 'wgMultiContentRevisionSchemaMigrationStage'
- => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW,
- 'wgCommentTableSchemaMigrationStage' => MIGRATION_WRITE_NEW,
- 'wgActorTableSchemaMigrationStage' => MIGRATION_WRITE_NEW,
- ],
- [
- 'tables' => [
- 'archive',
- 'actor_ar_user' => 'actor',
- 'comment_ar_comment' => 'comment',
- ],
- 'fields' => array_merge(
- $this->getArchiveQueryFields( false ),
- $this->getCompatActorQueryFields( 'ar' ),
- $this->getCompatCommentQueryFields( 'ar' )
- ),
- 'joins' => [
- 'comment_ar_comment'
- => [ 'LEFT JOIN', 'comment_ar_comment.comment_id = ar_comment_id' ],
- 'actor_ar_user' => [ 'LEFT JOIN', 'actor_ar_user.actor_id = ar_actor' ],
- ],
- ]
- ];
- yield 'MCR write-both/read-old' => [
- [
- 'wgContentHandlerUseDB' => true,
- 'wgMultiContentRevisionSchemaMigrationStage'
- => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD,
- 'wgCommentTableSchemaMigrationStage' => MIGRATION_WRITE_BOTH,
- 'wgActorTableSchemaMigrationStage' => MIGRATION_WRITE_BOTH,
- ],
- [
- 'tables' => [
- 'archive',
- 'actor_ar_user' => 'actor',
- 'comment_ar_comment' => 'comment',
- ],
- 'fields' => array_merge(
- $this->getArchiveQueryFields( true ),
- $this->getContentHandlerQueryFields( 'ar' ),
- $this->getCompatActorQueryFields( 'ar' ),
- $this->getCompatCommentQueryFields( 'ar' )
- ),
- 'joins' => [
- 'comment_ar_comment'
- => [ 'LEFT JOIN', 'comment_ar_comment.comment_id = ar_comment_id' ],
- 'actor_ar_user' => [ 'LEFT JOIN', 'actor_ar_user.actor_id = ar_actor' ],
- ],
- ]
- ];
- yield 'pre-MCR, no model' => [
- [
- 'wgContentHandlerUseDB' => false,
- 'wgMultiContentRevisionSchemaMigrationStage' => SCHEMA_COMPAT_OLD,
- 'wgCommentTableSchemaMigrationStage' => MIGRATION_OLD,
- 'wgActorTableSchemaMigrationStage' => MIGRATION_OLD,
- ],
- [
- 'tables' => [
- 'archive',
- ],
- 'fields' => array_merge(
- $this->getArchiveQueryFields( true ),
- $this->getOldActorQueryFields( 'ar' ),
- $this->getOldCommentQueryFields( 'ar' )
- ),
- 'joins' => [],
- ]
- ];
- }
-
- public function provideQueryInfo() {
- // TODO: more option variations
- yield 'MCR, page, user, comment, actor' => [
- [
- 'wgContentHandlerUseDB' => true,
- 'wgMultiContentRevisionSchemaMigrationStage' => SCHEMA_COMPAT_NEW,
- 'wgCommentTableSchemaMigrationStage' => MIGRATION_NEW,
- 'wgActorTableSchemaMigrationStage' => MIGRATION_NEW,
- ],
- [ 'page', 'user' ],
- [
- 'tables' => [
- 'revision',
- 'page',
- 'user',
- 'temp_rev_user' => 'revision_actor_temp',
- 'temp_rev_comment' => 'revision_comment_temp',
- 'actor_rev_user' => 'actor',
- 'comment_rev_comment' => 'comment',
- ],
- 'fields' => array_merge(
- $this->getRevisionQueryFields( false ),
- $this->getPageQueryFields(),
- $this->getUserQueryFields(),
- $this->getNewActorQueryFields( 'rev', 'temp_rev_user.revactor_actor' ),
- $this->getNewCommentQueryFields( 'rev' )
- ),
- 'joins' => [
- 'page' => [ 'INNER JOIN', [ 'page_id = rev_page' ] ],
- 'user' => [
- 'LEFT JOIN',
- [ 'actor_rev_user.actor_user != 0', 'user_id = actor_rev_user.actor_user' ],
- ],
- 'comment_rev_comment' => [
- 'JOIN',
- 'comment_rev_comment.comment_id = temp_rev_comment.revcomment_comment_id',
- ],
- 'actor_rev_user' => [
- 'JOIN',
- 'actor_rev_user.actor_id = temp_rev_user.revactor_actor',
- ],
- 'temp_rev_user' => [ 'JOIN', 'temp_rev_user.revactor_rev = rev_id' ],
- 'temp_rev_comment' => [ 'JOIN', 'temp_rev_comment.revcomment_rev = rev_id' ],
- ],
- ]
- ];
- yield 'MCR read-new, page, user, comment, actor' => [
- [
- 'wgContentHandlerUseDB' => true,
- 'wgMultiContentRevisionSchemaMigrationStage'
- => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW,
- 'wgCommentTableSchemaMigrationStage' => MIGRATION_WRITE_NEW,
- 'wgActorTableSchemaMigrationStage' => MIGRATION_WRITE_NEW,
- ],
- [ 'page', 'user' ],
- [
- 'tables' => [
- 'revision',
- 'page',
- 'user',
- 'temp_rev_user' => 'revision_actor_temp',
- 'temp_rev_comment' => 'revision_comment_temp',
- 'actor_rev_user' => 'actor',
- 'comment_rev_comment' => 'comment',
- ],
- 'fields' => array_merge(
- $this->getRevisionQueryFields( false ),
- $this->getPageQueryFields(),
- $this->getUserQueryFields(),
- $this->getCompatActorQueryFields( 'rev', 'temp_rev_user.revactor_actor' ),
- $this->getCompatCommentQueryFields( 'rev' )
- ),
- 'joins' => array_merge(
- [
- 'page' => [ 'INNER JOIN', [ 'page_id = rev_page' ] ],
- 'user' => [
- 'LEFT JOIN',
- [
- 'COALESCE( actor_rev_user.actor_user, rev_user ) != 0',
- 'user_id = COALESCE( actor_rev_user.actor_user, rev_user )'
- ]
- ],
- ],
- $this->getCompatActorJoins( 'rev' ),
- $this->getCompatCommentJoins( 'rev' )
- ),
- ]
- ];
- yield 'MCR read-new' => [
- [
- 'wgContentHandlerUseDB' => true,
- 'wgMultiContentRevisionSchemaMigrationStage'
- => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW,
- 'wgCommentTableSchemaMigrationStage' => MIGRATION_WRITE_NEW,
- 'wgActorTableSchemaMigrationStage' => MIGRATION_WRITE_NEW,
- ],
- [ 'page', 'user' ],
- [
- 'tables' => [
- 'revision',
- 'page',
- 'user',
- 'temp_rev_user' => 'revision_actor_temp',
- 'temp_rev_comment' => 'revision_comment_temp',
- 'actor_rev_user' => 'actor',
- 'comment_rev_comment' => 'comment',
- ],
- 'fields' => array_merge(
- $this->getRevisionQueryFields( false ),
- $this->getPageQueryFields(),
- $this->getUserQueryFields(),
- $this->getCompatActorQueryFields( 'rev', 'temp_rev_user.revactor_actor' ),
- $this->getCompatCommentQueryFields( 'rev' )
- ),
- 'joins' => array_merge(
- [
- 'page' => [ 'INNER JOIN', [ 'page_id = rev_page' ] ],
- 'user' => [
- 'LEFT JOIN',
- [
- 'COALESCE( actor_rev_user.actor_user, rev_user ) != 0',
- 'user_id = COALESCE( actor_rev_user.actor_user, rev_user )'
- ]
- ],
- ],
- $this->getCompatActorJoins( 'rev' ),
- $this->getCompatCommentJoins( 'rev' )
- ),
- ]
- ];
- yield 'MCR write-both/read-old' => [
- [
- 'wgContentHandlerUseDB' => true,
- 'wgMultiContentRevisionSchemaMigrationStage'
- => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD,
- 'wgCommentTableSchemaMigrationStage' => MIGRATION_WRITE_BOTH,
- 'wgActorTableSchemaMigrationStage' => MIGRATION_WRITE_BOTH,
- ],
- [],
- [
- 'tables' => [
- 'revision',
- 'temp_rev_user' => 'revision_actor_temp',
- 'temp_rev_comment' => 'revision_comment_temp',
- 'actor_rev_user' => 'actor',
- 'comment_rev_comment' => 'comment',
- ],
- 'fields' => array_merge(
- $this->getRevisionQueryFields( true ),
- $this->getContentHandlerQueryFields( 'rev' ),
- $this->getCompatActorQueryFields( 'rev', 'temp_rev_user.revactor_actor' ),
- $this->getCompatCommentQueryFields( 'rev' )
- ),
- 'joins' => array_merge(
- $this->getCompatActorJoins( 'rev' ),
- $this->getCompatCommentJoins( 'rev' )
- ),
- ]
- ];
- yield 'MCR write-both/read-old, page, user' => [
- [
- 'wgContentHandlerUseDB' => true,
- 'wgMultiContentRevisionSchemaMigrationStage'
- => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD,
- 'wgCommentTableSchemaMigrationStage' => MIGRATION_WRITE_BOTH,
- 'wgActorTableSchemaMigrationStage' => MIGRATION_WRITE_BOTH,
- ],
- [ 'page', 'user' ],
- [
- 'tables' => [
- 'revision',
- 'page',
- 'user',
- 'temp_rev_user' => 'revision_actor_temp',
- 'temp_rev_comment' => 'revision_comment_temp',
- 'actor_rev_user' => 'actor',
- 'comment_rev_comment' => 'comment',
- ],
- 'fields' => array_merge(
- $this->getRevisionQueryFields( true ),
- $this->getContentHandlerQueryFields( 'rev' ),
- $this->getUserQueryFields(),
- $this->getPageQueryFields(),
- $this->getCompatActorQueryFields( 'rev', 'temp_rev_user.revactor_actor' ),
- $this->getCompatCommentQueryFields( 'rev' )
- ),
- 'joins' => array_merge(
- [
- 'page' => [ 'INNER JOIN', [ 'page_id = rev_page' ] ],
- 'user' => [
- 'LEFT JOIN',
- [
- 'COALESCE( actor_rev_user.actor_user, rev_user ) != 0',
- 'user_id = COALESCE( actor_rev_user.actor_user, rev_user )'
- ]
- ],
- ],
- $this->getCompatActorJoins( 'rev' ),
- $this->getCompatCommentJoins( 'rev' )
- ),
- ]
- ];
- yield 'pre-MCR' => [
- [
- 'wgContentHandlerUseDB' => true,
- 'wgMultiContentRevisionSchemaMigrationStage' => SCHEMA_COMPAT_OLD,
- 'wgCommentTableSchemaMigrationStage' => MIGRATION_OLD,
- 'wgActorTableSchemaMigrationStage' => MIGRATION_OLD,
- ],
- [],
- [
- 'tables' => [ 'revision' ],
- 'fields' => array_merge(
- $this->getRevisionQueryFields( true ),
- $this->getContentHandlerQueryFields( 'rev' ),
- $this->getOldActorQueryFields( 'rev' ),
- $this->getOldCommentQueryFields( 'rev' )
- ),
- 'joins' => [],
- ]
- ];
- yield 'pre-MCR, page, user' => [
- [
- 'wgContentHandlerUseDB' => true,
- 'wgMultiContentRevisionSchemaMigrationStage' => SCHEMA_COMPAT_OLD,
- 'wgCommentTableSchemaMigrationStage' => MIGRATION_OLD,
- 'wgActorTableSchemaMigrationStage' => MIGRATION_OLD,
- ],
- [ 'page', 'user' ],
- [
- 'tables' => [ 'revision', 'page', 'user' ],
- 'fields' => array_merge(
- $this->getRevisionQueryFields( true ),
- $this->getContentHandlerQueryFields( 'rev' ),
- $this->getPageQueryFields(),
- $this->getUserQueryFields(),
- $this->getOldActorQueryFields( 'rev' ),
- $this->getOldCommentQueryFields( 'rev' )
- ),
- 'joins' => [
- 'page' => [ 'INNER JOIN', [ 'page_id = rev_page' ] ],
- 'user' => [ 'LEFT JOIN', [ 'rev_user != 0', 'user_id = rev_user' ] ],
- ],
- ]
- ];
- yield 'pre-MCR, no model' => [
- [
- 'wgContentHandlerUseDB' => false,
- 'wgMultiContentRevisionSchemaMigrationStage' => SCHEMA_COMPAT_OLD,
- 'wgCommentTableSchemaMigrationStage' => MIGRATION_OLD,
- 'wgActorTableSchemaMigrationStage' => MIGRATION_OLD,
- ],
- [],
- [
- 'tables' => [ 'revision' ],
- 'fields' => array_merge(
- $this->getRevisionQueryFields( true ),
- $this->getOldActorQueryFields( 'rev' ),
- $this->getOldCommentQueryFields( 'rev' )
- ),
- 'joins' => [],
- ],
- ];
- yield 'pre-MCR, no model, page' => [
- [
- 'wgContentHandlerUseDB' => false,
- 'wgMultiContentRevisionSchemaMigrationStage' => SCHEMA_COMPAT_OLD,
- 'wgCommentTableSchemaMigrationStage' => MIGRATION_OLD,
- 'wgActorTableSchemaMigrationStage' => MIGRATION_OLD,
- ],
- [ 'page' ],
- [
- 'tables' => [ 'revision', 'page' ],
- 'fields' => array_merge(
- $this->getRevisionQueryFields( true ),
- $this->getPageQueryFields(),
- $this->getOldActorQueryFields( 'rev' ),
- $this->getOldCommentQueryFields( 'rev' )
- ),
- 'joins' => [
- 'page' => [ 'INNER JOIN', [ 'page_id = rev_page' ], ],
- ],
- ],
- ];
- yield 'pre-MCR, no model, user' => [
- [
- 'wgContentHandlerUseDB' => false,
- 'wgMultiContentRevisionSchemaMigrationStage' => SCHEMA_COMPAT_OLD,
- 'wgCommentTableSchemaMigrationStage' => MIGRATION_OLD,
- 'wgActorTableSchemaMigrationStage' => MIGRATION_OLD,
- ],
- [ 'user' ],
- [
- 'tables' => [ 'revision', 'user' ],
- 'fields' => array_merge(
- $this->getRevisionQueryFields( true ),
- $this->getUserQueryFields(),
- $this->getOldActorQueryFields( 'rev' ),
- $this->getOldCommentQueryFields( 'rev' )
- ),
- 'joins' => [
- 'user' => [ 'LEFT JOIN', [ 'rev_user != 0', 'user_id = rev_user' ] ],
- ],
- ],
- ];
- yield 'pre-MCR, no model, text' => [
- [
- 'wgContentHandlerUseDB' => false,
- 'wgMultiContentRevisionSchemaMigrationStage' => SCHEMA_COMPAT_OLD,
- 'wgCommentTableSchemaMigrationStage' => MIGRATION_OLD,
- 'wgActorTableSchemaMigrationStage' => MIGRATION_OLD,
- ],
- [ 'text' ],
- [
- 'tables' => [ 'revision', 'text' ],
- 'fields' => array_merge(
- $this->getRevisionQueryFields( true ),
- $this->getTextQueryFields(),
- $this->getOldActorQueryFields( 'rev' ),
- $this->getOldCommentQueryFields( 'rev' )
- ),
- 'joins' => [
- 'text' => [ 'INNER JOIN', [ 'rev_text_id=old_id' ] ],
- ],
- ],
- ];
- yield 'pre-MCR, no model, text, page, user' => [
- [
- 'wgContentHandlerUseDB' => false,
- 'wgMultiContentRevisionSchemaMigrationStage' => SCHEMA_COMPAT_OLD,
- 'wgCommentTableSchemaMigrationStage' => MIGRATION_OLD,
- 'wgActorTableSchemaMigrationStage' => MIGRATION_OLD,
- ],
- [ 'text', 'page', 'user' ],
- [
- 'tables' => [
- 'revision', 'page', 'user', 'text'
- ],
- 'fields' => array_merge(
- $this->getRevisionQueryFields( true ),
- $this->getPageQueryFields(),
- $this->getUserQueryFields(),
- $this->getTextQueryFields(),
- $this->getOldActorQueryFields( 'rev' ),
- $this->getOldCommentQueryFields( 'rev' )
- ),
- 'joins' => [
- 'page' => [
- 'INNER JOIN',
- [ 'page_id = rev_page' ],
- ],
- 'user' => [
- 'LEFT JOIN',
- [
- 'rev_user != 0',
- 'user_id = rev_user',
- ],
- ],
- 'text' => [
- 'INNER JOIN',
- [ 'rev_text_id=old_id' ],
- ],
- ],
- ],
- ];
- }
-
- public function provideSlotsQueryInfo() {
- yield 'MCR, no options' => [
- [
- 'wgMultiContentRevisionSchemaMigrationStage' => SCHEMA_COMPAT_NEW,
- ],
- [],
- [
- 'tables' => [
- 'slots'
- ],
- 'fields' => [
- 'slot_revision_id',
- 'slot_content_id',
- 'slot_origin',
- 'slot_role_id',
- ],
- 'joins' => [],
- ]
- ];
- yield 'MCR, role option' => [
- [
- 'wgMultiContentRevisionSchemaMigrationStage' => SCHEMA_COMPAT_NEW,
- ],
- [ 'role' ],
- [
- 'tables' => [
- 'slots',
- 'slot_roles',
- ],
- 'fields' => [
- 'slot_revision_id',
- 'slot_content_id',
- 'slot_origin',
- 'slot_role_id',
- 'role_name',
- ],
- 'joins' => [
- 'slot_roles' => [ 'LEFT JOIN', [ 'slot_role_id = role_id' ] ],
- ],
- ]
- ];
- yield 'MCR read-new, content option' => [
- [
- 'wgMultiContentRevisionSchemaMigrationStage'
- => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW,
- ],
- [ 'content' ],
- [
- 'tables' => [
- 'slots',
- 'content',
- ],
- 'fields' => [
- 'slot_revision_id',
- 'slot_content_id',
- 'slot_origin',
- 'slot_role_id',
- 'content_size',
- 'content_sha1',
- 'content_address',
- 'content_model',
- ],
- 'joins' => [
- 'content' => [ 'INNER JOIN', [ 'slot_content_id = content_id' ] ],
- ],
- ]
- ];
- yield 'MCR read-new, content and model options' => [
- [
- 'wgMultiContentRevisionSchemaMigrationStage'
- => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW,
- ],
- [ 'content', 'model' ],
- [
- 'tables' => [
- 'slots',
- 'content',
- 'content_models',
- ],
- 'fields' => [
- 'slot_revision_id',
- 'slot_content_id',
- 'slot_origin',
- 'slot_role_id',
- 'content_size',
- 'content_sha1',
- 'content_address',
- 'content_model',
- 'model_name',
- ],
- 'joins' => [
- 'content' => [ 'INNER JOIN', [ 'slot_content_id = content_id' ] ],
- 'content_models' => [ 'LEFT JOIN', [ 'content_model = model_id' ] ],
- ],
- ]
- ];
-
- $db = wfGetDB( DB_REPLICA );
-
- yield 'MCR write-both/read-old' => [
- [
- 'wgMultiContentRevisionSchemaMigrationStage'
- => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD,
- ],
- [],
- [
- 'tables' => [
- 'slots' => 'revision',
- ],
- 'fields' => array_merge(
- [
- 'slot_revision_id' => 'slots.rev_id',
- 'slot_content_id' => 'NULL',
- 'slot_origin' => 'slots.rev_id',
- 'role_name' => $db->addQuotes( SlotRecord::MAIN ),
- ]
- ),
- 'joins' => [],
- ]
- ];
- yield 'MCR write-both/read-old, content' => [
- [
- 'wgMultiContentRevisionSchemaMigrationStage'
- => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD,
- ],
- [ 'content' ],
- [
- 'tables' => [
- 'slots' => 'revision',
- ],
- 'fields' => array_merge(
- [
- 'slot_revision_id' => 'slots.rev_id',
- 'slot_content_id' => 'NULL',
- 'slot_origin' => 'slots.rev_id',
- 'role_name' => $db->addQuotes( SlotRecord::MAIN ),
- 'content_size' => 'slots.rev_len',
- 'content_sha1' => 'slots.rev_sha1',
- 'content_address' => $db->buildConcat( [
- $db->addQuotes( 'tt:' ), 'slots.rev_text_id' ] ),
- 'model_name' => 'slots.rev_content_model',
- ]
- ),
- 'joins' => [],
- ]
- ];
- yield 'MCR write-both/read-old, content, model, role' => [
- [
- 'wgMultiContentRevisionSchemaMigrationStage'
- => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD,
- ],
- [ 'content', 'model', 'role' ],
- [
- 'tables' => [
- 'slots' => 'revision',
- ],
- 'fields' => array_merge(
- [
- 'slot_revision_id' => 'slots.rev_id',
- 'slot_content_id' => 'NULL',
- 'slot_origin' => 'slots.rev_id',
- 'role_name' => $db->addQuotes( SlotRecord::MAIN ),
- 'content_size' => 'slots.rev_len',
- 'content_sha1' => 'slots.rev_sha1',
- 'content_address' => $db->buildConcat( [
- $db->addQuotes( 'tt:' ), 'slots.rev_text_id' ] ),
- 'model_name' => 'slots.rev_content_model',
- ]
- ),
- 'joins' => [],
- ]
- ];
- yield 'pre-MCR' => [
- [
- 'wgMultiContentRevisionSchemaMigrationStage'
- => SCHEMA_COMPAT_OLD,
- ],
- [],
- [
- 'tables' => [
- 'slots' => 'revision',
- ],
- 'fields' => array_merge(
- [
- 'slot_revision_id' => 'slots.rev_id',
- 'slot_content_id' => 'NULL',
- 'slot_origin' => 'slots.rev_id',
- 'role_name' => $db->addQuotes( SlotRecord::MAIN ),
- ]
- ),
- 'joins' => [],
- ]
- ];
- yield 'pre-MCR, content' => [
- [
- 'wgMultiContentRevisionSchemaMigrationStage'
- => SCHEMA_COMPAT_OLD,
- ],
- [ 'content' ],
- [
- 'tables' => [
- 'slots' => 'revision',
- ],
- 'fields' => array_merge(
- [
- 'slot_revision_id' => 'slots.rev_id',
- 'slot_content_id' => 'NULL',
- 'slot_origin' => 'slots.rev_id',
- 'role_name' => $db->addQuotes( SlotRecord::MAIN ),
- 'content_size' => 'slots.rev_len',
- 'content_sha1' => 'slots.rev_sha1',
- 'content_address' =>
- $db->buildConcat( [ $db->addQuotes( 'tt:' ), 'slots.rev_text_id' ] ),
- 'model_name' => 'slots.rev_content_model',
- ]
- ),
- 'joins' => [],
- ]
- ];
- }
-
- public function provideSelectFields() {
- yield 'with model, comment, and actor' => [
- [
- 'wgContentHandlerUseDB' => true,
- 'wgCommentTableSchemaMigrationStage' => MIGRATION_WRITE_BOTH,
- 'wgActorTableSchemaMigrationStage' => MIGRATION_WRITE_BOTH,
- ],
- 'fields' => array_merge(
- [
- 'rev_id',
- 'rev_page',
- 'rev_text_id',
- 'rev_timestamp',
- 'rev_user_text',
- 'rev_user',
- 'rev_actor' => 'NULL',
- 'rev_minor_edit',
- 'rev_deleted',
- 'rev_len',
- 'rev_parent_id',
- 'rev_sha1',
- ],
- $this->getContentHandlerQueryFields( 'rev' ),
- [
- 'rev_comment_old' => 'rev_comment',
- 'rev_comment_pk' => 'rev_id',
- ]
- ),
- ];
- yield 'no mode, no comment, no actor' => [
- [
- 'wgContentHandlerUseDB' => false,
- 'wgCommentTableSchemaMigrationStage' => MIGRATION_OLD,
- 'wgActorTableSchemaMigrationStage' => MIGRATION_OLD,
- ],
- 'fields' => array_merge(
- [
- 'rev_id',
- 'rev_page',
- 'rev_text_id',
- 'rev_timestamp',
- 'rev_user_text',
- 'rev_user',
- 'rev_actor' => 'NULL',
- 'rev_minor_edit',
- 'rev_deleted',
- 'rev_len',
- 'rev_parent_id',
- 'rev_sha1',
- ],
- $this->getOldCommentQueryFields( 'rev' )
- ),
- ];
- }
-
- public function provideSelectArchiveFields() {
- yield 'with model, comment, and actor' => [
- [
- 'wgContentHandlerUseDB' => true,
- 'wgCommentTableSchemaMigrationStage' => MIGRATION_WRITE_BOTH,
- 'wgActorTableSchemaMigrationStage' => MIGRATION_WRITE_BOTH,
- ],
- 'fields' => array_merge(
- [
- 'ar_id',
- 'ar_page_id',
- 'ar_rev_id',
- 'ar_text_id',
- 'ar_timestamp',
- 'ar_user_text',
- 'ar_user',
- 'ar_actor' => 'NULL',
- 'ar_minor_edit',
- 'ar_deleted',
- 'ar_len',
- 'ar_parent_id',
- 'ar_sha1',
- ],
- $this->getContentHandlerQueryFields( 'ar' ),
- [
- 'ar_comment_old' => 'ar_comment',
- 'ar_comment_id' => 'ar_comment_id',
- ]
- ),
- ];
- yield 'no mode, no comment, no actor' => [
- [
- 'wgContentHandlerUseDB' => false,
- 'wgCommentTableSchemaMigrationStage' => MIGRATION_OLD,
- 'wgActorTableSchemaMigrationStage' => MIGRATION_OLD,
- ],
- 'fields' => array_merge(
- [
- 'ar_id',
- 'ar_page_id',
- 'ar_rev_id',
- 'ar_text_id',
- 'ar_timestamp',
- 'ar_user_text',
- 'ar_user',
- 'ar_actor' => 'NULL',
- 'ar_minor_edit',
- 'ar_deleted',
- 'ar_len',
- 'ar_parent_id',
- 'ar_sha1',
- ],
- $this->getOldCommentQueryFields( 'ar' )
- ),
- ];
- }
-
- /**
- * @dataProvider provideSelectFields
- * @covers Revision::selectFields
- */
- public function testRevisionSelectFields( $migrationStageSettings, $expected ) {
- $this->setMwGlobals( $migrationStageSettings );
- $this->overrideMwServices();
-
- $this->hideDeprecated( 'Revision::selectFields' );
- $this->assertArrayEqualsIgnoringIntKeyOrder( $expected, Revision::selectFields() );
- }
-
- /**
- * @dataProvider provideSelectArchiveFields
- * @covers Revision::selectArchiveFields
- */
- public function testRevisionSelectArchiveFields( $migrationStageSettings, $expected ) {
- $this->setMwGlobals( $migrationStageSettings );
- $this->overrideMwServices();
-
- $this->hideDeprecated( 'Revision::selectArchiveFields' );
- $this->assertArrayEqualsIgnoringIntKeyOrder( $expected, Revision::selectArchiveFields() );
- }
-
- /**
- * @covers Revision::userJoinCond
- */
- public function testRevisionUserJoinCond() {
- $this->hideDeprecated( 'Revision::userJoinCond' );
- $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', MIGRATION_OLD );
- $this->overrideMwServices();
- $this->assertEquals(
- [ 'LEFT JOIN', [ 'rev_user != 0', 'user_id = rev_user' ] ],
- Revision::userJoinCond()
- );
- }
-
- /**
- * @covers Revision::pageJoinCond
- */
- public function testRevisionPageJoinCond() {
- $this->hideDeprecated( 'Revision::pageJoinCond' );
- $this->assertEquals(
- [ 'INNER JOIN', [ 'page_id = rev_page' ] ],
- Revision::pageJoinCond()
- );
- }
-
- /**
- * @covers Revision::selectTextFields
- */
- public function testRevisionSelectTextFields() {
- $this->hideDeprecated( 'Revision::selectTextFields' );
- $this->assertEquals(
- $this->getTextQueryFields(),
- Revision::selectTextFields()
- );
- }
-
- /**
- * @covers Revision::selectPageFields
- */
- public function testRevisionSelectPageFields() {
- $this->hideDeprecated( 'Revision::selectPageFields' );
- $this->assertEquals(
- $this->getPageQueryFields(),
- Revision::selectPageFields()
- );
- }
-
- /**
- * @covers Revision::selectUserFields
- */
- public function testRevisionSelectUserFields() {
- $this->hideDeprecated( 'Revision::selectUserFields' );
- $this->assertEquals(
- $this->getUserQueryFields(),
- Revision::selectUserFields()
- );
- }
-
- /**
- * @covers Revision::getArchiveQueryInfo
- * @dataProvider provideArchiveQueryInfo
- */
- public function testRevisionGetArchiveQueryInfo( $migrationStageSettings, $expected ) {
- $this->setMwGlobals( $migrationStageSettings );
- $this->overrideMwServices();
-
- $queryInfo = Revision::getArchiveQueryInfo();
- $this->assertQueryInfoEquals( $expected, $queryInfo );
- }
-
- /**
- * @covers Revision::getQueryInfo
- * @dataProvider provideQueryInfo
- */
- public function testRevisionGetQueryInfo( $migrationStageSettings, $options, $expected ) {
- $this->setMwGlobals( $migrationStageSettings );
- $this->overrideMwServices();
-
- $queryInfo = Revision::getQueryInfo( $options );
- $this->assertQueryInfoEquals( $expected, $queryInfo );
- }
-
- /**
- * @dataProvider provideQueryInfo
- * @covers \MediaWiki\Storage\RevisionStore::getQueryInfo
- */
- public function testRevisionStoreGetQueryInfo( $migrationStageSettings, $options, $expected ) {
- $this->setMwGlobals( $migrationStageSettings );
- $this->overrideMwServices();
-
- $store = MediaWikiServices::getInstance()->getRevisionStore();
-
- $queryInfo = $store->getQueryInfo( $options );
- $this->assertQueryInfoEquals( $expected, $queryInfo );
- }
-
- /**
- * @dataProvider provideSlotsQueryInfo
- * @covers \MediaWiki\Storage\RevisionStore::getSlotsQueryInfo
- */
- public function testRevisionStoreGetSlotsQueryInfo(
- $migrationStageSettings,
- $options,
- $expected
- ) {
- $this->setMwGlobals( $migrationStageSettings );
- $this->overrideMwServices();
-
- $store = MediaWikiServices::getInstance()->getRevisionStore();
-
- $queryInfo = $store->getSlotsQueryInfo( $options );
- $this->assertQueryInfoEquals( $expected, $queryInfo );
- }
-
- /**
- * @dataProvider provideArchiveQueryInfo
- * @covers \MediaWiki\Storage\RevisionStore::getArchiveQueryInfo
- */
- public function testRevisionStoreGetArchiveQueryInfo( $migrationStageSettings, $expected ) {
- $this->setMwGlobals( $migrationStageSettings );
- $this->overrideMwServices();
-
- $store = MediaWikiServices::getInstance()->getRevisionStore();
-
- $queryInfo = $store->getArchiveQueryInfo();
- $this->assertQueryInfoEquals( $expected, $queryInfo );
- }
-
- private function assertQueryInfoEquals( $expected, $queryInfo ) {
- $this->assertArrayEqualsIgnoringIntKeyOrder(
- $expected['tables'],
- $queryInfo['tables'],
- 'tables'
- );
- $this->assertArrayEqualsIgnoringIntKeyOrder(
- $expected['fields'],
- $queryInfo['fields'],
- 'fields'
- );
- $this->assertArrayEqualsIgnoringIntKeyOrder(
- $expected['joins'],
- $queryInfo['joins'],
- 'joins'
- );
- }
-
- /**
- * Assert that the two arrays passed are equal, ignoring the order of the values that integer
- * keys.
- *
- * Note: Failures of this assertion can be slightly confusing as the arrays are actually
- * split into a string key array and an int key array before assertions occur.
- *
- * @param array $expected
- * @param array $actual
- */
- private function assertArrayEqualsIgnoringIntKeyOrder(
- array $expected,
- array $actual,
- $message = null
- ) {
- $this->objectAssociativeSort( $expected );
- $this->objectAssociativeSort( $actual );
-
- // Separate the int key values from the string key values so that assertion failures are
- // easier to understand.
- $expectedIntKeyValues = [];
- $actualIntKeyValues = [];
-
- // Remove all int keys and re add them at the end after sorting by value
- // This will result in all int keys being in the same order with same ints at the end of
- // the array
- foreach ( $expected as $key => $value ) {
- if ( is_int( $key ) ) {
- unset( $expected[$key] );
- $expectedIntKeyValues[] = $value;
- }
- }
- foreach ( $actual as $key => $value ) {
- if ( is_int( $key ) ) {
- unset( $actual[$key] );
- $actualIntKeyValues[] = $value;
- }
- }
-
- $this->objectAssociativeSort( $expected );
- $this->objectAssociativeSort( $actual );
-
- $this->objectAssociativeSort( $expectedIntKeyValues );
- $this->objectAssociativeSort( $actualIntKeyValues );
-
- $this->assertEquals( $expected, $actual, $message );
- $this->assertEquals( $expectedIntKeyValues, $actualIntKeyValues, $message );
- }
-
-}
+++ /dev/null
-<?php
-
-// phpcs:disable MediaWiki.Commenting.PhpunitAnnotations.NotClassTrait
-
-namespace MediaWiki\Tests\Storage;
-
-use CommentStoreComment;
-use LogicException;
-use MediaWiki\Storage\RevisionRecord;
-use MediaWiki\Storage\RevisionSlots;
-use MediaWiki\Storage\RevisionStoreRecord;
-use MediaWiki\Storage\SlotRecord;
-use MediaWiki\Storage\SuppressedDataException;
-use MediaWiki\User\UserIdentityValue;
-use TextContent;
-use Title;
-
-/**
- * @covers \MediaWiki\Storage\RevisionRecord
- *
- * @note Expects to be used in classes that extend MediaWikiTestCase.
- */
-trait RevisionRecordTests {
-
- /**
- * @param array $rowOverrides
- *
- * @return RevisionRecord
- */
- protected abstract function newRevision( array $rowOverrides = [] );
-
- private function provideAudienceCheckData( $field ) {
- yield 'field accessible for oversighter (ALL)' => [
- RevisionRecord::SUPPRESSED_ALL,
- [ 'oversight' ],
- true,
- false
- ];
-
- yield 'field accessible for oversighter' => [
- RevisionRecord::DELETED_RESTRICTED | $field,
- [ 'oversight' ],
- true,
- false
- ];
-
- yield 'field not accessible for sysops (ALL)' => [
- RevisionRecord::SUPPRESSED_ALL,
- [ 'sysop' ],
- false,
- false
- ];
-
- yield 'field not accessible for sysops' => [
- RevisionRecord::DELETED_RESTRICTED | $field,
- [ 'sysop' ],
- false,
- false
- ];
-
- yield 'field accessible for sysops' => [
- $field,
- [ 'sysop' ],
- true,
- false
- ];
-
- yield 'field suppressed for logged in users' => [
- $field,
- [ 'user' ],
- false,
- false
- ];
-
- yield 'unrelated field suppressed' => [
- $field === RevisionRecord::DELETED_COMMENT
- ? RevisionRecord::DELETED_USER
- : RevisionRecord::DELETED_COMMENT,
- [ 'user' ],
- true,
- true
- ];
-
- yield 'nothing suppressed' => [
- 0,
- [ 'user' ],
- true,
- true
- ];
- }
-
- public function testSerialization_fails() {
- $this->setExpectedException( LogicException::class );
- $rev = $this->newRevision();
- serialize( $rev );
- }
-
- public function provideGetComment_audience() {
- return $this->provideAudienceCheckData( RevisionRecord::DELETED_COMMENT );
- }
-
- private function forceStandardPermissions() {
- $this->setMwGlobals(
- 'wgGroupPermissions',
- [
- 'user' => [
- 'viewsuppressed' => false,
- 'suppressrevision' => false,
- 'deletedtext' => false,
- 'deletedhistory' => false,
- ],
- 'sysop' => [
- 'viewsuppressed' => false,
- 'suppressrevision' => false,
- 'deletedtext' => true,
- 'deletedhistory' => true,
- ],
- 'oversight' => [
- 'deletedtext' => true,
- 'deletedhistory' => true,
- 'viewsuppressed' => true,
- 'suppressrevision' => true,
- ],
- ]
- );
- }
-
- /**
- * @dataProvider provideGetComment_audience
- */
- public function testGetComment_audience( $visibility, $groups, $userCan, $publicCan ) {
- $this->forceStandardPermissions();
-
- $user = $this->getTestUser( $groups )->getUser();
- $rev = $this->newRevision( [ 'rev_deleted' => $visibility ] );
-
- $this->assertNotNull( $rev->getComment( RevisionRecord::RAW ), 'raw can' );
-
- $this->assertSame(
- $publicCan,
- $rev->getComment( RevisionRecord::FOR_PUBLIC ) !== null,
- 'public can'
- );
- $this->assertSame(
- $userCan,
- $rev->getComment( RevisionRecord::FOR_THIS_USER, $user ) !== null,
- 'user can'
- );
- }
-
- public function provideGetUser_audience() {
- return $this->provideAudienceCheckData( RevisionRecord::DELETED_USER );
- }
-
- /**
- * @dataProvider provideGetUser_audience
- */
- public function testGetUser_audience( $visibility, $groups, $userCan, $publicCan ) {
- $this->forceStandardPermissions();
-
- $user = $this->getTestUser( $groups )->getUser();
- $rev = $this->newRevision( [ 'rev_deleted' => $visibility ] );
-
- $this->assertNotNull( $rev->getUser( RevisionRecord::RAW ), 'raw can' );
-
- $this->assertSame(
- $publicCan,
- $rev->getUser( RevisionRecord::FOR_PUBLIC ) !== null,
- 'public can'
- );
- $this->assertSame(
- $userCan,
- $rev->getUser( RevisionRecord::FOR_THIS_USER, $user ) !== null,
- 'user can'
- );
- }
-
- public function provideGetSlot_audience() {
- return $this->provideAudienceCheckData( RevisionRecord::DELETED_TEXT );
- }
-
- /**
- * @dataProvider provideGetSlot_audience
- */
- public function testGetSlot_audience( $visibility, $groups, $userCan, $publicCan ) {
- $this->forceStandardPermissions();
-
- $user = $this->getTestUser( $groups )->getUser();
- $rev = $this->newRevision( [ 'rev_deleted' => $visibility ] );
-
- // NOTE: slot meta-data is never suppressed, just the content is!
- $this->assertTrue( $rev->hasSlot( SlotRecord::MAIN ), 'hasSlot is never suppressed' );
- $this->assertNotNull( $rev->getSlot( SlotRecord::MAIN, RevisionRecord::RAW ), 'raw meta' );
- $this->assertNotNull( $rev->getSlot( SlotRecord::MAIN, RevisionRecord::FOR_PUBLIC ),
- 'public meta' );
-
- $this->assertNotNull(
- $rev->getSlot( SlotRecord::MAIN, RevisionRecord::FOR_THIS_USER, $user ),
- 'user can'
- );
-
- try {
- $rev->getSlot( SlotRecord::MAIN, RevisionRecord::FOR_PUBLIC )->getContent();
- $exception = null;
- } catch ( SuppressedDataException $ex ) {
- $exception = $ex;
- }
-
- $this->assertSame(
- $publicCan,
- $exception === null,
- 'public can'
- );
-
- try {
- $rev->getSlot( SlotRecord::MAIN, RevisionRecord::FOR_THIS_USER, $user )->getContent();
- $exception = null;
- } catch ( SuppressedDataException $ex ) {
- $exception = $ex;
- }
-
- $this->assertSame(
- $userCan,
- $exception === null,
- 'user can'
- );
- }
-
- /**
- * @dataProvider provideGetSlot_audience
- */
- public function testGetContent_audience( $visibility, $groups, $userCan, $publicCan ) {
- $this->forceStandardPermissions();
-
- $user = $this->getTestUser( $groups )->getUser();
- $rev = $this->newRevision( [ 'rev_deleted' => $visibility ] );
-
- $this->assertNotNull( $rev->getContent( SlotRecord::MAIN, RevisionRecord::RAW ), 'raw can' );
-
- $this->assertSame(
- $publicCan,
- $rev->getContent( SlotRecord::MAIN, RevisionRecord::FOR_PUBLIC ) !== null,
- 'public can'
- );
- $this->assertSame(
- $userCan,
- $rev->getContent( SlotRecord::MAIN, RevisionRecord::FOR_THIS_USER, $user ) !== null,
- 'user can'
- );
- }
-
- public function testGetSlot() {
- $rev = $this->newRevision();
-
- $slot = $rev->getSlot( SlotRecord::MAIN );
- $this->assertNotNull( $slot, 'getSlot()' );
- $this->assertSame( 'main', $slot->getRole(), 'getRole()' );
- }
-
- public function testHasSlot() {
- $rev = $this->newRevision();
-
- $this->assertTrue( $rev->hasSlot( SlotRecord::MAIN ) );
- $this->assertFalse( $rev->hasSlot( 'xyz' ) );
- }
-
- public function testGetContent() {
- $rev = $this->newRevision();
-
- $content = $rev->getSlot( SlotRecord::MAIN );
- $this->assertNotNull( $content, 'getContent()' );
- $this->assertSame( CONTENT_MODEL_TEXT, $content->getModel(), 'getModel()' );
- }
-
- public function provideUserCanBitfield() {
- yield [ 0, 0, [], null, true ];
- // Bitfields match, user has no permissions
- yield [
- RevisionRecord::DELETED_TEXT,
- RevisionRecord::DELETED_TEXT,
- [],
- null,
- false
- ];
- yield [
- RevisionRecord::DELETED_COMMENT,
- RevisionRecord::DELETED_COMMENT,
- [],
- null,
- false,
- ];
- yield [
- RevisionRecord::DELETED_USER,
- RevisionRecord::DELETED_USER,
- [],
- null,
- false
- ];
- yield [
- RevisionRecord::DELETED_RESTRICTED,
- RevisionRecord::DELETED_RESTRICTED,
- [],
- null,
- false,
- ];
- // Bitfields match, user (admin) does have permissions
- yield [
- RevisionRecord::DELETED_TEXT,
- RevisionRecord::DELETED_TEXT,
- [ 'sysop' ],
- null,
- true,
- ];
- yield [
- RevisionRecord::DELETED_COMMENT,
- RevisionRecord::DELETED_COMMENT,
- [ 'sysop' ],
- null,
- true,
- ];
- yield [
- RevisionRecord::DELETED_USER,
- RevisionRecord::DELETED_USER,
- [ 'sysop' ],
- null,
- true,
- ];
- // Bitfields match, user (admin) does not have permissions
- yield [
- RevisionRecord::DELETED_RESTRICTED,
- RevisionRecord::DELETED_RESTRICTED,
- [ 'sysop' ],
- null,
- false,
- ];
- // Bitfields match, user (oversight) does have permissions
- yield [
- RevisionRecord::DELETED_RESTRICTED,
- RevisionRecord::DELETED_RESTRICTED,
- [ 'oversight' ],
- null,
- true,
- ];
- // Check permissions using the title
- yield [
- RevisionRecord::DELETED_TEXT,
- RevisionRecord::DELETED_TEXT,
- [ 'sysop' ],
- __METHOD__,
- true,
- ];
- yield [
- RevisionRecord::DELETED_TEXT,
- RevisionRecord::DELETED_TEXT,
- [],
- __METHOD__,
- false,
- ];
- }
-
- /**
- * @dataProvider provideUserCanBitfield
- * @covers \MediaWiki\Storage\RevisionRecord::userCanBitfield
- */
- public function testUserCanBitfield( $bitField, $field, $userGroups, $title, $expected ) {
- if ( is_string( $title ) ) {
- // NOTE: Data providers cannot instantiate Title objects! See T202641.
- $title = Title::newFromText( $title );
- }
-
- $this->forceStandardPermissions();
-
- $user = $this->getTestUser( $userGroups )->getUser();
-
- $this->assertSame(
- $expected,
- RevisionRecord::userCanBitfield( $bitField, $field, $user, $title )
- );
- }
-
- public function provideHasSameContent() {
- // Create some slots with content
- $mainA = SlotRecord::newUnsaved( SlotRecord::MAIN, new TextContent( 'A' ) );
- $mainB = SlotRecord::newUnsaved( SlotRecord::MAIN, new TextContent( 'B' ) );
- $auxA = SlotRecord::newUnsaved( 'aux', new TextContent( 'A' ) );
- $auxB = SlotRecord::newUnsaved( 'aux', new TextContent( 'A' ) );
-
- $initialRecordSpec = [ [ $mainA ], 12 ];
-
- return [
- 'same record object' => [
- true,
- $initialRecordSpec,
- $initialRecordSpec,
- ],
- 'same record content, different object' => [
- true,
- [ [ $mainA ], 12 ],
- [ [ $mainA ], 13 ],
- ],
- 'same record content, aux slot, different object' => [
- true,
- [ [ $auxA ], 12 ],
- [ [ $auxB ], 13 ],
- ],
- 'different content' => [
- false,
- [ [ $mainA ], 12 ],
- [ [ $mainB ], 13 ],
- ],
- 'different content and number of slots' => [
- false,
- [ [ $mainA ], 12 ],
- [ [ $mainA, $mainB ], 13 ],
- ],
- ];
- }
-
- /**
- * @note Do not call directly from a data provider! Data providers cannot instantiate
- * Title objects! See T202641.
- *
- * @param SlotRecord[] $slots
- * @param int $revId
- * @return RevisionStoreRecord
- */
- private function makeHasSameContentTestRecord( array $slots, $revId ) {
- $title = Title::newFromText( 'provideHasSameContent' );
- $title->resetArticleID( 19 );
- $slots = new RevisionSlots( $slots );
-
- return new RevisionStoreRecord(
- $title,
- new UserIdentityValue( 11, __METHOD__, 0 ),
- CommentStoreComment::newUnsavedComment( __METHOD__ ),
- (object)[
- 'rev_id' => strval( $revId ),
- 'rev_page' => strval( $title->getArticleID() ),
- 'rev_timestamp' => '20200101000000',
- 'rev_deleted' => 0,
- 'rev_minor_edit' => 0,
- 'rev_parent_id' => '5',
- 'rev_len' => $slots->computeSize(),
- 'rev_sha1' => $slots->computeSha1(),
- 'page_latest' => '18',
- ],
- $slots
- );
- }
-
- /**
- * @dataProvider provideHasSameContent
- * @covers \MediaWiki\Storage\RevisionRecord::hasSameContent
- * @group Database
- */
- public function testHasSameContent(
- $expected,
- $recordSpec1,
- $recordSpec2
- ) {
- $record1 = $this->makeHasSameContentTestRecord( ...$recordSpec1 );
- $record2 = $this->makeHasSameContentTestRecord( ...$recordSpec2 );
-
- $this->assertSame(
- $expected,
- $record1->hasSameContent( $record2 )
- );
- }
-
- public function provideIsDeleted() {
- yield 'no deletion' => [
- 0,
- [
- RevisionRecord::DELETED_TEXT => false,
- RevisionRecord::DELETED_COMMENT => false,
- RevisionRecord::DELETED_USER => false,
- RevisionRecord::DELETED_RESTRICTED => false,
- ]
- ];
- yield 'text deleted' => [
- RevisionRecord::DELETED_TEXT,
- [
- RevisionRecord::DELETED_TEXT => true,
- RevisionRecord::DELETED_COMMENT => false,
- RevisionRecord::DELETED_USER => false,
- RevisionRecord::DELETED_RESTRICTED => false,
- ]
- ];
- yield 'text and comment deleted' => [
- RevisionRecord::DELETED_TEXT + RevisionRecord::DELETED_COMMENT,
- [
- RevisionRecord::DELETED_TEXT => true,
- RevisionRecord::DELETED_COMMENT => true,
- RevisionRecord::DELETED_USER => false,
- RevisionRecord::DELETED_RESTRICTED => false,
- ]
- ];
- yield 'all 4 deleted' => [
- RevisionRecord::DELETED_TEXT +
- RevisionRecord::DELETED_COMMENT +
- RevisionRecord::DELETED_RESTRICTED +
- RevisionRecord::DELETED_USER,
- [
- RevisionRecord::DELETED_TEXT => true,
- RevisionRecord::DELETED_COMMENT => true,
- RevisionRecord::DELETED_USER => true,
- RevisionRecord::DELETED_RESTRICTED => true,
- ]
- ];
- }
-
- /**
- * @dataProvider provideIsDeleted
- * @covers \MediaWiki\Storage\RevisionRecord::isDeleted
- */
- public function testIsDeleted( $revDeleted, $assertionMap ) {
- $rev = $this->newRevision( [ 'rev_deleted' => $revDeleted ] );
- foreach ( $assertionMap as $deletionLevel => $expected ) {
- $this->assertSame( $expected, $rev->isDeleted( $deletionLevel ) );
- }
- }
-
- public function testIsReadyForInsertion() {
- $rev = $this->newRevision();
- $this->assertTrue( $rev->isReadyForInsertion() );
- }
-
-}
+++ /dev/null
-<?php
-
-namespace MediaWiki\Tests\Storage;
-
-use InvalidArgumentException;
-use MediaWiki\Storage\RevisionAccessException;
-use MediaWiki\Storage\RevisionSlots;
-use MediaWiki\Storage\SlotRecord;
-use MediaWikiTestCase;
-use TextContent;
-use WikitextContent;
-
-class RevisionSlotsTest extends MediaWikiTestCase {
-
- /**
- * @param SlotRecord[] $slots
- * @return RevisionSlots
- */
- protected function newRevisionSlots( $slots = [] ) {
- return new RevisionSlots( $slots );
- }
-
- public function provideConstructorFailue() {
- yield 'not an array or callable' => [
- 'foo'
- ];
- yield 'array of the wrong thing' => [
- [ 1, 2, 3 ]
- ];
- }
-
- /**
- * @dataProvider provideConstructorFailue
- * @param $slots
- *
- * @covers \MediaWiki\Storage\RevisionSlots::__construct
- * @covers \MediaWiki\Storage\RevisionSlots::setSlotsInternal
- */
- public function testConstructorFailue( $slots ) {
- $this->setExpectedException( InvalidArgumentException::class );
-
- new RevisionSlots( $slots );
- }
-
- /**
- * @covers \MediaWiki\Storage\RevisionSlots::getSlot
- */
- public function testGetSlot() {
- $mainSlot = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) );
- $auxSlot = SlotRecord::newUnsaved( 'aux', new WikitextContent( 'B' ) );
- $slots = $this->newRevisionSlots( [ $mainSlot, $auxSlot ] );
-
- $this->assertSame( $mainSlot, $slots->getSlot( SlotRecord::MAIN ) );
- $this->assertSame( $auxSlot, $slots->getSlot( 'aux' ) );
- $this->setExpectedException( RevisionAccessException::class );
- $slots->getSlot( 'nothere' );
- }
-
- /**
- * @covers \MediaWiki\Storage\RevisionSlots::hasSlot
- */
- public function testHasSlot() {
- $mainSlot = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) );
- $auxSlot = SlotRecord::newUnsaved( 'aux', new WikitextContent( 'B' ) );
- $slots = $this->newRevisionSlots( [ $mainSlot, $auxSlot ] );
-
- $this->assertTrue( $slots->hasSlot( SlotRecord::MAIN ) );
- $this->assertTrue( $slots->hasSlot( 'aux' ) );
- $this->assertFalse( $slots->hasSlot( 'AUX' ) );
- $this->assertFalse( $slots->hasSlot( 'xyz' ) );
- }
-
- /**
- * @covers \MediaWiki\Storage\RevisionSlots::getContent
- */
- public function testGetContent() {
- $mainContent = new WikitextContent( 'A' );
- $auxContent = new WikitextContent( 'B' );
- $mainSlot = SlotRecord::newUnsaved( SlotRecord::MAIN, $mainContent );
- $auxSlot = SlotRecord::newUnsaved( 'aux', $auxContent );
- $slots = $this->newRevisionSlots( [ $mainSlot, $auxSlot ] );
-
- $this->assertSame( $mainContent, $slots->getContent( SlotRecord::MAIN ) );
- $this->assertSame( $auxContent, $slots->getContent( 'aux' ) );
- $this->setExpectedException( RevisionAccessException::class );
- $slots->getContent( 'nothere' );
- }
-
- /**
- * @covers \MediaWiki\Storage\RevisionSlots::getSlotRoles
- */
- public function testGetSlotRoles_someSlots() {
- $mainSlot = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) );
- $auxSlot = SlotRecord::newUnsaved( 'aux', new WikitextContent( 'B' ) );
- $slots = $this->newRevisionSlots( [ $mainSlot, $auxSlot ] );
-
- $this->assertSame( [ 'main', 'aux' ], $slots->getSlotRoles() );
- }
-
- /**
- * @covers \MediaWiki\Storage\RevisionSlots::getSlotRoles
- */
- public function testGetSlotRoles_noSlots() {
- $slots = $this->newRevisionSlots( [] );
-
- $this->assertSame( [], $slots->getSlotRoles() );
- }
-
- /**
- * @covers \MediaWiki\Storage\RevisionSlots::getSlots
- */
- public function testGetSlots() {
- $mainSlot = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) );
- $auxSlot = SlotRecord::newUnsaved( 'aux', new WikitextContent( 'B' ) );
- $slotsArray = [ $mainSlot, $auxSlot ];
- $slots = $this->newRevisionSlots( $slotsArray );
-
- $this->assertEquals( [ 'main' => $mainSlot, 'aux' => $auxSlot ], $slots->getSlots() );
- }
-
- /**
- * @covers \MediaWiki\Storage\RevisionSlots::getInheritedSlots
- */
- public function testGetInheritedSlots() {
- $mainSlot = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) );
- $auxSlot = SlotRecord::newInherited(
- SlotRecord::newSaved(
- 7, 7, 'foo',
- SlotRecord::newUnsaved( 'aux', new WikitextContent( 'B' ) )
- )
- );
- $slotsArray = [ $mainSlot, $auxSlot ];
- $slots = $this->newRevisionSlots( $slotsArray );
-
- $this->assertEquals( [ 'aux' => $auxSlot ], $slots->getInheritedSlots() );
- }
-
- /**
- * @covers \MediaWiki\Storage\RevisionSlots::getOriginalSlots
- */
- public function testGetOriginalSlots() {
- $mainSlot = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) );
- $auxSlot = SlotRecord::newInherited(
- SlotRecord::newSaved(
- 7, 7, 'foo',
- SlotRecord::newUnsaved( 'aux', new WikitextContent( 'B' ) )
- )
- );
- $slotsArray = [ $mainSlot, $auxSlot ];
- $slots = $this->newRevisionSlots( $slotsArray );
-
- $this->assertEquals( [ 'main' => $mainSlot ], $slots->getOriginalSlots() );
- }
-
- public function provideComputeSize() {
- yield [ 1, [ 'A' ] ];
- yield [ 2, [ 'AA' ] ];
- yield [ 4, [ 'AA', 'X', 'H' ] ];
- }
-
- /**
- * @dataProvider provideComputeSize
- * @covers \MediaWiki\Storage\RevisionSlots::computeSize
- */
- public function testComputeSize( $expected, $contentStrings ) {
- $slotsArray = [];
- foreach ( $contentStrings as $key => $contentString ) {
- $slotsArray[] = SlotRecord::newUnsaved( strval( $key ), new WikitextContent( $contentString ) );
- }
- $slots = $this->newRevisionSlots( $slotsArray );
-
- $this->assertSame( $expected, $slots->computeSize() );
- }
-
- public function provideComputeSha1() {
- yield [ 'ctqm7794fr2dp1taki8a88ovwnvmnmj', [ 'A' ] ];
- yield [ 'eyq8wiwlcofnaiy4eid97gyfy60uw51', [ 'AA' ] ];
- yield [ 'lavctqfpxartyjr31f853drgfl4kj1g', [ 'AA', 'X', 'H' ] ];
- }
-
- /**
- * @dataProvider provideComputeSha1
- * @covers \MediaWiki\Storage\RevisionSlots::computeSha1
- * @note this test is a bit brittle as the hashes are hardcoded, perhaps just check that strings
- * are returned and different Slots objects return different strings?
- */
- public function testComputeSha1( $expected, $contentStrings ) {
- $slotsArray = [];
- foreach ( $contentStrings as $key => $contentString ) {
- $slotsArray[] = SlotRecord::newUnsaved( strval( $key ), new WikitextContent( $contentString ) );
- }
- $slots = $this->newRevisionSlots( $slotsArray );
-
- $this->assertSame( $expected, $slots->computeSha1() );
- }
-
- public function provideHasSameContent() {
- $fooX = SlotRecord::newUnsaved( 'x', new TextContent( 'Foo' ) );
- $barZ = SlotRecord::newUnsaved( 'z', new TextContent( 'Bar' ) );
- $fooY = SlotRecord::newUnsaved( 'y', new TextContent( 'Foo' ) );
- $barZS = SlotRecord::newSaved( 7, 7, 'xyz', $barZ );
- $barZ2 = SlotRecord::newUnsaved( 'z', new TextContent( 'Baz' ) );
-
- $a = $this->newRevisionSlots( [ 'x' => $fooX, 'z' => $barZ ] );
- $a2 = $this->newRevisionSlots( [ 'x' => $fooX, 'z' => $barZ ] );
- $a3 = $this->newRevisionSlots( [ 'x' => $fooX, 'z' => $barZS ] );
- $b = $this->newRevisionSlots( [ 'y' => $fooY, 'z' => $barZ ] );
- $c = $this->newRevisionSlots( [ 'x' => $fooX, 'z' => $barZ2 ] );
-
- yield 'same instance' => [ $a, $a, true ];
- yield 'same slots' => [ $a, $a2, true ];
- yield 'same content' => [ $a, $a3, true ];
-
- yield 'different roles' => [ $a, $b, false ];
- yield 'different content' => [ $a, $c, false ];
- }
-
- /**
- * @dataProvider provideHasSameContent
- * @covers \MediaWiki\Storage\RevisionSlots::hasSameContent
- */
- public function testHasSameContent( RevisionSlots $a, RevisionSlots $b, $same ) {
- $this->assertSame( $same, $a->hasSameContent( $b ) );
- $this->assertSame( $same, $b->hasSameContent( $a ) );
- }
-
- public function provideGetRolesWithDifferentContent() {
- $fooX = SlotRecord::newUnsaved( 'x', new TextContent( 'Foo' ) );
- $barZ = SlotRecord::newUnsaved( 'z', new TextContent( 'Bar' ) );
- $fooY = SlotRecord::newUnsaved( 'y', new TextContent( 'Foo' ) );
- $barZS = SlotRecord::newSaved( 7, 7, 'xyz', $barZ );
- $barZ2 = SlotRecord::newUnsaved( 'z', new TextContent( 'Baz' ) );
-
- $a = $this->newRevisionSlots( [ 'x' => $fooX, 'z' => $barZ ] );
- $a2 = $this->newRevisionSlots( [ 'x' => $fooX, 'z' => $barZ ] );
- $a3 = $this->newRevisionSlots( [ 'x' => $fooX, 'z' => $barZS ] );
- $b = $this->newRevisionSlots( [ 'y' => $fooY, 'z' => $barZ ] );
- $c = $this->newRevisionSlots( [ 'x' => $fooX, 'z' => $barZ2 ] );
-
- yield 'same instance' => [ $a, $a, [] ];
- yield 'same slots' => [ $a, $a2, [] ];
- yield 'same content' => [ $a, $a3, [] ];
-
- yield 'different roles' => [ $a, $b, [ 'x', 'y' ] ];
- yield 'different content' => [ $a, $c, [ 'z' ] ];
- }
-
- /**
- * @dataProvider provideGetRolesWithDifferentContent
- * @covers \MediaWiki\Storage\RevisionSlots::getRolesWithDifferentContent
- */
- public function testGetRolesWithDifferentContent( RevisionSlots $a, RevisionSlots $b, $roles ) {
- $this->assertArrayEquals( $roles, $a->getRolesWithDifferentContent( $b ) );
- $this->assertArrayEquals( $roles, $b->getRolesWithDifferentContent( $a ) );
- }
-
-}
namespace MediaWiki\Tests\Storage;
use Content;
-use MediaWiki\Storage\MutableRevisionSlots;
-use MediaWiki\Storage\RevisionSlots;
+use MediaWiki\Revision\MutableRevisionSlots;
+use MediaWiki\Revision\RevisionSlots;
+use MediaWiki\Revision\RevisionAccessException;
+use MediaWiki\Revision\SlotRecord;
use MediaWiki\Storage\RevisionSlotsUpdate;
-use MediaWiki\Storage\RevisionAccessException;
-use MediaWiki\Storage\SlotRecord;
use MediaWikiTestCase;
use WikitextContent;
+++ /dev/null
-<?php
-
-namespace MediaWiki\Tests\Storage;
-
-use CommentStoreComment;
-use Content;
-use Exception;
-use HashBagOStuff;
-use InvalidArgumentException;
-use Language;
-use MediaWiki\Linker\LinkTarget;
-use MediaWiki\MediaWikiServices;
-use MediaWiki\Storage\BlobStoreFactory;
-use MediaWiki\Storage\IncompleteRevisionException;
-use MediaWiki\Storage\MutableRevisionRecord;
-use MediaWiki\Storage\RevisionRecord;
-use MediaWiki\Storage\RevisionStore;
-use MediaWiki\Storage\SlotRecord;
-use MediaWiki\Storage\SqlBlobStore;
-use MediaWiki\User\UserIdentityValue;
-use MediaWikiTestCase;
-use PHPUnit_Framework_MockObject_MockObject;
-use Revision;
-use TestUserRegistry;
-use Title;
-use WANObjectCache;
-use Wikimedia\Rdbms\Database;
-use Wikimedia\Rdbms\DatabaseSqlite;
-use Wikimedia\Rdbms\FakeResultWrapper;
-use Wikimedia\Rdbms\LoadBalancer;
-use Wikimedia\Rdbms\TransactionProfiler;
-use WikiPage;
-use WikitextContent;
-
-/**
- * @group Database
- * @group RevisionStore
- */
-abstract class RevisionStoreDbTestBase extends MediaWikiTestCase {
-
- /**
- * @var Title
- */
- private $testPageTitle;
-
- /**
- * @var WikiPage
- */
- private $testPage;
-
- /**
- * @return int
- */
- abstract protected function getMcrMigrationStage();
-
- /**
- * @return bool
- */
- protected function getContentHandlerUseDB() {
- return true;
- }
-
- /**
- * @return string[]
- */
- abstract protected function getMcrTablesToReset();
-
- public function setUp() {
- parent::setUp();
- $this->tablesUsed[] = 'archive';
- $this->tablesUsed[] = 'page';
- $this->tablesUsed[] = 'revision';
- $this->tablesUsed[] = 'comment';
-
- $this->tablesUsed += $this->getMcrTablesToReset();
-
- $this->setMwGlobals( [
- 'wgMultiContentRevisionSchemaMigrationStage' => $this->getMcrMigrationStage(),
- 'wgContentHandlerUseDB' => $this->getContentHandlerUseDB(),
- 'wgCommentTableSchemaMigrationStage' => MIGRATION_OLD,
- 'wgActorTableSchemaMigrationStage' => MIGRATION_OLD,
- ] );
-
- $this->overrideMwServices();
- }
-
- protected function addCoreDBData() {
- // Blank out. This would fail with a modified schema, and we don't need it.
- }
-
- /**
- * @return Title
- */
- protected function getTestPageTitle() {
- if ( $this->testPageTitle ) {
- return $this->testPageTitle;
- }
-
- $this->testPageTitle = Title::newFromText( 'UTPage-' . __CLASS__ );
- return $this->testPageTitle;
- }
- /**
- * @return WikiPage
- */
- protected function getTestPage() {
- if ( $this->testPage ) {
- return $this->testPage;
- }
-
- $title = $this->getTestPageTitle();
- $this->testPage = WikiPage::factory( $title );
-
- if ( !$this->testPage->exists() ) {
- // Make sure we don't write to the live db.
- $this->ensureMockDatabaseConnection( wfGetDB( DB_MASTER ) );
-
- $user = static::getTestSysop()->getUser();
-
- $this->testPage->doEditContent(
- new WikitextContent( 'UTContent-' . __CLASS__ ),
- 'UTPageSummary-' . __CLASS__,
- EDIT_NEW | EDIT_SUPPRESS_RC,
- false,
- $user
- );
- }
-
- return $this->testPage;
- }
-
- /**
- * @return LoadBalancer|PHPUnit_Framework_MockObject_MockObject
- */
- private function getLoadBalancerMock( array $server ) {
- $lb = $this->getMockBuilder( LoadBalancer::class )
- ->setMethods( [ 'reallyOpenConnection' ] )
- ->setConstructorArgs( [ [ 'servers' => [ $server ] ] ] )
- ->getMock();
-
- $lb->method( 'reallyOpenConnection' )->willReturnCallback(
- function ( array $server, $dbNameOverride ) {
- return $this->getDatabaseMock( $server );
- }
- );
-
- return $lb;
- }
-
- /**
- * @return Database|PHPUnit_Framework_MockObject_MockObject
- */
- private function getDatabaseMock( array $params ) {
- $db = $this->getMockBuilder( DatabaseSqlite::class )
- ->setMethods( [ 'select', 'doQuery', 'open', 'closeConnection', 'isOpen' ] )
- ->setConstructorArgs( [ $params ] )
- ->getMock();
-
- $db->method( 'select' )->willReturn( new FakeResultWrapper( [] ) );
- $db->method( 'isOpen' )->willReturn( true );
-
- return $db;
- }
-
- public function provideDomainCheck() {
- yield [ false, 'test', '' ];
- yield [ 'test', 'test', '' ];
-
- yield [ false, 'test', 'foo_' ];
- yield [ 'test-foo_', 'test', 'foo_' ];
-
- yield [ false, 'dash-test', '' ];
- yield [ 'dash-test', 'dash-test', '' ];
-
- yield [ false, 'underscore_test', 'foo_' ];
- yield [ 'underscore_test-foo_', 'underscore_test', 'foo_' ];
- }
-
- /**
- * @dataProvider provideDomainCheck
- * @covers \MediaWiki\Storage\RevisionStore::checkDatabaseWikiId
- */
- public function testDomainCheck( $wikiId, $dbName, $dbPrefix ) {
- $this->setMwGlobals(
- [
- 'wgDBname' => $dbName,
- 'wgDBprefix' => $dbPrefix,
- ]
- );
-
- $loadBalancer = $this->getLoadBalancerMock(
- [
- 'host' => '*dummy*',
- 'dbDirectory' => '*dummy*',
- 'user' => 'test',
- 'password' => 'test',
- 'flags' => 0,
- 'variables' => [],
- 'schema' => '',
- 'cliMode' => true,
- 'agent' => '',
- 'load' => 100,
- 'profiler' => null,
- 'trxProfiler' => new TransactionProfiler(),
- 'connLogger' => new \Psr\Log\NullLogger(),
- 'queryLogger' => new \Psr\Log\NullLogger(),
- 'errorLogger' => function () {
- },
- 'deprecationLogger' => function () {
- },
- 'type' => 'test',
- 'dbname' => $dbName,
- 'tablePrefix' => $dbPrefix,
- ]
- );
- $db = $loadBalancer->getConnection( DB_REPLICA );
-
- /** @var SqlBlobStore $blobStore */
- $blobStore = $this->getMockBuilder( SqlBlobStore::class )
- ->disableOriginalConstructor()
- ->getMock();
-
- $store = new RevisionStore(
- $loadBalancer,
- $blobStore,
- new WANObjectCache( [ 'cache' => new HashBagOStuff() ] ),
- MediaWikiServices::getInstance()->getCommentStore(),
- MediaWikiServices::getInstance()->getContentModelStore(),
- MediaWikiServices::getInstance()->getSlotRoleStore(),
- $this->getMcrMigrationStage(),
- MediaWikiServices::getInstance()->getActorMigration(),
- $wikiId
- );
-
- $count = $store->countRevisionsByPageId( $db, 0 );
-
- // Dummy check to make PhpUnit happy. We are really only interested in
- // countRevisionsByPageId not failing due to the DB domain check.
- $this->assertSame( 0, $count );
- }
-
- protected function assertLinkTargetsEqual( LinkTarget $l1, LinkTarget $l2 ) {
- $this->assertEquals( $l1->getDBkey(), $l2->getDBkey() );
- $this->assertEquals( $l1->getNamespace(), $l2->getNamespace() );
- $this->assertEquals( $l1->getFragment(), $l2->getFragment() );
- $this->assertEquals( $l1->getInterwiki(), $l2->getInterwiki() );
- }
-
- protected function assertRevisionRecordsEqual( RevisionRecord $r1, RevisionRecord $r2 ) {
- $this->assertEquals(
- $r1->getPageAsLinkTarget()->getNamespace(),
- $r2->getPageAsLinkTarget()->getNamespace()
- );
-
- $this->assertEquals(
- $r1->getPageAsLinkTarget()->getText(),
- $r2->getPageAsLinkTarget()->getText()
- );
-
- if ( $r1->getParentId() ) {
- $this->assertEquals( $r1->getParentId(), $r2->getParentId() );
- }
-
- $this->assertEquals( $r1->getUser()->getName(), $r2->getUser()->getName() );
- $this->assertEquals( $r1->getUser()->getId(), $r2->getUser()->getId() );
- $this->assertEquals( $r1->getComment(), $r2->getComment() );
- $this->assertEquals( $r1->getTimestamp(), $r2->getTimestamp() );
- $this->assertEquals( $r1->getVisibility(), $r2->getVisibility() );
- $this->assertEquals( $r1->getSha1(), $r2->getSha1() );
- $this->assertEquals( $r1->getSize(), $r2->getSize() );
- $this->assertEquals( $r1->getPageId(), $r2->getPageId() );
- $this->assertArrayEquals( $r1->getSlotRoles(), $r2->getSlotRoles() );
- $this->assertEquals( $r1->getWikiId(), $r2->getWikiId() );
- $this->assertEquals( $r1->isMinor(), $r2->isMinor() );
- foreach ( $r1->getSlotRoles() as $role ) {
- $this->assertSlotRecordsEqual( $r1->getSlot( $role ), $r2->getSlot( $role ) );
- $this->assertTrue( $r1->getContent( $role )->equals( $r2->getContent( $role ) ) );
- }
- foreach ( [
- RevisionRecord::DELETED_TEXT,
- RevisionRecord::DELETED_COMMENT,
- RevisionRecord::DELETED_USER,
- RevisionRecord::DELETED_RESTRICTED,
- ] as $field ) {
- $this->assertEquals( $r1->isDeleted( $field ), $r2->isDeleted( $field ) );
- }
- }
-
- protected function assertSlotRecordsEqual( SlotRecord $s1, SlotRecord $s2 ) {
- $this->assertSame( $s1->getRole(), $s2->getRole() );
- $this->assertSame( $s1->getModel(), $s2->getModel() );
- $this->assertSame( $s1->getFormat(), $s2->getFormat() );
- $this->assertSame( $s1->getSha1(), $s2->getSha1() );
- $this->assertSame( $s1->getSize(), $s2->getSize() );
- $this->assertTrue( $s1->getContent()->equals( $s2->getContent() ) );
-
- $s1->hasRevision() ? $this->assertSame( $s1->getRevision(), $s2->getRevision() ) : null;
- $s1->hasAddress() ? $this->assertSame( $s1->hasAddress(), $s2->hasAddress() ) : null;
- }
-
- protected function assertRevisionCompleteness( RevisionRecord $r ) {
- $this->assertTrue( $r->hasSlot( SlotRecord::MAIN ) );
- $this->assertInstanceOf( SlotRecord::class, $r->getSlot( SlotRecord::MAIN ) );
- $this->assertInstanceOf( Content::class, $r->getContent( SlotRecord::MAIN ) );
-
- foreach ( $r->getSlotRoles() as $role ) {
- $this->assertSlotCompleteness( $r, $r->getSlot( $role ) );
- }
- }
-
- protected function assertSlotCompleteness( RevisionRecord $r, SlotRecord $slot ) {
- $this->assertTrue( $slot->hasAddress() );
- $this->assertSame( $r->getId(), $slot->getRevision() );
-
- $this->assertInstanceOf( Content::class, $slot->getContent() );
- }
-
- /**
- * @param mixed[] $details
- *
- * @return RevisionRecord
- */
- private function getRevisionRecordFromDetailsArray( $details = [] ) {
- // Convert some values that can't be provided by dataProviders
- if ( isset( $details['user'] ) && $details['user'] === true ) {
- $details['user'] = $this->getTestUser()->getUser();
- }
- if ( isset( $details['page'] ) && $details['page'] === true ) {
- $details['page'] = $this->getTestPage()->getId();
- }
- if ( isset( $details['parent'] ) && $details['parent'] === true ) {
- $details['parent'] = $this->getTestPage()->getLatest();
- }
-
- // Create the RevisionRecord with any available data
- $rev = new MutableRevisionRecord( $this->getTestPageTitle() );
- isset( $details['slot'] ) ? $rev->setSlot( $details['slot'] ) : null;
- isset( $details['parent'] ) ? $rev->setParentId( $details['parent'] ) : null;
- isset( $details['page'] ) ? $rev->setPageId( $details['page'] ) : null;
- isset( $details['size'] ) ? $rev->setSize( $details['size'] ) : null;
- isset( $details['sha1'] ) ? $rev->setSha1( $details['sha1'] ) : null;
- isset( $details['comment'] ) ? $rev->setComment( $details['comment'] ) : null;
- isset( $details['timestamp'] ) ? $rev->setTimestamp( $details['timestamp'] ) : null;
- isset( $details['minor'] ) ? $rev->setMinorEdit( $details['minor'] ) : null;
- isset( $details['user'] ) ? $rev->setUser( $details['user'] ) : null;
- isset( $details['visibility'] ) ? $rev->setVisibility( $details['visibility'] ) : null;
- isset( $details['id'] ) ? $rev->setId( $details['id'] ) : null;
-
- if ( isset( $details['content'] ) ) {
- foreach ( $details['content'] as $role => $content ) {
- $rev->setContent( $role, $content );
- }
- }
-
- return $rev;
- }
-
- public function provideInsertRevisionOn_successes() {
- yield 'Bare minimum revision insertion' => [
- [
- 'slot' => SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'Chicken' ) ),
- 'page' => true,
- 'comment' => $this->getRandomCommentStoreComment(),
- 'timestamp' => '20171117010101',
- 'user' => true,
- ],
- ];
- yield 'Detailed revision insertion' => [
- [
- 'slot' => SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'Chicken' ) ),
- 'parent' => true,
- 'page' => true,
- 'comment' => $this->getRandomCommentStoreComment(),
- 'timestamp' => '20171117010101',
- 'user' => true,
- 'minor' => true,
- 'visibility' => RevisionRecord::DELETED_RESTRICTED,
- ],
- ];
- }
-
- protected function getRandomCommentStoreComment() {
- return CommentStoreComment::newUnsavedComment( __METHOD__ . '.' . rand( 0, 1000 ) );
- }
-
- /**
- * @dataProvider provideInsertRevisionOn_successes
- * @covers \MediaWiki\Storage\RevisionStore::insertRevisionOn
- * @covers \MediaWiki\Storage\RevisionStore::insertSlotRowOn
- * @covers \MediaWiki\Storage\RevisionStore::insertContentRowOn
- */
- public function testInsertRevisionOn_successes(
- array $revDetails = []
- ) {
- $title = $this->getTestPageTitle();
- $rev = $this->getRevisionRecordFromDetailsArray( $revDetails );
-
- $this->overrideMwServices();
- $store = MediaWikiServices::getInstance()->getRevisionStore();
- $return = $store->insertRevisionOn( $rev, wfGetDB( DB_MASTER ) );
-
- // is the new revision correct?
- $this->assertRevisionCompleteness( $return );
- $this->assertLinkTargetsEqual( $title, $return->getPageAsLinkTarget() );
- $this->assertRevisionRecordsEqual( $rev, $return );
-
- // can we load it from the store?
- $loaded = $store->getRevisionById( $return->getId() );
- $this->assertRevisionCompleteness( $loaded );
- $this->assertRevisionRecordsEqual( $return, $loaded );
-
- // can we find it directly in the database?
- $this->assertRevisionExistsInDatabase( $return );
- }
-
- protected function assertRevisionExistsInDatabase( RevisionRecord $rev ) {
- $row = $this->revisionToRow( new Revision( $rev ), [] );
-
- // unset nulled fields
- unset( $row->rev_content_model );
- unset( $row->rev_content_format );
-
- // unset fake fields
- unset( $row->rev_comment_text );
- unset( $row->rev_comment_data );
- unset( $row->rev_comment_cid );
- unset( $row->rev_comment_id );
-
- $store = MediaWikiServices::getInstance()->getRevisionStore();
- $queryInfo = $store->getQueryInfo( [ 'user' ] );
-
- $row = get_object_vars( $row );
- $this->assertSelect(
- $queryInfo['tables'],
- array_keys( $row ),
- [ 'rev_id' => $rev->getId() ],
- [ array_values( $row ) ],
- [],
- $queryInfo['joins']
- );
- }
-
- /**
- * @param SlotRecord $a
- * @param SlotRecord $b
- */
- protected function assertSameSlotContent( SlotRecord $a, SlotRecord $b ) {
- // Assert that the same blob address has been used.
- $this->assertSame( $a->getAddress(), $b->getAddress() );
- }
-
- /**
- * @covers \MediaWiki\Storage\RevisionStore::insertRevisionOn
- */
- public function testInsertRevisionOn_blobAddressExists() {
- $title = $this->getTestPageTitle();
- $revDetails = [
- 'slot' => SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'Chicken' ) ),
- 'parent' => true,
- 'comment' => $this->getRandomCommentStoreComment(),
- 'timestamp' => '20171117010101',
- 'user' => true,
- ];
-
- $this->overrideMwServices();
- $store = MediaWikiServices::getInstance()->getRevisionStore();
-
- // Insert the first revision
- $revOne = $this->getRevisionRecordFromDetailsArray( $revDetails );
- $firstReturn = $store->insertRevisionOn( $revOne, wfGetDB( DB_MASTER ) );
- $this->assertLinkTargetsEqual( $title, $firstReturn->getPageAsLinkTarget() );
- $this->assertRevisionRecordsEqual( $revOne, $firstReturn );
-
- // Insert a second revision inheriting the same blob address
- $revDetails['slot'] = SlotRecord::newInherited( $firstReturn->getSlot( SlotRecord::MAIN ) );
- $revTwo = $this->getRevisionRecordFromDetailsArray( $revDetails );
- $secondReturn = $store->insertRevisionOn( $revTwo, wfGetDB( DB_MASTER ) );
- $this->assertLinkTargetsEqual( $title, $secondReturn->getPageAsLinkTarget() );
- $this->assertRevisionRecordsEqual( $revTwo, $secondReturn );
-
- $firstMainSlot = $firstReturn->getSlot( SlotRecord::MAIN );
- $secondMainSlot = $secondReturn->getSlot( SlotRecord::MAIN );
-
- $this->assertSameSlotContent( $firstMainSlot, $secondMainSlot );
-
- // And that different revisions have been created.
- $this->assertNotSame( $firstReturn->getId(), $secondReturn->getId() );
-
- // Make sure the slot rows reference the correct revision
- $this->assertSame( $firstReturn->getId(), $firstMainSlot->getRevision() );
- $this->assertSame( $secondReturn->getId(), $secondMainSlot->getRevision() );
- }
-
- public function provideInsertRevisionOn_failures() {
- yield 'no slot' => [
- [
- 'comment' => $this->getRandomCommentStoreComment(),
- 'timestamp' => '20171117010101',
- 'user' => true,
- ],
- new InvalidArgumentException( 'main slot must be provided' )
- ];
- yield 'no main slot' => [
- [
- 'slot' => SlotRecord::newUnsaved( 'aux', new WikitextContent( 'Turkey' ) ),
- 'comment' => $this->getRandomCommentStoreComment(),
- 'timestamp' => '20171117010101',
- 'user' => true,
- ],
- new InvalidArgumentException( 'main slot must be provided' )
- ];
- yield 'no timestamp' => [
- [
- 'slot' => SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'Chicken' ) ),
- 'comment' => $this->getRandomCommentStoreComment(),
- 'user' => true,
- ],
- new IncompleteRevisionException( 'timestamp field must not be NULL!' )
- ];
- yield 'no comment' => [
- [
- 'slot' => SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'Chicken' ) ),
- 'timestamp' => '20171117010101',
- 'user' => true,
- ],
- new IncompleteRevisionException( 'comment must not be NULL!' )
- ];
- yield 'no user' => [
- [
- 'slot' => SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'Chicken' ) ),
- 'comment' => $this->getRandomCommentStoreComment(),
- 'timestamp' => '20171117010101',
- ],
- new IncompleteRevisionException( 'user must not be NULL!' )
- ];
- }
-
- /**
- * @dataProvider provideInsertRevisionOn_failures
- * @covers \MediaWiki\Storage\RevisionStore::insertRevisionOn
- */
- public function testInsertRevisionOn_failures(
- array $revDetails = [],
- Exception $exception
- ) {
- $rev = $this->getRevisionRecordFromDetailsArray( $revDetails );
-
- $store = MediaWikiServices::getInstance()->getRevisionStore();
-
- $this->setExpectedException(
- get_class( $exception ),
- $exception->getMessage(),
- $exception->getCode()
- );
- $store->insertRevisionOn( $rev, wfGetDB( DB_MASTER ) );
- }
-
- public function provideNewNullRevision() {
- yield [
- Title::newFromText( 'UTPage_notAutoCreated' ),
- [ 'content' => [ 'main' => new WikitextContent( 'Flubber1' ) ] ],
- CommentStoreComment::newUnsavedComment( __METHOD__ . ' comment1' ),
- true,
- ];
- yield [
- Title::newFromText( 'UTPage_notAutoCreated' ),
- [ 'content' => [ 'main' => new WikitextContent( 'Flubber2' ) ] ],
- CommentStoreComment::newUnsavedComment( __METHOD__ . ' comment2', [ 'a' => 1 ] ),
- false,
- ];
- }
-
- /**
- * @dataProvider provideNewNullRevision
- * @covers \MediaWiki\Storage\RevisionStore::newNullRevision
- * @covers \MediaWiki\Storage\RevisionStore::findSlotContentId
- */
- public function testNewNullRevision( Title $title, $revDetails, $comment, $minor = false ) {
- $this->overrideMwServices();
-
- $user = TestUserRegistry::getMutableTestUser( __METHOD__ )->getUser();
- $page = WikiPage::factory( $title );
-
- if ( !$page->exists() ) {
- $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__, EDIT_NEW );
- }
-
- $revDetails['page'] = $page->getId();
- $revDetails['timestamp'] = wfTimestampNow();
- $revDetails['comment'] = CommentStoreComment::newUnsavedComment( 'Base' );
- $revDetails['user'] = $user;
-
- $baseRev = $this->getRevisionRecordFromDetailsArray( $revDetails );
- $store = MediaWikiServices::getInstance()->getRevisionStore();
-
- $dbw = wfGetDB( DB_MASTER );
- $baseRev = $store->insertRevisionOn( $baseRev, $dbw );
- $page->updateRevisionOn( $dbw, new Revision( $baseRev ), $page->getLatest() );
-
- $record = $store->newNullRevision(
- wfGetDB( DB_MASTER ),
- $title,
- $comment,
- $minor,
- $user
- );
-
- $this->assertEquals( $title->getNamespace(), $record->getPageAsLinkTarget()->getNamespace() );
- $this->assertEquals( $title->getDBkey(), $record->getPageAsLinkTarget()->getDBkey() );
- $this->assertEquals( $comment, $record->getComment() );
- $this->assertEquals( $minor, $record->isMinor() );
- $this->assertEquals( $user->getName(), $record->getUser()->getName() );
- $this->assertEquals( $baseRev->getId(), $record->getParentId() );
-
- $this->assertArrayEquals(
- $baseRev->getSlotRoles(),
- $record->getSlotRoles()
- );
-
- foreach ( $baseRev->getSlotRoles() as $role ) {
- $parentSlot = $baseRev->getSlot( $role );
- $slot = $record->getSlot( $role );
-
- $this->assertTrue( $slot->isInherited(), 'isInherited' );
- $this->assertSame( $parentSlot->getOrigin(), $slot->getOrigin(), 'getOrigin' );
- $this->assertSameSlotContent( $parentSlot, $slot );
- }
- }
-
- /**
- * @covers \MediaWiki\Storage\RevisionStore::newNullRevision
- */
- public function testNewNullRevision_nonExistingTitle() {
- $store = MediaWikiServices::getInstance()->getRevisionStore();
- $record = $store->newNullRevision(
- wfGetDB( DB_MASTER ),
- Title::newFromText( __METHOD__ . '.iDontExist!' ),
- CommentStoreComment::newUnsavedComment( __METHOD__ . ' comment' ),
- false,
- TestUserRegistry::getMutableTestUser( __METHOD__ )->getUser()
- );
- $this->assertNull( $record );
- }
-
- /**
- * @covers \MediaWiki\Storage\RevisionStore::getRcIdIfUnpatrolled
- */
- public function testGetRcIdIfUnpatrolled_returnsRecentChangesId() {
- $page = $this->getTestPage();
- $status = $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ );
- /** @var Revision $rev */
- $rev = $status->value['revision'];
-
- $store = MediaWikiServices::getInstance()->getRevisionStore();
- $revisionRecord = $store->getRevisionById( $rev->getId() );
- $result = $store->getRcIdIfUnpatrolled( $revisionRecord );
-
- $this->assertGreaterThan( 0, $result );
- $this->assertSame(
- $store->getRecentChange( $revisionRecord )->getAttribute( 'rc_id' ),
- $result
- );
- }
-
- /**
- * @covers \MediaWiki\Storage\RevisionStore::getRcIdIfUnpatrolled
- */
- public function testGetRcIdIfUnpatrolled_returnsZeroIfPatrolled() {
- // This assumes that sysops are auto patrolled
- $sysop = $this->getTestSysop()->getUser();
- $page = $this->getTestPage();
- $status = $page->doEditContent(
- new WikitextContent( __METHOD__ ),
- __METHOD__,
- 0,
- false,
- $sysop
- );
- /** @var Revision $rev */
- $rev = $status->value['revision'];
-
- $store = MediaWikiServices::getInstance()->getRevisionStore();
- $revisionRecord = $store->getRevisionById( $rev->getId() );
- $result = $store->getRcIdIfUnpatrolled( $revisionRecord );
-
- $this->assertSame( 0, $result );
- }
-
- /**
- * @covers \MediaWiki\Storage\RevisionStore::getRecentChange
- */
- public function testGetRecentChange() {
- $page = $this->getTestPage();
- $content = new WikitextContent( __METHOD__ );
- $status = $page->doEditContent( $content, __METHOD__ );
- /** @var Revision $rev */
- $rev = $status->value['revision'];
-
- $store = MediaWikiServices::getInstance()->getRevisionStore();
- $revRecord = $store->getRevisionById( $rev->getId() );
- $recentChange = $store->getRecentChange( $revRecord );
-
- $this->assertEquals( $rev->getId(), $recentChange->getAttribute( 'rc_this_oldid' ) );
- $this->assertEquals( $rev->getRecentChange(), $recentChange );
- }
-
- /**
- * @covers \MediaWiki\Storage\RevisionStore::getRevisionById
- */
- public function testGetRevisionById() {
- $page = $this->getTestPage();
- $content = new WikitextContent( __METHOD__ );
- $status = $page->doEditContent( $content, __METHOD__ );
- /** @var Revision $rev */
- $rev = $status->value['revision'];
-
- $store = MediaWikiServices::getInstance()->getRevisionStore();
- $revRecord = $store->getRevisionById( $rev->getId() );
-
- $this->assertSame( $rev->getId(), $revRecord->getId() );
- $this->assertTrue( $revRecord->getSlot( SlotRecord::MAIN )->getContent()->equals( $content ) );
- $this->assertSame( __METHOD__, $revRecord->getComment()->text );
- }
-
- /**
- * @covers \MediaWiki\Storage\RevisionStore::getRevisionByTitle
- */
- public function testGetRevisionByTitle() {
- $page = $this->getTestPage();
- $content = new WikitextContent( __METHOD__ );
- $status = $page->doEditContent( $content, __METHOD__ );
- /** @var Revision $rev */
- $rev = $status->value['revision'];
-
- $store = MediaWikiServices::getInstance()->getRevisionStore();
- $revRecord = $store->getRevisionByTitle( $page->getTitle() );
-
- $this->assertSame( $rev->getId(), $revRecord->getId() );
- $this->assertTrue( $revRecord->getSlot( SlotRecord::MAIN )->getContent()->equals( $content ) );
- $this->assertSame( __METHOD__, $revRecord->getComment()->text );
- }
-
- /**
- * @covers \MediaWiki\Storage\RevisionStore::getRevisionByPageId
- */
- public function testGetRevisionByPageId() {
- $page = $this->getTestPage();
- $content = new WikitextContent( __METHOD__ );
- $status = $page->doEditContent( $content, __METHOD__ );
- /** @var Revision $rev */
- $rev = $status->value['revision'];
-
- $store = MediaWikiServices::getInstance()->getRevisionStore();
- $revRecord = $store->getRevisionByPageId( $page->getId() );
-
- $this->assertSame( $rev->getId(), $revRecord->getId() );
- $this->assertTrue( $revRecord->getSlot( SlotRecord::MAIN )->getContent()->equals( $content ) );
- $this->assertSame( __METHOD__, $revRecord->getComment()->text );
- }
-
- /**
- * @covers \MediaWiki\Storage\RevisionStore::getRevisionByTimestamp
- */
- public function testGetRevisionByTimestamp() {
- // Make sure there is 1 second between the last revision and the rev we create...
- // Otherwise we might not get the correct revision and the test may fail...
- // :(
- $page = $this->getTestPage();
- sleep( 1 );
- $content = new WikitextContent( __METHOD__ );
- $status = $page->doEditContent( $content, __METHOD__ );
- /** @var Revision $rev */
- $rev = $status->value['revision'];
-
- $store = MediaWikiServices::getInstance()->getRevisionStore();
- $revRecord = $store->getRevisionByTimestamp(
- $page->getTitle(),
- $rev->getTimestamp()
- );
-
- $this->assertSame( $rev->getId(), $revRecord->getId() );
- $this->assertTrue( $revRecord->getSlot( SlotRecord::MAIN )->getContent()->equals( $content ) );
- $this->assertSame( __METHOD__, $revRecord->getComment()->text );
- }
-
- protected function revisionToRow( Revision $rev, $options = [ 'page', 'user', 'comment' ] ) {
- // XXX: the WikiPage object loads another RevisionRecord from the database. Not great.
- $page = WikiPage::factory( $rev->getTitle() );
-
- $fields = [
- 'rev_id' => (string)$rev->getId(),
- 'rev_page' => (string)$rev->getPage(),
- 'rev_timestamp' => $this->db->timestamp( $rev->getTimestamp() ),
- 'rev_user_text' => (string)$rev->getUserText(),
- 'rev_user' => (string)$rev->getUser(),
- 'rev_minor_edit' => $rev->isMinor() ? '1' : '0',
- 'rev_deleted' => (string)$rev->getVisibility(),
- 'rev_len' => (string)$rev->getSize(),
- 'rev_parent_id' => (string)$rev->getParentId(),
- 'rev_sha1' => (string)$rev->getSha1(),
- ];
-
- if ( in_array( 'page', $options ) ) {
- $fields += [
- 'page_namespace' => (string)$page->getTitle()->getNamespace(),
- 'page_title' => $page->getTitle()->getDBkey(),
- 'page_id' => (string)$page->getId(),
- 'page_latest' => (string)$page->getLatest(),
- 'page_is_redirect' => $page->isRedirect() ? '1' : '0',
- 'page_len' => (string)$page->getContent()->getSize(),
- ];
- }
-
- if ( in_array( 'user', $options ) ) {
- $fields += [
- 'user_name' => (string)$rev->getUserText(),
- ];
- }
-
- if ( in_array( 'comment', $options ) ) {
- $fields += [
- 'rev_comment_text' => $rev->getComment(),
- 'rev_comment_data' => null,
- 'rev_comment_cid' => null,
- ];
- }
-
- if ( $rev->getId() ) {
- $fields += [
- 'rev_id' => (string)$rev->getId(),
- ];
- }
-
- return (object)$fields;
- }
-
- protected function assertRevisionRecordMatchesRevision(
- Revision $rev,
- RevisionRecord $record
- ) {
- $this->assertSame( $rev->getId(), $record->getId() );
- $this->assertSame( $rev->getPage(), $record->getPageId() );
- $this->assertSame( $rev->getTimestamp(), $record->getTimestamp() );
- $this->assertSame( $rev->getUserText(), $record->getUser()->getName() );
- $this->assertSame( $rev->getUser(), $record->getUser()->getId() );
- $this->assertSame( $rev->isMinor(), $record->isMinor() );
- $this->assertSame( $rev->getVisibility(), $record->getVisibility() );
- $this->assertSame( $rev->getSize(), $record->getSize() );
- /**
- * @note As of MW 1.31, the database schema allows the parent ID to be
- * NULL to indicate that it is unknown.
- */
- $expectedParent = $rev->getParentId();
- if ( $expectedParent === null ) {
- $expectedParent = 0;
- }
- $this->assertSame( $expectedParent, $record->getParentId() );
- $this->assertSame( $rev->getSha1(), $record->getSha1() );
- $this->assertSame( $rev->getComment(), $record->getComment()->text );
- $this->assertSame( $rev->getContentFormat(),
- $record->getContent( SlotRecord::MAIN )->getDefaultFormat() );
- $this->assertSame( $rev->getContentModel(), $record->getContent( SlotRecord::MAIN )->getModel() );
- $this->assertLinkTargetsEqual( $rev->getTitle(), $record->getPageAsLinkTarget() );
-
- $revRec = $rev->getRevisionRecord();
- $revMain = $revRec->getSlot( SlotRecord::MAIN );
- $recMain = $record->getSlot( SlotRecord::MAIN );
-
- $this->assertSame( $revMain->hasOrigin(), $recMain->hasOrigin(), 'hasOrigin' );
- $this->assertSame( $revMain->hasAddress(), $recMain->hasAddress(), 'hasAddress' );
- $this->assertSame( $revMain->hasContentId(), $recMain->hasContentId(), 'hasContentId' );
-
- if ( $revMain->hasOrigin() ) {
- $this->assertSame( $revMain->getOrigin(), $recMain->getOrigin(), 'getOrigin' );
- }
-
- if ( $revMain->hasAddress() ) {
- $this->assertSame( $revMain->getAddress(), $recMain->getAddress(), 'getAddress' );
- }
-
- if ( $revMain->hasContentId() ) {
- $this->assertSame( $revMain->getContentId(), $recMain->getContentId(), 'getContentId' );
- }
- }
-
- /**
- * @covers \MediaWiki\Storage\RevisionStore::newRevisionFromRow
- * @covers \MediaWiki\Storage\RevisionStore::getQueryInfo
- */
- public function testNewRevisionFromRow_getQueryInfo() {
- $page = $this->getTestPage();
- $text = __METHOD__ . 'a-ä';
- /** @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']
- );
- $record = $store->newRevisionFromRow(
- $row,
- [],
- $page->getTitle()
- );
- $this->assertRevisionRecordMatchesRevision( $rev, $record );
- $this->assertSame( $text, $rev->getContent()->serialize() );
- }
-
- /**
- * @covers \MediaWiki\Storage\RevisionStore::newRevisionFromRow
- */
- public function testNewRevisionFromRow_anonEdit() {
- $page = $this->getTestPage();
- $text = __METHOD__ . 'a-ä';
- /** @var Revision $rev */
- $rev = $page->doEditContent(
- new WikitextContent( $text ),
- __METHOD__ . 'a'
- )->value['revision'];
-
- $store = MediaWikiServices::getInstance()->getRevisionStore();
- $record = $store->newRevisionFromRow(
- $this->revisionToRow( $rev ),
- [],
- $page->getTitle()
- );
- $this->assertRevisionRecordMatchesRevision( $rev, $record );
- $this->assertSame( $text, $rev->getContent()->serialize() );
- }
-
- /**
- * @covers \MediaWiki\Storage\RevisionStore::newRevisionFromRow
- */
- public function testNewRevisionFromRow_anonEdit_legacyEncoding() {
- $this->setMwGlobals( 'wgLegacyEncoding', 'windows-1252' );
- $this->overrideMwServices();
- $page = $this->getTestPage();
- $text = __METHOD__ . 'a-ä';
- /** @var Revision $rev */
- $rev = $page->doEditContent(
- new WikitextContent( $text ),
- __METHOD__ . 'a'
- )->value['revision'];
-
- $store = MediaWikiServices::getInstance()->getRevisionStore();
- $record = $store->newRevisionFromRow(
- $this->revisionToRow( $rev ),
- [],
- $page->getTitle()
- );
- $this->assertRevisionRecordMatchesRevision( $rev, $record );
- $this->assertSame( $text, $rev->getContent()->serialize() );
- }
-
- /**
- * @covers \MediaWiki\Storage\RevisionStore::newRevisionFromRow
- */
- public function testNewRevisionFromRow_userEdit() {
- $page = $this->getTestPage();
- $text = __METHOD__ . 'b-ä';
- /** @var Revision $rev */
- $rev = $page->doEditContent(
- new WikitextContent( $text ),
- __METHOD__ . 'b',
- 0,
- false,
- $this->getTestUser()->getUser()
- )->value['revision'];
-
- $store = MediaWikiServices::getInstance()->getRevisionStore();
- $record = $store->newRevisionFromRow(
- $this->revisionToRow( $rev ),
- [],
- $page->getTitle()
- );
- $this->assertRevisionRecordMatchesRevision( $rev, $record );
- $this->assertSame( $text, $rev->getContent()->serialize() );
- }
-
- /**
- * @covers \MediaWiki\Storage\RevisionStore::newRevisionFromArchiveRow
- * @covers \MediaWiki\Storage\RevisionStore::getArchiveQueryInfo
- */
- public function testNewRevisionFromArchiveRow_getArchiveQueryInfo() {
- $store = MediaWikiServices::getInstance()->getRevisionStore();
- $title = Title::newFromText( __METHOD__ );
- $text = __METHOD__ . '-bä';
- $page = WikiPage::factory( $title );
- /** @var Revision $orig */
- $orig = $page->doEditContent( new WikitextContent( $text ), __METHOD__ )
- ->value['revision'];
- $page->doDeleteArticle( __METHOD__ );
-
- $db = wfGetDB( DB_MASTER );
- $arQuery = $store->getArchiveQueryInfo();
- $res = $db->select(
- $arQuery['tables'], $arQuery['fields'], [ 'ar_rev_id' => $orig->getId() ],
- __METHOD__, [], $arQuery['joins']
- );
- $this->assertTrue( is_object( $res ), 'query failed' );
-
- $row = $res->fetchObject();
- $res->free();
- $record = $store->newRevisionFromArchiveRow( $row );
-
- $this->assertRevisionRecordMatchesRevision( $orig, $record );
- $this->assertSame( $text, $record->getContent( SlotRecord::MAIN )->serialize() );
- }
-
- /**
- * @covers \MediaWiki\Storage\RevisionStore::newRevisionFromArchiveRow
- */
- public function testNewRevisionFromArchiveRow_legacyEncoding() {
- $this->setMwGlobals( 'wgLegacyEncoding', 'windows-1252' );
- $this->overrideMwServices();
- $store = MediaWikiServices::getInstance()->getRevisionStore();
- $title = Title::newFromText( __METHOD__ );
- $text = __METHOD__ . '-bä';
- $page = WikiPage::factory( $title );
- /** @var Revision $orig */
- $orig = $page->doEditContent( new WikitextContent( $text ), __METHOD__ )
- ->value['revision'];
- $page->doDeleteArticle( __METHOD__ );
-
- $db = wfGetDB( DB_MASTER );
- $arQuery = $store->getArchiveQueryInfo();
- $res = $db->select(
- $arQuery['tables'], $arQuery['fields'], [ 'ar_rev_id' => $orig->getId() ],
- __METHOD__, [], $arQuery['joins']
- );
- $this->assertTrue( is_object( $res ), 'query failed' );
-
- $row = $res->fetchObject();
- $res->free();
- $record = $store->newRevisionFromArchiveRow( $row );
-
- $this->assertRevisionRecordMatchesRevision( $orig, $record );
- $this->assertSame( $text, $record->getContent( SlotRecord::MAIN )->serialize() );
- }
-
- /**
- * @covers \MediaWiki\Storage\RevisionStore::newRevisionFromArchiveRow
- */
- public function testNewRevisionFromArchiveRow_no_user() {
- $store = MediaWikiServices::getInstance()->getRevisionStore();
-
- $row = (object)[
- 'ar_id' => '1',
- 'ar_page_id' => '2',
- 'ar_namespace' => '0',
- 'ar_title' => 'Something',
- 'ar_rev_id' => '2',
- 'ar_text_id' => '47',
- 'ar_timestamp' => '20180528192356',
- 'ar_minor_edit' => '0',
- 'ar_deleted' => '0',
- 'ar_len' => '78',
- 'ar_parent_id' => '0',
- 'ar_sha1' => 'deadbeef',
- 'ar_comment_text' => 'whatever',
- 'ar_comment_data' => null,
- 'ar_comment_cid' => null,
- 'ar_user' => '0',
- 'ar_user_text' => '', // this is the important bit
- 'ar_actor' => null,
- 'ar_content_format' => null,
- 'ar_content_model' => null,
- ];
-
- \Wikimedia\suppressWarnings();
- $record = $store->newRevisionFromArchiveRow( $row );
- \Wikimedia\suppressWarnings( true );
-
- $this->assertInstanceOf( RevisionRecord::class, $record );
- $this->assertInstanceOf( UserIdentityValue::class, $record->getUser() );
- $this->assertSame( 'Unknown user', $record->getUser()->getName() );
- }
-
- /**
- * @covers \MediaWiki\Storage\RevisionStore::newRevisionFromRow
- */
- public function testNewRevisionFromRow_no_user() {
- $store = MediaWikiServices::getInstance()->getRevisionStore();
- $title = Title::newFromText( __METHOD__ );
-
- $row = (object)[
- 'rev_id' => '2',
- 'rev_page' => '2',
- 'page_namespace' => '0',
- 'page_title' => $title->getText(),
- 'rev_text_id' => '47',
- 'rev_timestamp' => '20180528192356',
- 'rev_minor_edit' => '0',
- 'rev_deleted' => '0',
- 'rev_len' => '78',
- 'rev_parent_id' => '0',
- 'rev_sha1' => 'deadbeef',
- 'rev_comment_text' => 'whatever',
- 'rev_comment_data' => null,
- 'rev_comment_cid' => null,
- 'rev_user' => '0',
- 'rev_user_text' => '', // this is the important bit
- 'rev_actor' => null,
- 'rev_content_format' => null,
- 'rev_content_model' => null,
- ];
-
- \Wikimedia\suppressWarnings();
- $record = $store->newRevisionFromRow( $row, 0, $title );
- \Wikimedia\suppressWarnings( true );
-
- $this->assertNotNull( $record );
- $this->assertNotNull( $record->getUser() );
- $this->assertNotEmpty( $record->getUser()->getName() );
- }
-
- /**
- * @covers \MediaWiki\Storage\RevisionStore::insertRevisionOn
- */
- public function testInsertRevisionOn_archive() {
- // This is a round trip test for deletion and undeletion of a
- // revision row via the archive table.
-
- $store = MediaWikiServices::getInstance()->getRevisionStore();
- $title = Title::newFromText( __METHOD__ );
-
- $page = WikiPage::factory( $title );
- /** @var Revision $origRev */
- $page->doEditContent( new WikitextContent( "First" ), __METHOD__ . '-first' );
- $origRev = $page->doEditContent( new WikitextContent( "Foo" ), __METHOD__ )
- ->value['revision'];
- $orig = $origRev->getRevisionRecord();
- $page->doDeleteArticle( __METHOD__ );
-
- // re-create page, so we can later load revisions for it
- $page->doEditContent( new WikitextContent( 'Two' ), __METHOD__ );
-
- $db = wfGetDB( DB_MASTER );
- $arQuery = $store->getArchiveQueryInfo();
- $row = $db->selectRow(
- $arQuery['tables'], $arQuery['fields'], [ 'ar_rev_id' => $orig->getId() ],
- __METHOD__, [], $arQuery['joins']
- );
-
- $this->assertNotFalse( $row, 'query failed' );
-
- $record = $store->newRevisionFromArchiveRow(
- $row,
- 0,
- $title,
- [ 'page_id' => $title->getArticleID() ]
- );
-
- $restored = $store->insertRevisionOn( $record, $db );
-
- // is the new revision correct?
- $this->assertRevisionCompleteness( $restored );
- $this->assertRevisionRecordsEqual( $record, $restored );
-
- // does the new revision use the original slot?
- $recMain = $record->getSlot( SlotRecord::MAIN );
- $restMain = $restored->getSlot( SlotRecord::MAIN );
- $this->assertSame( $recMain->getAddress(), $restMain->getAddress() );
- $this->assertSame( $recMain->getContentId(), $restMain->getContentId() );
- $this->assertSame( $recMain->getOrigin(), $restMain->getOrigin() );
- $this->assertSame( 'Foo', $restMain->getContent()->serialize() );
-
- // can we load it from the store?
- $loaded = $store->getRevisionById( $restored->getId() );
- $this->assertNotNull( $loaded );
- $this->assertRevisionCompleteness( $loaded );
- $this->assertRevisionRecordsEqual( $restored, $loaded );
-
- // can we find it directly in the database?
- $this->assertRevisionExistsInDatabase( $restored );
- }
-
- /**
- * @covers \MediaWiki\Storage\RevisionStore::loadRevisionFromId
- */
- public function testLoadRevisionFromId() {
- $title = Title::newFromText( __METHOD__ );
- $page = WikiPage::factory( $title );
- /** @var Revision $rev */
- $rev = $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ )
- ->value['revision'];
-
- $store = MediaWikiServices::getInstance()->getRevisionStore();
- $result = $store->loadRevisionFromId( wfGetDB( DB_MASTER ), $rev->getId() );
- $this->assertRevisionRecordMatchesRevision( $rev, $result );
- }
-
- /**
- * @covers \MediaWiki\Storage\RevisionStore::loadRevisionFromPageId
- */
- public function testLoadRevisionFromPageId() {
- $title = Title::newFromText( __METHOD__ );
- $page = WikiPage::factory( $title );
- /** @var Revision $rev */
- $rev = $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ )
- ->value['revision'];
-
- $store = MediaWikiServices::getInstance()->getRevisionStore();
- $result = $store->loadRevisionFromPageId( wfGetDB( DB_MASTER ), $page->getId() );
- $this->assertRevisionRecordMatchesRevision( $rev, $result );
- }
-
- /**
- * @covers \MediaWiki\Storage\RevisionStore::loadRevisionFromTitle
- */
- public function testLoadRevisionFromTitle() {
- $title = Title::newFromText( __METHOD__ );
- $page = WikiPage::factory( $title );
- /** @var Revision $rev */
- $rev = $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ )
- ->value['revision'];
-
- $store = MediaWikiServices::getInstance()->getRevisionStore();
- $result = $store->loadRevisionFromTitle( wfGetDB( DB_MASTER ), $title );
- $this->assertRevisionRecordMatchesRevision( $rev, $result );
- }
-
- /**
- * @covers \MediaWiki\Storage\RevisionStore::loadRevisionFromTimestamp
- */
- public function testLoadRevisionFromTimestamp() {
- $title = Title::newFromText( __METHOD__ );
- $page = WikiPage::factory( $title );
- /** @var Revision $revOne */
- $revOne = $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ )
- ->value['revision'];
- // Sleep to ensure different timestamps... )(evil)
- sleep( 1 );
- /** @var Revision $revTwo */
- $revTwo = $page->doEditContent( new WikitextContent( __METHOD__ . 'a' ), '' )
- ->value['revision'];
-
- $store = MediaWikiServices::getInstance()->getRevisionStore();
- $this->assertNull(
- $store->loadRevisionFromTimestamp( wfGetDB( DB_MASTER ), $title, '20150101010101' )
- );
- $this->assertSame(
- $revOne->getId(),
- $store->loadRevisionFromTimestamp(
- wfGetDB( DB_MASTER ),
- $title,
- $revOne->getTimestamp()
- )->getId()
- );
- $this->assertSame(
- $revTwo->getId(),
- $store->loadRevisionFromTimestamp(
- wfGetDB( DB_MASTER ),
- $title,
- $revTwo->getTimestamp()
- )->getId()
- );
- }
-
- /**
- * @covers \MediaWiki\Storage\RevisionStore::listRevisionSizes
- */
- public function testGetParentLengths() {
- $page = WikiPage::factory( Title::newFromText( __METHOD__ ) );
- /** @var Revision $revOne */
- $revOne = $page->doEditContent(
- new WikitextContent( __METHOD__ ), __METHOD__
- )->value['revision'];
- /** @var Revision $revTwo */
- $revTwo = $page->doEditContent(
- new WikitextContent( __METHOD__ . '2' ), __METHOD__
- )->value['revision'];
-
- $store = MediaWikiServices::getInstance()->getRevisionStore();
- $this->assertSame(
- [
- $revOne->getId() => strlen( __METHOD__ ),
- ],
- $store->listRevisionSizes(
- wfGetDB( DB_MASTER ),
- [ $revOne->getId() ]
- )
- );
- $this->assertSame(
- [
- $revOne->getId() => strlen( __METHOD__ ),
- $revTwo->getId() => strlen( __METHOD__ ) + 1,
- ],
- $store->listRevisionSizes(
- wfGetDB( DB_MASTER ),
- [ $revOne->getId(), $revTwo->getId() ]
- )
- );
- }
-
- /**
- * @covers \MediaWiki\Storage\RevisionStore::getPreviousRevision
- */
- public function testGetPreviousRevision() {
- $page = WikiPage::factory( Title::newFromText( __METHOD__ ) );
- /** @var Revision $revOne */
- $revOne = $page->doEditContent(
- new WikitextContent( __METHOD__ ), __METHOD__
- )->value['revision'];
- /** @var Revision $revTwo */
- $revTwo = $page->doEditContent(
- new WikitextContent( __METHOD__ . '2' ), __METHOD__
- )->value['revision'];
-
- $store = MediaWikiServices::getInstance()->getRevisionStore();
- $this->assertNull(
- $store->getPreviousRevision( $store->getRevisionById( $revOne->getId() ) )
- );
- $this->assertSame(
- $revOne->getId(),
- $store->getPreviousRevision( $store->getRevisionById( $revTwo->getId() ) )->getId()
- );
- }
-
- /**
- * @covers \MediaWiki\Storage\RevisionStore::getNextRevision
- */
- public function testGetNextRevision() {
- $page = WikiPage::factory( Title::newFromText( __METHOD__ ) );
- /** @var Revision $revOne */
- $revOne = $page->doEditContent(
- new WikitextContent( __METHOD__ ), __METHOD__
- )->value['revision'];
- /** @var Revision $revTwo */
- $revTwo = $page->doEditContent(
- new WikitextContent( __METHOD__ . '2' ), __METHOD__
- )->value['revision'];
-
- $store = MediaWikiServices::getInstance()->getRevisionStore();
- $this->assertSame(
- $revTwo->getId(),
- $store->getNextRevision( $store->getRevisionById( $revOne->getId() ) )->getId()
- );
- $this->assertNull(
- $store->getNextRevision( $store->getRevisionById( $revTwo->getId() ) )
- );
- }
-
- /**
- * @covers \MediaWiki\Storage\RevisionStore::getTimestampFromId
- */
- public function testGetTimestampFromId_found() {
- $page = $this->getTestPage();
- /** @var Revision $rev */
- $rev = $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ )
- ->value['revision'];
-
- $store = MediaWikiServices::getInstance()->getRevisionStore();
- $result = $store->getTimestampFromId(
- $page->getTitle(),
- $rev->getId()
- );
-
- $this->assertSame( $rev->getTimestamp(), $result );
- }
-
- /**
- * @covers \MediaWiki\Storage\RevisionStore::getTimestampFromId
- */
- public function testGetTimestampFromId_notFound() {
- $page = $this->getTestPage();
- /** @var Revision $rev */
- $rev = $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ )
- ->value['revision'];
-
- $store = MediaWikiServices::getInstance()->getRevisionStore();
- $result = $store->getTimestampFromId(
- $page->getTitle(),
- $rev->getId() + 1
- );
-
- $this->assertFalse( $result );
- }
-
- /**
- * @covers \MediaWiki\Storage\RevisionStore::countRevisionsByPageId
- */
- public function testCountRevisionsByPageId() {
- $store = MediaWikiServices::getInstance()->getRevisionStore();
- $page = WikiPage::factory( Title::newFromText( __METHOD__ ) );
-
- $this->assertSame(
- 0,
- $store->countRevisionsByPageId( wfGetDB( DB_MASTER ), $page->getId() )
- );
- $page->doEditContent( new WikitextContent( 'a' ), 'a' );
- $this->assertSame(
- 1,
- $store->countRevisionsByPageId( wfGetDB( DB_MASTER ), $page->getId() )
- );
- $page->doEditContent( new WikitextContent( 'b' ), 'b' );
- $this->assertSame(
- 2,
- $store->countRevisionsByPageId( wfGetDB( DB_MASTER ), $page->getId() )
- );
- }
-
- /**
- * @covers \MediaWiki\Storage\RevisionStore::countRevisionsByTitle
- */
- public function testCountRevisionsByTitle() {
- $store = MediaWikiServices::getInstance()->getRevisionStore();
- $page = WikiPage::factory( Title::newFromText( __METHOD__ ) );
-
- $this->assertSame(
- 0,
- $store->countRevisionsByTitle( wfGetDB( DB_MASTER ), $page->getTitle() )
- );
- $page->doEditContent( new WikitextContent( 'a' ), 'a' );
- $this->assertSame(
- 1,
- $store->countRevisionsByTitle( wfGetDB( DB_MASTER ), $page->getTitle() )
- );
- $page->doEditContent( new WikitextContent( 'b' ), 'b' );
- $this->assertSame(
- 2,
- $store->countRevisionsByTitle( wfGetDB( DB_MASTER ), $page->getTitle() )
- );
- }
-
- /**
- * @covers \MediaWiki\Storage\RevisionStore::userWasLastToEdit
- */
- public function testUserWasLastToEdit_false() {
- $sysop = $this->getTestSysop()->getUser();
- $page = $this->getTestPage();
- $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ );
-
- $store = MediaWikiServices::getInstance()->getRevisionStore();
- $result = $store->userWasLastToEdit(
- wfGetDB( DB_MASTER ),
- $page->getId(),
- $sysop->getId(),
- '20160101010101'
- );
- $this->assertFalse( $result );
- }
-
- /**
- * @covers \MediaWiki\Storage\RevisionStore::userWasLastToEdit
- */
- public function testUserWasLastToEdit_true() {
- $startTime = wfTimestampNow();
- $sysop = $this->getTestSysop()->getUser();
- $page = $this->getTestPage();
- $page->doEditContent(
- new WikitextContent( __METHOD__ ),
- __METHOD__,
- 0,
- false,
- $sysop
- );
-
- $store = MediaWikiServices::getInstance()->getRevisionStore();
- $result = $store->userWasLastToEdit(
- wfGetDB( DB_MASTER ),
- $page->getId(),
- $sysop->getId(),
- $startTime
- );
- $this->assertTrue( $result );
- }
-
- /**
- * @covers \MediaWiki\Storage\RevisionStore::getKnownCurrentRevision
- */
- public function testGetKnownCurrentRevision() {
- $page = $this->getTestPage();
- /** @var Revision $rev */
- $rev = $page->doEditContent(
- new WikitextContent( __METHOD__ . 'b' ),
- __METHOD__ . 'b',
- 0,
- false,
- $this->getTestUser()->getUser()
- )->value['revision'];
-
- $store = MediaWikiServices::getInstance()->getRevisionStore();
- $record = $store->getKnownCurrentRevision(
- $page->getTitle(),
- $rev->getId()
- );
-
- $this->assertRevisionRecordMatchesRevision( $rev, $record );
- }
-
- public function provideNewMutableRevisionFromArray() {
- yield 'Basic array, content object' => [
- [
- 'id' => 2,
- 'page' => 1,
- 'timestamp' => '20171017114835',
- 'user_text' => '111.0.1.2',
- 'user' => 0,
- 'minor_edit' => false,
- 'deleted' => 0,
- 'len' => 46,
- 'parent_id' => 1,
- 'sha1' => 'rdqbbzs3pkhihgbs8qf2q9jsvheag5z',
- 'comment' => 'Goat Comment!',
- 'content' => new WikitextContent( 'Some Content' ),
- ]
- ];
- yield 'Basic array, serialized text' => [
- [
- 'id' => 2,
- 'page' => 1,
- 'timestamp' => '20171017114835',
- 'user_text' => '111.0.1.2',
- 'user' => 0,
- 'minor_edit' => false,
- 'deleted' => 0,
- 'len' => 46,
- 'parent_id' => 1,
- 'sha1' => 'rdqbbzs3pkhihgbs8qf2q9jsvheag5z',
- 'comment' => 'Goat Comment!',
- 'text' => ( new WikitextContent( 'Söme Content' ) )->serialize(),
- ]
- ];
- yield 'Basic array, serialized text, utf-8 flags' => [
- [
- 'id' => 2,
- 'page' => 1,
- 'timestamp' => '20171017114835',
- 'user_text' => '111.0.1.2',
- 'user' => 0,
- 'minor_edit' => false,
- 'deleted' => 0,
- 'len' => 46,
- 'parent_id' => 1,
- 'sha1' => 'rdqbbzs3pkhihgbs8qf2q9jsvheag5z',
- 'comment' => 'Goat Comment!',
- 'text' => ( new WikitextContent( 'Söme Content' ) )->serialize(),
- 'flags' => 'utf-8',
- ]
- ];
- yield 'Basic array, with title' => [
- [
- 'title' => Title::newFromText( 'SomeText' ),
- 'timestamp' => '20171017114835',
- 'user_text' => '111.0.1.2',
- 'user' => 0,
- 'minor_edit' => false,
- 'deleted' => 0,
- 'len' => 46,
- 'parent_id' => 1,
- 'sha1' => 'rdqbbzs3pkhihgbs8qf2q9jsvheag5z',
- 'comment' => 'Goat Comment!',
- 'content' => new WikitextContent( 'Some Content' ),
- ]
- ];
- yield 'Basic array, no user field' => [
- [
- 'id' => 2,
- 'page' => 1,
- 'timestamp' => '20171017114835',
- 'user_text' => '111.0.1.3',
- 'minor_edit' => false,
- 'deleted' => 0,
- 'len' => 46,
- 'parent_id' => 1,
- 'sha1' => 'rdqbbzs3pkhihgbs8qf2q9jsvheag5z',
- 'comment' => 'Goat Comment!',
- 'content' => new WikitextContent( 'Some Content' ),
- ]
- ];
- }
-
- /**
- * @dataProvider provideNewMutableRevisionFromArray
- * @covers \MediaWiki\Storage\RevisionStore::newMutableRevisionFromArray
- */
- public function testNewMutableRevisionFromArray( array $array ) {
- $store = MediaWikiServices::getInstance()->getRevisionStore();
-
- // HACK: if $array['page'] is given, make sure that the page exists
- if ( isset( $array['page'] ) ) {
- $t = Title::newFromID( $array['page'] );
- if ( !$t || !$t->exists() ) {
- $t = Title::makeTitle( NS_MAIN, __METHOD__ );
- $info = $this->insertPage( $t );
- $array['page'] = $info['id'];
- }
- }
-
- $result = $store->newMutableRevisionFromArray( $array );
-
- if ( isset( $array['id'] ) ) {
- $this->assertSame( $array['id'], $result->getId() );
- }
- if ( isset( $array['page'] ) ) {
- $this->assertSame( $array['page'], $result->getPageId() );
- }
- $this->assertSame( $array['timestamp'], $result->getTimestamp() );
- $this->assertSame( $array['user_text'], $result->getUser()->getName() );
- if ( isset( $array['user'] ) ) {
- $this->assertSame( $array['user'], $result->getUser()->getId() );
- }
- $this->assertSame( (bool)$array['minor_edit'], $result->isMinor() );
- $this->assertSame( $array['deleted'], $result->getVisibility() );
- $this->assertSame( $array['len'], $result->getSize() );
- $this->assertSame( $array['parent_id'], $result->getParentId() );
- $this->assertSame( $array['sha1'], $result->getSha1() );
- $this->assertSame( $array['comment'], $result->getComment()->text );
- if ( isset( $array['content'] ) ) {
- foreach ( $array['content'] as $role => $content ) {
- $this->assertTrue(
- $result->getContent( $role )->equals( $content )
- );
- }
- } elseif ( isset( $array['text'] ) ) {
- $this->assertSame( $array['text'],
- $result->getSlot( SlotRecord::MAIN )->getContent()->serialize() );
- } elseif ( isset( $array['content_format'] ) ) {
- $this->assertSame(
- $array['content_format'],
- $result->getSlot( SlotRecord::MAIN )->getContent()->getDefaultFormat()
- );
- $this->assertSame( $array['content_model'], $result->getSlot( SlotRecord::MAIN )->getModel() );
- }
- }
-
- /**
- * @dataProvider provideNewMutableRevisionFromArray
- * @covers \MediaWiki\Storage\RevisionStore::newMutableRevisionFromArray
- */
- public function testNewMutableRevisionFromArray_legacyEncoding( array $array ) {
- $cache = new WANObjectCache( [ 'cache' => new HashBagOStuff() ] );
- $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
- $blobStore = new SqlBlobStore( $lb, $cache );
- $blobStore->setLegacyEncoding( 'windows-1252', Language::factory( 'en' ) );
-
- $factory = $this->getMockBuilder( BlobStoreFactory::class )
- ->setMethods( [ 'newBlobStore', 'newSqlBlobStore' ] )
- ->disableOriginalConstructor()
- ->getMock();
- $factory->expects( $this->any() )
- ->method( 'newBlobStore' )
- ->willReturn( $blobStore );
- $factory->expects( $this->any() )
- ->method( 'newSqlBlobStore' )
- ->willReturn( $blobStore );
-
- $this->setService( 'BlobStoreFactory', $factory );
-
- $this->testNewMutableRevisionFromArray( $array );
- }
-
-}
+++ /dev/null
-<?php
-
-namespace MediaWiki\Tests\Storage;
-
-use ActorMigration;
-use CommentStore;
-use MediaWiki\Logger\Spi as LoggerSpi;
-use MediaWiki\Storage\BlobStore;
-use MediaWiki\Storage\BlobStoreFactory;
-use MediaWiki\Storage\NameTableStore;
-use MediaWiki\Storage\NameTableStoreFactory;
-use MediaWiki\Storage\RevisionStore;
-use MediaWiki\Storage\RevisionStoreFactory;
-use MediaWiki\Storage\SqlBlobStore;
-use MediaWikiTestCase;
-use Psr\Log\LoggerInterface;
-use Psr\Log\NullLogger;
-use WANObjectCache;
-use Wikimedia\Rdbms\ILBFactory;
-use Wikimedia\Rdbms\ILoadBalancer;
-use Wikimedia\TestingAccessWrapper;
-
-class RevisionStoreFactoryTest extends MediaWikiTestCase {
-
- public function testValidConstruction_doesntCauseErrors() {
- new RevisionStoreFactory(
- $this->getMockLoadBalancerFactory(),
- $this->getMockBlobStoreFactory(),
- $this->getNameTableStoreFactory(),
- $this->getHashWANObjectCache(),
- $this->getMockCommentStore(),
- ActorMigration::newMigration(),
- MIGRATION_OLD,
- $this->getMockLoggerSpi(),
- true
- );
- $this->assertTrue( true );
- }
-
- public function provideWikiIds() {
- yield [ true ];
- yield [ false ];
- yield [ 'somewiki' ];
- yield [ 'somewiki', MIGRATION_OLD , false ];
- yield [ 'somewiki', MIGRATION_NEW , true ];
- }
-
- /**
- * @dataProvider provideWikiIds
- */
- public function testGetRevisionStore(
- $wikiId,
- $mcrMigrationStage = MIGRATION_OLD,
- $contentHandlerUseDb = true
- ) {
- $lbFactory = $this->getMockLoadBalancerFactory();
- $blobStoreFactory = $this->getMockBlobStoreFactory();
- $nameTableStoreFactory = $this->getNameTableStoreFactory();
- $cache = $this->getHashWANObjectCache();
- $commentStore = $this->getMockCommentStore();
- $actorMigration = ActorMigration::newMigration();
- $loggerProvider = $this->getMockLoggerSpi();
-
- $factory = new RevisionStoreFactory(
- $lbFactory,
- $blobStoreFactory,
- $nameTableStoreFactory,
- $cache,
- $commentStore,
- $actorMigration,
- $mcrMigrationStage,
- $loggerProvider,
- $contentHandlerUseDb
- );
-
- $store = $factory->getRevisionStore( $wikiId );
- $wrapper = TestingAccessWrapper::newFromObject( $store );
-
- // ensure the correct object type is returned
- $this->assertInstanceOf( RevisionStore::class, $store );
-
- // ensure the RevisionStore is for the given wikiId
- $this->assertSame( $wikiId, $wrapper->wikiId );
-
- // ensure all other required services are correctly set
- $this->assertSame( $cache, $wrapper->cache );
- $this->assertSame( $commentStore, $wrapper->commentStore );
- $this->assertSame( $mcrMigrationStage, $wrapper->mcrMigrationStage );
- $this->assertSame( $actorMigration, $wrapper->actorMigration );
- $this->assertSame( $contentHandlerUseDb, $store->getContentHandlerUseDB() );
-
- $this->assertInstanceOf( ILoadBalancer::class, $wrapper->loadBalancer );
- $this->assertInstanceOf( BlobStore::class, $wrapper->blobStore );
- $this->assertInstanceOf( NameTableStore::class, $wrapper->contentModelStore );
- $this->assertInstanceOf( NameTableStore::class, $wrapper->slotRoleStore );
- $this->assertInstanceOf( LoggerInterface::class, $wrapper->logger );
- }
-
- /**
- * @return \PHPUnit_Framework_MockObject_MockObject|ILoadBalancer
- */
- private function getMockLoadBalancer() {
- return $this->getMockBuilder( ILoadBalancer::class )
- ->disableOriginalConstructor()->getMock();
- }
-
- /**
- * @return \PHPUnit_Framework_MockObject_MockObject|ILBFactory
- */
- private function getMockLoadBalancerFactory() {
- $mock = $this->getMockBuilder( ILBFactory::class )
- ->disableOriginalConstructor()->getMock();
-
- $mock->method( 'getMainLB' )
- ->willReturnCallback( function () {
- return $this->getMockLoadBalancer();
- } );
-
- return $mock;
- }
-
- /**
- * @return \PHPUnit_Framework_MockObject_MockObject|SqlBlobStore
- */
- private function getMockSqlBlobStore() {
- return $this->getMockBuilder( SqlBlobStore::class )
- ->disableOriginalConstructor()->getMock();
- }
-
- /**
- * @return \PHPUnit_Framework_MockObject_MockObject|BlobStoreFactory
- */
- private function getMockBlobStoreFactory() {
- $mock = $this->getMockBuilder( BlobStoreFactory::class )
- ->disableOriginalConstructor()->getMock();
-
- $mock->method( 'newSqlBlobStore' )
- ->willReturnCallback( function () {
- return $this->getMockSqlBlobStore();
- } );
-
- return $mock;
- }
-
- /**
- * @return NameTableStoreFactory
- */
- private function getNameTableStoreFactory() {
- return new NameTableStoreFactory(
- $this->getMockLoadBalancerFactory(),
- $this->getHashWANObjectCache(),
- new NullLogger() );
- }
-
- /**
- * @return \PHPUnit_Framework_MockObject_MockObject|CommentStore
- */
- private function getMockCommentStore() {
- return $this->getMockBuilder( CommentStore::class )
- ->disableOriginalConstructor()->getMock();
- }
-
- private function getHashWANObjectCache() {
- return new WANObjectCache( [ 'cache' => new \HashBagOStuff() ] );
- }
-
- /**
- * @return \PHPUnit_Framework_MockObject_MockObject|LoggerSpi
- */
- private function getMockLoggerSpi() {
- $mock = $this->getMock( LoggerSpi::class );
-
- $mock->method( 'getLogger' )
- ->willReturn( new NullLogger() );
-
- return $mock;
- }
-
-}
+++ /dev/null
-<?php
-
-namespace MediaWiki\Tests\Storage;
-
-use CommentStoreComment;
-use InvalidArgumentException;
-use MediaWiki\Storage\RevisionRecord;
-use MediaWiki\Storage\RevisionSlots;
-use MediaWiki\Storage\RevisionStoreRecord;
-use MediaWiki\Storage\SlotRecord;
-use MediaWiki\User\UserIdentity;
-use MediaWiki\User\UserIdentityValue;
-use MediaWikiTestCase;
-use TextContent;
-use Title;
-
-/**
- * @covers \MediaWiki\Storage\RevisionStoreRecord
- * @covers \MediaWiki\Storage\RevisionRecord
- */
-class RevisionStoreRecordTest extends MediaWikiTestCase {
-
- use RevisionRecordTests;
-
- /**
- * @param array $rowOverrides
- *
- * @return RevisionStoreRecord
- */
- protected function newRevision( array $rowOverrides = [] ) {
- $title = Title::newFromText( 'Dummy' );
- $title->resetArticleID( 17 );
-
- $user = new UserIdentityValue( 11, 'Tester', 0 );
- $comment = CommentStoreComment::newUnsavedComment( 'Hello World' );
-
- $main = SlotRecord::newUnsaved( SlotRecord::MAIN, new TextContent( 'Lorem Ipsum' ) );
- $aux = SlotRecord::newUnsaved( 'aux', new TextContent( 'Frumious Bandersnatch' ) );
- $slots = new RevisionSlots( [ $main, $aux ] );
-
- $row = [
- 'rev_id' => '7',
- 'rev_page' => strval( $title->getArticleID() ),
- 'rev_timestamp' => '20200101000000',
- 'rev_deleted' => 0,
- 'rev_minor_edit' => 0,
- 'rev_parent_id' => '5',
- 'rev_len' => $slots->computeSize(),
- 'rev_sha1' => $slots->computeSha1(),
- 'page_latest' => '18',
- ];
-
- $row = array_merge( $row, $rowOverrides );
-
- return new RevisionStoreRecord( $title, $user, $comment, (object)$row, $slots );
- }
-
- public function provideConstructor() {
- $title = Title::newFromText( 'Dummy' );
- $title->resetArticleID( 17 );
-
- $user = new UserIdentityValue( 11, 'Tester', 0 );
- $comment = CommentStoreComment::newUnsavedComment( 'Hello World' );
-
- $main = SlotRecord::newUnsaved( SlotRecord::MAIN, new TextContent( 'Lorem Ipsum' ) );
- $aux = SlotRecord::newUnsaved( 'aux', new TextContent( 'Frumious Bandersnatch' ) );
- $slots = new RevisionSlots( [ $main, $aux ] );
-
- $protoRow = [
- 'rev_id' => '7',
- 'rev_page' => strval( $title->getArticleID() ),
- 'rev_timestamp' => '20200101000000',
- 'rev_deleted' => 0,
- 'rev_minor_edit' => 0,
- 'rev_parent_id' => '5',
- 'rev_len' => $slots->computeSize(),
- 'rev_sha1' => $slots->computeSha1(),
- 'page_latest' => '18',
- ];
-
- $row = $protoRow;
- yield 'all info' => [
- $title,
- $user,
- $comment,
- (object)$row,
- $slots,
- 'acmewiki'
- ];
-
- $row = $protoRow;
- $row['rev_minor_edit'] = '1';
- $row['rev_deleted'] = strval( RevisionRecord::DELETED_USER );
-
- yield 'minor deleted' => [
- $title,
- $user,
- $comment,
- (object)$row,
- $slots
- ];
-
- $row = $protoRow;
- $row['page_latest'] = $row['rev_id'];
-
- yield 'latest' => [
- $title,
- $user,
- $comment,
- (object)$row,
- $slots
- ];
-
- $row = $protoRow;
- unset( $row['rev_parent'] );
-
- yield 'no parent' => [
- $title,
- $user,
- $comment,
- (object)$row,
- $slots
- ];
-
- $row = $protoRow;
- $row['rev_len'] = null;
- $row['rev_sha1'] = '';
-
- yield 'rev_len is null, rev_sha1 is ""' => [
- $title,
- $user,
- $comment,
- (object)$row,
- $slots
- ];
-
- $row = $protoRow;
- yield 'no length, no hash' => [
- Title::newFromText( 'DummyDoesNotExist' ),
- $user,
- $comment,
- (object)$row,
- $slots
- ];
- }
-
- /**
- * @dataProvider provideConstructor
- *
- * @param Title $title
- * @param UserIdentity $user
- * @param CommentStoreComment $comment
- * @param object $row
- * @param RevisionSlots $slots
- * @param bool $wikiId
- */
- public function testConstructorAndGetters(
- Title $title,
- UserIdentity $user,
- CommentStoreComment $comment,
- $row,
- RevisionSlots $slots,
- $wikiId = false
- ) {
- $rec = new RevisionStoreRecord( $title, $user, $comment, $row, $slots, $wikiId );
-
- $this->assertSame( $title, $rec->getPageAsLinkTarget(), 'getPageAsLinkTarget' );
- $this->assertSame( $user, $rec->getUser( RevisionRecord::RAW ), 'getUser' );
- $this->assertSame( $comment, $rec->getComment(), 'getComment' );
-
- $this->assertSame( $slots, $rec->getSlots(), 'getSlots' );
- $this->assertSame( $slots->getSlotRoles(), $rec->getSlotRoles(), 'getSlotRoles' );
- $this->assertSame( $slots->getSlots(), $rec->getSlots()->getSlots(), 'getSlots' );
- $this->assertSame( $wikiId, $rec->getWikiId(), 'getWikiId' );
-
- $this->assertSame( (int)$row->rev_id, $rec->getId(), 'getId' );
- $this->assertSame( (int)$row->rev_page, $rec->getPageId(), 'getId' );
- $this->assertSame( $row->rev_timestamp, $rec->getTimestamp(), 'getTimestamp' );
- $this->assertSame( (int)$row->rev_deleted, $rec->getVisibility(), 'getVisibility' );
- $this->assertSame( (bool)$row->rev_minor_edit, $rec->isMinor(), 'getIsMinor' );
-
- if ( isset( $row->rev_parent_id ) ) {
- $this->assertSame( (int)$row->rev_parent_id, $rec->getParentId(), 'getParentId' );
- } else {
- $this->assertSame( 0, $rec->getParentId(), 'getParentId' );
- }
-
- if ( isset( $row->rev_len ) ) {
- $this->assertSame( (int)$row->rev_len, $rec->getSize(), 'getSize' );
- } else {
- $this->assertSame( $slots->computeSize(), $rec->getSize(), 'getSize' );
- }
-
- if ( !empty( $row->rev_sha1 ) ) {
- $this->assertSame( $row->rev_sha1, $rec->getSha1(), 'getSha1' );
- } else {
- $this->assertSame( $slots->computeSha1(), $rec->getSha1(), 'getSha1' );
- }
-
- if ( isset( $row->page_latest ) ) {
- $this->assertSame(
- (int)$row->rev_id === (int)$row->page_latest,
- $rec->isCurrent(),
- 'isCurrent'
- );
- } else {
- $this->assertSame(
- false,
- $rec->isCurrent(),
- 'isCurrent'
- );
- }
- }
-
- public function provideConstructorFailure() {
- $title = Title::newFromText( 'Dummy' );
- $title->resetArticleID( 17 );
-
- $user = new UserIdentityValue( 11, 'Tester', 0 );
-
- $comment = CommentStoreComment::newUnsavedComment( 'Hello World' );
-
- $main = SlotRecord::newUnsaved( SlotRecord::MAIN, new TextContent( 'Lorem Ipsum' ) );
- $aux = SlotRecord::newUnsaved( 'aux', new TextContent( 'Frumious Bandersnatch' ) );
- $slots = new RevisionSlots( [ $main, $aux ] );
-
- $protoRow = [
- 'rev_id' => '7',
- 'rev_page' => strval( $title->getArticleID() ),
- 'rev_timestamp' => '20200101000000',
- 'rev_deleted' => 0,
- 'rev_minor_edit' => 0,
- 'rev_parent_id' => '5',
- 'rev_len' => $slots->computeSize(),
- 'rev_sha1' => $slots->computeSha1(),
- 'page_latest' => '18',
- ];
-
- yield 'not a row' => [
- $title,
- $user,
- $comment,
- 'not a row',
- $slots,
- 'acmewiki'
- ];
-
- $row = $protoRow;
- $row['rev_timestamp'] = 'kittens';
-
- yield 'bad timestamp' => [
- $title,
- $user,
- $comment,
- (object)$row,
- $slots
- ];
-
- $row = $protoRow;
- $row['rev_page'] = 99;
-
- yield 'page ID mismatch' => [
- $title,
- $user,
- $comment,
- (object)$row,
- $slots
- ];
-
- $row = $protoRow;
-
- yield 'bad wiki' => [
- $title,
- $user,
- $comment,
- (object)$row,
- $slots,
- 12345
- ];
- }
-
- /**
- * @dataProvider provideConstructorFailure
- *
- * @param Title $title
- * @param UserIdentity $user
- * @param CommentStoreComment $comment
- * @param object $row
- * @param RevisionSlots $slots
- * @param bool $wikiId
- */
- public function testConstructorFailure(
- Title $title,
- UserIdentity $user,
- CommentStoreComment $comment,
- $row,
- RevisionSlots $slots,
- $wikiId = false
- ) {
- $this->setExpectedException( InvalidArgumentException::class );
- new RevisionStoreRecord( $title, $user, $comment, $row, $slots, $wikiId );
- }
-
- public function provideIsCurrent() {
- yield [
- [
- 'rev_id' => 11,
- 'page_latest' => 11,
- ],
- true,
- ];
- yield [
- [
- 'rev_id' => 11,
- 'page_latest' => 10,
- ],
- false,
- ];
- }
-
- /**
- * @dataProvider provideIsCurrent
- */
- public function testIsCurrent( $row, $current ) {
- $rev = $this->newRevision( $row );
-
- $this->assertSame( $current, $rev->isCurrent(), 'isCurrent()' );
- }
-
- public function provideGetSlot_audience_latest() {
- return $this->provideAudienceCheckData( RevisionRecord::DELETED_TEXT );
- }
-
- /**
- * @dataProvider provideGetSlot_audience_latest
- */
- public function testGetSlot_audience_latest( $visibility, $groups, $userCan, $publicCan ) {
- $this->forceStandardPermissions();
-
- $user = $this->getTestUser( $groups )->getUser();
- $rev = $this->newRevision(
- [
- 'rev_deleted' => $visibility,
- 'rev_id' => 11,
- 'page_latest' => 11, // revision is current
- ]
- );
-
- // NOTE: slot meta-data is never suppressed, just the content is!
- $this->assertNotNull( $rev->getSlot( SlotRecord::MAIN, RevisionRecord::RAW ), 'raw can' );
- $this->assertNotNull( $rev->getSlot( SlotRecord::MAIN, RevisionRecord::FOR_PUBLIC ),
- 'public can' );
-
- $this->assertNotNull(
- $rev->getSlot( SlotRecord::MAIN, RevisionRecord::FOR_THIS_USER, $user ),
- 'user can'
- );
-
- $rev->getSlot( SlotRecord::MAIN, RevisionRecord::RAW )->getContent();
- // NOTE: the content of the current revision is never suppressed!
- // Check that getContent() doesn't throw SuppressedDataException
- $rev->getSlot( SlotRecord::MAIN, RevisionRecord::FOR_PUBLIC )->getContent();
- $rev->getSlot( SlotRecord::MAIN, RevisionRecord::FOR_THIS_USER, $user )->getContent();
- }
-
-}
+++ /dev/null
-<?php
-
-namespace MediaWiki\Tests\Storage;
-
-use CommentStore;
-use HashBagOStuff;
-use InvalidArgumentException;
-use Language;
-use MediaWiki\MediaWikiServices;
-use MediaWiki\Storage\RevisionAccessException;
-use MediaWiki\Storage\RevisionStore;
-use MediaWiki\Storage\SlotRecord;
-use MediaWiki\Storage\SqlBlobStore;
-use MediaWikiTestCase;
-use MWException;
-use Title;
-use WANObjectCache;
-use Wikimedia\Rdbms\Database;
-use Wikimedia\Rdbms\LoadBalancer;
-use Wikimedia\TestingAccessWrapper;
-use WikitextContent;
-
-class RevisionStoreTest extends MediaWikiTestCase {
-
- private function useTextId() {
- global $wgMultiContentRevisionSchemaMigrationStage;
-
- return (bool)( $wgMultiContentRevisionSchemaMigrationStage & SCHEMA_COMPAT_READ_OLD );
- }
-
- /**
- * @param LoadBalancer $loadBalancer
- * @param SqlBlobStore $blobStore
- * @param WANObjectCache $WANObjectCache
- *
- * @return RevisionStore
- */
- private function getRevisionStore(
- $loadBalancer = null,
- $blobStore = null,
- $WANObjectCache = null
- ) {
- global $wgMultiContentRevisionSchemaMigrationStage;
- // the migration stage should be irrelevant, since all the tests that interact with
- // the database are in RevisionStoreDbTest, not here.
-
- return new RevisionStore(
- $loadBalancer ?: $this->getMockLoadBalancer(),
- $blobStore ?: $this->getMockSqlBlobStore(),
- $WANObjectCache ?: $this->getHashWANObjectCache(),
- MediaWikiServices::getInstance()->getCommentStore(),
- MediaWikiServices::getInstance()->getContentModelStore(),
- MediaWikiServices::getInstance()->getSlotRoleStore(),
- $wgMultiContentRevisionSchemaMigrationStage,
- MediaWikiServices::getInstance()->getActorMigration()
- );
- }
-
- /**
- * @return \PHPUnit_Framework_MockObject_MockObject|LoadBalancer
- */
- private function getMockLoadBalancer() {
- return $this->getMockBuilder( LoadBalancer::class )
- ->disableOriginalConstructor()->getMock();
- }
-
- /**
- * @return \PHPUnit_Framework_MockObject_MockObject|Database
- */
- private function getMockDatabase() {
- return $this->getMockBuilder( Database::class )
- ->disableOriginalConstructor()->getMock();
- }
-
- /**
- * @return \PHPUnit_Framework_MockObject_MockObject|SqlBlobStore
- */
- private function getMockSqlBlobStore() {
- return $this->getMockBuilder( SqlBlobStore::class )
- ->disableOriginalConstructor()->getMock();
- }
-
- /**
- * @return \PHPUnit_Framework_MockObject_MockObject|CommentStore
- */
- private function getMockCommentStore() {
- return $this->getMockBuilder( CommentStore::class )
- ->disableOriginalConstructor()->getMock();
- }
-
- private function getHashWANObjectCache() {
- return new WANObjectCache( [ 'cache' => new \HashBagOStuff() ] );
- }
-
- public function provideSetContentHandlerUseDB() {
- return [
- // ContentHandlerUseDB can be true of false pre migration.
- [ false, SCHEMA_COMPAT_OLD, false ],
- [ true, SCHEMA_COMPAT_OLD, false ],
- // During and after migration it can not be false...
- [ false, SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD, true ],
- [ false, SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW, true ],
- [ false, SCHEMA_COMPAT_NEW, true ],
- // ...but it can be true.
- [ true, SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD, false ],
- [ true, SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW, false ],
- [ true, SCHEMA_COMPAT_NEW, false ],
- ];
- }
-
- /**
- * @dataProvider provideSetContentHandlerUseDB
- * @covers \MediaWiki\Storage\RevisionStore::getContentHandlerUseDB
- * @covers \MediaWiki\Storage\RevisionStore::setContentHandlerUseDB
- */
- public function testSetContentHandlerUseDB( $contentHandlerDb, $migrationMode, $expectedFail ) {
- if ( $expectedFail ) {
- $this->setExpectedException( MWException::class );
- }
-
- $nameTables = MediaWikiServices::getInstance()->getNameTableStoreFactory();
-
- $store = new RevisionStore(
- $this->getMockLoadBalancer(),
- $this->getMockSqlBlobStore(),
- $this->getHashWANObjectCache(),
- $this->getMockCommentStore(),
- $nameTables->getContentModels(),
- $nameTables->getSlotRoles(),
- $migrationMode,
- MediaWikiServices::getInstance()->getActorMigration()
- );
-
- $store->setContentHandlerUseDB( $contentHandlerDb );
- $this->assertSame( $contentHandlerDb, $store->getContentHandlerUseDB() );
- }
-
- public function testGetTitle_successFromPageId() {
- $mockLoadBalancer = $this->getMockLoadBalancer();
- // Title calls wfGetDB() so we have to set the main service
- $this->setService( 'DBLoadBalancer', $mockLoadBalancer );
-
- $db = $this->getMockDatabase();
- // Title calls wfGetDB() which uses a regular Connection
- $mockLoadBalancer->expects( $this->atLeastOnce() )
- ->method( 'getConnection' )
- ->willReturn( $db );
-
- // First call to Title::newFromID, faking no result (db lag?)
- $db->expects( $this->at( 0 ) )
- ->method( 'selectRow' )
- ->with(
- 'page',
- $this->anything(),
- [ 'page_id' => 1 ]
- )
- ->willReturn( (object)[
- 'page_namespace' => '1',
- 'page_title' => 'Food',
- ] );
-
- $store = $this->getRevisionStore( $mockLoadBalancer );
- $title = $store->getTitle( 1, 2, RevisionStore::READ_NORMAL );
-
- $this->assertSame( 1, $title->getNamespace() );
- $this->assertSame( 'Food', $title->getDBkey() );
- }
-
- public function testGetTitle_successFromPageIdOnFallback() {
- $mockLoadBalancer = $this->getMockLoadBalancer();
- // Title calls wfGetDB() so we have to set the main service
- $this->setService( 'DBLoadBalancer', $mockLoadBalancer );
-
- $db = $this->getMockDatabase();
- // Title calls wfGetDB() which uses a regular Connection
- // Assert that the first call uses a REPLICA and the second falls back to master
- $mockLoadBalancer->expects( $this->exactly( 2 ) )
- ->method( 'getConnection' )
- ->willReturn( $db );
- // RevisionStore getTitle uses a ConnectionRef
- $mockLoadBalancer->expects( $this->atLeastOnce() )
- ->method( 'getConnectionRef' )
- ->willReturn( $db );
-
- // First call to Title::newFromID, faking no result (db lag?)
- $db->expects( $this->at( 0 ) )
- ->method( 'selectRow' )
- ->with(
- 'page',
- $this->anything(),
- [ 'page_id' => 1 ]
- )
- ->willReturn( false );
-
- // First select using rev_id, faking no result (db lag?)
- $db->expects( $this->at( 1 ) )
- ->method( 'selectRow' )
- ->with(
- [ 'revision', 'page' ],
- $this->anything(),
- [ 'rev_id' => 2 ]
- )
- ->willReturn( false );
-
- // Second call to Title::newFromID, no result
- $db->expects( $this->at( 2 ) )
- ->method( 'selectRow' )
- ->with(
- 'page',
- $this->anything(),
- [ 'page_id' => 1 ]
- )
- ->willReturn( (object)[
- 'page_namespace' => '2',
- 'page_title' => 'Foodey',
- ] );
-
- $store = $this->getRevisionStore( $mockLoadBalancer );
- $title = $store->getTitle( 1, 2, RevisionStore::READ_NORMAL );
-
- $this->assertSame( 2, $title->getNamespace() );
- $this->assertSame( 'Foodey', $title->getDBkey() );
- }
-
- public function testGetTitle_successFromRevId() {
- $mockLoadBalancer = $this->getMockLoadBalancer();
- // Title calls wfGetDB() so we have to set the main service
- $this->setService( 'DBLoadBalancer', $mockLoadBalancer );
-
- $db = $this->getMockDatabase();
- // Title calls wfGetDB() which uses a regular Connection
- $mockLoadBalancer->expects( $this->atLeastOnce() )
- ->method( 'getConnection' )
- ->willReturn( $db );
- // RevisionStore getTitle uses a ConnectionRef
- $mockLoadBalancer->expects( $this->atLeastOnce() )
- ->method( 'getConnectionRef' )
- ->willReturn( $db );
-
- // First call to Title::newFromID, faking no result (db lag?)
- $db->expects( $this->at( 0 ) )
- ->method( 'selectRow' )
- ->with(
- 'page',
- $this->anything(),
- [ 'page_id' => 1 ]
- )
- ->willReturn( false );
-
- // First select using rev_id, faking no result (db lag?)
- $db->expects( $this->at( 1 ) )
- ->method( 'selectRow' )
- ->with(
- [ 'revision', 'page' ],
- $this->anything(),
- [ 'rev_id' => 2 ]
- )
- ->willReturn( (object)[
- 'page_namespace' => '1',
- 'page_title' => 'Food2',
- ] );
-
- $store = $this->getRevisionStore( $mockLoadBalancer );
- $title = $store->getTitle( 1, 2, RevisionStore::READ_NORMAL );
-
- $this->assertSame( 1, $title->getNamespace() );
- $this->assertSame( 'Food2', $title->getDBkey() );
- }
-
- public function testGetTitle_successFromRevIdOnFallback() {
- $mockLoadBalancer = $this->getMockLoadBalancer();
- // Title calls wfGetDB() so we have to set the main service
- $this->setService( 'DBLoadBalancer', $mockLoadBalancer );
-
- $db = $this->getMockDatabase();
- // Title calls wfGetDB() which uses a regular Connection
- // Assert that the first call uses a REPLICA and the second falls back to master
- $mockLoadBalancer->expects( $this->exactly( 2 ) )
- ->method( 'getConnection' )
- ->willReturn( $db );
- // RevisionStore getTitle uses a ConnectionRef
- $mockLoadBalancer->expects( $this->atLeastOnce() )
- ->method( 'getConnectionRef' )
- ->willReturn( $db );
-
- // First call to Title::newFromID, faking no result (db lag?)
- $db->expects( $this->at( 0 ) )
- ->method( 'selectRow' )
- ->with(
- 'page',
- $this->anything(),
- [ 'page_id' => 1 ]
- )
- ->willReturn( false );
-
- // First select using rev_id, faking no result (db lag?)
- $db->expects( $this->at( 1 ) )
- ->method( 'selectRow' )
- ->with(
- [ 'revision', 'page' ],
- $this->anything(),
- [ 'rev_id' => 2 ]
- )
- ->willReturn( false );
-
- // Second call to Title::newFromID, no result
- $db->expects( $this->at( 2 ) )
- ->method( 'selectRow' )
- ->with(
- 'page',
- $this->anything(),
- [ 'page_id' => 1 ]
- )
- ->willReturn( false );
-
- // Second select using rev_id, result
- $db->expects( $this->at( 3 ) )
- ->method( 'selectRow' )
- ->with(
- [ 'revision', 'page' ],
- $this->anything(),
- [ 'rev_id' => 2 ]
- )
- ->willReturn( (object)[
- 'page_namespace' => '2',
- 'page_title' => 'Foodey',
- ] );
-
- $store = $this->getRevisionStore( $mockLoadBalancer );
- $title = $store->getTitle( 1, 2, RevisionStore::READ_NORMAL );
-
- $this->assertSame( 2, $title->getNamespace() );
- $this->assertSame( 'Foodey', $title->getDBkey() );
- }
-
- /**
- * @covers \MediaWiki\Storage\RevisionStore::getTitle
- */
- public function testGetTitle_correctFallbackAndthrowsExceptionAfterFallbacks() {
- $mockLoadBalancer = $this->getMockLoadBalancer();
- // Title calls wfGetDB() so we have to set the main service
- $this->setService( 'DBLoadBalancer', $mockLoadBalancer );
-
- $db = $this->getMockDatabase();
- // Title calls wfGetDB() which uses a regular Connection
- // Assert that the first call uses a REPLICA and the second falls back to master
-
- // RevisionStore getTitle uses getConnectionRef
- // Title::newFromID uses getConnection
- foreach ( [ 'getConnection', 'getConnectionRef' ] as $method ) {
- $mockLoadBalancer->expects( $this->exactly( 2 ) )
- ->method( $method )
- ->willReturnCallback( function ( $masterOrReplica ) use ( $db ) {
- static $callCounter = 0;
- $callCounter++;
- // The first call should be to a REPLICA, and the second a MASTER.
- if ( $callCounter === 1 ) {
- $this->assertSame( DB_REPLICA, $masterOrReplica );
- } elseif ( $callCounter === 2 ) {
- $this->assertSame( DB_MASTER, $masterOrReplica );
- }
- return $db;
- } );
- }
- // First and third call to Title::newFromID, faking no result
- foreach ( [ 0, 2 ] as $counter ) {
- $db->expects( $this->at( $counter ) )
- ->method( 'selectRow' )
- ->with(
- 'page',
- $this->anything(),
- [ 'page_id' => 1 ]
- )
- ->willReturn( false );
- }
-
- foreach ( [ 1, 3 ] as $counter ) {
- $db->expects( $this->at( $counter ) )
- ->method( 'selectRow' )
- ->with(
- [ 'revision', 'page' ],
- $this->anything(),
- [ 'rev_id' => 2 ]
- )
- ->willReturn( false );
- }
-
- $store = $this->getRevisionStore( $mockLoadBalancer );
-
- $this->setExpectedException( RevisionAccessException::class );
- $store->getTitle( 1, 2, RevisionStore::READ_NORMAL );
- }
-
- public function provideNewRevisionFromRow_legacyEncoding_applied() {
- yield 'windows-1252, old_flags is empty' => [
- 'windows-1252',
- 'en',
- [
- 'old_flags' => '',
- 'old_text' => "S\xF6me Content",
- ],
- 'Söme Content'
- ];
-
- yield 'windows-1252, old_flags is null' => [
- 'windows-1252',
- 'en',
- [
- 'old_flags' => null,
- 'old_text' => "S\xF6me Content",
- ],
- 'Söme Content'
- ];
- }
-
- /**
- * @dataProvider provideNewRevisionFromRow_legacyEncoding_applied
- *
- * @covers \MediaWiki\Storage\RevisionStore::newRevisionFromRow
- */
- public function testNewRevisionFromRow_legacyEncoding_applied( $encoding, $locale, $row, $text ) {
- if ( !$this->useTextId() ) {
- $this->markTestSkipped( 'No longer applicable with MCR schema' );
- }
-
- $cache = new WANObjectCache( [ 'cache' => new HashBagOStuff() ] );
- $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
-
- $blobStore = new SqlBlobStore( $lb, $cache );
- $blobStore->setLegacyEncoding( $encoding, Language::factory( $locale ) );
-
- $store = $this->getRevisionStore( $lb, $blobStore, $cache );
-
- $record = $store->newRevisionFromRow(
- $this->makeRow( $row ),
- 0,
- Title::newFromText( __METHOD__ . '-UTPage' )
- );
-
- $this->assertSame( $text, $record->getContent( SlotRecord::MAIN )->serialize() );
- }
-
- /**
- * @covers \MediaWiki\Storage\RevisionStore::newRevisionFromRow
- */
- public function testNewRevisionFromRow_legacyEncoding_ignored() {
- if ( !$this->useTextId() ) {
- $this->markTestSkipped( 'No longer applicable with MCR schema' );
- }
-
- $row = [
- 'old_flags' => 'utf-8',
- 'old_text' => 'Söme Content',
- ];
-
- $cache = new WANObjectCache( [ 'cache' => new HashBagOStuff() ] );
- $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
-
- $blobStore = new SqlBlobStore( $lb, $cache );
- $blobStore->setLegacyEncoding( 'windows-1252', Language::factory( 'en' ) );
-
- $store = $this->getRevisionStore( $lb, $blobStore, $cache );
-
- $record = $store->newRevisionFromRow(
- $this->makeRow( $row ),
- 0,
- Title::newFromText( __METHOD__ . '-UTPage' )
- );
- $this->assertSame( 'Söme Content', $record->getContent( SlotRecord::MAIN )->serialize() );
- }
-
- private function makeRow( array $array ) {
- $row = $array + [
- 'rev_id' => 7,
- 'rev_page' => 5,
- 'rev_timestamp' => '20110101000000',
- 'rev_user_text' => 'Tester',
- 'rev_user' => 17,
- 'rev_minor_edit' => 0,
- 'rev_deleted' => 0,
- 'rev_len' => 100,
- 'rev_parent_id' => 0,
- 'rev_sha1' => 'deadbeef',
- 'rev_comment_text' => 'Testing',
- 'rev_comment_data' => '{}',
- 'rev_comment_cid' => 111,
- 'page_namespace' => 0,
- 'page_title' => 'TEST',
- 'page_id' => 5,
- 'page_latest' => 7,
- 'page_is_redirect' => 0,
- 'page_len' => 100,
- 'user_name' => 'Tester',
- ];
-
- if ( $this->useTextId() ) {
- $row += [
- 'rev_content_format' => CONTENT_FORMAT_TEXT,
- 'rev_content_model' => CONTENT_MODEL_TEXT,
- 'rev_text_id' => 11,
- 'old_id' => 11,
- 'old_text' => 'Hello World',
- 'old_flags' => 'utf-8',
- ];
- } else {
- if ( !isset( $row['content'] ) && isset( $array['old_text'] ) ) {
- $row['content'] = [
- 'main' => new WikitextContent( $array['old_text'] ),
- ];
- }
- }
-
- return (object)$row;
- }
-
- public function provideMigrationConstruction() {
- return [
- [ SCHEMA_COMPAT_OLD, false ],
- [ SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD, false ],
- [ SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW, false ],
- [ SCHEMA_COMPAT_NEW, false ],
- [ SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_BOTH, true ],
- [ SCHEMA_COMPAT_WRITE_OLD | SCHEMA_COMPAT_READ_BOTH, true ],
- [ SCHEMA_COMPAT_WRITE_NEW | SCHEMA_COMPAT_READ_BOTH, true ],
- ];
- }
-
- /**
- * @covers \MediaWiki\Storage\RevisionStore::__construct
- * @dataProvider provideMigrationConstruction
- */
- public function testMigrationConstruction( $migration, $expectException ) {
- if ( $expectException ) {
- $this->setExpectedException( InvalidArgumentException::class );
- }
- $loadBalancer = $this->getMockLoadBalancer();
- $blobStore = $this->getMockSqlBlobStore();
- $cache = $this->getHashWANObjectCache();
- $commentStore = $this->getMockCommentStore();
- $services = MediaWikiServices::getInstance();
- $nameTables = $services->getNameTableStoreFactory();
- $contentModelStore = $nameTables->getContentModels();
- $slotRoleStore = $nameTables->getSlotRoles();
- $store = new RevisionStore(
- $loadBalancer,
- $blobStore,
- $cache,
- $commentStore,
- $nameTables->getContentModels(),
- $nameTables->getSlotRoles(),
- $migration,
- $services->getActorMigration()
- );
- if ( !$expectException ) {
- $store = TestingAccessWrapper::newFromObject( $store );
- $this->assertSame( $loadBalancer, $store->loadBalancer );
- $this->assertSame( $blobStore, $store->blobStore );
- $this->assertSame( $cache, $store->cache );
- $this->assertSame( $commentStore, $store->commentStore );
- $this->assertSame( $contentModelStore, $store->contentModelStore );
- $this->assertSame( $slotRoleStore, $store->slotRoleStore );
- $this->assertSame( $migration, $store->mcrMigrationStage );
- }
- }
-
-}
+++ /dev/null
-<?php
-
-namespace MediaWiki\Tests\Storage;
-
-use InvalidArgumentException;
-use LogicException;
-use MediaWiki\Storage\IncompleteRevisionException;
-use MediaWiki\Storage\SlotRecord;
-use MediaWiki\Storage\SuppressedDataException;
-use MediaWikiTestCase;
-use WikitextContent;
-
-/**
- * @covers \MediaWiki\Storage\SlotRecord
- */
-class SlotRecordTest extends MediaWikiTestCase {
-
- private function makeRow( $data = [] ) {
- $data = $data + [
- 'slot_id' => 1234,
- 'slot_content_id' => 33,
- 'content_size' => '5',
- 'content_sha1' => 'someHash',
- 'content_address' => 'tt:456',
- 'model_name' => CONTENT_MODEL_WIKITEXT,
- 'format_name' => CONTENT_FORMAT_WIKITEXT,
- 'slot_revision_id' => '2',
- 'slot_origin' => '1',
- 'role_name' => 'myRole',
- ];
- return (object)$data;
- }
-
- public function testCompleteConstruction() {
- $row = $this->makeRow();
- $record = new SlotRecord( $row, new WikitextContent( 'A' ) );
-
- $this->assertTrue( $record->hasAddress() );
- $this->assertTrue( $record->hasContentId() );
- $this->assertTrue( $record->hasRevision() );
- $this->assertTrue( $record->isInherited() );
- $this->assertSame( 'A', $record->getContent()->getNativeData() );
- $this->assertSame( 5, $record->getSize() );
- $this->assertSame( 'someHash', $record->getSha1() );
- $this->assertSame( CONTENT_MODEL_WIKITEXT, $record->getModel() );
- $this->assertSame( 2, $record->getRevision() );
- $this->assertSame( 1, $record->getOrigin() );
- $this->assertSame( 'tt:456', $record->getAddress() );
- $this->assertSame( 33, $record->getContentId() );
- $this->assertSame( CONTENT_FORMAT_WIKITEXT, $record->getFormat() );
- $this->assertSame( 'myRole', $record->getRole() );
- }
-
- public function testConstructionDeferred() {
- $row = $this->makeRow( [
- 'content_size' => null, // to be computed
- 'content_sha1' => null, // to be computed
- 'format_name' => function () {
- return CONTENT_FORMAT_WIKITEXT;
- },
- 'slot_revision_id' => '2',
- 'slot_origin' => '2',
- 'slot_content_id' => function () {
- return null;
- },
- ] );
-
- $content = function () {
- return new WikitextContent( 'A' );
- };
-
- $record = new SlotRecord( $row, $content );
-
- $this->assertTrue( $record->hasAddress() );
- $this->assertTrue( $record->hasRevision() );
- $this->assertFalse( $record->hasContentId() );
- $this->assertFalse( $record->isInherited() );
- $this->assertSame( 'A', $record->getContent()->getNativeData() );
- $this->assertSame( 1, $record->getSize() );
- $this->assertNotNull( $record->getSha1() );
- $this->assertSame( CONTENT_MODEL_WIKITEXT, $record->getModel() );
- $this->assertSame( 2, $record->getRevision() );
- $this->assertSame( 2, $record->getRevision() );
- $this->assertSame( 'tt:456', $record->getAddress() );
- $this->assertSame( CONTENT_FORMAT_WIKITEXT, $record->getFormat() );
- $this->assertSame( 'myRole', $record->getRole() );
- }
-
- public function testNewUnsaved() {
- $record = SlotRecord::newUnsaved( 'myRole', new WikitextContent( 'A' ) );
-
- $this->assertFalse( $record->hasAddress() );
- $this->assertFalse( $record->hasContentId() );
- $this->assertFalse( $record->hasRevision() );
- $this->assertFalse( $record->isInherited() );
- $this->assertFalse( $record->hasOrigin() );
- $this->assertSame( 'A', $record->getContent()->getNativeData() );
- $this->assertSame( 1, $record->getSize() );
- $this->assertNotNull( $record->getSha1() );
- $this->assertSame( CONTENT_MODEL_WIKITEXT, $record->getModel() );
- $this->assertSame( 'myRole', $record->getRole() );
- }
-
- public function provideInvalidConstruction() {
- yield 'both null' => [ null, null ];
- yield 'null row' => [ null, new WikitextContent( 'A' ) ];
- yield 'array row' => [ [], new WikitextContent( 'A' ) ];
- yield 'empty row' => [ (object)[], new WikitextContent( 'A' ) ];
- yield 'null content' => [ (object)[], null ];
- }
-
- /**
- * @dataProvider provideInvalidConstruction
- */
- public function testInvalidConstruction( $row, $content ) {
- $this->setExpectedException( InvalidArgumentException::class );
- new SlotRecord( $row, $content );
- }
-
- public function testGetContentId_fails() {
- $record = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) );
- $this->setExpectedException( IncompleteRevisionException::class );
-
- $record->getContentId();
- }
-
- public function testGetAddress_fails() {
- $record = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) );
- $this->setExpectedException( IncompleteRevisionException::class );
-
- $record->getAddress();
- }
-
- public function provideIncomplete() {
- $unsaved = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) );
- yield 'unsaved' => [ $unsaved ];
-
- $parent = new SlotRecord( $this->makeRow(), new WikitextContent( 'A' ) );
- $inherited = SlotRecord::newInherited( $parent );
- yield 'inherited' => [ $inherited ];
- }
-
- /**
- * @dataProvider provideIncomplete
- */
- public function testGetRevision_fails( SlotRecord $record ) {
- $record = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) );
- $this->setExpectedException( IncompleteRevisionException::class );
-
- $record->getRevision();
- }
-
- /**
- * @dataProvider provideIncomplete
- */
- public function testGetOrigin_fails( SlotRecord $record ) {
- $record = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) );
- $this->setExpectedException( IncompleteRevisionException::class );
-
- $record->getOrigin();
- }
-
- public function provideHashStability() {
- yield [ '', 'phoiac9h4m842xq45sp7s6u21eteeq1' ];
- yield [ 'Lorem ipsum', 'hcr5u40uxr81d3nx89nvwzclfz6r9c5' ];
- }
-
- /**
- * @dataProvider provideHashStability
- */
- public function testHashStability( $text, $hash ) {
- // Changing the output of the hash function will break things horribly!
-
- $this->assertSame( $hash, SlotRecord::base36Sha1( $text ) );
-
- $record = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( $text ) );
- $this->assertSame( $hash, $record->getSha1() );
- }
-
- public function testNewWithSuppressedContent() {
- $input = new SlotRecord( $this->makeRow(), new WikitextContent( 'A' ) );
- $output = SlotRecord::newWithSuppressedContent( $input );
-
- $this->setExpectedException( SuppressedDataException::class );
- $output->getContent();
- }
-
- public function testNewInherited() {
- $row = $this->makeRow( [ 'slot_revision_id' => 7, 'slot_origin' => 7 ] );
- $parent = new SlotRecord( $row, new WikitextContent( 'A' ) );
-
- // This would happen while doing an edit, before saving revision meta-data.
- $inherited = SlotRecord::newInherited( $parent );
-
- $this->assertSame( $parent->getContentId(), $inherited->getContentId() );
- $this->assertSame( $parent->getAddress(), $inherited->getAddress() );
- $this->assertSame( $parent->getContent(), $inherited->getContent() );
- $this->assertTrue( $inherited->isInherited() );
- $this->assertTrue( $inherited->hasOrigin() );
- $this->assertFalse( $inherited->hasRevision() );
-
- // make sure we didn't mess with the internal state of $parent
- $this->assertFalse( $parent->isInherited() );
- $this->assertSame( 7, $parent->getRevision() );
-
- // This would happen while doing an edit, after saving the revision meta-data
- // and content meta-data.
- $saved = SlotRecord::newSaved(
- 10,
- $inherited->getContentId(),
- $inherited->getAddress(),
- $inherited
- );
- $this->assertSame( $parent->getContentId(), $saved->getContentId() );
- $this->assertSame( $parent->getAddress(), $saved->getAddress() );
- $this->assertSame( $parent->getContent(), $saved->getContent() );
- $this->assertTrue( $saved->isInherited() );
- $this->assertTrue( $saved->hasRevision() );
- $this->assertSame( 10, $saved->getRevision() );
-
- // make sure we didn't mess with the internal state of $parent or $inherited
- $this->assertSame( 7, $parent->getRevision() );
- $this->assertFalse( $inherited->hasRevision() );
- }
-
- public function testNewSaved() {
- // This would happen while doing an edit, before saving revision meta-data.
- $unsaved = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) );
-
- // This would happen while doing an edit, after saving the revision meta-data
- // and content meta-data.
- $saved = SlotRecord::newSaved( 10, 20, 'theNewAddress', $unsaved );
- $this->assertFalse( $saved->isInherited() );
- $this->assertTrue( $saved->hasOrigin() );
- $this->assertTrue( $saved->hasRevision() );
- $this->assertTrue( $saved->hasAddress() );
- $this->assertTrue( $saved->hasContentId() );
- $this->assertSame( 'theNewAddress', $saved->getAddress() );
- $this->assertSame( 20, $saved->getContentId() );
- $this->assertSame( 'A', $saved->getContent()->getNativeData() );
- $this->assertSame( 10, $saved->getRevision() );
- $this->assertSame( 10, $saved->getOrigin() );
-
- // make sure we didn't mess with the internal state of $unsaved
- $this->assertFalse( $unsaved->hasAddress() );
- $this->assertFalse( $unsaved->hasContentId() );
- $this->assertFalse( $unsaved->hasRevision() );
- }
-
- public function provideNewSaved_LogicException() {
- $freshRow = $this->makeRow( [
- 'content_id' => 10,
- 'content_address' => 'address:1',
- 'slot_origin' => 1,
- 'slot_revision_id' => 1,
- ] );
-
- $freshSlot = new SlotRecord( $freshRow, new WikitextContent( 'A' ) );
- yield 'mismatching address' => [ 1, 10, 'address:BAD', $freshSlot ];
- yield 'mismatching revision' => [ 5, 10, 'address:1', $freshSlot ];
- yield 'mismatching content ID' => [ 1, 17, 'address:1', $freshSlot ];
-
- $inheritedRow = $this->makeRow( [
- 'content_id' => null,
- 'content_address' => null,
- 'slot_origin' => 0,
- 'slot_revision_id' => 1,
- ] );
-
- $inheritedSlot = new SlotRecord( $inheritedRow, new WikitextContent( 'A' ) );
- yield 'inherited, but no address' => [ 1, 10, 'address:2', $inheritedSlot ];
- }
-
- /**
- * @dataProvider provideNewSaved_LogicException
- */
- public function testNewSaved_LogicException(
- $revisionId,
- $contentId,
- $contentAddress,
- SlotRecord $protoSlot
- ) {
- $this->setExpectedException( LogicException::class );
- SlotRecord::newSaved( $revisionId, $contentId, $contentAddress, $protoSlot );
- }
-
- public function provideNewSaved_InvalidArgumentException() {
- $unsaved = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) );
-
- yield 'bad revision id' => [ 'xyzzy', 5, 'address', $unsaved ];
- yield 'bad content id' => [ 7, 'xyzzy', 'address', $unsaved ];
- yield 'bad content address' => [ 7, 5, 77, $unsaved ];
- }
-
- /**
- * @dataProvider provideNewSaved_InvalidArgumentException
- */
- public function testNewSaved_InvalidArgumentException(
- $revisionId,
- $contentId,
- $contentAddress,
- SlotRecord $protoSlot
- ) {
- $this->setExpectedException( InvalidArgumentException::class );
- SlotRecord::newSaved( $revisionId, $contentId, $contentAddress, $protoSlot );
- }
-
- public function provideHasSameContent() {
- $fail = function () {
- self::fail( 'There should be no need to actually load the content.' );
- };
-
- $a100a1 = new SlotRecord(
- $this->makeRow(
- [
- 'model_name' => 'A',
- 'content_size' => 100,
- 'content_sha1' => 'hash-a',
- 'content_address' => 'xxx:a1',
- ]
- ),
- $fail
- );
- $a100a1b = new SlotRecord(
- $this->makeRow(
- [
- 'model_name' => 'A',
- 'content_size' => 100,
- 'content_sha1' => 'hash-a',
- 'content_address' => 'xxx:a1',
- ]
- ),
- $fail
- );
- $a100null = new SlotRecord(
- $this->makeRow(
- [
- 'model_name' => 'A',
- 'content_size' => 100,
- 'content_sha1' => 'hash-a',
- 'content_address' => null,
- ]
- ),
- $fail
- );
- $a100a2 = new SlotRecord(
- $this->makeRow(
- [
- 'model_name' => 'A',
- 'content_size' => 100,
- 'content_sha1' => 'hash-a',
- 'content_address' => 'xxx:a2',
- ]
- ),
- $fail
- );
- $b100a1 = new SlotRecord(
- $this->makeRow(
- [
- 'model_name' => 'B',
- 'content_size' => 100,
- 'content_sha1' => 'hash-a',
- 'content_address' => 'xxx:a1',
- ]
- ),
- $fail
- );
- $a200a1 = new SlotRecord(
- $this->makeRow(
- [
- 'model_name' => 'A',
- 'content_size' => 200,
- 'content_sha1' => 'hash-a',
- 'content_address' => 'xxx:a2',
- ]
- ),
- $fail
- );
- $a100x1 = new SlotRecord(
- $this->makeRow(
- [
- 'model_name' => 'A',
- 'content_size' => 100,
- 'content_sha1' => 'hash-x',
- 'content_address' => 'xxx:x1',
- ]
- ),
- $fail
- );
-
- yield 'same instance' => [ $a100a1, $a100a1, true ];
- yield 'no address' => [ $a100a1, $a100null, true ];
- yield 'same address' => [ $a100a1, $a100a1b, true ];
- yield 'different address' => [ $a100a1, $a100a2, true ];
- yield 'different model' => [ $a100a1, $b100a1, false ];
- yield 'different size' => [ $a100a1, $a200a1, false ];
- yield 'different hash' => [ $a100a1, $a100x1, false ];
- }
-
- /**
- * @dataProvider provideHasSameContent
- */
- public function testHasSameContent( SlotRecord $a, SlotRecord $b, $sameContent ) {
- $this->assertSame( $sameContent, $a->hasSameContent( $b ) );
- $this->assertSame( $sameContent, $b->hasSameContent( $a ) );
- }
-
-}
+++ /dev/null
-ALTER TABLE /*_*/revision ADD rev_text_id INTEGER DEFAULT 0;
-ALTER TABLE /*_*/revision ADD rev_content_model VARBINARY(32) DEFAULT NULL;
-ALTER TABLE /*_*/revision ADD rev_content_format VARBINARY(64) DEFAULT NULL;
+++ /dev/null
-DROP TABLE /*_*/slots;
-DROP TABLE /*_*/content;
-DROP TABLE /*_*/content_models;
-DROP TABLE /*_*/slot_roles;
+++ /dev/null
-ALTER TABLE /*_*/revision DROP COLUMN rev_text_id;
-ALTER TABLE /*_*/revision DROP COLUMN rev_content_model;
-ALTER TABLE /*_*/revision DROP COLUMN rev_content_format;
+++ /dev/null
-DROP TABLE /*_*/revision;
-
-CREATE TABLE /*_*/revision (
- rev_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
- rev_page INTEGER NOT NULL,
- rev_comment BLOB NOT NULL,
- rev_user INTEGER NOT NULL default 0,
- rev_user_text varchar(255) NOT NULL default '',
- rev_timestamp blob(14) NOT NULL default '',
- rev_minor_edit INTEGER NOT NULL default 0,
- rev_deleted INTEGER NOT NULL default 0,
- rev_len INTEGER unsigned,
- rev_parent_id INTEGER default NULL,
- rev_sha1 varbinary(32) NOT NULL default ''
-) /*$wgDBTableOptions*/;
* @covers Title::checkUserConfigPermissions
*/
public function testJsConfigEditPermissions() {
- $prefix = MediaWikiServices::getInstance()->getContentLanguage()->
- getFormattedNsText( NS_PROJECT );
$this->setUser( $this->userName );
$this->setTitle( NS_USER, $this->userName . '/test.js' );
[ [ 'badaccess-group0' ], [ 'mycustomjsprotected', 'bogus' ] ],
[ [ 'badaccess-group0' ], [ 'mycustomjsprotected', 'bogus' ] ],
[ [ 'badaccess-group0' ] ],
- [ [ 'badaccess-groups', "[[$prefix:Administrators|Administrators]]", 1 ] ]
+ [ [ 'badaccess-groups' ] ]
);
}
[ [ 'badaccess-group0' ], [ 'mycustomjsonprotected', 'bogus' ] ],
[ [ 'badaccess-group0' ] ],
[ [ 'badaccess-group0' ], [ 'mycustomjsonprotected', 'bogus' ] ],
- [ [ 'badaccess-groups', "[[$prefix:Administrators|Administrators]]", 1 ] ]
+ [ [ 'badaccess-groups' ] ]
);
}
* @covers Title::checkUserConfigPermissions
*/
public function testCssConfigEditPermissions() {
- $prefix = MediaWikiServices::getInstance()->getContentLanguage()->
- getFormattedNsText( NS_PROJECT );
$this->setUser( $this->userName );
$this->setTitle( NS_USER, $this->userName . '/test.css' );
[ [ 'badaccess-group0' ] ],
[ [ 'badaccess-group0' ], [ 'mycustomcssprotected', 'bogus' ] ],
[ [ 'badaccess-group0' ], [ 'mycustomcssprotected', 'bogus' ] ],
- [ [ 'badaccess-groups', "[[$prefix:Administrators|Administrators]]", 1 ] ]
+ [ [ 'badaccess-groups' ] ]
);
}
* @covers Title::checkUserConfigPermissions
*/
public function testOtherJsConfigEditPermissions() {
- $prefix = MediaWikiServices::getInstance()->getContentLanguage()->
- getFormattedNsText( NS_PROJECT );
$this->setUser( $this->userName );
$this->setTitle( NS_USER, $this->altUserName . '/test.js' );
[ [ 'badaccess-group0' ], [ 'customjsprotected', 'bogus' ] ],
[ [ 'badaccess-group0' ], [ 'customjsprotected', 'bogus' ] ],
[ [ 'badaccess-group0' ] ],
- [ [ 'badaccess-groups', "[[$prefix:Administrators|Administrators]]", 1 ] ]
+ [ [ 'badaccess-groups' ] ]
);
}
* @covers Title::checkUserConfigPermissions
*/
public function testOtherJsonConfigEditPermissions() {
- $prefix = MediaWikiServices::getInstance()->getContentLanguage()->
- getFormattedNsText( NS_PROJECT );
$this->setUser( $this->userName );
$this->setTitle( NS_USER, $this->altUserName . '/test.json' );
[ [ 'badaccess-group0' ], [ 'customjsonprotected', 'bogus' ] ],
[ [ 'badaccess-group0' ] ],
[ [ 'badaccess-group0' ], [ 'customjsonprotected', 'bogus' ] ],
- [ [ 'badaccess-groups', "[[$prefix:Administrators|Administrators]]", 1 ] ]
+ [ [ 'badaccess-groups' ] ]
);
}
* @covers Title::checkUserConfigPermissions
*/
public function testOtherCssConfigEditPermissions() {
- $prefix = MediaWikiServices::getInstance()->getContentLanguage()->
- getFormattedNsText( NS_PROJECT );
$this->setUser( $this->userName );
$this->setTitle( NS_USER, $this->altUserName . '/test.css' );
[ [ 'badaccess-group0' ] ],
[ [ 'badaccess-group0' ], [ 'customcssprotected', 'bogus' ] ],
[ [ 'badaccess-group0' ], [ 'customcssprotected', 'bogus' ] ],
- [ [ 'badaccess-groups', "[[$prefix:Administrators|Administrators]]", 1 ] ]
+ [ [ 'badaccess-groups' ] ]
);
}
* @covers Title::checkUserConfigPermissions
*/
public function testOtherNonConfigEditPermissions() {
- $prefix = MediaWikiServices::getInstance()->getContentLanguage()->
- getFormattedNsText( NS_PROJECT );
$this->setUser( $this->userName );
$this->setTitle( NS_USER, $this->altUserName . '/tempo' );
[ [ 'badaccess-group0' ] ],
[ [ 'badaccess-group0' ] ],
[ [ 'badaccess-group0' ] ],
- [ [ 'badaccess-groups', "[[$prefix:Administrators|Administrators]]", 1 ] ]
+ [ [ 'badaccess-groups' ] ]
);
}
* @covers Title::checkUserConfigPermissions
*/
public function testPatrolActionConfigEditPermissions() {
- $prefix = MediaWikiServices::getInstance()->getContentLanguage()->
- getFormattedNsText( NS_PROJECT );
$this->setUser( 'anon' );
$this->setTitle( NS_USER, 'ToPatrolOrNotToPatrol' );
$this->runConfigEditPermissions(
[ [ 'badaccess-group0' ] ],
[ [ 'badaccess-group0' ] ],
[ [ 'badaccess-group0' ] ],
- [ [ 'badaccess-groups', "[[$prefix:Administrators|Administrators]]", 1 ] ]
+ [ [ 'badaccess-groups' ] ]
);
}
$this->setUserPerm( '' );
$result = $this->title->getUserPermissionsErrors( 'patrol', $this->user );
- $this->assertEquals( $resultPatrol, $result );
+ $this->assertEquals( reset( $resultPatrol[0] ), reset( $result[0] ) );
$this->setUserPerm( [ 'edituserjs', 'edituserjson', 'editusercss' ] );
$result = $this->title->getUserPermissionsErrors( 'bogus', $this->user );
* @covers ApiLogin
*/
class ApiLoginTest extends ApiTestCase {
+ public function setUp() {
+ parent::setUp();
+
+ $this->tablesUsed[] = 'bot_passwords';
+ }
+
+ public static function provideEnableBotPasswords() {
+ return [
+ 'Bot passwords enabled' => [ true ],
+ 'Bot passwords disabled' => [ false ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideEnableBotPasswords
+ */
+ public function testExtendedDescription( $enableBotPasswords ) {
+ $this->setMwGlobals( 'wgEnableBotPasswords', $enableBotPasswords );
+ $ret = $this->doApiRequest( [
+ 'action' => 'paraminfo',
+ 'modules' => 'login',
+ 'helpformat' => 'raw',
+ ] );
+ $this->assertSame(
+ 'apihelp-login-extended-description' . ( $enableBotPasswords ? '' : '-nobotpasswords' ),
+ $ret[0]['paraminfo']['modules'][0]['description'][1]['key']
+ );
+ }
/**
* Test result of attempted login with an empty username
$this->assertSame( 'Failed', $ret[0]['login']['result'] );
}
- private function doUserLogin( $name, $password ) {
+ /**
+ * @dataProvider provideEnableBotPasswords
+ */
+ public function testDeprecatedUserLogin( $enableBotPasswords ) {
+ $this->setMwGlobals( 'wgEnableBotPasswords', $enableBotPasswords );
+
+ $user = $this->getTestUser();
+
$ret = $this->doApiRequest( [
'action' => 'login',
- 'lgname' => $name,
+ 'lgname' => $user->getUser()->getName(),
] );
+ $this->assertSame(
+ [ 'warnings' => ApiErrorFormatter::stripMarkup( wfMessage(
+ 'apiwarn-deprecation-login-token' )->text() ) ],
+ $ret[0]['warnings']['login']
+ );
$this->assertSame( 'NeedToken', $ret[0]['login']['result'] );
- return $this->doApiRequest( [
+ $ret = $this->doApiRequest( [
'action' => 'login',
'lgtoken' => $ret[0]['login']['token'],
- 'lgname' => $name,
- 'lgpassword' => $password,
+ 'lgname' => $user->getUser()->getName(),
+ 'lgpassword' => $user->getPassword(),
], $ret[2] );
+
+ $this->assertSame(
+ [ 'warnings' => ApiErrorFormatter::stripMarkup( wfMessage(
+ 'apiwarn-deprecation-login-' . ( $enableBotPasswords ? '' : 'no' ) . 'botpw' )
+ ->text() ) ],
+ $ret[0]['warnings']['login']
+ );
+ $this->assertSame(
+ [
+ 'result' => 'Success',
+ 'lguserid' => $user->getUser()->getId(),
+ 'lgusername' => $user->getUser()->getName(),
+ ],
+ $ret[0]['login']
+ );
+ }
+
+ /**
+ * Attempts to log in with the given name and password, retrieves the returned token, and makes
+ * a second API request to actually log in with the token.
+ *
+ * @param string $name
+ * @param string $password
+ * @param array $params To pass to second request
+ * @return array Result of second doApiRequest
+ */
+ private function doUserLogin( $name, $password, array $params = [] ) {
+ $ret = $this->doApiRequest( [
+ 'action' => 'query',
+ 'meta' => 'tokens',
+ 'type' => 'login',
+ ] );
+
+ $this->assertArrayNotHasKey( 'warnings', $ret );
+
+ return $this->doApiRequest( array_merge(
+ [
+ 'action' => 'login',
+ 'lgtoken' => $ret[0]['query']['tokens']['logintoken'],
+ 'lgname' => $name,
+ 'lgpassword' => $password,
+ ], $params
+ ), $ret[2] );
+ }
+
+ public function testBadToken() {
+ $user = self::$users['sysop'];
+ $userName = $user->getUser()->getName();
+ $password = $user->getPassword();
+ $user->getUser()->logout();
+
+ $ret = $this->doUserLogin( $userName, $password, [ 'lgtoken' => 'invalid token' ] );
+
+ $this->assertSame( 'WrongToken', $ret[0]['login']['result'] );
}
public function testBadPass() {
$this->assertSame( 'Failed', $ret[0]['login']['result'] );
}
- public function testGoodPass() {
+ /**
+ * @dataProvider provideEnableBotPasswords
+ */
+ public function testGoodPass( $enableBotPasswords ) {
+ $this->setMwGlobals( 'wgEnableBotPasswords', $enableBotPasswords );
+
$user = self::$users['sysop'];
$userName = $user->getUser()->getName();
$password = $user->getPassword();
$ret = $this->doUserLogin( $userName, $password );
$this->assertSame( 'Success', $ret[0]['login']['result'] );
+ $this->assertSame(
+ [ 'warnings' => ApiErrorFormatter::stripMarkup( wfMessage(
+ 'apiwarn-deprecation-login-' . ( $enableBotPasswords ? '' : 'no' ) . 'botpw' )->
+ text() ) ],
+ $ret[0]['warnings']['login']
+ );
+ }
+
+ /**
+ * @dataProvider provideEnableBotPasswords
+ */
+ public function testUnsupportedAuthResponseType( $enableBotPasswords ) {
+ $this->setMwGlobals( 'wgEnableBotPasswords', $enableBotPasswords );
+
+ $mockProvider = $this->createMock(
+ MediaWiki\Auth\AbstractSecondaryAuthenticationProvider::class );
+ $mockProvider->method( 'beginSecondaryAuthentication' )->willReturn(
+ MediaWiki\Auth\AuthenticationResponse::newUI(
+ [ new MediaWiki\Auth\UsernameAuthenticationRequest ],
+ // Slightly silly message here
+ wfMessage( 'mainpage' )
+ )
+ );
+ $mockProvider->method( 'getAuthenticationRequests' )
+ ->willReturn( [] );
+
+ $this->mergeMwGlobalArrayValue( 'wgAuthManagerConfig', [
+ 'secondaryauth' => [ [
+ 'factory' => function () use ( $mockProvider ) {
+ return $mockProvider;
+ },
+ ] ],
+ ] );
+
+ $user = self::$users['sysop'];
+ $userName = $user->getUser()->getName();
+ $password = $user->getPassword();
+ $user->getUser()->logout();
+
+ $ret = $this->doUserLogin( $userName, $password );
+
+ $this->assertSame( [ 'login' => [
+ 'result' => 'Aborted',
+ 'reason' => ApiErrorFormatter::stripMarkup( wfMessage(
+ 'api-login-fail-aborted' . ( $enableBotPasswords ? '' : '-nobotpw' ) )->text() ),
+ ] ], $ret[0] );
}
/**
+ * @todo Should this test just be deleted?
* @group Broken
*/
public function testGotCookie() {
);
}
- public function testBotPassword() {
+ /**
+ * @return [ $username, $password ] suitable for passing to an API request for successful login
+ */
+ private function setUpForBotPassword() {
global $wgSessionProviders;
$this->setMwGlobals( [
$lgName = $user->getUser()->getName() . BotPassword::getSeparator() . 'foo';
- $ret = $this->doUserLogin( $lgName, $password );
+ return [ $lgName, $password ];
+ }
+
+ public function testBotPassword() {
+ $ret = $this->doUserLogin( ...$this->setUpForBotPassword() );
$this->assertSame( 'Success', $ret[0]['login']['result'] );
}
- public function testLoginWithNoSameOriginSecurity() {
+ public function testBotPasswordThrottled() {
+ global $wgPasswordAttemptThrottle;
+
+ $this->setGroupPermissions( 'sysop', 'noratelimit', false );
+ $this->setMwGlobals( 'wgMainCacheType', 'hash' );
+
+ list( $name, $password ) = $this->setUpForBotPassword();
+
+ for ( $i = 0; $i < $wgPasswordAttemptThrottle[0]['count']; $i++ ) {
+ $this->doUserLogin( $name, 'incorrectpasswordincorrectpassword' );
+ }
+
+ $ret = $this->doUserLogin( $name, $password );
+
+ $this->assertSame( [
+ 'result' => 'Failed',
+ 'reason' => ApiErrorFormatter::stripMarkup( wfMessage( 'login-throttled' )->
+ durationParams( $wgPasswordAttemptThrottle[0]['seconds'] )->text() ),
+ ], $ret[0]['login'] );
+ }
+
+ public function testBotPasswordLocked() {
+ $this->setTemporaryHook( 'UserIsLocked', function ( User $unused, &$isLocked ) {
+ $isLocked = true;
+ return true;
+ } );
+
+ $ret = $this->doUserLogin( ...$this->setUpForBotPassword() );
+
+ $this->assertSame( [
+ 'result' => 'Failed',
+ 'reason' => wfMessage( 'botpasswords-locked' )->text(),
+ ], $ret[0]['login'] );
+ }
+
+ public function testNoSameOriginSecurity() {
$this->setTemporaryHook( 'RequestHasSameOriginSecurity',
function () {
return false;
$ret = $this->doApiRequest( [
'action' => 'login',
+ 'errorformat' => 'plaintext',
] )[0]['login'];
$this->assertSame( [
'result' => 'Aborted',
- 'reason' => 'Cannot log in when the same-origin policy is not applied.',
+ 'reason' => [
+ 'code' => 'api-login-fail-sameorigin',
+ 'text' => 'Cannot log in when the same-origin policy is not applied.',
+ ],
], $ret );
}
}
function ( $code, $name ) {
return [
'code' => $code,
+ 'bcp47' => LanguageCode::bcp47( $code ),
'name' => $name
];
},
use MediaWiki\Linker\LinkTarget;
use MediaWiki\MediaWikiServices;
-use MediaWiki\Storage\SlotRecord;
+use MediaWiki\Revision\SlotRecord;
/**
* @group medium
$wgActorTableSchemaMigrationStage = $v;
$this->overrideMwServices();
}, [ $wgActorTableSchemaMigrationStage ] );
- $wgActorTableSchemaMigrationStage = MIGRATION_WRITE_BOTH;
+ $wgActorTableSchemaMigrationStage = SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD;
$this->overrideMwServices();
$users = [
/**
* @dataProvider provideSorting
- * @param int $stage One of the MIGRATION_* constants for $wgActorTableSchemaMigrationStage
+ * @param int $stage SCHEMA_COMPAT contants for $wgActorTableSchemaMigrationStage
* @param array $params Extra parameters for the query
* @param bool $reverse Reverse order?
* @param int $revs Number of revisions to expect
*/
public function testSorting( $stage, $params, $reverse, $revs ) {
- if ( isset( $params['ucuserprefix'] ) &&
- ( $stage === MIGRATION_WRITE_BOTH || $stage === MIGRATION_WRITE_NEW ) &&
- $this->db->getType() === 'mysql' && $this->usesTemporaryTables()
- ) {
- // https://bugs.mysql.com/bug.php?id=10327
- $this->markTestSkipped( 'MySQL bug 10327 - can\'t reopen temporary tables' );
- }
// FIXME: fails under sqlite
$this->markTestSkippedIfDbType( 'sqlite' );
foreach (
[
- 'old' => MIGRATION_OLD,
- 'write both' => MIGRATION_WRITE_BOTH,
- 'write new' => MIGRATION_WRITE_NEW,
- 'new' => MIGRATION_NEW,
+ 'old' => SCHEMA_COMPAT_OLD,
+ 'read old' => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD,
+ 'read new' => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW,
+ 'new' => SCHEMA_COMPAT_NEW,
] as $stageName => $stage
) {
foreach ( [ false, true ] as $reverse ) {
/**
* @dataProvider provideInterwikiUser
- * @param int $stage One of the MIGRATION_* constants for $wgActorTableSchemaMigrationStage
+ * @param int $stage SCHEMA_COMPAT constants for $wgActorTableSchemaMigrationStage
*/
public function testInterwikiUser( $stage ) {
$this->setMwGlobals( 'wgActorTableSchemaMigrationStage', $stage );
public static function provideInterwikiUser() {
return [
- 'old' => [ MIGRATION_OLD ],
- 'write both' => [ MIGRATION_WRITE_BOTH ],
- 'write new' => [ MIGRATION_WRITE_NEW ],
- 'new' => [ MIGRATION_NEW ],
+ 'old' => [ SCHEMA_COMPAT_OLD ],
+ 'read old' => [ SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD ],
+ 'read new' => [ SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW ],
+ 'new' => [ SCHEMA_COMPAT_NEW ],
];
}
$this->mergeMwGlobalArrayValue( 'wgHooks', [
$hook => [ $mock ],
] );
+ $mockClass = get_class( $mock );
+ $this->hideDeprecated( "$hook hook (used in $mockClass::on$hook)" );
return $mock->expects( $expect )->method( "on$hook" );
}
$this->assertEquals( $oldText, $messageCache->get( $message ), 'Content restored' );
}
+ public function testReplaceCache() {
+ global $wgWANObjectCaches;
+
+ // We need a WAN cache for this.
+ $this->setMwGlobals( [
+ 'wgMainWANCache' => 'hash',
+ 'wgWANObjectCaches' => $wgWANObjectCaches + [
+ 'hash' => [
+ 'class' => WANObjectCache::class,
+ 'cacheId' => 'hash',
+ 'channels' => []
+ ]
+ ]
+ ] );
+ $this->overrideMwServices();
+
+ MessageCache::destroyInstance();
+ $messageCache = MessageCache::singleton();
+ $messageCache->enable();
+
+ // Populate one key
+ $this->makePage( 'Key1', 'de', 'Value1' );
+ $this->assertEquals( 0,
+ DeferredUpdates::pendingUpdatesCount(),
+ 'Post-commit deferred update triggers a run of all updates' );
+ $this->assertEquals( 'Value1', $messageCache->get( 'Key1' ), 'Key1 was successfully edited' );
+
+ // Screw up the database so MessageCache::loadFromDB() will
+ // produce the wrong result for reloading Key1
+ $this->db->delete(
+ 'page', [ 'page_namespace' => NS_MEDIAWIKI, 'page_title' => 'Key1' ], __METHOD__
+ );
+
+ // Populate the second key
+ $this->makePage( 'Key2', 'de', 'Value2' );
+ $this->assertEquals( 0,
+ DeferredUpdates::pendingUpdatesCount(),
+ 'Post-commit deferred update triggers a run of all updates' );
+ $this->assertEquals( 'Value2', $messageCache->get( 'Key2' ), 'Key2 was successfully edited' );
+
+ // Now test that the second edit didn't reload Key1
+ $this->assertEquals( 'Value1', $messageCache->get( 'Key1' ),
+ 'Key1 wasn\'t reloaded by edit of Key2' );
+ }
+
/**
* @dataProvider provideNormalizeKey
*/
$this->tablesUsed[] = 'change_tag_def';
$this->tablesUsed[] = 'tag_summary';
$this->tablesUsed[] = 'valid_tag';
+
+ // Truncate these to avoid the supposed-to-be-unused IDs in tests here turning
+ // out to be used, leading ChangeTags::updateTags() to pick up bogus rc_id,
+ // log_id, or rev_id values and run into unique constraint violations.
+ $this->tablesUsed[] = 'recentchanges';
+ $this->tablesUsed[] = 'logging';
+ $this->tablesUsed[] = 'revision';
+ $this->tablesUsed[] = 'archive';
}
// TODO only modifyDisplayQuery and getSoftwareTags are tested, nothing else is
<?php
use MediaWiki\MediaWikiServices;
+use MediaWiki\Revision\SlotRecord;
use MediaWiki\Revision\SlotRenderingProvider;
-use MediaWiki\Storage\SlotRecord;
/**
* @group ContentHandler
wfWarn( $msg );
};
$this->currentDomain = DatabaseDomain::newUnspecified();
- $this->open( 'localhost', 'testuser', 'password', 'testdb' );
+ $this->open( 'localhost', 'testuser', 'password', 'testdb', null, '' );
}
/**
return 'test';
}
- function open( $server, $user, $password, $dbName ) {
+ function open( $server, $user, $password, $dbName, $schema, $tablePrefix ) {
$this->conn = (object)[ 'test' ];
return true;
$this->assertFalse( $db->isOpen() );
} else {
\Wikimedia\suppressWarnings();
- $this->assertFalse( $db->selectDB( 'garbage-db' ) );
+ try {
+ $this->assertFalse( $db->selectDB( 'garbage-db' ) );
+ $this->fail( "No error thrown." );
+ } catch ( \Wikimedia\Rdbms\DBExpectedError $e ) {
+ $this->assertEquals(
+ "Could not select database 'garbage-db'.",
+ $e->getMessage()
+ );
+ }
\Wikimedia\restoreWarnings();
}
}
<?php
-use MediaWiki\Storage\MutableRevisionRecord;
-use MediaWiki\Storage\RevisionRecord;
-use MediaWiki\Storage\SlotRecord;
+use MediaWiki\Revision\MutableRevisionRecord;
+use MediaWiki\Revision\RevisionRecord;
+use MediaWiki\Revision\SlotRecord;
use Wikimedia\TestingAccessWrapper;
/**
private function getMockForViews() {
$db = $this->getMockBuilder( DatabaseMysqli::class )
->disableOriginalConstructor()
- ->setMethods( [ 'fetchRow', 'query' ] )
+ ->setMethods( [ 'fetchRow', 'query', 'getDBname' ] )
->getMock();
$db->method( 'query' )
(object)[ 'Tables_in_' => 'view2' ],
(object)[ 'Tables_in_' => 'myview' ]
] ) );
+ $db->method( 'getDBname' )->willReturn( '' );
return $db;
}
public function testIndexAliases() {
$db = $this->getMockBuilder( DatabaseMysqli::class )
->disableOriginalConstructor()
- ->setMethods( [ 'mysqlRealEscapeString' ] )
+ ->setMethods( [ 'mysqlRealEscapeString', 'dbSchema', 'tablePrefix' ] )
->getMock();
$db->method( 'mysqlRealEscapeString' )->willReturnCallback(
function ( $s ) {
public function testTableAliases() {
$db = $this->getMockBuilder( DatabaseMysqli::class )
->disableOriginalConstructor()
- ->setMethods( [ 'mysqlRealEscapeString' ] )
+ ->setMethods( [ 'mysqlRealEscapeString', 'dbSchema', 'tablePrefix' ] )
->getMock();
$db->method( 'mysqlRealEscapeString' )->willReturnCallback(
function ( $s ) {
* @covers Wikimedia\Rdbms\Database::runOnTransactionIdleCallbacks
*/
public function testTransactionIdle_TRX() {
- $db = $this->getMockDB( [ 'isOpen', 'ping' ] );
+ $db = $this->getMockDB( [ 'isOpen', 'ping', 'getDBname' ] );
$db->method( 'isOpen' )->willReturn( true );
$db->method( 'ping' )->willReturn( true );
+ $db->method( 'getDBname' )->willReturn( '' );
$db->setFlag( DBO_TRX );
$lbFactory = LBFactorySingle::newFromConnection( $db );
* @covers Wikimedia\Rdbms\Database::runOnTransactionPreCommitCallbacks
*/
public function testTransactionPreCommitOrIdle_TRX() {
- $db = $this->getMockDB( [ 'isOpen', 'ping' ] );
+ $db = $this->getMockDB( [ 'isOpen', 'ping', 'getDBname' ] );
$db->method( 'isOpen' )->willReturn( true );
$db->method( 'ping' )->willReturn( true );
+ $db->method( 'getDBname' )->willReturn( 'unittest' );
$db->setFlag( DBO_TRX );
$lbFactory = LBFactorySingle::newFromConnection( $db );
* @covers Wikimedia\Rdbms\Database::lockIsFree
*/
public function testGetScopedLock() {
- $db = $this->getMockDB( [ 'isOpen' ] );
+ $db = $this->getMockDB( [ 'isOpen', 'getDBname' ] );
$db->method( 'isOpen' )->willReturn( true );
+ $db->method( 'getDBname' )->willReturn( 'unittest' );
$this->assertEquals( 0, $db->trxLevel() );
$this->assertEquals( true, $db->lockIsFree( 'x', __METHOD__ ) );
* @covers Wikimedia\Rdbms\Database::tablePrefix
* @covers Wikimedia\Rdbms\Database::dbSchema
*/
- public function testMutators() {
+ public function testSchemaAndPrefixMutators() {
$old = $this->db->tablePrefix();
+ $oldDomain = $this->db->getDomainId();
$this->assertInternalType( 'string', $old, 'Prefix is string' );
- $this->assertEquals( $old, $this->db->tablePrefix(), "Prefix unchanged" );
- $this->assertEquals( $old, $this->db->tablePrefix( 'xxx' ) );
- $this->assertEquals( 'xxx', $this->db->tablePrefix(), "Prefix set" );
+ $this->assertSame( $old, $this->db->tablePrefix(), "Prefix unchanged" );
+ $this->assertSame( $old, $this->db->tablePrefix( 'xxx' ) );
+ $this->assertSame( 'xxx', $this->db->tablePrefix(), "Prefix set" );
$this->db->tablePrefix( $old );
$this->assertNotEquals( 'xxx', $this->db->tablePrefix() );
+ $this->assertSame( $oldDomain, $this->db->getDomainId() );
$old = $this->db->dbSchema();
+ $oldDomain = $this->db->getDomainId();
$this->assertInternalType( 'string', $old, 'Schema is string' );
- $this->assertEquals( $old, $this->db->dbSchema(), "Schema unchanged" );
- $this->assertEquals( $old, $this->db->dbSchema( 'xxx' ) );
- $this->assertEquals( 'xxx', $this->db->dbSchema(), "Schema set" );
+ $this->assertSame( $old, $this->db->dbSchema(), "Schema unchanged" );
+ $this->assertSame( $old, $this->db->dbSchema( 'xxx' ) );
+ $this->assertSame( 'xxx', $this->db->dbSchema(), "Schema set" );
$this->db->dbSchema( $old );
$this->assertNotEquals( 'xxx', $this->db->dbSchema() );
+ $this->assertSame( $oldDomain, $this->db->getDomainId() );
+ }
+
+ /**
+ * @covers Wikimedia\Rdbms\Database::selectDomain
+ */
+ public function testSelectDomain() {
+ $oldDomain = $this->db->getDomainId();
+ $oldDatabase = $this->db->getDBname();
+ $oldSchema = $this->db->dbSchema();
+ $oldPrefix = $this->db->tablePrefix();
+
+ $this->db->selectDomain( 'testselectdb-xxx' );
+ $this->assertSame( 'testselectdb', $this->db->getDBname() );
+ $this->assertSame( '', $this->db->dbSchema() );
+ $this->assertSame( 'xxx', $this->db->tablePrefix() );
+
+ $this->db->selectDomain( $oldDomain );
+ $this->assertSame( $oldDatabase, $this->db->getDBname() );
+ $this->assertSame( $oldSchema, $this->db->dbSchema() );
+ $this->assertSame( $oldPrefix, $this->db->tablePrefix() );
+ $this->assertSame( $oldDomain, $this->db->getDomainId() );
+
+ $this->db->selectDomain( 'testselectdb-schema-xxx' );
+ $this->assertSame( 'testselectdb', $this->db->getDBname() );
+ $this->assertSame( 'schema', $this->db->dbSchema() );
+ $this->assertSame( 'xxx', $this->db->tablePrefix() );
+
+ $this->db->selectDomain( $oldDomain );
+ $this->assertSame( $oldDatabase, $this->db->getDBname() );
+ $this->assertSame( $oldSchema, $this->db->dbSchema() );
+ $this->assertSame( $oldPrefix, $this->db->tablePrefix() );
+ $this->assertSame( $oldDomain, $this->db->getDomainId() );
}
}
* @param array $selectFields
* @param string[]|null $row
* @param string[]|null $expectedFields
- * @param string $migration
+ * @param int $commentMigration
+ * @param int $actorMigration
*/
public function testNewFromId( $id,
array $selectFields,
array $row = null,
array $expectedFields = null,
- $migration
+ $commentMigration,
+ $actorMigration
) {
$this->setMwGlobals( [
- 'wgCommentTableSchemaMigrationStage' => $migration,
- 'wgActorTableSchemaMigrationStage' => $migration,
+ 'wgCommentTableSchemaMigrationStage' => $commentMigration,
+ 'wgActorTableSchemaMigrationStage' => $actorMigration,
] );
$row = $row ? (object)$row : null;
null,
null,
MIGRATION_OLD,
+ SCHEMA_COMPAT_OLD,
],
[
123,
],
[ 'type' => 'foobarize', 'comment' => 'test!' ],
MIGRATION_OLD,
+ SCHEMA_COMPAT_OLD,
],
[
567,
],
[ 'type' => 'foobarize', 'comment' => 'test!' ],
MIGRATION_NEW,
+ SCHEMA_COMPAT_NEW,
],
];
}
<?php
use MediaWiki\MediaWikiServices;
-use MediaWiki\Storage\MutableRevisionRecord;
-use MediaWiki\Storage\RevisionRecord;
-use MediaWiki\Storage\SlotRecord;
+use MediaWiki\Revision\MutableRevisionRecord;
+use MediaWiki\Revision\RevisionRecord;
+use MediaWiki\Revision\SlotRecord;
use PHPUnit\Framework\MockObject\MockObject;
/**
<?php
use MediaWiki\MediaWikiServices;
-use MediaWiki\Tests\Storage\McrSchemaOverride;
+use MediaWiki\Tests\Revision\McrSchemaOverride;
/**
* Test class for page archiving, using the new MCR schema.
<?php
use MediaWiki\MediaWikiServices;
-use MediaWiki\Storage\SlotRecord;
+use MediaWiki\Revision\SlotRecord;
use MediaWiki\Storage\SqlBlobStore;
-use MediaWiki\Tests\Storage\PreMcrSchemaOverride;
+use MediaWiki\Tests\Revision\PreMcrSchemaOverride;
/**
* Test class for page archiving, using the pre-MCR schema.
<?php
use MediaWiki\MediaWikiServices;
-use MediaWiki\Storage\RevisionRecord;
+use MediaWiki\Revision\RevisionRecord;
/**
* Base class for tests of PageArchive against different database schemas.
$this->tablesUsed += $this->getMcrTablesToReset();
$this->setMwGlobals( 'wgCommentTableSchemaMigrationStage', MIGRATION_OLD );
- $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', MIGRATION_OLD );
+ $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', SCHEMA_COMPAT_OLD );
$this->setMwGlobals( 'wgContentHandlerUseDB', $this->getContentHandlerUseDB() );
$this->setMwGlobals(
'wgMultiContentRevisionSchemaMigrationStage',
use MediaWiki\Edit\PreparedEdit;
use MediaWiki\MediaWikiServices;
+use MediaWiki\Revision\SlotRecord;
use MediaWiki\Storage\RevisionSlotsUpdate;
-use MediaWiki\Storage\SlotRecord;
use PHPUnit\Framework\MockObject\MockObject;
use Wikimedia\TestingAccessWrapper;
"#REDIRECT [[hello world]]",
"Hello world"
],
+ // The below added to protect against Media namespace
+ // redirects which throw a fatal: (T203942)
+ [
+ 'WikiPageTest_testGetRedirectTarget_3',
+ CONTENT_MODEL_WIKITEXT,
+ "#REDIRECT [[Media:hello_world]]",
+ "File:Hello world"
+ ],
];
}
<?php
-use MediaWiki\Tests\Storage\McrSchemaOverride;
+use MediaWiki\Tests\Revision\McrSchemaOverride;
/**
* Tests WikiPage against the MCR DB schema after schema migration.
<?php
-use MediaWiki\Tests\Storage\McrReadNewSchemaOverride;
+use MediaWiki\Tests\Revision\McrReadNewSchemaOverride;
/**
* Tests WikiPage against the intermediate MCR DB schema for use during schema migration.
<?php
-use MediaWiki\Tests\Storage\McrWriteBothSchemaOverride;
+use MediaWiki\Tests\Revision\McrWriteBothSchemaOverride;
/**
* Tests WikiPage against the intermediate MCR DB schema for use during schema migration.
<?php
-use MediaWiki\Tests\Storage\PreMcrSchemaOverride;
+use MediaWiki\Tests\Revision\PreMcrSchemaOverride;
/**
* Tests WikiPage against the pre-MCR, pre ContentHandler DB schema.
<?php
-use MediaWiki\Tests\Storage\PreMcrSchemaOverride;
+use MediaWiki\Tests\Revision\PreMcrSchemaOverride;
/**
* Tests WikiPage against the pre-MCR DB schema.
<?php
-use MediaWiki\Storage\MutableRevisionRecord;
-use MediaWiki\Storage\RevisionStore;
-use MediaWiki\Storage\SlotRecord;
+use MediaWiki\Revision\MutableRevisionRecord;
+use MediaWiki\Revision\RevisionStore;
+use MediaWiki\Revision\SlotRecord;
use MediaWiki\User\UserIdentityValue;
/**
$this->assertNotContains( 'class="foo bar"', $text );
}
- public function testT203716() {
- // simulate extra wrapping from old parser cache
- $out = new ParserOutput( '<div class="mw-parser-output">Foo</div>' );
- $out = unserialize( serialize( $out ) );
-
- $plainText = $out->getText( [ 'unwrap' => true ] );
- $wrappedText = $out->getText( [ 'unwrap' => false ] );
- $wrappedText2 = $out->getText( [ 'wrapperDivClass' => 'mw-parser-output' ] );
-
- $this->assertNotContains( '<div', $plainText );
- $this->assertContains( '<div', $wrappedText );
- $this->assertStringNotMatchesFormat( '<div%s<div%s', $wrappedText );
- $this->assertContains( '<div', $wrappedText2 );
- $this->assertStringNotMatchesFormat( '<div%s<div%s', $wrappedText2 );
-
- // simulate ParserOuput creation by new parser code
- $out = new ParserOutput( 'Foo' );
- $out->addWrapperDivClass( 'mw-parser-outout' );
- $out = unserialize( serialize( $out ) );
-
- $plainText = $out->getText( [ 'unwrap' => true ] );
- $wrappedText = $out->getText( [ 'unwrap' => false ] );
- $wrappedText2 = $out->getText( [ 'wrapperDivClass' => 'mw-parser-output' ] );
-
- $this->assertNotContains( '<div', $plainText );
- $this->assertContains( '<div', $wrappedText );
- $this->assertStringNotMatchesFormat( '<div%s<div%s', $wrappedText );
- $this->assertContains( '<div', $wrappedText2 );
- $this->assertStringNotMatchesFormat( '<div%s<div%s', $wrappedText2 );
- }
-
/**
* @covers ParserOutput::getText
* @dataProvider provideGetText
<?php
use MediaWiki\MediaWikiServices;
-use MediaWiki\Storage\MutableRevisionRecord;
-use MediaWiki\Storage\RevisionRecord;
-use MediaWiki\Storage\SlotRecord;
+use MediaWiki\Revision\MutableRevisionRecord;
+use MediaWiki\Revision\RevisionRecord;
+use MediaWiki\Revision\SlotRecord;
/**
* @covers PoolWorkArticleView
'patrol' => true,
];
- // Deprecated
- $this->setTemporaryHook(
- 'ChangesListSpecialPageFilters',
- null
- );
-
# setup the ChangesListSpecialPage (or subclass) object
$this->changesListSpecialPage = $this->getPage();
$context = $this->changesListSpecialPage->getContext();
}
public function testRcHidemyselfFilter() {
- $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', MIGRATION_WRITE_BOTH );
+ $this->setMwGlobals(
+ 'wgActorTableSchemaMigrationStage', SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD
+ );
$this->overrideMwServices();
$user = $this->getTestUser()->getUser();
$user->getActorId( wfGetDB( DB_MASTER ) );
$this->assertConditions(
[ # expected
- "NOT((rc_actor = '{$user->getActorId()}') OR "
- . "(rc_actor = '0' AND rc_user = '{$user->getId()}'))",
+ "NOT((rc_user = '{$user->getId()}'))",
],
[
'hidemyself' => 1,
$id = $user->getActorId( wfGetDB( DB_MASTER ) );
$this->assertConditions(
[ # expected
- "NOT((rc_actor = '$id') OR (rc_actor = '0' AND rc_user_text = '10.11.12.13'))",
+ "NOT((rc_user_text = '10.11.12.13'))",
],
[
'hidemyself' => 1,
}
public function testRcHidebyothersFilter() {
- $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', MIGRATION_WRITE_BOTH );
+ $this->setMwGlobals(
+ 'wgActorTableSchemaMigrationStage', SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD
+ );
$this->overrideMwServices();
$user = $this->getTestUser()->getUser();
$user->getActorId( wfGetDB( DB_MASTER ) );
$this->assertConditions(
[ # expected
- "(rc_actor = '{$user->getActorId()}') OR "
- . "(rc_actor = '0' AND rc_user_text = '{$user->getName()}')",
+ "(rc_user_text = '{$user->getName()}')",
],
[
'hidebyothers' => 1,
$id = $user->getActorId( wfGetDB( DB_MASTER ) );
$this->assertConditions(
[ # expected
- "(rc_actor = '$id') OR (rc_actor = '0' AND rc_user_text = '10.11.12.13')",
+ "(rc_user_text = '10.11.12.13')",
],
[
'hidebyothers' => 1,
}
public function testFilterUserExpLevelAllExperienceLevels() {
- $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', MIGRATION_WRITE_BOTH );
+ $this->setMwGlobals(
+ 'wgActorTableSchemaMigrationStage', SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD
+ );
$this->overrideMwServices();
$this->assertConditions(
[
# expected
- 'COALESCE( actor_rc_user.actor_user, rc_user ) != 0',
+ 'rc_user != 0',
],
[
'userExpLevel' => 'newcomer;learner;experienced',
}
public function testFilterUserExpLevelRegistrered() {
- $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', MIGRATION_WRITE_BOTH );
+ $this->setMwGlobals(
+ 'wgActorTableSchemaMigrationStage', SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD
+ );
$this->overrideMwServices();
$this->assertConditions(
[
# expected
- 'COALESCE( actor_rc_user.actor_user, rc_user ) != 0',
+ 'rc_user != 0',
],
[
'userExpLevel' => 'registered',
}
public function testFilterUserExpLevelUnregistrered() {
- $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', MIGRATION_WRITE_BOTH );
+ $this->setMwGlobals(
+ 'wgActorTableSchemaMigrationStage', SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD
+ );
$this->overrideMwServices();
$this->assertConditions(
[
# expected
- 'COALESCE( actor_rc_user.actor_user, rc_user ) = 0',
+ 'rc_user = 0',
],
[
'userExpLevel' => 'unregistered',
}
public function testFilterUserExpLevelRegistreredOrLearner() {
- $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', MIGRATION_WRITE_BOTH );
+ $this->setMwGlobals(
+ 'wgActorTableSchemaMigrationStage', SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD
+ );
$this->overrideMwServices();
$this->assertConditions(
[
# expected
- 'COALESCE( actor_rc_user.actor_user, rc_user ) != 0',
+ 'rc_user != 0',
],
[
'userExpLevel' => 'registered;learner',
}
public function testFilterUserExpLevelUnregistreredOrExperienced() {
- $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', MIGRATION_WRITE_BOTH );
+ $this->setMwGlobals(
+ 'wgActorTableSchemaMigrationStage', SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD
+ );
$this->overrideMwServices();
$conds = $this->buildQuery( [ 'userExpLevel' => 'unregistered;experienced' ] );
$this->assertRegExp(
- '/\(COALESCE\( actor_rc_user.actor_user, rc_user \) = 0\) OR '
+ '/\(rc_user = 0\) OR '
. '\(\(user_editcount >= 500\) AND \(user_registration <= \'[^\']+\'\)\)/',
reset( $conds ),
"rc conditions: userExpLevel=unregistered;experienced"
public function setUp() {
parent::setUp();
- $this->setTemporaryHook(
- 'ChangesListSpecialPageFilters',
- null
- );
-
$this->setTemporaryHook(
'ChangesListSpecialPageQuery',
null
$this->newSpecialPage()
);
- $this->setTemporaryHook(
- 'ChangesListSpecialPageFilters',
- null
- );
-
$page->registerFilters();
// Does not consider $preferences, just wiki's defaults
[ 'user', 'abc@def', false ],
[ 'legacy@user', 'pass', false ],
[ 'user@bot', '12345678901234567890123456789012',
- [ 'user@bot', '12345678901234567890123456789012', true ] ],
+ [ 'user@bot', '12345678901234567890123456789012' ] ],
[ 'user', 'bot@12345678901234567890123456789012',
- [ 'user@bot', '12345678901234567890123456789012', true ] ],
+ [ 'user@bot', '12345678901234567890123456789012' ] ],
[ 'user', 'bot@12345678901234567890123456789012345',
- [ 'user@bot', '12345678901234567890123456789012345', true ] ],
+ [ 'user@bot', '12345678901234567890123456789012345' ] ],
[ 'user', 'bot@x@12345678901234567890123456789012',
- [ 'user@bot@x', '12345678901234567890123456789012', true ] ],
+ [ 'user@bot@x', '12345678901234567890123456789012' ] ],
];
}
$this->setMwGlobals( [
'wgGroupPermissions' => [],
'wgRevokePermissions' => [],
- 'wgActorTableSchemaMigrationStage' => MIGRATION_WRITE_BOTH,
+ 'wgActorTableSchemaMigrationStage' => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD,
] );
$this->overrideMwServices();
* @dataProvider provideLanguageCodes()
*/
public function testBcp47( $code, $expected ) {
+ $this->assertEquals( $expected, LanguageCode::bcp47( $code ),
+ "Applying BCP 47 standard to '$code'"
+ );
+
$code = strtolower( $code );
$this->assertEquals( $expected, LanguageCode::bcp47( $code ),
- "Applying BCP47 standard to lower case '$code'"
+ "Applying BCP 47 standard to lower case '$code'"
);
$code = strtoupper( $code );
$this->assertEquals( $expected, LanguageCode::bcp47( $code ),
- "Applying BCP47 standard to upper case '$code'"
+ "Applying BCP 47 standard to upper case '$code'"
);
}
// de-419-DE
// a-DE
// ar-a-aaa-b-bbb-a-ccc
+
+ # Non-standard and deprecated language codes used by MediaWiki
+ [ 'als', 'gsw' ],
+ [ 'bat-smg', 'sgs' ],
+ [ 'be-x-old', 'be-tarask' ],
+ [ 'fiu-vro', 'vro' ],
+ [ 'roa-rup', 'rup' ],
+ [ 'zh-classical', 'lzh' ],
+ [ 'zh-min-nan', 'nan' ],
+ [ 'zh-yue', 'yue' ],
+ [ 'cbk-zam', 'cbk' ],
+ [ 'de-formal', 'de-x-formal' ],
+ [ 'eml', 'egl' ],
+ [ 'en-rtl', 'en-x-rtl' ],
+ [ 'es-formal', 'es-x-formal' ],
+ [ 'hu-formal', 'hu-x-formal' ],
+ [ 'kk-Arab', 'kk-Arab' ],
+ [ 'kk-Cyrl', 'kk-Cyrl' ],
+ [ 'kk-Latn', 'kk-Latn' ],
+ [ 'map-bms', 'jv-x-bms' ],
+ [ 'mo', 'ro-Cyrl-MD' ],
+ [ 'nrm', 'nrf' ],
+ [ 'nl-informal', 'nl-x-informal' ],
+ [ 'roa-tara', 'nap-x-tara' ],
+ [ 'simple', 'en-simple' ],
+ [ 'sr-ec', 'sr-Cyrl' ],
+ [ 'sr-el', 'sr-Latn' ],
+ [ 'zh-cn', 'zh-Hans-CN' ],
+ [ 'zh-sg', 'zh-Hans-SG' ],
+ [ 'zh-my', 'zh-Hans-MY' ],
+ [ 'zh-tw', 'zh-Hant-TW' ],
+ [ 'zh-hk', 'zh-Hant-HK' ],
+ [ 'zh-mo', 'zh-Hant-MO' ],
+ [ 'zh-hans', 'zh-Hans' ],
+ [ 'zh-hant', 'zh-Hant' ],
];
}
$this->lang = new LanguageToTest();
$this->lc = new TestConverter(
$this->lang, 'tg',
- [ 'tg', 'tg-latn' ]
+ # Adding 'sgs' as a variant to ensure we handle deprecated codes
+ # adding 'simple' as a variant to ensure we handle non BCP 47 codes
+ [ 'tg', 'tg-latn', 'sgs', 'simple' ]
);
}
$this->assertEquals( 'tg', $this->lc->getPreferredVariant() );
}
+ /**
+ * @covers LanguageConverter::getPreferredVariant
+ * @covers LanguageConverter::getURLVariant
+ */
+ public function testGetPreferredVariantUrl() {
+ global $wgRequest;
+ $wgRequest->setVal( 'variant', 'tg-latn' );
+
+ $this->assertEquals( 'tg-latn', $this->lc->getPreferredVariant() );
+ }
+
+ /**
+ * @covers LanguageConverter::getPreferredVariant
+ * @covers LanguageConverter::getURLVariant
+ */
+ public function testGetPreferredVariantUrlDeprecated() {
+ global $wgRequest;
+ $wgRequest->setVal( 'variant', 'bat-smg' );
+
+ $this->assertEquals( 'sgs', $this->lc->getPreferredVariant() );
+ }
+
+ /**
+ * @covers LanguageConverter::getPreferredVariant
+ * @covers LanguageConverter::getURLVariant
+ */
+ public function testGetPreferredVariantUrlBCP47() {
+ global $wgRequest;
+ $wgRequest->setVal( 'variant', 'en-simple' );
+
+ $this->assertEquals( 'simple', $this->lc->getPreferredVariant() );
+ }
+
/**
* @covers LanguageConverter::getPreferredVariant
* @covers LanguageConverter::getHeaderVariant
$this->assertEquals( 'tg-latn', $this->lc->getPreferredVariant() );
}
+ /**
+ * @covers LanguageConverter::getPreferredVariant
+ * @covers LanguageConverter::getHeaderVariant
+ */
+ public function testGetPreferredVariantHeadersBCP47() {
+ global $wgRequest;
+ $wgRequest->setHeader( 'Accept-Language', 'en-simple' );
+
+ $this->assertEquals( 'simple', $this->lc->getPreferredVariant() );
+ }
+
/**
* @covers LanguageConverter::getPreferredVariant
* @covers LanguageConverter::getHeaderVariant
$this->assertEquals( 'tg-latn', $this->lc->getPreferredVariant() );
}
+ /**
+ * @covers LanguageConverter::getPreferredVariant
+ */
+ public function testGetPreferredVariantUserOptionDeprecated() {
+ global $wgUser;
+
+ $wgUser = new User;
+ $wgUser->load(); // from 'defaults'
+ $wgUser->mId = 1;
+ $wgUser->mDataLoaded = true;
+ $wgUser->mOptionsLoaded = true;
+ $wgUser->setOption( 'variant', 'bat-smg' );
+
+ $this->assertEquals( 'sgs', $this->lc->getPreferredVariant() );
+ }
+
+ /**
+ * @covers LanguageConverter::getPreferredVariant
+ */
+ public function testGetPreferredVariantUserOptionBCP47() {
+ global $wgUser;
+
+ $wgUser = new User;
+ $wgUser->load(); // from 'defaults'
+ $wgUser->mId = 1;
+ $wgUser->mDataLoaded = true;
+ $wgUser->mOptionsLoaded = true;
+ $wgUser->setOption( 'variant', 'en-simple' );
+
+ $this->assertEquals( 'simple', $this->lc->getPreferredVariant() );
+ }
+
/**
* @covers LanguageConverter::getPreferredVariant
* @covers LanguageConverter::getUserVariant
$this->assertEquals( 'tg-latn', $this->lc->getPreferredVariant() );
}
+ /**
+ * @covers LanguageConverter::getPreferredVariant
+ * @covers LanguageConverter::getUserVariant
+ */
+ public function testGetPreferredVariantUserOptionForForeignLanguageDeprecated() {
+ global $wgUser;
+
+ $this->setContentLang( 'en' );
+ $wgUser = new User;
+ $wgUser->load(); // from 'defaults'
+ $wgUser->mId = 1;
+ $wgUser->mDataLoaded = true;
+ $wgUser->mOptionsLoaded = true;
+ $wgUser->setOption( 'variant-tg', 'bat-smg' );
+
+ $this->assertEquals( 'sgs', $this->lc->getPreferredVariant() );
+ }
+
+ /**
+ * @covers LanguageConverter::getPreferredVariant
+ * @covers LanguageConverter::getUserVariant
+ */
+ public function testGetPreferredVariantUserOptionForForeignLanguageBCP47() {
+ global $wgUser;
+
+ $this->setContentLang( 'en' );
+ $wgUser = new User;
+ $wgUser->load(); // from 'defaults'
+ $wgUser->mId = 1;
+ $wgUser->mDataLoaded = true;
+ $wgUser->mOptionsLoaded = true;
+ $wgUser->setOption( 'variant-tg', 'en-simple' );
+
+ $this->assertEquals( 'simple', $this->lc->getPreferredVariant() );
+ }
+
/**
* @covers LanguageConverter::getPreferredVariant
* @covers LanguageConverter::getUserVariant
$this->assertEquals( 'tg-latn', $this->lc->getPreferredVariant() );
}
+ /**
+ * @covers LanguageConverter::getPreferredVariant
+ */
+ public function testGetPreferredVariantDefaultLanguageVariantDeprecated() {
+ global $wgDefaultLanguageVariant;
+
+ $wgDefaultLanguageVariant = 'bat-smg';
+ $this->assertEquals( 'sgs', $this->lc->getPreferredVariant() );
+ }
+
+ /**
+ * @covers LanguageConverter::getPreferredVariant
+ */
+ public function testGetPreferredVariantDefaultLanguageVariantBCP47() {
+ global $wgDefaultLanguageVariant;
+
+ $wgDefaultLanguageVariant = 'en-simple';
+ $this->assertEquals( 'simple', $this->lc->getPreferredVariant() );
+ }
+
/**
* @covers LanguageConverter::getPreferredVariant
* @covers LanguageConverter::getURLVariant
$testString .= 'xxx xxx xxx';
}
$testString .= "\n<big id='в'></big>";
- $old = ini_set( 'pcre.backtrack_limit', 200 );
+ $this->setIniSetting( 'pcre.backtrack_limit', 200 );
$result = $this->lc->autoConvert( $testString, 'tg-latn' );
- ini_set( 'pcre.backtrack_limit', $old );
// The в in the id attribute should not get converted to a v
$this->assertFalse(
strpos( $result, 'v' ),
function loadDefaultTables() {
$this->mTables = [
+ 'sgs' => new ReplacementArray(),
+ 'simple' => new ReplacementArray(),
'tg-latn' => new ReplacementArray( $this->table ),
'tg' => new ReplacementArray()
];
'test.sinonjs' => [
'scripts' => [
'tests/qunit/suites/resources/test.sinonjs/index.js',
- 'resources/lib/sinonjs/sinon-1.17.3.js',
- // We want tests to work in IE, but can't include this as it
- // will break the placeholders in Sinon because the hack it uses
- // to hijack IE globals relies on running in the global scope
- // and in ResourceLoader this won't be running in the global scope.
- // Including it results (among other things) in sandboxed timers
- // being broken due to Date inheritance being undefined.
- // 'resources/lib/sinonjs/sinon-ie-1.15.4.js',
+ 'resources/lib/sinonjs/sinon.js',
],
'targets' => [ 'desktop', 'mobile' ],
],
// # Tags that use extensions
[ 'en-us-u-islamcal', 'en-US-u-islamcal' ],
[ 'zh-cn-a-myext-x-private', 'zh-CN-a-myext-x-private' ],
- [ 'en-a-myext-b-another', 'en-a-myext-b-another' ]
+ [ 'en-a-myext-b-another', 'en-a-myext-b-another' ],
// # Invalid:
// de-419-DE
// a-DE
// ar-a-aaa-b-bbb-a-ccc
+
+ // Non-standard and deprecated language codes used by MediaWiki
+ [ 'als', 'gsw' ],
+ [ 'bat-smg', 'sgs' ],
+ [ 'be-x-old', 'be-tarask' ],
+ [ 'fiu-vro', 'vro' ],
+ [ 'roa-rup', 'rup' ],
+ [ 'zh-classical', 'lzh' ],
+ [ 'zh-min-nan', 'nan' ],
+ [ 'zh-yue', 'yue' ],
+ [ 'cbk-zam', 'cbk' ],
+ [ 'de-formal', 'de-x-formal' ],
+ [ 'eml', 'egl' ],
+ [ 'en-rtl', 'en-x-rtl' ],
+ [ 'es-formal', 'es-x-formal' ],
+ [ 'hu-formal', 'hu-x-formal' ],
+ [ 'kk-Arab', 'kk-Arab' ],
+ [ 'kk-Cyrl', 'kk-Cyrl' ],
+ [ 'kk-Latn', 'kk-Latn' ],
+ [ 'map-bms', 'jv-x-bms' ],
+ [ 'mo', 'ro-Cyrl-MD' ],
+ [ 'nrm', 'nrf' ],
+ [ 'nl-informal', 'nl-x-informal' ],
+ [ 'roa-tara', 'nap-x-tara' ],
+ [ 'simple', 'en-simple' ],
+ [ 'sr-ec', 'sr-Cyrl' ],
+ [ 'sr-el', 'sr-Latn' ],
+ [ 'zh-cn', 'zh-Hans-CN' ],
+ [ 'zh-sg', 'zh-Hans-SG' ],
+ [ 'zh-my', 'zh-Hans-MY' ],
+ [ 'zh-tw', 'zh-Hant-TW' ],
+ [ 'zh-hk', 'zh-Hant-HK' ],
+ [ 'zh-mo', 'zh-Hant-MO' ],
+ [ 'zh-hans', 'zh-Hans' ],
+ [ 'zh-hant', 'zh-Hant' ]
];
QUnit.test( 'mw.language.bcp47', function ( assert ) {
+ mw.language.data = this.liveLangData;
bcp47Tests.forEach( function ( data ) {
var input = data[ 0 ],
expected = data[ 1 ];
assert.strictEqual( mw.language.bcp47( input ), expected );
+ assert.strictEqual( mw.language.bcp47( input.toLowerCase() ), expected );
+ assert.strictEqual( mw.language.bcp47( input.toUpperCase() ), expected );
} );
} );
}() );