Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.74% covered (success)
97.74%
130 / 133
84.62% covered (warning)
84.62%
11 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
BlockRestrictionStore
97.74% covered (success)
97.74%
130 / 133
84.62% covered (warning)
84.62%
11 / 13
40
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 loadByBlockId
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 insert
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 update
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
6
 updateByParentBlockId
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
5
 delete
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 deleteByBlockId
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 equals
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 setBlockId
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 restrictionsToRemove
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 restrictionsByBlockId
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 resultToRestrictions
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 rowToRestriction
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
5.05
1<?php
2/**
3 * Block restriction interface.
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 */
22
23namespace MediaWiki\Block;
24
25use MediaWiki\Block\Restriction\ActionRestriction;
26use MediaWiki\Block\Restriction\NamespaceRestriction;
27use MediaWiki\Block\Restriction\PageRestriction;
28use MediaWiki\Block\Restriction\Restriction;
29use MediaWiki\DAO\WikiAwareEntity;
30use stdClass;
31use Wikimedia\Rdbms\IConnectionProvider;
32use Wikimedia\Rdbms\IResultWrapper;
33
34class BlockRestrictionStore {
35
36    private IConnectionProvider $dbProvider;
37
38    /**
39     * @var string|false
40     */
41    private $wikiId;
42
43    public function __construct(
44        IConnectionProvider $dbProvider,
45        /* string|false */ $wikiId = WikiAwareEntity::LOCAL
46    ) {
47        $this->dbProvider = $dbProvider;
48        $this->wikiId = $wikiId;
49    }
50
51    /**
52     * Retrieve the restrictions from the database by block ID.
53     *
54     * @since 1.33
55     * @param int|int[] $blockId
56     * @return Restriction[]
57     */
58    public function loadByBlockId( $blockId ) {
59        if ( $blockId === null || $blockId === [] ) {
60            return [];
61        }
62
63        $result = $this->dbProvider->getReplicaDatabase( $this->wikiId )
64            ->newSelectQueryBuilder()
65            ->select( [ 'ir_ipb_id', 'ir_type', 'ir_value', 'page_namespace', 'page_title' ] )
66            ->from( 'ipblocks_restrictions' )
67            ->leftJoin( 'page', null, [ 'ir_type' => PageRestriction::TYPE_ID, 'ir_value=page_id' ] )
68            ->where( [ 'ir_ipb_id' => $blockId ] )
69            ->caller( __METHOD__ )->fetchResultSet();
70
71        return $this->resultToRestrictions( $result );
72    }
73
74    /**
75     * Insert the restrictions into the database.
76     *
77     * @since 1.33
78     * @param Restriction[] $restrictions
79     * @return bool
80     */
81    public function insert( array $restrictions ) {
82        if ( !$restrictions ) {
83            return false;
84        }
85
86        $rows = [];
87        foreach ( $restrictions as $restriction ) {
88            $rows[] = $restriction->toRow();
89        }
90
91        $dbw = $this->dbProvider->getPrimaryDatabase( $this->wikiId );
92
93        $dbw->newInsertQueryBuilder()
94            ->insertInto( 'ipblocks_restrictions' )
95            ->ignore()
96            ->rows( $rows )
97            ->caller( __METHOD__ )->execute();
98
99        return true;
100    }
101
102    /**
103     * Update the list of restrictions. This method does not allow removing all
104     * of the restrictions. To do that, use ::deleteByBlockId().
105     *
106     * @since 1.33
107     * @param Restriction[] $restrictions
108     * @return bool Whether all operations were successful
109     */
110    public function update( array $restrictions ) {
111        $dbw = $this->dbProvider->getPrimaryDatabase( $this->wikiId );
112
113        $dbw->startAtomic( __METHOD__ );
114
115        // Organize the restrictions by block ID.
116        $restrictionList = $this->restrictionsByBlockId( $restrictions );
117
118        // Load the existing restrictions and organize by block ID. Any block IDs
119        // that were passed into this function will be used to load all of the
120        // existing restrictions. This list might be the same, or may be completely
121        // different.
122        $existingList = [];
123        $blockIds = array_keys( $restrictionList );
124        if ( $blockIds ) {
125            $result = $dbw->newSelectQueryBuilder()
126                ->select( [ 'ir_ipb_id', 'ir_type', 'ir_value' ] )
127                ->forUpdate()
128                ->from( 'ipblocks_restrictions' )
129                ->where( [ 'ir_ipb_id' => $blockIds ] )
130                ->caller( __METHOD__ )->fetchResultSet();
131
132            $existingList = $this->restrictionsByBlockId(
133                $this->resultToRestrictions( $result )
134            );
135        }
136
137        $result = true;
138        // Perform the actions on a per block-ID basis.
139        foreach ( $restrictionList as $blockId => $blockRestrictions ) {
140            // Insert all of the restrictions first, ignoring ones that already exist.
141            $success = $this->insert( $blockRestrictions );
142
143            $result = $success && $result;
144
145            $restrictionsToRemove = $this->restrictionsToRemove(
146                $existingList[$blockId] ?? [],
147                $restrictions
148            );
149
150            if ( !$restrictionsToRemove ) {
151                continue;
152            }
153
154            $success = $this->delete( $restrictionsToRemove );
155
156            $result = $success && $result;
157        }
158
159        $dbw->endAtomic( __METHOD__ );
160
161        return $result;
162    }
163
164    /**
165     * Updates the list of restrictions by parent ID.
166     *
167     * @since 1.33
168     * @param int $parentBlockId
169     * @param Restriction[] $restrictions
170     * @return bool Whether all updates were successful
171     */
172    public function updateByParentBlockId( $parentBlockId, array $restrictions ) {
173        $parentBlockId = (int)$parentBlockId;
174
175        $db = $this->dbProvider->getPrimaryDatabase( $this->wikiId );
176
177        $blockIds = $db->newSelectQueryBuilder()
178            ->select( 'bl_id' )
179            ->forUpdate()
180            ->from( 'block' )
181            ->where( [ 'bl_parent_block_id' => $parentBlockId ] )
182            ->caller( __METHOD__ )->fetchFieldValues();
183        if ( !$blockIds ) {
184            return true;
185        }
186
187        // If removing all of the restrictions, then just delete them all.
188        if ( !$restrictions ) {
189            $blockIds = array_map( 'intval', $blockIds );
190            return $this->deleteByBlockId( $blockIds );
191        }
192
193        $db->startAtomic( __METHOD__ );
194
195        $result = true;
196        foreach ( $blockIds as $id ) {
197            $success = $this->update( $this->setBlockId( $id, $restrictions ) );
198            $result = $success && $result;
199        }
200
201        $db->endAtomic( __METHOD__ );
202
203        return $result;
204    }
205
206    /**
207     * Delete the restrictions.
208     *
209     * @since 1.33
210     * @param Restriction[] $restrictions
211     * @return bool
212     */
213    public function delete( array $restrictions ) {
214        $dbw = $this->dbProvider->getPrimaryDatabase( $this->wikiId );
215        foreach ( $restrictions as $restriction ) {
216            $dbw->newDeleteQueryBuilder()
217                ->deleteFrom( 'ipblocks_restrictions' )
218                // The restriction row is made up of a compound primary key. Therefore,
219                // the row and the delete conditions are the same.
220                ->where( $restriction->toRow() )
221                ->caller( __METHOD__ )->execute();
222        }
223
224        return true;
225    }
226
227    /**
228     * Delete the restrictions by block ID.
229     *
230     * @since 1.33
231     * @param int|int[] $blockId
232     * @return bool
233     */
234    public function deleteByBlockId( $blockId ) {
235        $this->dbProvider->getPrimaryDatabase( $this->wikiId )
236            ->newDeleteQueryBuilder()
237            ->deleteFrom( 'ipblocks_restrictions' )
238            ->where( [ 'ir_ipb_id' => $blockId ] )
239            ->caller( __METHOD__ )->execute();
240        return true;
241    }
242
243    /**
244     * Check if two arrays of Restrictions are effectively equal. This is a loose
245     * equality check as the restrictions do not have to contain the same block
246     * IDs.
247     *
248     * @since 1.33
249     * @param Restriction[] $a
250     * @param Restriction[] $b
251     * @return bool
252     */
253    public function equals( array $a, array $b ) {
254        $aCount = count( $a );
255        $bCount = count( $b );
256
257        // If the count is different, then they are obviously a different set.
258        if ( $aCount !== $bCount ) {
259            return false;
260        }
261
262        // If both sets contain no items, then they are the same set.
263        if ( $aCount === 0 && $bCount === 0 ) {
264            return true;
265        }
266
267        $hasher = static function ( Restriction $r ) {
268            return $r->getHash();
269        };
270
271        $aHashes = array_map( $hasher, $a );
272        $bHashes = array_map( $hasher, $b );
273
274        sort( $aHashes );
275        sort( $bHashes );
276
277        return $aHashes === $bHashes;
278    }
279
280    /**
281     * Set the blockId on a set of restrictions and return a new set.
282     *
283     * @since 1.33
284     * @param int $blockId
285     * @param Restriction[] $restrictions
286     * @return Restriction[]
287     */
288    public function setBlockId( $blockId, array $restrictions ) {
289        $blockRestrictions = [];
290
291        foreach ( $restrictions as $restriction ) {
292            // Clone the restriction so any references to the current restriction are
293            // not suddenly changed to a different blockId.
294            $restriction = clone $restriction;
295            $restriction->setBlockId( $blockId );
296
297            $blockRestrictions[] = $restriction;
298        }
299
300        return $blockRestrictions;
301    }
302
303    /**
304     * Get the restrictions that should be removed, which are existing
305     * restrictions that are not in the new list of restrictions.
306     *
307     * @param Restriction[] $existing
308     * @param Restriction[] $new
309     * @return array
310     */
311    private function restrictionsToRemove( array $existing, array $new ) {
312        $restrictionsByHash = [];
313        foreach ( $existing as $restriction ) {
314            $restrictionsByHash[$restriction->getHash()] = $restriction;
315        }
316        foreach ( $new as $restriction ) {
317            unset( $restrictionsByHash[$restriction->getHash()] );
318        }
319        return array_values( $restrictionsByHash );
320    }
321
322    /**
323     * Converts an array of restrictions to an associative array of restrictions
324     * where the keys are the block IDs.
325     *
326     * @param Restriction[] $restrictions
327     * @return array
328     */
329    private function restrictionsByBlockId( array $restrictions ) {
330        $blockRestrictions = [];
331
332        foreach ( $restrictions as $restriction ) {
333            $blockRestrictions[$restriction->getBlockId()][] = $restriction;
334        }
335
336        return $blockRestrictions;
337    }
338
339    /**
340     * Convert a result wrapper to an array of restrictions.
341     *
342     * @param IResultWrapper $result
343     * @return Restriction[]
344     */
345    private function resultToRestrictions( IResultWrapper $result ) {
346        $restrictions = [];
347        foreach ( $result as $row ) {
348            $restriction = $this->rowToRestriction( $row );
349
350            if ( !$restriction ) {
351                continue;
352            }
353
354            $restrictions[] = $restriction;
355        }
356
357        return $restrictions;
358    }
359
360    /**
361     * Convert a result row from the database into a restriction object.
362     *
363     * @param stdClass $row
364     * @return Restriction|null
365     */
366    private function rowToRestriction( stdClass $row ) {
367        switch ( (int)$row->ir_type ) {
368            case PageRestriction::TYPE_ID:
369                return PageRestriction::newFromRow( $row );
370            case NamespaceRestriction::TYPE_ID:
371                return NamespaceRestriction::newFromRow( $row );
372            case ActionRestriction::TYPE_ID:
373                return ActionRestriction::newFromRow( $row );
374            default:
375                return null;
376        }
377    }
378}