Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
73 / 73
100.00% covered (success)
100.00%
8 / 8
CRAP
100.00% covered (success)
100.00%
1 / 1
ArchivedRevisionLookup
100.00% covered (success)
100.00%
73 / 73
100.00% covered (success)
100.00%
8 / 8
19
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 listRevisions
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 getRevisionRecordByTimestamp
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getArchivedRevisionRecord
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRevisionByConditions
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 getPreviousRevisionRecord
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
8
 getLastRevisionId
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 hasArchivedRevisions
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
17 *
18 * @file
19 */
20
21namespace MediaWiki\Revision;
22
23use MediaWiki\MediaWikiServices;
24use MediaWiki\Page\PageIdentity;
25use Wikimedia\Rdbms\IConnectionProvider;
26use Wikimedia\Rdbms\IResultWrapper;
27use Wikimedia\Rdbms\SelectQueryBuilder;
28
29/**
30 * @since 1.38
31 */
32class ArchivedRevisionLookup {
33
34    /** @var IConnectionProvider */
35    private $dbProvider;
36
37    /** @var RevisionStore */
38    private $revisionStore;
39
40    /**
41     * @param IConnectionProvider $dbProvider
42     * @param RevisionStore $revisionStore
43     */
44    public function __construct(
45        IConnectionProvider $dbProvider,
46        RevisionStore $revisionStore
47    ) {
48        $this->dbProvider = $dbProvider;
49        $this->revisionStore = $revisionStore;
50    }
51
52    /**
53     * List the revisions of the given page. Returns result wrapper with
54     * various archive table fields.
55     *
56     * @param PageIdentity $page
57     * @param array $extraConds Extra conditions to be added to the query
58     * @param ?int $limit The limit to be applied to the query, or null for no limit
59     * @return IResultWrapper
60     */
61    public function listRevisions( PageIdentity $page, array $extraConds = [], ?int $limit = null ) {
62        $queryBuilder = $this->revisionStore->newArchiveSelectQueryBuilder( $this->dbProvider->getReplicaDatabase() )
63            ->joinComment()
64            ->where( $extraConds )
65            ->andWhere( [ 'ar_namespace' => $page->getNamespace(), 'ar_title' => $page->getDBkey() ] );
66
67        // NOTE: ordering by ar_timestamp and ar_id, to remove ambiguity.
68        // XXX: Ideally, we would be ordering by ar_timestamp and ar_rev_id, but since we
69        // don't have an index on ar_rev_id, that causes a file sort.
70        $queryBuilder->orderBy( [ 'ar_timestamp', 'ar_id' ], SelectQueryBuilder::SORT_DESC );
71        if ( $limit !== null ) {
72            $queryBuilder->limit( $limit );
73        }
74
75        MediaWikiServices::getInstance()->getChangeTagsStore()->modifyDisplayQueryBuilder( $queryBuilder, 'archive' );
76
77        return $queryBuilder->caller( __METHOD__ )->fetchResultSet();
78    }
79
80    /**
81     * Return a RevisionRecord object containing data for the deleted revision.
82     *
83     * @internal only for use in SpecialUndelete
84     *
85     * @param PageIdentity $page
86     * @param string $timestamp
87     * @return RevisionRecord|null
88     */
89    public function getRevisionRecordByTimestamp( PageIdentity $page, $timestamp ): ?RevisionRecord {
90        return $this->getRevisionByConditions(
91            $page,
92            [ 'ar_timestamp' => $this->dbProvider->getReplicaDatabase()->timestamp( $timestamp ) ]
93        );
94    }
95
96    /**
97     * Return the archived revision with the given ID.
98     *
99     * @param PageIdentity|null $page
100     * @param int $revId
101     * @return RevisionRecord|null
102     */
103    public function getArchivedRevisionRecord( ?PageIdentity $page, int $revId ): ?RevisionRecord {
104        return $this->getRevisionByConditions( $page, [ 'ar_rev_id' => $revId ] );
105    }
106
107    /**
108     * @param PageIdentity|null $page
109     * @param array $conditions
110     * @param array $options
111     *
112     * @return RevisionRecord|null
113     */
114    private function getRevisionByConditions(
115        ?PageIdentity $page,
116        array $conditions,
117        array $options = []
118    ): ?RevisionRecord {
119        $queryBuilder = $this->revisionStore->newArchiveSelectQueryBuilder( $this->dbProvider->getReplicaDatabase() )
120            ->joinComment()
121            ->where( $conditions )
122            ->options( $options );
123
124        if ( $page ) {
125            $queryBuilder->andWhere( [ 'ar_namespace' => $page->getNamespace(), 'ar_title' => $page->getDBkey() ] );
126        }
127
128        $row = $queryBuilder->caller( __METHOD__ )->fetchRow();
129
130        if ( $row ) {
131            return $this->revisionStore->newRevisionFromArchiveRow( $row, 0, $page );
132        }
133
134        return null;
135    }
136
137    /**
138     * Return the most-previous revision, either live or deleted, against
139     * the deleted revision given by timestamp.
140     *
141     * May produce unexpected results in case of history merges or other
142     * unusual time issues.
143     *
144     * @param PageIdentity $page
145     * @param string $timestamp
146     * @return RevisionRecord|null Null when there is no previous revision
147     */
148    public function getPreviousRevisionRecord( PageIdentity $page, string $timestamp ): ?RevisionRecord {
149        $dbr = $this->dbProvider->getReplicaDatabase();
150
151        // Check the previous deleted revision...
152        $row = $dbr->newSelectQueryBuilder()
153            ->select( [ 'ar_rev_id', 'ar_timestamp' ] )
154            ->from( 'archive' )
155            ->where( [
156                'ar_namespace' => $page->getNamespace(),
157                'ar_title' => $page->getDBkey(),
158                $dbr->expr( 'ar_timestamp', '<', $dbr->timestamp( $timestamp ) ),
159            ] )
160            ->orderBy( 'ar_timestamp DESC' )
161            ->caller( __METHOD__ )->fetchRow();
162        $prevDeleted = $row ? wfTimestamp( TS_MW, $row->ar_timestamp ) : false;
163        $prevDeletedId = $row ? intval( $row->ar_rev_id ) : null;
164
165        $row = $dbr->newSelectQueryBuilder()
166            ->select( [ 'rev_id', 'rev_timestamp' ] )
167            ->from( 'page' )
168            ->join( 'revision', null, 'page_id = rev_page' )
169            ->where( [
170                'page_namespace' => $page->getNamespace(),
171                'page_title' => $page->getDBkey(),
172                $dbr->expr( 'rev_timestamp', '<', $dbr->timestamp( $timestamp ) )
173            ] )
174            ->orderBy( 'rev_timestamp DESC' )
175            ->caller( __METHOD__ )->fetchRow();
176        $prevLive = $row ? wfTimestamp( TS_MW, $row->rev_timestamp ) : false;
177        $prevLiveId = $row ? intval( $row->rev_id ) : null;
178
179        if ( $prevLive && $prevLive > $prevDeleted ) {
180            // Most prior revision was live
181            $rec = $this->revisionStore->getRevisionById( $prevLiveId );
182        } elseif ( $prevDeleted ) {
183            // Most prior revision was deleted
184            $rec = $this->getArchivedRevisionRecord( $page, $prevDeletedId );
185        } else {
186            $rec = null;
187        }
188
189        return $rec;
190    }
191
192    /**
193     * Returns the ID of the latest deleted revision.
194     *
195     * @param PageIdentity $page
196     *
197     * @return int|false The revision's ID, or false if there is no deleted revision.
198     */
199    public function getLastRevisionId( PageIdentity $page ) {
200        $revId = $this->dbProvider->getReplicaDatabase()->newSelectQueryBuilder()
201            ->select( 'ar_rev_id' )
202            ->from( 'archive' )
203            ->where( [ 'ar_namespace' => $page->getNamespace(), 'ar_title' => $page->getDBkey() ] )
204            ->orderBy( [ 'ar_timestamp', 'ar_id' ], SelectQueryBuilder::SORT_DESC )
205            ->caller( __METHOD__ )->fetchField();
206
207        return $revId ? intval( $revId ) : false;
208    }
209
210    /**
211     * Quick check if any archived revisions are present for the page.
212     * This says nothing about whether the page currently exists in the page table or not.
213     *
214     * @param PageIdentity $page
215     *
216     * @return bool
217     */
218    public function hasArchivedRevisions( PageIdentity $page ): bool {
219        $row = $this->dbProvider->getReplicaDatabase()->selectRow(
220            'archive',
221            '1', // We don't care about the value. Allow the database to optimize.
222            [ 'ar_namespace' => $page->getNamespace(),
223                'ar_title' => $page->getDBkey() ],
224            __METHOD__
225        );
226
227        return (bool)$row;
228    }
229
230}