Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
100.00% |
88 / 88 |
|
100.00% |
9 / 9 |
CRAP | |
100.00% |
1 / 1 |
| Storage | |
100.00% |
88 / 88 |
|
100.00% |
9 / 9 |
25 | |
100.00% |
1 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| load | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
| loadFromDB | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
2 | |||
| save | |
100.00% |
20 / 20 |
|
100.00% |
1 / 1 |
8 | |||
| replaceAndDelete | |
100.00% |
29 / 29 |
|
100.00% |
1 / 1 |
6 | |||
| delete | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
2 | |||
| getDatabase | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
| getCache | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getCacheKey | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * This file contains only the Storage class. |
| 4 | * @package GlobalPreferences |
| 5 | */ |
| 6 | |
| 7 | namespace GlobalPreferences; |
| 8 | |
| 9 | use MediaWiki\MediaWikiServices; |
| 10 | use Wikimedia\LightweightObjectStore\ExpirationAwareness; |
| 11 | use Wikimedia\ObjectCache\WANObjectCache; |
| 12 | use Wikimedia\Rdbms\IDatabase; |
| 13 | use Wikimedia\Rdbms\IDBAccessObject; |
| 14 | use Wikimedia\Rdbms\IReadableDatabase; |
| 15 | |
| 16 | /** |
| 17 | * This class handles all database storage of global preferences. |
| 18 | * @package GlobalPreferences |
| 19 | */ |
| 20 | class Storage { |
| 21 | |
| 22 | /** The non-prefixed name of the global preferences database table. */ |
| 23 | public const TABLE_NAME = 'global_preferences'; |
| 24 | |
| 25 | /** Update this constant when making incompatible changes to caching */ |
| 26 | private const CACHE_VERSION = 1; |
| 27 | |
| 28 | /** Cache lifetime */ |
| 29 | private const CACHE_TTL = ExpirationAwareness::TTL_WEEK; |
| 30 | |
| 31 | /** Instructs preference loading code to load the preferences from cache directly */ |
| 32 | public const SKIP_CACHE = true; |
| 33 | |
| 34 | /** @var int The global user ID. */ |
| 35 | protected $userId; |
| 36 | |
| 37 | /** |
| 38 | * Create a new Global Preferences Storage object for a given user. |
| 39 | * @param int $userId The global user ID. |
| 40 | */ |
| 41 | public function __construct( int $userId ) { |
| 42 | $this->userId = $userId; |
| 43 | } |
| 44 | |
| 45 | /** |
| 46 | * Get the user's global preferences. |
| 47 | * |
| 48 | * @param bool $skipCache Whether the preferences should be loaded strictly from DB |
| 49 | * @return string[] Keyed by the preference name. |
| 50 | */ |
| 51 | public function load( bool $skipCache = false ): array { |
| 52 | if ( $skipCache ) { |
| 53 | return $this->loadFromDB(); |
| 54 | } |
| 55 | |
| 56 | $cache = $this->getCache(); |
| 57 | $key = $this->getCacheKey(); |
| 58 | |
| 59 | return $cache->getWithSetCallback( $key, self::CACHE_TTL, |
| 60 | fn ( $oldValue, &$ttl ) => $this->loadFromDB() |
| 61 | ); |
| 62 | } |
| 63 | |
| 64 | /** |
| 65 | * @param int $dbType One of DB_* constants |
| 66 | * @param int $recency One of the IDBAccessObject::READ_* constants |
| 67 | * @return string[] |
| 68 | */ |
| 69 | public function loadFromDB( $dbType = DB_REPLICA, $recency = IDBAccessObject::READ_NORMAL |
| 70 | ): array { |
| 71 | $dbr = $this->getDatabase( $dbType ); |
| 72 | $res = $dbr->newSelectQueryBuilder() |
| 73 | ->select( [ 'gp_property', 'gp_value' ] ) |
| 74 | ->from( static::TABLE_NAME ) |
| 75 | ->where( [ 'gp_user' => $this->userId ] ) |
| 76 | ->recency( $recency ) |
| 77 | ->caller( __METHOD__ ) |
| 78 | ->fetchResultSet(); |
| 79 | $preferences = []; |
| 80 | foreach ( $res as $row ) { |
| 81 | $preferences[$row->gp_property] = $row->gp_value; |
| 82 | } |
| 83 | return $preferences; |
| 84 | } |
| 85 | |
| 86 | /** |
| 87 | * Save a set of global preferences. All existing preferences will be deleted before the new |
| 88 | * ones are saved. |
| 89 | * @param string[] $newPrefs Keyed by the preference name. |
| 90 | * @param string[] $knownPrefs Only work with the preferences we know about. |
| 91 | * @param string[] $checkMatricesToClear List of check matrix controls that |
| 92 | * need their rows purged |
| 93 | */ |
| 94 | public function save( array $newPrefs, array $knownPrefs, array $checkMatricesToClear = [] |
| 95 | ): void { |
| 96 | $currentPrefs = $this->loadFromDB( DB_PRIMARY ); |
| 97 | |
| 98 | // Find records needing an insert or update |
| 99 | $save = []; |
| 100 | $delete = []; |
| 101 | foreach ( $newPrefs as $prop => $value ) { |
| 102 | if ( $value !== null ) { |
| 103 | if ( !isset( $currentPrefs[$prop] ) || $currentPrefs[$prop] != $value ) { |
| 104 | $save[$prop] = $value; |
| 105 | } |
| 106 | } else { |
| 107 | $delete[] = $prop; |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | // Delete unneeded rows |
| 112 | $keys = array_keys( $currentPrefs ); |
| 113 | // Only delete prefs present on the local wiki |
| 114 | $keys = array_intersect( $keys, $knownPrefs ); |
| 115 | $keys = array_values( array_diff( $keys, array_keys( $newPrefs ) ) ); |
| 116 | $delete = array_merge( $delete, $keys ); |
| 117 | |
| 118 | // And specifically nuke the rows of a deglobalized CheckMatrix |
| 119 | foreach ( $checkMatricesToClear as $matrix ) { |
| 120 | foreach ( array_keys( $currentPrefs ) as $pref ) { |
| 121 | if ( strpos( $pref, $matrix ) === 0 ) { |
| 122 | $delete[] = $pref; |
| 123 | } |
| 124 | } |
| 125 | } |
| 126 | $delete = array_unique( $delete ); |
| 127 | |
| 128 | $this->replaceAndDelete( $save, $delete ); |
| 129 | |
| 130 | $key = $this->getCacheKey(); |
| 131 | // Because we don't have the full preferences, just clear the cache |
| 132 | $this->getCache()->delete( $key ); |
| 133 | } |
| 134 | |
| 135 | /** |
| 136 | * Do a replace query and a delete query to update the database |
| 137 | * |
| 138 | * @param string[] $replacements |
| 139 | * @param string[] $deletions |
| 140 | * @return void |
| 141 | */ |
| 142 | public function replaceAndDelete( array $replacements, array $deletions ) { |
| 143 | // Assemble the records to save |
| 144 | $rows = []; |
| 145 | foreach ( $replacements as $prop => $value ) { |
| 146 | $rows[] = [ |
| 147 | 'gp_user' => $this->userId, |
| 148 | 'gp_property' => $prop, |
| 149 | 'gp_value' => $value, |
| 150 | ]; |
| 151 | } |
| 152 | // Save |
| 153 | $dbw = null; |
| 154 | if ( $rows ) { |
| 155 | $dbw = $this->getDatabase( DB_PRIMARY ); |
| 156 | $dbw->newReplaceQueryBuilder() |
| 157 | ->replaceInto( static::TABLE_NAME ) |
| 158 | ->uniqueIndexFields( [ 'gp_user', 'gp_property' ] ) |
| 159 | ->rows( $rows ) |
| 160 | ->caller( __METHOD__ ) |
| 161 | ->execute(); |
| 162 | } |
| 163 | if ( $deletions ) { |
| 164 | $dbw ??= $this->getDatabase( DB_PRIMARY ); |
| 165 | $dbw->newDeleteQueryBuilder() |
| 166 | ->deleteFrom( static::TABLE_NAME ) |
| 167 | ->where( [ |
| 168 | 'gp_user' => $this->userId, |
| 169 | 'gp_property' => $deletions |
| 170 | ] ) |
| 171 | ->caller( __METHOD__ ) |
| 172 | ->execute(); |
| 173 | } |
| 174 | if ( $rows || $deletions ) { |
| 175 | $key = $this->getCacheKey(); |
| 176 | $this->getCache()->delete( $key ); |
| 177 | } |
| 178 | } |
| 179 | |
| 180 | /** |
| 181 | * Delete all of this user's global preferences. |
| 182 | * @param string[]|null $knownPrefs Only delete the preferences we know about. |
| 183 | */ |
| 184 | public function delete( ?array $knownPrefs = null ): void { |
| 185 | $db = $this->getDatabase( DB_PRIMARY ); |
| 186 | $conds = [ 'gp_user' => $this->userId ]; |
| 187 | if ( $knownPrefs !== null ) { |
| 188 | $conds['gp_property'] = $knownPrefs; |
| 189 | } |
| 190 | $db->newDeleteQueryBuilder() |
| 191 | ->deleteFrom( static::TABLE_NAME ) |
| 192 | ->where( $conds ) |
| 193 | ->caller( __METHOD__ ) |
| 194 | ->execute(); |
| 195 | $key = $this->getCacheKey(); |
| 196 | $this->getCache()->delete( $key ); |
| 197 | } |
| 198 | |
| 199 | /** |
| 200 | * Get the database object pointing to the Global Preferences database. |
| 201 | * @param int $type One of the DB_* constants |
| 202 | * @return IDatabase|IReadableDatabase |
| 203 | */ |
| 204 | protected function getDatabase( int $type = DB_REPLICA ) { |
| 205 | $globalPreferencesConnectionProvider = GlobalPreferencesServices::wrap( MediaWikiServices::getInstance() ) |
| 206 | ->getGlobalPreferencesConnectionProvider(); |
| 207 | if ( $type === DB_PRIMARY ) { |
| 208 | return $globalPreferencesConnectionProvider->getPrimaryDatabase(); |
| 209 | } else { |
| 210 | return $globalPreferencesConnectionProvider->getReplicaDatabase(); |
| 211 | } |
| 212 | } |
| 213 | |
| 214 | /** |
| 215 | * @return WANObjectCache |
| 216 | */ |
| 217 | protected function getCache(): WANObjectCache { |
| 218 | return MediaWikiServices::getInstance()->getMainWANObjectCache(); |
| 219 | } |
| 220 | |
| 221 | /** |
| 222 | * @return string |
| 223 | */ |
| 224 | protected function getCacheKey(): string { |
| 225 | return $this->getCache() |
| 226 | ->makeGlobalKey( 'globalpreferences', 'prefs', self::CACHE_VERSION, $this->userId ); |
| 227 | } |
| 228 | } |