MediaWiki  master
Category.php
Go to the documentation of this file.
1 <?php
27 
33 class Category {
35  private $mName = null;
36  private $mID = null;
41  private $mPage = null;
43  private $mPages = null, $mSubcats = null, $mFiles = null;
44 
45  protected const LOAD_ONLY = 0;
46  protected const LAZY_INIT_ROW = 1;
47 
48  public const ROW_COUNT_SMALL = 100;
49 
51  private $loadBalancer;
52 
54  private $readOnlyMode;
55 
56  private function __construct() {
57  $services = MediaWikiServices::getInstance();
58  $this->loadBalancer = $services->getDBLoadBalancer();
59  $this->readOnlyMode = $services->getReadOnlyMode();
60  }
61 
68  protected function initialize( $mode = self::LOAD_ONLY ) {
69  if ( $this->mName === null && $this->mID === null ) {
70  throw new MWException( __METHOD__ . ' has both names and IDs null' );
71  } elseif ( $this->mID === null ) {
72  $where = [ 'cat_title' => $this->mName ];
73  } elseif ( $this->mName === null ) {
74  $where = [ 'cat_id' => $this->mID ];
75  } else {
76  # Already initialized
77  return true;
78  }
79 
80  $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
81  $row = $dbr->selectRow(
82  'category',
83  [ 'cat_id', 'cat_title', 'cat_pages', 'cat_subcats', 'cat_files' ],
84  $where,
85  __METHOD__
86  );
87 
88  if ( !$row ) {
89  # Okay, there were no contents. Nothing to initialize.
90  if ( $this->mPage ) {
91  # If there is a page object but no record in the category table,
92  # treat this as an empty category.
93  $this->mID = false;
94  $this->mName = $this->mPage->getDBkey();
95  $this->mPages = 0;
96  $this->mSubcats = 0;
97  $this->mFiles = 0;
98 
99  # If the page exists, call refreshCounts to add a row for it.
100  if ( $mode === self::LAZY_INIT_ROW && $this->mPage->exists() ) {
101  DeferredUpdates::addCallableUpdate( [ $this, 'refreshCounts' ] );
102  }
103 
104  return true;
105  } else {
106  return false; # Fail
107  }
108  }
109 
110  $this->mID = $row->cat_id;
111  $this->mName = $row->cat_title;
112  $this->mPages = $row->cat_pages;
113  $this->mSubcats = $row->cat_subcats;
114  $this->mFiles = $row->cat_files;
115 
116  # (T15683) If the count is negative, then 1) it's obviously wrong
117  # and should not be kept, and 2) we *probably* don't have to scan many
118  # rows to obtain the correct figure, so let's risk a one-time recount.
119  if ( $this->mPages < 0 || $this->mSubcats < 0 || $this->mFiles < 0 ) {
120  $this->mPages = max( $this->mPages, 0 );
121  $this->mSubcats = max( $this->mSubcats, 0 );
122  $this->mFiles = max( $this->mFiles, 0 );
123 
124  if ( $mode === self::LAZY_INIT_ROW ) {
125  DeferredUpdates::addCallableUpdate( [ $this, 'refreshCounts' ] );
126  }
127  }
128 
129  return true;
130  }
131 
139  public static function newFromName( $name ) {
140  $cat = new self();
142 
143  if ( !is_object( $title ) ) {
144  return false;
145  }
146 
147  $cat->mPage = $title;
148  $cat->mName = $title->getDBkey();
149 
150  return $cat;
151  }
152 
159  public static function newFromTitle( PageIdentity $page ): self {
160  $cat = new self();
161 
162  $cat->mPage = $page;
163  $cat->mName = $page->getDBkey();
164 
165  return $cat;
166  }
167 
174  public static function newFromID( $id ) {
175  $cat = new self();
176  $cat->mID = intval( $id );
177  return $cat;
178  }
179 
190  public static function newFromRow( stdClass $row, ?PageIdentity $page = null ) {
191  $cat = new self();
192  $cat->mPage = $page;
193 
194  # NOTE: the row often results from a LEFT JOIN on categorylinks. This may result in
195  # all the cat_xxx fields being null, if the category page exists, but nothing
196  # was ever added to the category. This case should be treated link an empty
197  # category, if possible.
198 
199  if ( $row->cat_title === null ) {
200  if ( $page === null ) {
201  # the name is probably somewhere in the row, for example as page_title,
202  # but we can't know that here...
203  return false;
204  } else {
205  # if we have a PageIdentity object, fetch the category name from there
206  $cat->mName = $page->getDBkey();
207  }
208 
209  $cat->mID = false;
210  $cat->mSubcats = 0;
211  $cat->mPages = 0;
212  $cat->mFiles = 0;
213  } else {
214  $cat->mName = $row->cat_title;
215  $cat->mID = $row->cat_id;
216  $cat->mSubcats = $row->cat_subcats;
217  $cat->mPages = $row->cat_pages;
218  $cat->mFiles = $row->cat_files;
219  }
220 
221  return $cat;
222  }
223 
227  public function getName() {
228  return $this->getX( 'mName' );
229  }
230 
234  public function getID() {
235  return $this->getX( 'mID' );
236  }
237 
241  public function getPageCount() {
242  return $this->getX( 'mPages' );
243  }
244 
248  public function getSubcatCount() {
249  return $this->getX( 'mSubcats' );
250  }
251 
255  public function getFileCount() {
256  return $this->getX( 'mFiles' );
257  }
258 
264  public function getPage(): ?PageIdentity {
265  if ( $this->mPage ) {
266  return $this->mPage;
267  }
268 
269  if ( !$this->initialize( self::LAZY_INIT_ROW ) ) {
270  return null;
271  }
272 
273  $this->mPage = Title::makeTitleSafe( NS_CATEGORY, $this->mName );
274  return $this->mPage;
275  }
276 
281  public function getTitle() {
282  return Title::castFromPageIdentity( $this->getPage() ) ?? false;
283  }
284 
292  public function getMembers( $limit = false, $offset = '' ) {
293  $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
294 
295  $conds = [ 'cl_to' => $this->getName(), 'cl_from = page_id' ];
296  $options = [ 'ORDER BY' => 'cl_sortkey' ];
297 
298  if ( $limit ) {
299  $options['LIMIT'] = $limit;
300  }
301 
302  if ( $offset !== '' ) {
303  $conds[] = 'cl_sortkey > ' . $dbr->addQuotes( $offset );
304  }
305 
306  $result = TitleArray::newFromResult(
307  $dbr->select(
308  [ 'page', 'categorylinks' ],
309  [ 'page_id', 'page_namespace', 'page_title', 'page_len',
310  'page_is_redirect', 'page_latest' ],
311  $conds,
312  __METHOD__,
313  $options
314  )
315  );
316 
317  return $result;
318  }
319 
325  private function getX( $key ) {
326  if ( $this->{$key} === null && !$this->initialize( self::LAZY_INIT_ROW ) ) {
327  return false;
328  }
329  return $this->{$key};
330  }
331 
337  public function refreshCounts() {
338  if ( $this->readOnlyMode->isReadOnly() ) {
339  return false;
340  }
341 
342  # If we have just a category name, find out whether there is an
343  # existing row. Or if we have just an ID, get the name, because
344  # that's what categorylinks uses.
345  if ( !$this->initialize( self::LOAD_ONLY ) ) {
346  return false;
347  }
348 
349  $dbw = $this->loadBalancer->getConnectionRef( DB_PRIMARY );
350  # Avoid excess contention on the same category (T162121)
351  $name = __METHOD__ . ':' . md5( $this->mName );
352  $scopedLock = $dbw->getScopedLockAndFlush( $name, __METHOD__, 0 );
353  if ( !$scopedLock ) {
354  return false;
355  }
356 
357  $dbw->startAtomic( __METHOD__ );
358 
359  // Lock the `category` row before locking `categorylinks` rows to try
360  // to avoid deadlocks with LinksDeletionUpdate (T195397)
361  $dbw->lockForUpdate( 'category', [ 'cat_title' => $this->mName ], __METHOD__ );
362 
363  // Lock all the `categorylinks` records and gaps for this category;
364  // this is a separate query due to postgres limitations
365  $dbw->selectRowCount(
366  [ 'categorylinks', 'page' ],
367  '*',
368  [ 'cl_to' => $this->mName, 'page_id = cl_from' ],
369  __METHOD__,
370  [ 'LOCK IN SHARE MODE' ]
371  );
372  // Get the aggregate `categorylinks` row counts for this category
373  $catCond = $dbw->conditional( [ 'page_namespace' => NS_CATEGORY ], '1', 'NULL' );
374  $fileCond = $dbw->conditional( [ 'page_namespace' => NS_FILE ], '1', 'NULL' );
375  $result = $dbw->selectRow(
376  [ 'categorylinks', 'page' ],
377  [
378  'pages' => 'COUNT(*)',
379  'subcats' => "COUNT($catCond)",
380  'files' => "COUNT($fileCond)"
381  ],
382  [ 'cl_to' => $this->mName, 'page_id = cl_from' ],
383  __METHOD__
384  );
385 
386  $shouldExist = $result->pages > 0 || $this->getPage()->exists();
387 
388  if ( $this->mID ) {
389  if ( $shouldExist ) {
390  # The category row already exists, so do a plain UPDATE instead
391  # of INSERT...ON DUPLICATE KEY UPDATE to avoid creating a gap
392  # in the cat_id sequence. The row may or may not be "affected".
393  $dbw->update(
394  'category',
395  [
396  'cat_pages' => $result->pages,
397  'cat_subcats' => $result->subcats,
398  'cat_files' => $result->files
399  ],
400  [ 'cat_title' => $this->mName ],
401  __METHOD__
402  );
403  } else {
404  # The category is empty and has no description page, delete it
405  $dbw->delete(
406  'category',
407  [ 'cat_title' => $this->mName ],
408  __METHOD__
409  );
410  $this->mID = false;
411  }
412  } elseif ( $shouldExist ) {
413  # The category row doesn't exist but should, so create it. Use
414  # upsert in case of races.
415  $dbw->upsert(
416  'category',
417  [
418  'cat_title' => $this->mName,
419  'cat_pages' => $result->pages,
420  'cat_subcats' => $result->subcats,
421  'cat_files' => $result->files
422  ],
423  'cat_title',
424  [
425  'cat_pages' => $result->pages,
426  'cat_subcats' => $result->subcats,
427  'cat_files' => $result->files
428  ],
429  __METHOD__
430  );
431  // @todo: Should we update $this->mID here? Or not since Category
432  // objects tend to be short lived enough to not matter?
433  }
434 
435  $dbw->endAtomic( __METHOD__ );
436 
437  # Now we should update our local counts.
438  $this->mPages = $result->pages;
439  $this->mSubcats = $result->subcats;
440  $this->mFiles = $result->files;
441 
442  return true;
443  }
444 
456  public function refreshCountsIfEmpty() {
457  return $this->refreshCountsIfSmall( 0 );
458  }
459 
473  public function refreshCountsIfSmall( $maxSize = self::ROW_COUNT_SMALL ) {
474  $dbw = $this->loadBalancer->getConnectionRef( DB_PRIMARY );
475  $dbw->startAtomic( __METHOD__ );
476 
477  $typeOccurances = $dbw->selectFieldValues(
478  'categorylinks',
479  'cl_type',
480  [ 'cl_to' => $this->getName() ],
481  __METHOD__,
482  [ 'LIMIT' => $maxSize + 1 ]
483  );
484 
485  if ( !$typeOccurances ) {
486  $doRefresh = true; // delete any category table entry
487  } elseif ( count( $typeOccurances ) <= $maxSize ) {
488  $countByType = array_count_values( $typeOccurances );
489  $doRefresh = !$dbw->selectField(
490  'category',
491  '1',
492  [
493  'cat_title' => $this->getName(),
494  'cat_pages' => $countByType['page'] ?? 0,
495  'cat_subcats' => $countByType['subcat'] ?? 0,
496  'cat_files' => $countByType['file'] ?? 0
497  ],
498  __METHOD__
499  );
500  } else {
501  $doRefresh = false; // category is too big
502  }
503 
504  $dbw->endAtomic( __METHOD__ );
505 
506  if ( $doRefresh ) {
507  $this->refreshCounts(); // update the row
508 
509  return true;
510  }
511 
512  return false;
513  }
514 }
Page\PageIdentity
Interface for objects (potentially) representing an editable wiki page.
Definition: PageIdentity.php:64
Category\getTitle
getTitle()
Definition: Category.php:281
Category\ROW_COUNT_SMALL
const ROW_COUNT_SMALL
Definition: Category.php:48
Category\refreshCountsIfEmpty
refreshCountsIfEmpty()
Call refreshCounts() if there are no entries in the categorylinks table or if the category table has ...
Definition: Category.php:456
Category\getID
getID()
Definition: Category.php:234
TitleArray\newFromResult
static newFromResult( $res)
Definition: TitleArray.php:44
Category\newFromTitle
static newFromTitle(PageIdentity $page)
Factory function.
Definition: Category.php:159
MediaWiki\MediaWikiServices
MediaWikiServices is the service locator for the application scope of MediaWiki.
Definition: MediaWikiServices.php:191
Category\$mName
$mName
Name of the category, normalized to DB-key form.
Definition: Category.php:35
Category
Category objects are immutable, strictly speaking.
Definition: Category.php:33
Category\$readOnlyMode
ReadOnlyMode $readOnlyMode
Definition: Category.php:54
ReadOnlyMode
A service class for fetching the wiki's current read-only mode.
Definition: ReadOnlyMode.php:11
Category\LAZY_INIT_ROW
const LAZY_INIT_ROW
Definition: Category.php:46
Category\initialize
initialize( $mode=self::LOAD_ONLY)
Set up all member variables using a database query.
Definition: Category.php:68
Category\newFromRow
static newFromRow(stdClass $row, ?PageIdentity $page=null)
Factory function, for constructing a Category object from a result set.
Definition: Category.php:190
Title\castFromPageIdentity
static castFromPageIdentity(?PageIdentity $pageIdentity)
Return a Title for a given PageIdentity.
Definition: Title.php:332
$dbr
$dbr
Definition: testCompression.php:54
Category\getPage
getPage()
Definition: Category.php:264
MWException
MediaWiki exception.
Definition: MWException.php:29
Category\refreshCountsIfSmall
refreshCountsIfSmall( $maxSize=self::ROW_COUNT_SMALL)
Call refreshCounts() if there are few entries in the categorylinks table.
Definition: Category.php:473
Category\getX
getX( $key)
Generic accessor.
Definition: Category.php:325
Category\$mFiles
$mFiles
Definition: Category.php:43
Category\$mID
$mID
Definition: Category.php:36
Category\refreshCounts
refreshCounts()
Refresh the counts for this category.
Definition: Category.php:337
Category\getName
getName()
Definition: Category.php:227
Category\getSubcatCount
getSubcatCount()
Definition: Category.php:248
$title
$title
Definition: testCompression.php:38
Category\getMembers
getMembers( $limit=false, $offset='')
Fetch a TitleArray of up to $limit category members, beginning after the category sort key $offset.
Definition: Category.php:292
DB_REPLICA
const DB_REPLICA
Definition: defines.php:25
Category\$mSubcats
$mSubcats
Definition: Category.php:43
Title\makeTitleSafe
static makeTitleSafe( $ns, $title, $fragment='', $interwiki='')
Create a new Title from a namespace index and a DB key.
Definition: Title.php:677
Page\PageReference\getDBkey
getDBkey()
Get the page title in DB key form.
DB_PRIMARY
const DB_PRIMARY
Definition: defines.php:27
Category\$mPage
PageIdentity $mPage
Category page title.
Definition: Category.php:41
Category\getPageCount
getPageCount()
Definition: Category.php:241
Category\LOAD_ONLY
const LOAD_ONLY
Definition: Category.php:45
Category\newFromID
static newFromID( $id)
Factory function.
Definition: Category.php:174
Category\getFileCount
getFileCount()
Definition: Category.php:255
Category\$loadBalancer
ILoadBalancer $loadBalancer
Definition: Category.php:51
NS_CATEGORY
const NS_CATEGORY
Definition: Defines.php:78
Category\__construct
__construct()
Definition: Category.php:56
Category\newFromName
static newFromName( $name)
Factory function.
Definition: Category.php:139
NS_FILE
const NS_FILE
Definition: Defines.php:70
DeferredUpdates\addCallableUpdate
static addCallableUpdate( $callable, $stage=self::POSTSEND, $dbw=null)
Add an update to the pending update queue that invokes the specified callback when run.
Definition: DeferredUpdates.php:145
Wikimedia\Rdbms\ILoadBalancer
Database cluster connection, tracking, load balancing, and transaction manager interface.
Definition: ILoadBalancer.php:81
Category\$mPages
$mPages
Counts of membership (cat_pages, cat_subcats, cat_files)
Definition: Category.php:43