Add a CategoryCache service for use on Special:TrackingCategories

Replace the dynamic property on SpecialTrackingCategories object,
this was used to store preloaded Category objects.
Dynamic properties are deprecated in php8.2
The preload and storage is now done with the service.

Adjust doc to LinkTarget (Id0cc2ca)

Bug: T324897
Change-Id: I891ad79bc357d32585ef4d9206d398c5a75222aa
This commit is contained in:
Umherirrender 2023-01-13 00:26:06 +01:00
parent 9acd8edf62
commit 9d98cc1ae6
6 changed files with 274 additions and 35 deletions

View file

@ -42,6 +42,9 @@
"AutoloadNamespaces": { "AutoloadNamespaces": {
"MediaWiki\\Extension\\CategoryTree\\": "includes/" "MediaWiki\\Extension\\CategoryTree\\": "includes/"
}, },
"TestAutoloadNamespaces": {
"MediaWiki\\Extension\\CategoryTree\\Tests\\": "tests/phpunit/"
},
"ResourceModules": { "ResourceModules": {
"ext.categoryTree": { "ext.categoryTree": {
"localBasePath": "modules/ext.categoryTree", "localBasePath": "modules/ext.categoryTree",
@ -89,7 +92,7 @@
"HookHandlers": { "HookHandlers": {
"default": { "default": {
"class": "MediaWiki\\Extension\\CategoryTree\\Hooks", "class": "MediaWiki\\Extension\\CategoryTree\\Hooks",
"services": [ "DBLoadBalancer", "MainConfig" ] "services": [ "CategoryTree.CategoryCache", "MainConfig" ]
}, },
"config": { "config": {
"class": "MediaWiki\\Extension\\CategoryTree\\ConfigHookHandler" "class": "MediaWiki\\Extension\\CategoryTree\\ConfigHookHandler"
@ -183,5 +186,8 @@
} }
} }
}, },
"ServiceWiringFiles": [
"includes/ServiceWiring.php"
],
"manifest_version": 2 "manifest_version": 2
} }

View file

@ -0,0 +1,99 @@
<?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
*
* @file
*/
namespace MediaWiki\Extension\CategoryTree;
use Category;
use MediaWiki\Linker\LinkTarget;
use Wikimedia\Rdbms\ILoadBalancer;
/**
* Caches Category::class objects
*/
class CategoryCache {
/** @var (?Category)[] Keys are category database names, values are either a Category object or null */
private $cache = [];
/** @var ILoadBalancer */
private $loadBalancer;
/**
* @param ILoadBalancer $loadBalancer
*/
public function __construct(
ILoadBalancer $loadBalancer
) {
$this->loadBalancer = $loadBalancer;
}
/**
* Get a preloaded Category object or null when the Category does not exists. Loaded the Category on demand,
* if not in cache, use self::doQuery when requesting a high number of category
* @param LinkTarget $categoryTarget
* @return ?Category
*/
public function getCategory( LinkTarget $categoryTarget ): ?Category {
if ( $categoryTarget->getNamespace() !== NS_CATEGORY ) {
return null;
}
$categoryDbKey = $categoryTarget->getDBkey();
if ( !array_key_exists( $categoryDbKey, $this->cache ) ) {
$this->doQuery( [ $categoryTarget ] );
}
return $this->cache[$categoryDbKey];
}
/**
* Preloads category counts in this cache
* @param LinkTarget[] $linkTargets
*/
public function doQuery( array $linkTargets ): void {
$categoryDbKeys = [];
foreach ( $linkTargets as $linkTarget ) {
if ( $linkTarget->getNamespace() !== NS_CATEGORY ) {
continue;
}
$categoryDbKey = $linkTarget->getDBkey();
if ( !array_key_exists( $categoryDbKey, $this->cache ) ) {
$categoryDbKeys[] = $categoryDbKey;
// To cache db misses, also avoid duplicates in the db query
$this->cache[$categoryDbKey] = null;
}
}
if ( $categoryDbKeys === [] ) {
return;
}
$rows = $this->loadBalancer->getConnection( ILoadBalancer::DB_REPLICA )
->newSelectQueryBuilder()
->select( [ 'cat_id', 'cat_title', 'cat_pages', 'cat_subcats', 'cat_files' ] )
->from( 'category' )
->where( [ 'cat_title' => $categoryDbKeys ] )
->caller( __METHOD__ )
->fetchResultSet();
foreach ( $rows as $row ) {
$this->cache[$row->cat_title] = Category::newFromRow( $row );
}
}
}

View file

