Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
88 / 88
100.00% covered (success)
100.00%
9 / 9
CRAP
100.00% covered (success)
100.00%
1 / 1
Storage
100.00% covered (success)
100.00%
88 / 88
100.00% covered (success)
100.00%
9 / 9
25
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 load
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 loadFromDB
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 save
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
8
 replaceAndDelete
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
6
 delete
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 getDatabase
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 getCache
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCacheKey
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * This file contains only the Storage class.
4 * @package GlobalPreferences
5 */
6
7namespace GlobalPreferences;
8
9use MediaWiki\MediaWikiServices;
10use Wikimedia\LightweightObjectStore\ExpirationAwareness;
11use Wikimedia\ObjectCache\WANObjectCache;
12use Wikimedia\Rdbms\IDatabase;
13use Wikimedia\Rdbms\IDBAccessObject;
14use Wikimedia\Rdbms\IReadableDatabase;
15
16/**
17 * This class handles all database storage of global preferences.
18 * @package GlobalPreferences
19 */
20class 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, function () {
60            return $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}