Category.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494
  1. <?php
  2. /**
  3. * Representation for a category.
  4. *
  5. * This program is free software; you can redistribute it and/or modify
  6. * it under the terms of the GNU General Public License as published by
  7. * the Free Software Foundation; either version 2 of the License, or
  8. * (at your option) any later version.
  9. *
  10. * This program is distributed in the hope that it will be useful,
  11. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  12. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  13. * GNU General Public License for more details.
  14. *
  15. * You should have received a copy of the GNU General Public License along
  16. * with this program; if not, write to the Free Software Foundation, Inc.,
  17. * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
  18. * http://www.gnu.org/copyleft/gpl.html
  19. *
  20. * @file
  21. * @author Simetrical
  22. */
  23. /**
  24. * Category objects are immutable, strictly speaking. If you call methods that change the database,
  25. * like to refresh link counts, the objects will be appropriately reinitialized.
  26. * Member variables are lazy-initialized.
  27. */
  28. class Category {
  29. /** Name of the category, normalized to DB-key form */
  30. private $mName = null;
  31. private $mID = null;
  32. /**
  33. * Category page title
  34. * @var Title
  35. */
  36. private $mTitle = null;
  37. /** Counts of membership (cat_pages, cat_subcats, cat_files) */
  38. private $mPages = null, $mSubcats = null, $mFiles = null;
  39. const LOAD_ONLY = 0;
  40. const LAZY_INIT_ROW = 1;
  41. const ROW_COUNT_SMALL = 100;
  42. private function __construct() {
  43. }
  44. /**
  45. * Set up all member variables using a database query.
  46. * @param int $mode One of (Category::LOAD_ONLY, Category::LAZY_INIT_ROW)
  47. * @throws MWException
  48. * @return bool True on success, false on failure.
  49. */
  50. protected function initialize( $mode = self::LOAD_ONLY ) {
  51. if ( $this->mName === null && $this->mID === null ) {
  52. throw new MWException( __METHOD__ . ' has both names and IDs null' );
  53. } elseif ( $this->mID === null ) {
  54. $where = [ 'cat_title' => $this->mName ];
  55. } elseif ( $this->mName === null ) {
  56. $where = [ 'cat_id' => $this->mID ];
  57. } else {
  58. # Already initialized
  59. return true;
  60. }
  61. $dbr = wfGetDB( DB_REPLICA );
  62. $row = $dbr->selectRow(
  63. 'category',
  64. [ 'cat_id', 'cat_title', 'cat_pages', 'cat_subcats', 'cat_files' ],
  65. $where,
  66. __METHOD__
  67. );
  68. if ( !$row ) {
  69. # Okay, there were no contents. Nothing to initialize.
  70. if ( $this->mTitle ) {
  71. # If there is a title object but no record in the category table,
  72. # treat this as an empty category.
  73. $this->mID = false;
  74. $this->mName = $this->mTitle->getDBkey();
  75. $this->mPages = 0;
  76. $this->mSubcats = 0;
  77. $this->mFiles = 0;
  78. # If the title exists, call refreshCounts to add a row for it.
  79. if ( $mode === self::LAZY_INIT_ROW && $this->mTitle->exists() ) {
  80. DeferredUpdates::addCallableUpdate( [ $this, 'refreshCounts' ] );
  81. }
  82. return true;
  83. } else {
  84. return false; # Fail
  85. }
  86. }
  87. $this->mID = $row->cat_id;
  88. $this->mName = $row->cat_title;
  89. $this->mPages = $row->cat_pages;
  90. $this->mSubcats = $row->cat_subcats;
  91. $this->mFiles = $row->cat_files;
  92. # (T15683) If the count is negative, then 1) it's obviously wrong
  93. # and should not be kept, and 2) we *probably* don't have to scan many
  94. # rows to obtain the correct figure, so let's risk a one-time recount.
  95. if ( $this->mPages < 0 || $this->mSubcats < 0 || $this->mFiles < 0 ) {
  96. $this->mPages = max( $this->mPages, 0 );
  97. $this->mSubcats = max( $this->mSubcats, 0 );
  98. $this->mFiles = max( $this->mFiles, 0 );
  99. if ( $mode === self::LAZY_INIT_ROW ) {
  100. DeferredUpdates::addCallableUpdate( [ $this, 'refreshCounts' ] );
  101. }
  102. }
  103. return true;
  104. }
  105. /**
  106. * Factory function.
  107. *
  108. * @param string $name A category name (no "Category:" prefix). It need
  109. * not be normalized, with spaces replaced by underscores.
  110. * @return Category|bool Category, or false on a totally invalid name
  111. */
  112. public static function newFromName( $name ) {
  113. $cat = new self();
  114. $title = Title::makeTitleSafe( NS_CATEGORY, $name );
  115. if ( !is_object( $title ) ) {
  116. return false;
  117. }
  118. $cat->mTitle = $title;
  119. $cat->mName = $title->getDBkey();
  120. return $cat;
  121. }
  122. /**
  123. * Factory function.
  124. *
  125. * @param Title $title Title for the category page
  126. * @return Category|bool On a totally invalid name
  127. */
  128. public static function newFromTitle( $title ) {
  129. $cat = new self();
  130. $cat->mTitle = $title;
  131. $cat->mName = $title->getDBkey();
  132. return $cat;
  133. }
  134. /**
  135. * Factory function.
  136. *
  137. * @param int $id A category id
  138. * @return Category
  139. */
  140. public static function newFromID( $id ) {
  141. $cat = new self();
  142. $cat->mID = intval( $id );
  143. return $cat;
  144. }
  145. /**
  146. * Factory function, for constructing a Category object from a result set
  147. *
  148. * @param object $row Result set row, must contain the cat_xxx fields. If the
  149. * fields are null, the resulting Category object will represent an empty
  150. * category if a title object was given. If the fields are null and no
  151. * title was given, this method fails and returns false.
  152. * @param Title|null $title Optional title object for the category represented by
  153. * the given row. May be provided if it is already known, to avoid having
  154. * to re-create a title object later.
  155. * @return Category|false
  156. */
  157. public static function newFromRow( $row, $title = null ) {
  158. $cat = new self();
  159. $cat->mTitle = $title;
  160. # NOTE: the row often results from a LEFT JOIN on categorylinks. This may result in
  161. # all the cat_xxx fields being null, if the category page exists, but nothing
  162. # was ever added to the category. This case should be treated link an empty
  163. # category, if possible.
  164. if ( $row->cat_title === null ) {
  165. if ( $title === null ) {
  166. # the name is probably somewhere in the row, for example as page_title,
  167. # but we can't know that here...
  168. return false;
  169. } else {
  170. # if we have a title object, fetch the category name from there
  171. $cat->mName = $title->getDBkey();
  172. }
  173. $cat->mID = false;
  174. $cat->mSubcats = 0;
  175. $cat->mPages = 0;
  176. $cat->mFiles = 0;
  177. } else {
  178. $cat->mName = $row->cat_title;
  179. $cat->mID = $row->cat_id;
  180. $cat->mSubcats = $row->cat_subcats;
  181. $cat->mPages = $row->cat_pages;
  182. $cat->mFiles = $row->cat_files;
  183. }
  184. return $cat;
  185. }
  186. /**
  187. * @return mixed DB key name, or false on failure
  188. */
  189. public function getName() {
  190. return $this->getX( 'mName' );
  191. }
  192. /**
  193. * @return mixed Category ID, or false on failure
  194. */
  195. public function getID() {
  196. return $this->getX( 'mID' );
  197. }
  198. /**
  199. * @return mixed Total number of member pages, or false on failure
  200. */
  201. public function getPageCount() {
  202. return $this->getX( 'mPages' );
  203. }
  204. /**
  205. * @return mixed Number of subcategories, or false on failure
  206. */
  207. public function getSubcatCount() {
  208. return $this->getX( 'mSubcats' );
  209. }
  210. /**
  211. * @return mixed Number of member files, or false on failure
  212. */
  213. public function getFileCount() {
  214. return $this->getX( 'mFiles' );
  215. }
  216. /**
  217. * @return Title|bool Title for this category, or false on failure.
  218. */
  219. public function getTitle() {
  220. if ( $this->mTitle ) {
  221. return $this->mTitle;
  222. }
  223. if ( !$this->initialize( self::LAZY_INIT_ROW ) ) {
  224. return false;
  225. }
  226. $this->mTitle = Title::makeTitleSafe( NS_CATEGORY, $this->mName );
  227. return $this->mTitle;
  228. }
  229. /**
  230. * Fetch a TitleArray of up to $limit category members, beginning after the
  231. * category sort key $offset.
  232. * @param int|bool $limit
  233. * @param string $offset
  234. * @return TitleArray TitleArray object for category members.
  235. */
  236. public function getMembers( $limit = false, $offset = '' ) {
  237. $dbr = wfGetDB( DB_REPLICA );
  238. $conds = [ 'cl_to' => $this->getName(), 'cl_from = page_id' ];
  239. $options = [ 'ORDER BY' => 'cl_sortkey' ];
  240. if ( $limit ) {
  241. $options['LIMIT'] = $limit;
  242. }
  243. if ( $offset !== '' ) {
  244. $conds[] = 'cl_sortkey > ' . $dbr->addQuotes( $offset );
  245. }
  246. $result = TitleArray::newFromResult(
  247. $dbr->select(
  248. [ 'page', 'categorylinks' ],
  249. [ 'page_id', 'page_namespace', 'page_title', 'page_len',
  250. 'page_is_redirect', 'page_latest' ],
  251. $conds,
  252. __METHOD__,
  253. $options
  254. )
  255. );
  256. return $result;
  257. }
  258. /**
  259. * Generic accessor
  260. * @param string $key
  261. * @return mixed
  262. */
  263. private function getX( $key ) {
  264. if ( $this->{$key} === null && !$this->initialize( self::LAZY_INIT_ROW ) ) {
  265. return false;
  266. }
  267. return $this->{$key};
  268. }
  269. /**
  270. * Refresh the counts for this category.
  271. *
  272. * @return bool True on success, false on failure
  273. */
  274. public function refreshCounts() {
  275. if ( wfReadOnly() ) {
  276. return false;
  277. }
  278. # If we have just a category name, find out whether there is an
  279. # existing row. Or if we have just an ID, get the name, because
  280. # that's what categorylinks uses.
  281. if ( !$this->initialize( self::LOAD_ONLY ) ) {
  282. return false;
  283. }
  284. $dbw = wfGetDB( DB_MASTER );
  285. # Avoid excess contention on the same category (T162121)
  286. $name = __METHOD__ . ':' . md5( $this->mName );
  287. $scopedLock = $dbw->getScopedLockAndFlush( $name, __METHOD__, 0 );
  288. if ( !$scopedLock ) {
  289. return false;
  290. }
  291. $dbw->startAtomic( __METHOD__ );
  292. // Lock the `category` row before locking `categorylinks` rows to try
  293. // to avoid deadlocks with LinksDeletionUpdate (T195397)
  294. $dbw->lockForUpdate( 'category', [ 'cat_title' => $this->mName ], __METHOD__ );
  295. // Lock all the `categorylinks` records and gaps for this category;
  296. // this is a separate query due to postgres limitations
  297. $dbw->selectRowCount(
  298. [ 'categorylinks', 'page' ],
  299. '*',
  300. [ 'cl_to' => $this->mName, 'page_id = cl_from' ],
  301. __METHOD__,
  302. [ 'LOCK IN SHARE MODE' ]
  303. );
  304. // Get the aggregate `categorylinks` row counts for this category
  305. $catCond = $dbw->conditional( [ 'page_namespace' => NS_CATEGORY ], 1, 'NULL' );
  306. $fileCond = $dbw->conditional( [ 'page_namespace' => NS_FILE ], 1, 'NULL' );
  307. $result = $dbw->selectRow(
  308. [ 'categorylinks', 'page' ],
  309. [
  310. 'pages' => 'COUNT(*)',
  311. 'subcats' => "COUNT($catCond)",
  312. 'files' => "COUNT($fileCond)"
  313. ],
  314. [ 'cl_to' => $this->mName, 'page_id = cl_from' ],
  315. __METHOD__
  316. );
  317. $shouldExist = $result->pages > 0 || $this->getTitle()->exists();
  318. if ( $this->mID ) {
  319. if ( $shouldExist ) {
  320. # The category row already exists, so do a plain UPDATE instead
  321. # of INSERT...ON DUPLICATE KEY UPDATE to avoid creating a gap
  322. # in the cat_id sequence. The row may or may not be "affected".
  323. $dbw->update(
  324. 'category',
  325. [
  326. 'cat_pages' => $result->pages,
  327. 'cat_subcats' => $result->subcats,
  328. 'cat_files' => $result->files
  329. ],
  330. [ 'cat_title' => $this->mName ],
  331. __METHOD__
  332. );
  333. } else {
  334. # The category is empty and has no description page, delete it
  335. $dbw->delete(
  336. 'category',
  337. [ 'cat_title' => $this->mName ],
  338. __METHOD__
  339. );
  340. $this->mID = false;
  341. }
  342. } elseif ( $shouldExist ) {
  343. # The category row doesn't exist but should, so create it. Use
  344. # upsert in case of races.
  345. $dbw->upsert(
  346. 'category',
  347. [
  348. 'cat_title' => $this->mName,
  349. 'cat_pages' => $result->pages,
  350. 'cat_subcats' => $result->subcats,
  351. 'cat_files' => $result->files
  352. ],
  353. [ 'cat_title' ],
  354. [
  355. 'cat_pages' => $result->pages,
  356. 'cat_subcats' => $result->subcats,
  357. 'cat_files' => $result->files
  358. ],
  359. __METHOD__
  360. );
  361. // @todo: Should we update $this->mID here? Or not since Category
  362. // objects tend to be short lived enough to not matter?
  363. }
  364. $dbw->endAtomic( __METHOD__ );
  365. # Now we should update our local counts.
  366. $this->mPages = $result->pages;
  367. $this->mSubcats = $result->subcats;
  368. $this->mFiles = $result->files;
  369. return true;
  370. }
  371. /**
  372. * Call refreshCounts() if there are no entries in the categorylinks table
  373. * or if the category table has a row that states that there are no entries
  374. *
  375. * Due to lock errors or other failures, the precomputed counts can get out of sync,
  376. * making it hard to know when to delete the category row without checking the
  377. * categorylinks table.
  378. *
  379. * @return bool Whether links were refreshed
  380. * @since 1.32
  381. */
  382. public function refreshCountsIfEmpty() {
  383. return $this->refreshCountsIfSmall( 0 );
  384. }
  385. /**
  386. * Call refreshCounts() if there are few entries in the categorylinks table
  387. *
  388. * Due to lock errors or other failures, the precomputed counts can get out of sync,
  389. * making it hard to know when to delete the category row without checking the
  390. * categorylinks table.
  391. *
  392. * This method will do a non-locking select first to reduce contention.
  393. *
  394. * @param int $maxSize Only refresh if there are this or less many backlinks
  395. * @return bool Whether links were refreshed
  396. * @since 1.34
  397. */
  398. public function refreshCountsIfSmall( $maxSize = self::ROW_COUNT_SMALL ) {
  399. $dbw = wfGetDB( DB_MASTER );
  400. $dbw->startAtomic( __METHOD__ );
  401. $typeOccurances = $dbw->selectFieldValues(
  402. 'categorylinks',
  403. 'cl_type',
  404. [ 'cl_to' => $this->getName() ],
  405. __METHOD__,
  406. [ 'LIMIT' => $maxSize + 1 ]
  407. );
  408. if ( !$typeOccurances ) {
  409. $doRefresh = true; // delete any category table entry
  410. } elseif ( count( $typeOccurances ) <= $maxSize ) {
  411. $countByType = array_count_values( $typeOccurances );
  412. $doRefresh = !$dbw->selectField(
  413. 'category',
  414. '1',
  415. [
  416. 'cat_title' => $this->getName(),
  417. 'cat_pages' => $countByType['page'] ?? 0,
  418. 'cat_subcats' => $countByType['subcat'] ?? 0,
  419. 'cat_files' => $countByType['file'] ?? 0
  420. ],
  421. __METHOD__
  422. );
  423. } else {
  424. $doRefresh = false; // category is too big
  425. }
  426. $dbw->endAtomic( __METHOD__ );
  427. if ( $doRefresh ) {
  428. $this->refreshCounts(); // update the row
  429. return true;
  430. }
  431. return false;
  432. }
  433. }