diff --git a/includes/Title.php b/includes/Title.php index 81c05d43cb6..b91415d2a7a 100644 --- a/includes/Title.php +++ b/includes/Title.php @@ -27,8 +27,10 @@ use MediaWiki\Interwiki\InterwikiLookup; use MediaWiki\Linker\LinkTarget; use MediaWiki\Logger\LoggerFactory; use MediaWiki\MediaWikiServices; +use MediaWiki\Page\ExistingPageRecord; use MediaWiki\Page\PageIdentity; use MediaWiki\Page\PageIdentityValue; +use MediaWiki\Page\PageStoreRecord; use MediaWiki\Page\ProperPageIdentity; use Wikimedia\Assert\Assert; use Wikimedia\Assert\PreconditionException; @@ -4682,4 +4684,44 @@ class Title implements LinkTarget, PageIdentity, IDBAccessObject { ); } + /** + * Returns the page represented by this Title as a ProperPageRecord. + * The PageRecord returned by this method is guaranteed to be immutable, + * the page is guaranteed to exist. + * + * @note For now, this method queries the database on every call. + * @since 1.36 + * + * @param int $flags Either a bitfield of class READ_* constants or GAID_FOR_UPDATE + * + * @return ExistingPageRecord + * @throws PreconditionException if the page does not exist, or is not a proper page, + * that is, if it is a section link, interwiki link, link to a special page, or such. + */ + public function toPageRecord( $flags = 0 ): ExistingPageRecord { + // TODO: Cache this? Construct is more efficiently? + + $this->assertProperPage(); + + Assert::precondition( + $this->exists(), + 'This Title instance does not represent an existing page: ' . $this + ); + + return new PageStoreRecord( + (object)[ + 'page_id' => $this->getArticleID( $flags ), + 'page_namespace' => $this->getNamespace(), + 'page_title' => $this->getDBkey(), + 'page_wiki_id' => $this->getWikiId(), + 'page_latest' => $this->getLatestRevID( $flags ), + 'page_is_new' => $this->isNewPage(), // no flags? + 'page_is_redirect' => $this->isRedirect( $flags ), + 'page_touched' => $this->getTouched(), // no flags? + 'page_lang' => $this->getPageLanguage()->getCode(), + ], + PageIdentity::LOCAL + ); + } + } diff --git a/includes/page/ExistingPageRecord.php b/includes/page/ExistingPageRecord.php new file mode 100644 index 00000000000..27de7b75730 --- /dev/null +++ b/includes/page/ExistingPageRecord.php @@ -0,0 +1,23 @@ +page_id ), '$row->page_id', 'is required' ); + Assert::parameter( isset( $row->page_namespace ), '$row->page_namespace', 'is required' ); + Assert::parameter( isset( $row->page_title ), '$row->page_title', 'is required' ); + Assert::parameter( isset( $row->page_latest ), '$row->page_latest', 'is required' ); + Assert::parameter( isset( $row->page_is_new ), '$row->page_is_new', 'is required' ); + Assert::parameter( isset( $row->page_is_redirect ), '$row->page_is_redirect', 'is required' ); + Assert::parameter( isset( $row->page_touched ), '$row->page_touched', 'is required' ); + Assert::parameter( isset( $row->page_lang ), '$row->page_lang', 'is required' ); + + Assert::parameter( $row->page_id > 0, '$pageId', 'must be greater than zero (page must exist)' ); + + parent::__construct( $row->page_id, $row->page_namespace, $row->page_title, $wikiId ); + + $this->row = $row; + } + + /** + * False if the page has had more than one edit. + * + * @return bool + */ + public function isNew(): bool { + return (bool)$this->row->page_is_new; + } + + /** + * True if the page is a redirect. + * + * @return bool + */ + public function isRedirect(): bool { + return (bool)$this->row->page_is_redirect; + } + + /** + * The ID of the page'S latest revision. + * + * @param bool $wikiId + * + * @return int + */ + public function getLatest( $wikiId = self::LOCAL ): int { + $this->assertWiki( $wikiId ); + return (int)$this->row->page_latest; + } + + /** + * Timestamp at which the page was last rerendered. + * + * @return string + */ + public function getTouched(): string { + return MWTimestamp::convert( TS_MW, $this->row->page_touched ); + } + + /** + * Language in which the page is written. + * + * @return string + */ + public function getLanguage(): string { + return (string)$this->row->page_lang; + } + +} diff --git a/includes/page/WikiPage.php b/includes/page/WikiPage.php index 088d9a3766b..0109d374e66 100644 --- a/includes/page/WikiPage.php +++ b/includes/page/WikiPage.php @@ -29,7 +29,10 @@ use MediaWiki\Edit\PreparedEdit; use MediaWiki\HookContainer\ProtectedHookAccessorTrait; use MediaWiki\Logger\LoggerFactory; use MediaWiki\MediaWikiServices; +use MediaWiki\Page\ExistingPageRecord; use MediaWiki\Page\PageIdentity; +use MediaWiki\Page\PageRecord; +use MediaWiki\Page\PageStoreRecord; use MediaWiki\Page\ParserOutputAccess; use MediaWiki\Permissions\Authority; use MediaWiki\Permissions\PermissionStatus; @@ -58,7 +61,7 @@ use Wikimedia\Rdbms\LoadBalancer; * Some fields are public only for backwards-compatibility. Use accessors. * In the past, this class was part of Article.php and everything was public. */ -class WikiPage implements Page, IDBAccessObject, PageIdentity { +class WikiPage implements Page, IDBAccessObject, PageRecord { use NonSerializableTrait; use ProtectedHookAccessorTrait; use WikiAwareEntityTrait; @@ -100,6 +103,16 @@ class WikiPage implements Page, IDBAccessObject, PageIdentity { */ protected $mRedirectTarget = null; + /** + * @var bool + */ + private $mIsNew = false; + + /** + * @var bool + */ + private $mIsRedirect = false; + /** * @var int|false False means "not loaded" * @todo make protected @@ -139,6 +152,11 @@ class WikiPage implements Page, IDBAccessObject, PageIdentity { */ protected $mTouched = '19700101000000'; + /** + * @var string|null + */ + protected $mLanguage = null; + /** * @var string */ @@ -340,8 +358,11 @@ class WikiPage implements Page, IDBAccessObject, PageIdentity { $this->mPageIsRedirectField = false; $this->mLastRevision = null; // Latest revision $this->mTouched = '19700101000000'; + $this->mLanguage = null; $this->mLinksUpdated = '19700101000000'; $this->mTimestamp = ''; + $this->mIsNew = false; + $this->mIsRedirect = false; $this->mLatest = false; // T59026: do not clear $this->derivedDataUpdater since getDerivedDataUpdater() already // checks the requested rev ID and content against the cached one. For most @@ -561,12 +582,17 @@ class WikiPage implements Page, IDBAccessObject, PageIdentity { // Old-fashioned restrictions $this->mTitle->loadRestrictions( $data->page_restrictions ); + $contLang = MediaWikiServices::getInstance()->getContentLanguage(); + $this->mId = intval( $data->page_id ); $this->mTouched = MWTimestamp::convert( TS_MW, $data->page_touched ); + $this->mLanguage = $data->page_lang ?? $contLang->getCode(); $this->mLinksUpdated = $data->page_links_updated === null ? null : MWTimestamp::convert( TS_MW, $data->page_links_updated ); $this->mPageIsRedirectField = (bool)$data->page_is_redirect; + $this->mIsNew = intval( $data->page_is_new ?? 0 ); + $this->mIsRedirect = intval( $data->page_is_redirect ?? 0 ); $this->mLatest = intval( $data->page_latest ); // T39225: $latest may no longer match the cached latest RevisionRecord object. // Double-check the ID of any cached latest RevisionRecord object for consistency. @@ -650,7 +676,11 @@ class WikiPage implements Page, IDBAccessObject, PageIdentity { * @return bool */ public function isRedirect() { - return $this->getRedirectTarget() !== null; + if ( !$this->mDataLoaded ) { + $this->loadPageData(); + } + + return (bool)$this->mIsRedirect; } /** @@ -669,6 +699,22 @@ class WikiPage implements Page, IDBAccessObject, PageIdentity { return $this->mPageIsRedirectField; } + /** + * Tests if the page is new (only has one revision). + * May produce false negatives for some old pages. + * + * @since 1.36 + * + * @return bool + */ + public function isNew() { + if ( !$this->mDataLoaded ) { + $this->loadPageData(); + } + + return (bool)$this->mIsNew; + } + /** * Returns the page's content model id (see the CONTENT_MODEL_XXX constants). * @@ -733,6 +779,17 @@ class WikiPage implements Page, IDBAccessObject, PageIdentity { return $this->mTouched; } + /** + * @return string language code for the page + */ + public function getLanguage() { + if ( !$this->mDataLoaded ) { + $this->loadLastEdit(); + } + + return $this->mLanguage ?: MediaWikiServices::getInstance()->getContentLanguage()->getCode(); + } + /** * Get the page_links_updated field * @return string|null Containing GMT timestamp @@ -746,9 +803,12 @@ class WikiPage implements Page, IDBAccessObject, PageIdentity { /** * Get the page_latest field + * @param bool $wikiId * @return int The rev_id of current revision */ - public function getLatest() { + public function getLatest( $wikiId = self::LOCAL ) { + $this->assertWiki( $wikiId ); + if ( !$this->mDataLoaded ) { $this->loadPageData(); } @@ -1454,6 +1514,8 @@ class WikiPage implements Page, IDBAccessObject, PageIdentity { $content = $revision->getContent( SlotRecord::MAIN ); $len = $content ? $content->getSize() : 0; $rt = $content ? $content->getUltimateRedirectTarget() : null; + $isNew = ( $lastRevision === 0 ) ? 1 : 0; + $isRedirect = $rt !== null ? 1 : 0; $conditions = [ 'page_id' => $this->getId() ]; @@ -1470,8 +1532,8 @@ class WikiPage implements Page, IDBAccessObject, PageIdentity { $row = [ /* SET */ 'page_latest' => $revId, 'page_touched' => $dbw->timestamp( $revision->getTimestamp() ), - 'page_is_new' => ( $lastRevision === 0 ) ? 1 : 0, - 'page_is_redirect' => $rt !== null ? 1 : 0, + 'page_is_new' => $isNew, + 'page_is_redirect' => $isRedirect, 'page_len' => $len, 'page_content_model' => $model, ]; @@ -1489,6 +1551,8 @@ class WikiPage implements Page, IDBAccessObject, PageIdentity { $this->mRedirectTarget = null; $this->mHasRedirectTarget = null; $this->mPageIsRedirectField = (bool)$rt; + $this->mIsNew = (bool)$isNew; + $this->mIsRedirect = (bool)$isRedirect; // Update the LinkCache. $linkCache = MediaWikiServices::getInstance()->getLinkCache(); $linkCache->addGoodLinkObj( @@ -4223,4 +4287,42 @@ class WikiPage implements Page, IDBAccessObject, PageIdentity { return true; } + /** + * Returns the page represented by this WikiPage as a PageStoreRecord. + * The PageRecord returned by this method is guaranteed to be immutable. + * + * It is preferred to use this method rather than using the WikiPage as a PageIdentity directly. + * @since 1.36 + * + * @throws PreconditionException if the page does not exist. + * + * @return ExistingPageRecord + */ + public function toPageRecord(): ExistingPageRecord { + // TODO: replace individual member fields with a PageRecord instance that is always present + + if ( !$this->mDataLoaded ) { + $this->loadPageData(); + } + + Assert::precondition( + $this->exists(), + 'This WikiPage instance does not represent an existing page: ' . $this->mTitle + ); + + return new PageStoreRecord( + (object)[ + 'page_id' => $this->getId(), + 'page_namespace' => $this->mTitle->getNamespace(), + 'page_title' => $this->mTitle->getDBkey(), + 'page_latest' => $this->mLatest, + 'page_is_new' => $this->mIsNew, + 'page_is_redirect' => $this->mIsRedirect, + 'page_touched' => $this->mTouched, + 'page_lang' => $this->getLanguage() + ], + PageIdentity::LOCAL + ); + } + } diff --git a/tests/phpunit/includes/TitleMethodsTest.php b/tests/phpunit/includes/TitleMethodsTest.php index 180f0506ca1..c67a31dad31 100644 --- a/tests/phpunit/includes/TitleMethodsTest.php +++ b/tests/phpunit/includes/TitleMethodsTest.php @@ -475,6 +475,41 @@ class TitleMethodsTest extends MediaWikiLangTestCase { $this->assertSame( $title->getWikiId(), $page->getWikiId() ); } + /** + * @dataProvider provideProperPage + * @covers Title::toPageRecord + */ + public function testToPageRecord( $ns, $text ) { + $title = Title::makeTitle( $ns, $text ); + $wikiPage = $this->getExistingTestPage( $title ); + + $record = $title->toPageRecord(); + + $this->assertNotSame( $title, $record ); + $this->assertNotSame( $title, $wikiPage ); + + $this->assertSame( $title->getId(), $record->getId() ); + $this->assertSame( $title->getNamespace(), $record->getNamespace() ); + $this->assertSame( $title->getDBkey(), $record->getDBkey() ); + $this->assertSame( $title->getWikiId(), $record->getWikiId() ); + + $this->assertSame( $title->getLatestRevID(), $record->getLatest() ); + $this->assertSame( MWTimestamp::convert( TS_MW, $title->getTouched() ), $record->getTouched() ); + $this->assertSame( $title->isNewPage(), $record->isNew() ); + $this->assertSame( $title->isRedirect(), $record->isRedirect() ); + } + + /** + * @dataProvider provideImproperPage + * @covers Title::toPageRecord + */ + public function testToPageRecord_fail( $ns, $text, $fragment = '', $interwiki = '' ) { + $title = Title::makeTitle( $ns, $text, $fragment, $interwiki ); + + $this->expectException( PreconditionException::class ); + $title->toPageRecord(); + } + public function provideImproperPage() { return [ [ NS_MAIN, '' ], diff --git a/tests/phpunit/includes/page/WikiPageDbTest.php b/tests/phpunit/includes/page/WikiPageDbTest.php index 8f9b63bbbb7..407da23a2b2 100644 --- a/tests/phpunit/includes/page/WikiPageDbTest.php +++ b/tests/phpunit/includes/page/WikiPageDbTest.php @@ -1931,12 +1931,14 @@ more stuff 'page_id' => '44', 'page_len' => '76', 'page_is_redirect' => '1', + 'page_is_new' => '1', 'page_latest' => '99', 'page_namespace' => '3', 'page_title' => 'JaJaTitle', 'page_restrictions' => 'edit=autoconfirmed,sysop:move=sysop', 'page_touched' => '20120101020202', 'page_links_updated' => '20140101020202', + 'page_lang' => 'it', ]; foreach ( $overrides as $key => $value ) { $row[$key] = $value; @@ -1952,6 +1954,8 @@ more stuff $test->assertSame( 76, $wikiPage->getTitle()->getLength() ); $test->assertTrue( $wikiPage->getPageIsRedirectField() ); $test->assertSame( 99, $wikiPage->getLatest() ); + $test->assertSame( true, $wikiPage->isNew() ); + $test->assertSame( 'it', $wikiPage->getLanguage() ); $test->assertSame( 3, $wikiPage->getTitle()->getNamespace() ); $test->assertSame( 'JaJaTitle', $wikiPage->getTitle()->getDBkey() ); $test->assertSame( @@ -1989,6 +1993,17 @@ more stuff ); } ]; + yield 'no language' => [ + $this->getRow( [ + 'page_lang' => null, + ] ), + function ( WikiPage $wikiPage, self $test ) { + $test->assertSame( + 'en', + $wikiPage->getLanguage() + ); + } + ]; yield 'not redirect' => [ $this->getRow( [ 'page_is_redirect' => '0', @@ -1997,6 +2012,14 @@ more stuff $test->assertFalse( $wikiPage->isRedirect() ); } ]; + yield 'not new' => [ + $this->getRow( [ + 'page_is_new' => '0', + ] ), + function ( WikiPage $wikiPage, self $test ) { + $test->assertFalse( $wikiPage->isNew() ); + } + ]; } /** @@ -2699,4 +2722,48 @@ more stuff $this->assertTrue( $title->isRedirect() ); } + /** + * @covers WikiPage::getTitle + * @covers WikiPage::getId + * @covers WikiPage::getNamespace + * @covers WikiPage::getDBkey + * @covers WikiPage::getWikiId + * @covers WikiPage::canExist + */ + public function testGetTitle() { + $page = $this->createPage( __METHOD__, 'whatever' ); + + $title = $page->getTitle(); + $this->assertSame( __METHOD__, $title->getText() ); + + $this->assertSame( $page->getId(), $title->getId() ); + $this->assertSame( $page->getNamespace(), $title->getNamespace() ); + $this->assertSame( $page->getDBkey(), $title->getDBkey() ); + $this->assertSame( $page->getWikiId(), $title->getWikiId() ); + $this->assertSame( $page->canExist(), $title->canExist() ); + } + + /** + * @covers WikiPage::toPageRecord + * @covers WikiPage::getLatest + * @covers WikiPage::getTouched + * @covers WikiPage::isNew + * @covers WikiPage::isRedirect + */ + public function testToPageRecord() { + $page = $this->createPage( __METHOD__, 'whatever' ); + $record = $page->toPageRecord(); + + $this->assertSame( $page->getId(), $record->getId() ); + $this->assertSame( $page->getNamespace(), $record->getNamespace() ); + $this->assertSame( $page->getDBkey(), $record->getDBkey() ); + $this->assertSame( $page->getWikiId(), $record->getWikiId() ); + $this->assertSame( $page->canExist(), $record->canExist() ); + + $this->assertSame( $page->getLatest(), $record->getLatest() ); + $this->assertSame( $page->getTouched(), $record->getTouched() ); + $this->assertSame( $page->isNew(), $record->isNew() ); + $this->assertSame( $page->isRedirect(), $record->isRedirect() ); + } + } diff --git a/tests/phpunit/unit/includes/page/PageStoreRecordTest.php b/tests/phpunit/unit/includes/page/PageStoreRecordTest.php new file mode 100644 index 00000000000..c3524302950 --- /dev/null +++ b/tests/phpunit/unit/includes/page/PageStoreRecordTest.php @@ -0,0 +1,218 @@ + 7, + 'page_namespace' => NS_MAIN, + 'page_title' => 'Test', + 'page_touched' => '20200909001122', + 'page_latest' => 1717, + 'page_is_new' => true, + 'page_is_redirect' => true, + 'page_lang' => 'it', + ], + PageIdentity::LOCAL + ], + [ + (object)[ + 'page_id' => 3, + 'page_namespace' => NS_USER, + 'page_title' => 'Test', + 'page_touched' => '20200909001122', + 'page_latest' => 1717, + 'page_is_new' => false, + 'page_is_redirect' => false, + 'page_lang' => 'und', + ], + 'h2g2' + ] + ]; + } + + /** + * @dataProvider goodConstructorProvider + */ + public function testConstruction( $row, $wikiId ) { + $pageRecord = new PageStoreRecord( $row, $wikiId ); + + $this->assertSame( $row->page_id, $pageRecord->getId( $wikiId ) ); + $this->assertSame( $row->page_id > 0, $pageRecord->exists() ); + $this->assertSame( $row->page_namespace, $pageRecord->getNamespace() ); + $this->assertSame( $row->page_title, $pageRecord->getDBkey() ); + + $this->assertTrue( $pageRecord->canExist() ); + + $this->assertSame( $wikiId, $pageRecord->getWikiId() ); + $this->assertSame( $row->page_touched, $pageRecord->getTouched() ); + $this->assertSame( $row->page_latest, $pageRecord->getLatest( $wikiId ) ); + $this->assertSame( $row->page_is_new, $pageRecord->isNew() ); + $this->assertSame( $row->page_is_redirect, $pageRecord->isRedirect() ); + $this->assertSame( $row->page_lang, $pageRecord->getLanguage() ); + } + + public function badConstructorProvider() { + $row = [ + 'page_id' => 1, + 'page_namespace' => NS_MAIN, + 'page_title' => 'Test', + 'page_touched' => '20200909001122', + 'page_latest' => 1717, + 'page_is_new' => true, + 'page_is_redirect' => true, + ]; + return [ + 'nonexisting page' => [ (object)( [ 'page_id' => 0 ] + $row ) ], + 'negative id' => [ (object)( [ 'page_id' => -1 ] + $row ) ], + 'special page' => [ (object)( [ 'page_namespace' => NS_SPECIAL ] + $row ) ], + 'empty title' => [ (object)( [ 'page_title' => '' ] + $row ) ], + 'section link' => [ (object)( [ 'page_title' => 'Foo#Bar' ] + $row ) ], + 'pipe in title' => [ (object)( [ 'page_title' => 'Foo|Bar' ] + $row ) ], + 'tab in title' => [ (object)( [ 'page_title' => "Foo\tBar" ] + $row ) ], + + // missing data + 'missing touched' => [ (object)array_diff_key( $row, [ 'touched' => 'foo' ] ) ], + 'missing latest' => [ (object)array_diff_key( $row, [ 'latest' => 'foo' ] ) ], + 'missing is_new' => [ (object)array_diff_key( $row, [ 'is_new' => 'foo' ] ) ], + 'missing lang' => [ (object)array_diff_key( $row, [ 'lang' => 'foo' ] ) ], + 'missing is_redirect' => [ (object)array_diff_key( $row, [ 'is_redirect' => 'foo' ] ) ], + ]; + } + + /** + * @dataProvider badConstructorProvider + */ + public function testConstructionErrors( $row ) { + $this->expectException( ParameterAssertionException::class ); + new PageStoreRecord( $row, PageStoreRecord::LOCAL ); + } + + public function testGetLatestRequiresForeignWikiId() { + $row = (object)[ + 'page_id' => 7, + 'page_namespace' => NS_MAIN, + 'page_title' => 'Test', + 'page_touched' => '20200909001122', + 'page_latest' => 1717, + 'page_is_new' => true, + 'page_is_redirect' => true, + 'page_lang' => 'it', + ]; + $pageRecord = new PageStoreRecord( $row, 'acme' ); + + $this->expectException( RuntimeException::class ); + $pageRecord->getLatest( 'xyzzy' ); + } + + public function provideToString() { + $row = [ + 'page_id' => 7, + 'page_namespace' => NS_MAIN, + 'page_title' => 'Test', + 'page_touched' => '20200909001122', + 'page_latest' => 1717, + 'page_is_new' => true, + 'page_is_redirect' => true, + 'page_lang' => 'it', + ]; + + yield [ + new PageStoreRecord( (object)$row, PageIdentity::LOCAL ), + '#7 [0:Test]' + ]; + yield [ + new PageStoreRecord( (object)( [ 'page_namespace' => 200 ] + $row ), 'codewiki' ), + '#7@codewiki [200:Test]' + ]; + } + + /** + * @dataProvider provideToString + */ + public function testToString( PageStoreRecord $value, $expected ) { + $this->assertSame( + $expected, + $value->__toString() + ); + } + + public function provideIsSamePageAs() { + $row = [ + 'page_id' => 7, + 'page_namespace' => NS_MAIN, + 'page_title' => 'Test', + 'page_touched' => '20200909001122', + 'page_latest' => 1717, + 'page_is_new' => true, + 'page_is_redirect' => true, + 'page_lang' => 'it', + ]; + + yield [ + new PageStoreRecord( (object)$row, PageRecord::LOCAL ), + new PageStoreRecord( (object)$row, PageRecord::LOCAL ), + true + ]; + yield [ + new PageStoreRecord( (object)$row, PageRecord::LOCAL ), + new PageStoreRecord( (object)$row, 'acme' ), + false + ]; + yield [ + new PageStoreRecord( (object)$row, 'acme' ), + new PageStoreRecord( (object)$row, 'acme' ), + true + ]; + yield [ + new PageStoreRecord( (object)$row, 'acme' ), + new PageIdentityValue( 7, NS_MAIN, 'Test', 'acme' ), + true + ]; + } + + /** + * @dataProvider provideIsSamePageAs + */ + public function testIsSamePageAs( PageIdentity $a, PageIdentity $b, $expected ) { + $this->assertSame( $expected, $a->isSamePageAs( $b ) ); + $this->assertSame( $expected, $b->isSamePageAs( $a ) ); + } + +}