@ -25,7 +25,6 @@
namespace MediaWiki\Extension\CategoryTree; namespace MediaWiki\Extension\CategoryTree;
use Article; use Article;
use Category;
use Config; use Config;
use Html; use Html;
use IContextSource; use IContextSource;
@ -35,6 +34,7 @@ use MediaWiki\Hook\ParserFirstCallInitHook;
use MediaWiki\Hook\SkinBuildSidebarHook; use MediaWiki\Hook\SkinBuildSidebarHook;
use MediaWiki\Hook\SpecialTrackingCategories__generateCatLinkHook; use MediaWiki\Hook\SpecialTrackingCategories__generateCatLinkHook;
use MediaWiki\Hook\SpecialTrackingCategories__preprocessHook; use MediaWiki\Hook\SpecialTrackingCategories__preprocessHook;
use MediaWiki\Linker\LinkTarget;
use MediaWiki\Page\Hook\ArticleFromTitleHook; use MediaWiki\Page\Hook\ArticleFromTitleHook;
use OutputPage; use OutputPage;
use Parser; use Parser;
@ -44,7 +44,6 @@ use Sanitizer;
use Skin; use Skin;
use SpecialPage; use SpecialPage;
use Title; use Title;
use Wikimedia\Rdbms\ILoadBalancer;
/** /**
* Hooks for the CategoryTree extension, an AJAX based gadget * Hooks for the CategoryTree extension, an AJAX based gadget
@ -64,18 +63,18 @@ class Hooks implements
private const EXTENSION_DATA_FLAG = 'CategoryTree'; private const EXTENSION_DATA_FLAG = 'CategoryTree';
/** @var ILoadBalancer */ /** @var CategoryCache */
private $loadBalancer; private $categoryCache;
/** @var Config */ /** @var Config */
private $config; private $config;
/** /**
* @param ILoadBalancer $loadBalancer * @param CategoryCache $categoryCache
* @param Config $config * @param Config $config
*/ */
public function __construct( ILoadBalancer $loadBalancer, Config $config ) { public function __construct( CategoryCache $categoryCache, Config $config ) {
$this->loadBalancer = $loadBalancer; $this->categoryCache = $categoryCache;
$this->config = $config; $this->config = $config;
} }
@ -281,52 +280,33 @@ class Hooks implements
/** /**
* Hook handler for the SpecialTrackingCategories::preprocess hook * Hook handler for the SpecialTrackingCategories::preprocess hook
* @suppress PhanUndeclaredProperty SpecialPage->categoryTreeCategories
* @param SpecialPage $specialPage SpecialTrackingCategories object * @param SpecialPage $specialPage SpecialTrackingCategories object
* @param array $trackingCategories [ 'msg' => Title, 'cats' => Title[] ] * @param array $trackingCategories [ 'msg' => LinkTarget, 'cats' => LinkTarget[] ]
* @phan-param array<string,array{msg:Title,cats:Title[]}> $trackingCategories * @phan-param array<string,array{msg:LinkTarget,cats:LinkTarget[]}> $trackingCategories
*/ */
public function onSpecialTrackingCategories__preprocess( public function onSpecialTrackingCategories__preprocess(
$specialPage, $specialPage,
$trackingCategories $trackingCategories
) { ) {
$categoryDbKeys = []; $categoryTargets = [];
foreach ( $trackingCategories as $catMsg => $data ) { foreach ( $trackingCategories as $data ) {
foreach ( $data['cats'] as $catTitle ) { foreach ( $data['cats'] as $catTitle ) {
$categoryDbKeys[] = $catTitle->getDbKey(); $categoryTargets[] = $catTitle;
} }
} }
$categories = []; $this->categoryCache->doQuery( $categoryTargets );
if ( $categoryDbKeys ) {
$dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
$res = $dbr->select(
'category',
[ 'cat_id', 'cat_title', 'cat_pages', 'cat_subcats', 'cat_files' ],
[ 'cat_title' => array_unique( $categoryDbKeys ) ],
__METHOD__
);
foreach ( $res as $row ) {
$categories[$row->cat_title] = Category::newFromRow( $row );
}
}
$specialPage->categoryTreeCategories = $categories;
} }
/** /**
* Hook handler for the SpecialTrackingCategories::generateCatLink hook * Hook handler for the SpecialTrackingCategories::generateCatLink hook
* @suppress PhanUndeclaredProperty SpecialPage->categoryTreeCategories
* @param SpecialPage $specialPage SpecialTrackingCategories object * @param SpecialPage $specialPage SpecialTrackingCategories object
* @param Title $catTitle Title object of the linked category * @param LinkTarget $catTitle LinkTarget object of the linked category
* @param string &$html Result html * @param string &$html Result html
*/ */
public function onSpecialTrackingCategories__generateCatLink( $specialPage, public function onSpecialTrackingCategories__generateCatLink( $specialPage,
$catTitle, &$html $catTitle, &$html
) { ) {
if ( !isset( $specialPage->categoryTreeCategories ) ) { $cat = $this->categoryCache->getCategory( $catTitle );
return;
}
$cat = $specialPage->categoryTreeCategories[$catTitle->getDBkey()] ?? null;
$html .= CategoryTree::createCountString( $specialPage->getContext(), $cat, 0 ); $html .= CategoryTree::createCountString( $specialPage->getContext(), $cat, 0 );
} }

