wikimedia/mediawiki-core

View on GitHub
includes/block/BlockRestrictionStore.php

Summary

Maintainability
A
1 hr
Test Coverage
<?php
/**
 * Block restriction interface.
 *
 * 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\Block;

use MediaWiki\Block\Restriction\ActionRestriction;
use MediaWiki\Block\Restriction\NamespaceRestriction;
use MediaWiki\Block\Restriction\PageRestriction;
use MediaWiki\Block\Restriction\Restriction;
use MediaWiki\DAO\WikiAwareEntity;
use stdClass;
use Wikimedia\Rdbms\IConnectionProvider;
use Wikimedia\Rdbms\IResultWrapper;

class BlockRestrictionStore {

    private IConnectionProvider $dbProvider;

    /**
     * @var string|false
     */
    private $wikiId;

    public function __construct(
        IConnectionProvider $dbProvider,
        /* string|false */ $wikiId = WikiAwareEntity::LOCAL
    ) {
        $this->dbProvider = $dbProvider;
        $this->wikiId = $wikiId;
    }

    /**
     * Retrieve the restrictions from the database by block ID.
     *
     * @since 1.33
     * @param int|int[] $blockId
     * @return Restriction[]
     */
    public function loadByBlockId( $blockId ) {
        if ( $blockId === null || $blockId === [] ) {
            return [];
        }

        $result = $this->dbProvider->getReplicaDatabase( $this->wikiId )
            ->newSelectQueryBuilder()
            ->select( [ 'ir_ipb_id', 'ir_type', 'ir_value', 'page_namespace', 'page_title' ] )
            ->from( 'ipblocks_restrictions' )
            ->leftJoin( 'page', null, [ 'ir_type' => PageRestriction::TYPE_ID, 'ir_value=page_id' ] )
            ->where( [ 'ir_ipb_id' => $blockId ] )
            ->caller( __METHOD__ )->fetchResultSet();

        return $this->resultToRestrictions( $result );
    }

    /**
     * Insert the restrictions into the database.
     *
     * @since 1.33
     * @param Restriction[] $restrictions
     * @return bool
     */
    public function insert( array $restrictions ) {
        if ( !$restrictions ) {
            return false;
        }

        $rows = [];
        foreach ( $restrictions as $restriction ) {
            $rows[] = $restriction->toRow();
        }

        $dbw = $this->dbProvider->getPrimaryDatabase( $this->wikiId );

        $dbw->newInsertQueryBuilder()
            ->insertInto( 'ipblocks_restrictions' )
            ->ignore()
            ->rows( $rows )
            ->caller( __METHOD__ )->execute();

        return true;
    }

    /**
     * Update the list of restrictions. This method does not allow removing all
     * of the restrictions. To do that, use ::deleteByBlockId().
     *
     * @since 1.33
     * @param Restriction[] $restrictions
     * @return bool Whether all operations were successful
     */
    public function update( array $restrictions ) {
        $dbw = $this->dbProvider->getPrimaryDatabase( $this->wikiId );

        $dbw->startAtomic( __METHOD__ );

        // Organize the restrictions by block ID.
        $restrictionList = $this->restrictionsByBlockId( $restrictions );

        // Load the existing restrictions and organize by block ID. Any block IDs
        // that were passed into this function will be used to load all of the
        // existing restrictions. This list might be the same, or may be completely
        // different.
        $existingList = [];
        $blockIds = array_keys( $restrictionList );
        if ( $blockIds ) {
            $result = $dbw->newSelectQueryBuilder()
                ->select( [ 'ir_ipb_id', 'ir_type', 'ir_value' ] )
                ->forUpdate()
                ->from( 'ipblocks_restrictions' )
                ->where( [ 'ir_ipb_id' => $blockIds ] )
                ->caller( __METHOD__ )->fetchResultSet();

            $existingList = $this->restrictionsByBlockId(
                $this->resultToRestrictions( $result )
            );
        }

        $result = true;
        // Perform the actions on a per block-ID basis.
        foreach ( $restrictionList as $blockId => $blockRestrictions ) {
            // Insert all of the restrictions first, ignoring ones that already exist.
            $success = $this->insert( $blockRestrictions );

            $result = $success && $result;

            $restrictionsToRemove = $this->restrictionsToRemove(
                $existingList[$blockId] ?? [],
                $restrictions
            );

            if ( !$restrictionsToRemove ) {
                continue;
            }

            $success = $this->delete( $restrictionsToRemove );

            $result = $success && $result;
        }

        $dbw->endAtomic( __METHOD__ );

        return $result;
    }

    /**
     * Updates the list of restrictions by parent ID.
     *
     * @since 1.33
     * @param int $parentBlockId
     * @param Restriction[] $restrictions
     * @return bool Whether all updates were successful
     */
    public function updateByParentBlockId( $parentBlockId, array $restrictions ) {
        $parentBlockId = (int)$parentBlockId;

        $db = $this->dbProvider->getPrimaryDatabase( $this->wikiId );

        $blockIds = $db->newSelectQueryBuilder()
            ->select( 'bl_id' )
            ->forUpdate()
            ->from( 'block' )
            ->where( [ 'bl_parent_block_id' => $parentBlockId ] )
            ->caller( __METHOD__ )->fetchFieldValues();
        if ( !$blockIds ) {
            return true;
        }

        // If removing all of the restrictions, then just delete them all.
        if ( !$restrictions ) {
            $blockIds = array_map( 'intval', $blockIds );
            return $this->deleteByBlockId( $blockIds );
        }

        $db->startAtomic( __METHOD__ );

        $result = true;
        foreach ( $blockIds as $id ) {
            $success = $this->update( $this->setBlockId( $id, $restrictions ) );
            $result = $success && $result;
        }

        $db->endAtomic( __METHOD__ );

        return $result;
    }

    /**
     * Delete the restrictions.
     *
     * @since 1.33
     * @param Restriction[] $restrictions
     * @return bool
     */
    public function delete( array $restrictions ) {
        $dbw = $this->dbProvider->getPrimaryDatabase( $this->wikiId );
        foreach ( $restrictions as $restriction ) {
            $dbw->newDeleteQueryBuilder()
                ->deleteFrom( 'ipblocks_restrictions' )
                // The restriction row is made up of a compound primary key. Therefore,
                // the row and the delete conditions are the same.
                ->where( $restriction->toRow() )
                ->caller( __METHOD__ )->execute();
        }

        return true;
    }

    /**
     * Delete the restrictions by block ID.
     *
     * @since 1.33
     * @param int|int[] $blockId
     * @return bool
     */
    public function deleteByBlockId( $blockId ) {
        $this->dbProvider->getPrimaryDatabase( $this->wikiId )
            ->newDeleteQueryBuilder()
            ->deleteFrom( 'ipblocks_restrictions' )
            ->where( [ 'ir_ipb_id' => $blockId ] )
            ->caller( __METHOD__ )->execute();
        return true;
    }

    /**
     * Check if two arrays of Restrictions are effectively equal. This is a loose
     * equality check as the restrictions do not have to contain the same block
     * IDs.
     *
     * @since 1.33
     * @param Restriction[] $a
     * @param Restriction[] $b
     * @return bool
     */
    public function equals( array $a, array $b ) {
        $aCount = count( $a );
        $bCount = count( $b );

        // If the count is different, then they are obviously a different set.
        if ( $aCount !== $bCount ) {
            return false;
        }

        // If both sets contain no items, then they are the same set.
        if ( $aCount === 0 && $bCount === 0 ) {
            return true;
        }

        $hasher = static function ( Restriction $r ) {
            return $r->getHash();
        };

        $aHashes = array_map( $hasher, $a );
        $bHashes = array_map( $hasher, $b );

        sort( $aHashes );
        sort( $bHashes );

        return $aHashes === $bHashes;
    }

    /**
     * Set the blockId on a set of restrictions and return a new set.
     *
     * @since 1.33
     * @param int $blockId
     * @param Restriction[] $restrictions
     * @return Restriction[]
     */
    public function setBlockId( $blockId, array $restrictions ) {
        $blockRestrictions = [];

        foreach ( $restrictions as $restriction ) {
            // Clone the restriction so any references to the current restriction are
            // not suddenly changed to a different blockId.
            $restriction = clone $restriction;
            $restriction->setBlockId( $blockId );

            $blockRestrictions[] = $restriction;
        }

        return $blockRestrictions;
    }

    /**
     * Get the restrictions that should be removed, which are existing
     * restrictions that are not in the new list of restrictions.
     *
     * @param Restriction[] $existing
     * @param Restriction[] $new
     * @return array
     */
    private function restrictionsToRemove( array $existing, array $new ) {
        $restrictionsByHash = [];
        foreach ( $existing as $restriction ) {
            $restrictionsByHash[$restriction->getHash()] = $restriction;
        }
        foreach ( $new as $restriction ) {
            unset( $restrictionsByHash[$restriction->getHash()] );
        }
        return array_values( $restrictionsByHash );
    }

    /**
     * Converts an array of restrictions to an associative array of restrictions
     * where the keys are the block IDs.
     *
     * @param Restriction[] $restrictions
     * @return array
     */
    private function restrictionsByBlockId( array $restrictions ) {
        $blockRestrictions = [];

        foreach ( $restrictions as $restriction ) {
            $blockRestrictions[$restriction->getBlockId()][] = $restriction;
        }

        return $blockRestrictions;
    }

    /**
     * Convert a result wrapper to an array of restrictions.
     *
     * @param IResultWrapper $result
     * @return Restriction[]
     */
    private function resultToRestrictions( IResultWrapper $result ) {
        $restrictions = [];
        foreach ( $result as $row ) {
            $restriction = $this->rowToRestriction( $row );

            if ( !$restriction ) {
                continue;
            }

            $restrictions[] = $restriction;
        }

        return $restrictions;
    }

    /**
     * Convert a result row from the database into a restriction object.
     *
     * @param stdClass $row
     * @return Restriction|null
     */
    private function rowToRestriction( stdClass $row ) {
        switch ( (int)$row->ir_type ) {
            case PageRestriction::TYPE_ID:
                return PageRestriction::newFromRow( $row );
            case NamespaceRestriction::TYPE_ID:
                return NamespaceRestriction::newFromRow( $row );
            case ActionRestriction::TYPE_ID:
                return ActionRestriction::newFromRow( $row );
            default:
                return null;
        }
    }
}