View file

@ -0,0 +1,40 @@
<?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
*
* @file
*/
namespace MediaWiki\Extension\CategoryTree;
use MediaWiki\MediaWikiServices;
// PHP unit does not understand code coverage for this file
// as the @covers annotation cannot cover a specific file
// This is fully tested in CategoryTreeServiceWiringTest.php
// @codeCoverageIgnoreStart
/**
* CategoryTree wiring for MediaWiki services.
*/
return [
'CategoryTree.CategoryCache' => static function ( MediaWikiServices $services ) {
return new CategoryCache(
$services->getDBLoadBalancer()
);
},
];
// @codeCoverageIgnoreEnd

View file

@ -0,0 +1,86 @@
<?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
*
* @file
*/
namespace MediaWiki\Extension\CategoryTree\Tests;
use Category;
use MediaWiki\Extension\CategoryTree\CategoryCache;
use MediaWikiIntegrationTestCase;
use TitleValue;
use Wikimedia\Rdbms\ILoadBalancer;
use Wikimedia\TestingAccessWrapper;
/**
* @group Database
* @covers \MediaWiki\Extension\CategoryTree\CategoryCache
*/
class CategoryCacheTest extends MediaWikiIntegrationTestCase {
public function testConstruct() {
new CategoryCache(
$this->createMock( ILoadBalancer::class )
);
$this->addToAssertionCount( 1 );
}
public function testDoQuery() {
// Create a row in the category table
$this->editPage(
new TitleValue( NS_MAIN, 'CategoryTreeCategoryCacheTest' ),
'[[Category:Exists]]'
);
$categoryCache = TestingAccessWrapper::newFromObject(
$this->getServiceContainer()->get( 'CategoryTree.CategoryCache' )
);
$categoryCache->doQuery( [
new TitleValue( NS_CATEGORY, 'Exists' ),
new TitleValue( NS_CATEGORY, 'Missed' ),
] );
$this->assertCount( 2, $categoryCache->cache );
$this->assertArrayHasKey( 'Exists', $categoryCache->cache );
$this->assertInstanceOf( Category::class, $categoryCache->cache['Exists'] );
$this->assertArrayHasKey( 'Missed', $categoryCache->cache );
$this->assertNull( $categoryCache->cache['Missed'] );
}
public function testGetCategory() {
// Create a row in the category table
$this->editPage(
new TitleValue( NS_MAIN, 'CategoryTreeCategoryCacheTest' ),
'[[Category:Exists]]'
);
$categoryCache = TestingAccessWrapper::newFromObject(
$this->getServiceContainer()->get( 'CategoryTree.CategoryCache' )
);
$title = new TitleValue( NS_CATEGORY, 'Exists' );
// Test for cache miss
$this->assertCount( 0, $categoryCache->cache );
$this->assertInstanceOf( Category::class, $categoryCache->getCategory( $title ) );
$this->assertCount( 1, $categoryCache->cache );
// Test normal access
$this->assertInstanceOf( Category::class, $categoryCache->getCategory( $title ) );
}
}

View file

@ -0,0 +1,28 @@
<?php
/**
* Copy of CentralAuth's CentralAuthServiceWiringTest.php
* as it could not be included as it's in another extension.
*/
use MediaWiki\MediaWikiServices;
/**
* @coversNothing
*/
class CategoryTreeServiceWiringTest extends MediaWikiIntegrationTestCase {
/**
* @dataProvider provideService
*/
public function testService( string $name ) {
MediaWikiServices::getInstance()->get( $name );
$this->addToAssertionCount( 1 );
}
public function provideService() {
$wiring = require __DIR__ . '/../../includes/ServiceWiring.php';
foreach ( $wiring as $name => $_ ) {
yield $name => [ $name ];
}
}
}