wikimedia/mediawiki-core

View on GitHub
includes/libs/StatusValue.php

Summary

Maintainability
D
1 day
Test Coverage
<?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
 */

use MediaWiki\Message\Converter;
use MediaWiki\Message\Message;
use Wikimedia\Assert\Assert;
use Wikimedia\Message\MessageSpecifier;
use Wikimedia\Message\MessageValue;

/**
 * Generic operation result class
 * Has warning/error list, boolean status and arbitrary value
 *
 * "Good" means the operation was completed with no warnings or errors.
 *
 * "OK" means the operation was partially or wholly completed.
 *
 * An operation which is not OK should have errors so that the user can be
 * informed as to what went wrong. Calling the fatal() function sets an error
 * message and simultaneously switches off the OK flag.
 *
 * The recommended pattern for Status objects is to return a StatusValue
 * unconditionally, i.e. both on success and on failure -- so that the
 * developer of the calling code is reminded that the function can fail, and
 * so that a lack of error-handling will be explicit.
 *
 * The use of Message objects should be avoided when serializability is needed.
 *
 * @newable
 * @stable to extend
 * @since 1.25
 */
class StatusValue implements Stringable {

    /**
     * @var bool
     * @internal Only for use by Status. Use {@link self::isOK()} or {@link self::setOK()}.
     */
    protected $ok = true;

    /**
     * @var array[]
     * @internal Only for use by Status. Use {@link self::getErrors()} (get full list),
     * {@link self::splitByErrorType()} (get errors/warnings), or
     * {@link self::fatal()}, {@link self::error()} or {@link self::warning()} (add error/warning).
     */
    protected $errors = [];

    /** @var mixed */
    public $value;

    /** @var bool[] Map of (key => bool) to indicate success of each part of batch operations */
    public $success = [];

    /** @var int Counter for batch operations */
    public $successCount = 0;

    /** @var int Counter for batch operations */
    public $failCount = 0;

    /** @var mixed arbitrary extra data about the operation */
    public $statusData;

    /**
     * Factory function for fatal errors
     *
     * @param string|MessageSpecifier $message Message key or object
     * @param mixed ...$parameters
     * @return static
     */
    public static function newFatal( $message, ...$parameters ) {
        $result = new static();
        $result->fatal( $message, ...$parameters );
        return $result;
    }

    /**
     * Factory function for good results
     *
     * @param mixed|null $value
     * @return static
     */
    public static function newGood( $value = null ) {
        $result = new static();
        $result->value = $value;
        return $result;
    }

    /**
     * Splits this StatusValue object into two new StatusValue objects, one which contains only
     * the error messages, and one that contains the warnings, only. The returned array is
     * defined as:
     * [
     *     0 => object(StatusValue) # the StatusValue with error messages, only
     *     1 => object(StatusValue) # The StatusValue with warning messages, only
     * ]
     *
     * @return static[]
     */
    public function splitByErrorType() {
        $errorsOnlyStatusValue = static::newGood();
        $warningsOnlyStatusValue = static::newGood();
        $warningsOnlyStatusValue->setResult( true, $this->getValue() );
        $errorsOnlyStatusValue->setResult( $this->isOK(), $this->getValue() );

        foreach ( $this->errors as $item ) {
            if ( $item['type'] === 'warning' ) {
                $warningsOnlyStatusValue->errors[] = $item;
            } else {
                $errorsOnlyStatusValue->errors[] = $item;
            }
        }

        return [ $errorsOnlyStatusValue, $warningsOnlyStatusValue ];
    }

    /**
     * Returns whether the operation completed and didn't have any error or
     * warnings
     *
     * @return bool
     */
    public function isGood() {
        return $this->ok && !$this->errors;
    }

    /**
     * Returns whether the operation completed
     *
     * @return bool
     */
    public function isOK() {
        return $this->ok;
    }

    /**
     * @return mixed
     */
    public function getValue() {
        return $this->value;
    }

    /**
     * Get the list of errors
     *
     * Each error is a (message:string or MessageSpecifier,params:array) map
     *
     * @deprecated since 1.43 Use `->getMessages()` instead
     * @return array[]
     * @phan-return array{type:'warning'|'error', message:string|MessageSpecifier, params:array}[]
     */
    public function getErrors() {
        return $this->errors;
    }

    /**
     * Change operation status
     *
     * @param bool $ok
     * @return $this
     */
    public function setOK( $ok ) {
        $this->ok = $ok;
        return $this;
    }

    /**
     * Change operation result
     *
     * @param bool $ok Whether the operation completed
     * @param mixed|null $value
     * @return $this
     */
    public function setResult( $ok, $value = null ) {
        $this->ok = (bool)$ok;
        $this->value = $value;
        return $this;
    }

    /**
     * Add a new error to the error array ($this->errors) if that error is not already in the
     * error array. Each error is passed as an array with the following fields:
     *
     * - type: 'error' or 'warning'
     * - message: a string (message key) or MessageSpecifier
     * - params: an array of string parameters
     *
     * If the new error is of type 'error' and it matches an existing error of type 'warning',
     * the existing error is upgraded to type 'error'. An error provided as a MessageSpecifier
     * will successfully match an error provided as the same string message key and array of
     * parameters as separate array elements.
     *
     * @param array $newError
     * @phan-param array{type:'warning'|'error', message:string|MessageSpecifier, params:array} $newError
     * @return $this
     */
    private function addError( array $newError ) {
        [ 'type' => $newType, 'message' => $newKey, 'params' => $newParams ] = $newError;
        if ( $newKey instanceof MessageSpecifier ) {
            if ( $newParams ) {
                // Deprecate code like `Status::newFatal( wfMessage( 'foo' ), 'param' )`
                // - the parameters have always been ignored, so this is usually a mistake.
                wfDeprecatedMsg( 'Combining MessageSpecifier and parameters array' .
                    ' was deprecated in MediaWiki 1.43', '1.43' );
            }
            $newParams = $newKey->getParams();
            $newKey = $newKey->getKey();
        }

        foreach ( $this->errors as [ 'type' => &$type, 'message' => $key, 'params' => $params ] ) {
            if ( $key instanceof MessageSpecifier ) {
                $params = $key->getParams();
                $key = $key->getKey();
            }
            if ( $newKey === $key && $newParams === $params ) {
                if ( $type === 'warning' && $newType === 'error' ) {
                    $type = 'error';
                }
                return $this;
            }
        }

        $this->errors[] = $newError;

        return $this;
    }

    /**
     * Add a new warning
     *
     * @param string|MessageSpecifier|MessageValue $message Message key or object
     * @param mixed ...$parameters
     * @return $this
     */
    public function warning( $message, ...$parameters ) {
        $message = $this->normalizeMessage( $message );

        return $this->addError( [
            'type' => 'warning',
            'message' => $message,
            'params' => $parameters
        ] );
    }

    /**
     * Add an error, do not set fatal flag
     * This can be used for non-fatal errors
     *
     * @param string|MessageSpecifier|MessageValue $message Message key or object
     * @param mixed ...$parameters
     * @return $this
     */
    public function error( $message, ...$parameters ) {
        $message = $this->normalizeMessage( $message );

        return $this->addError( [
            'type' => 'error',
            'message' => $message,
            'params' => $parameters
        ] );
    }

    /**
     * Add an error and set OK to false, indicating that the operation
     * as a whole was fatal
     *
     * @param string|MessageSpecifier|MessageValue $message Message key or object
     * @param mixed ...$parameters
     * @return $this
     */
    public function fatal( $message, ...$parameters ) {
        $this->ok = false;
        return $this->error( $message, ...$parameters );
    }

    /**
     * Merge another status object into this one
     *
     * @param StatusValue $other
     * @param bool $overwriteValue Whether to override the "value" member
     * @return $this
     */
    public function merge( $other, $overwriteValue = false ) {
        if ( $this->statusData !== null && $other->statusData !== null ) {
            throw new RuntimeException( "Status cannot be merged, because they both have \$statusData" );
        } else {
            $this->statusData ??= $other->statusData;
        }

        foreach ( $other->errors as $error ) {
            $this->addError( $error );
        }
        $this->ok = $this->ok && $other->ok;
        if ( $overwriteValue ) {
            $this->value = $other->value;
        }
        $this->successCount += $other->successCount;
        $this->failCount += $other->failCount;

        return $this;
    }

    /**
     * Returns a list of status messages of the given type
     *
     * Each entry is a map of:
     *   - message: string message key or MessageSpecifier
     *   - params: array list of parameters
     *
     * @deprecated since 1.43 Use `->getMessages( $type )` instead
     * @param string $type
     * @return array[]
     * @phan-return array{type:'warning'|'error', message:string|MessageSpecifier, params:array}[]
     */
    public function getErrorsByType( $type ) {
        $result = [];
        foreach ( $this->errors as $error ) {
            if ( $error['type'] === $type ) {
                $result[] = $error;
            }
        }

        return $result;
    }

    /**
     * Returns a list of error messages, optionally only those of the given type
     *
     * @since 1.43
     * @param ?string $type If provided, only return messages of the type 'warning' or 'error'
     * @phan-param null|'warning'|'error' $type
     * @return MessageSpecifier[]
     */
    public function getMessages( ?string $type = null ): array {
        Assert::parameter( $type === null || $type === 'warning' || $type === 'error',
            '$type', "must be null, 'warning', or 'error'" );
        $result = [];
        foreach ( $this->errors as $error ) {
            if ( $type === null || $error['type'] === $type ) {
                [ 'message' => $key, 'params' => $params ] = $error;
                if ( $key instanceof MessageSpecifier ) {
                    $result[] = $key;
                } else {
                    // TODO: Make MessageValue implement MessageSpecifier, and use a MessageValue here
                    $result[] = new Message( $key, $params );
                }
            }
        }

        return $result;
    }

    /**
     * Returns true if the specified message is present as a warning or error.
     * Any message using the same key will be found (ignoring the message parameters).
     *
     * @param string $message Message key to search for
     *   (this parameter used to allow MessageSpecifier|MessageValue too, deprecated since 1.43)
     * @return bool
     */
    public function hasMessage( $message ) {
        if ( $message instanceof MessageSpecifier ) {
            wfDeprecatedMsg( 'Passing MessageSpecifier to hasMessage()' .
                ' was deprecated in MediaWiki 1.43', '1.43' );
            $message = $message->getKey();
        } elseif ( $message instanceof MessageValue ) {
            wfDeprecatedMsg( 'Passing MessageValue to hasMessage()' .
                ' was deprecated in MediaWiki 1.43', '1.43' );
            $message = $message->getKey();
        }

        foreach ( $this->errors as [ 'message' => $key ] ) {
            if ( ( $key instanceof MessageSpecifier && $key->getKey() === $message ) ||
                $key === $message
            ) {
                return true;
            }
        }

        return false;
    }

    /**
     * Returns true if any other message than the specified ones is present as a warning or error.
     * Any messages using the same keys will be found (ignoring the message parameters).
     *
     * @param string ...$messages Message keys to search for
     *   (this parameter used to allow MessageSpecifier|MessageValue too, deprecated since 1.43)
     * @return bool
     */
    public function hasMessagesExcept( ...$messages ) {
        $exceptedKeys = [];
        foreach ( $messages as $message ) {
            if ( $message instanceof MessageSpecifier ) {
                wfDeprecatedMsg( 'Passing MessageSpecifier to hasMessagesExcept()' .
                    ' was deprecated in MediaWiki 1.43', '1.43' );
                $message = $message->getKey();
            } elseif ( $message instanceof MessageValue ) {
                wfDeprecatedMsg( 'Passing MessageValue to hasMessagesExcept()' .
                    ' was deprecated in MediaWiki 1.43', '1.43' );
                $message = $message->getKey();
            }
            $exceptedKeys[] = $message;
        }

        foreach ( $this->errors as [ 'message' => $key ] ) {
            if ( $key instanceof MessageSpecifier ) {
                $key = $key->getKey();
            }
            if ( !in_array( $key, $exceptedKeys, true ) ) {
                return true;
            }
        }

        return false;
    }

    /**
     * If the specified source message exists, replace it with the specified
     * destination message, but keep the same parameters as in the original error.
     *
     * When using a string as the `$source` parameter, any message using the same key will be replaced
     * (regardless of whether it was stored as string or as MessageSpecifier, and ignoring the
     * message parameters).
     *
     * When using a MessageValue as the `$source` parameter, this function does not work. This is a
     * bug, but it's impractical to fix. Therefore, passing a MessageValue is deprecated (since 1.43).
     *
     * When using a MessageSpecifier as the `$source` parameter, the message will only be replaced
     * when the same MessageSpecifier object was stored in the StatusValue (compared with `===`).
     * Since the only reliable way to obtain one is to use getErrors(), which is deprecated,
     * passing a MessageSpecifier is deprecated as well (since 1.43).
     *
     * @param string $source Message key to search for
     *   (this parameter used to allow MessageSpecifier|MessageValue too, deprecated since 1.43)
     * @param MessageSpecifier|MessageValue|string $dest Replacement message key or object
     * @return bool Return true if the replacement was done, false otherwise.
     */
    public function replaceMessage( $source, $dest ) {
        $replaced = false;

        if ( $source instanceof MessageSpecifier ) {
            wfDeprecatedMsg( 'Passing MessageSpecifier as $source to replaceMessage()' .
                ' was deprecated in MediaWiki 1.43', '1.43' );
        } elseif ( $source instanceof MessageValue ) {
            wfDeprecatedMsg( 'Passing MessageValue as $source to replaceMessage()' .
                ' was deprecated in MediaWiki 1.43', '1.43' );
            $source = $this->normalizeMessage( $source );
        }

        $dest = $this->normalizeMessage( $dest );

        foreach ( $this->errors as [ 'message' => &$message, 'params' => &$params ] ) {
            if ( $message === $source ||
                ( $message instanceof MessageSpecifier && $message->getKey() === $source )
            ) {
                $message = $dest;
                if ( $dest instanceof MessageSpecifier ) {
                    // 'params' will be ignored now, so remove them from the internal array
                    $params = [];
                }
                $replaced = true;
            }
        }

        return $replaced;
    }

    /**
     * Returns a string representation of the status for debugging.
     * This is fairly verbose and may change without notice.
     *
     * @return string
     */
    public function __toString() {
        $status = $this->isOK() ? "OK" : "Error";
        if ( count( $this->errors ) ) {
            $errorcount = "collected " . ( count( $this->errors ) ) . " message(s) on the way";
        } else {
            $errorcount = "no errors detected";
        }
        if ( isset( $this->value ) ) {
            $valstr = get_debug_type( $this->value ) . " value set";
        } else {
            $valstr = "no value set";
        }
        $out = sprintf( "<%s, %s, %s>",
            $status,
            $errorcount,
            $valstr
        );
        if ( count( $this->errors ) > 0 ) {
            $hdr = sprintf( "+-%'-8s-+-%'-25s-+-%'-36s-+\n", "", "", "" );
            $out .= "\n" . $hdr;
            foreach ( $this->errors as [ 'type' => $type, 'message' => $key, 'params' => $params ] ) {
                if ( $key instanceof MessageSpecifier ) {
                    $params = $key->getParams();
                    $key = $key->getKey();
                }

                $keyChunks = mb_str_split( $key, 25 );
                $paramsChunks = mb_str_split( $this->flattenParams( $params, " | " ), 36 );

                // array_map(null,...) is like Python's zip()
                foreach ( array_map( null, [ $type ], $keyChunks, $paramsChunks )
                    as [ $typeChunk, $keyChunk, $paramsChunk ]
                ) {
                    $out .= sprintf( "| %-8s | %-25s | %-36s |\n",
                        $typeChunk,
                        $keyChunk,
                        $paramsChunk
                    );
                }
            }
            $out .= $hdr;
        }

        return $out;
    }

    /**
     * @param array $params Message parameters
     * @param string $joiner
     *
     * @return string String representation
     */
    private function flattenParams( array $params, string $joiner = ', ' ): string {
        $ret = [];
        foreach ( $params as $p ) {
            if ( is_array( $p ) ) {
                $r = '[ ' . self::flattenParams( $p ) . ' ]';
            } elseif ( $p instanceof MessageSpecifier ) {
                $r = '{ ' . $p->getKey() . ': ' . self::flattenParams( $p->getParams() ) . ' }';
            } else {
                $r = (string)$p;
            }

            $ret[] = mb_strlen( $r ) > 100 ? mb_substr( $r, 0, 99 ) . "..." : $r;
        }
        return implode( $joiner, $ret );
    }

    /**
     * Returns a list of status messages of the given type (or all if false)
     *
     * @internal Only for use by Status.
     *
     * @param string|bool $type
     * @return array[]
     */
    protected function getStatusArray( $type = false ) {
        $result = [];

        foreach ( $this->getErrors() as $error ) {
            if ( !$type || $error['type'] === $type ) {
                if ( $error['message'] instanceof MessageSpecifier ) {
                    $result[] = [ $error['message']->getKey(), ...$error['message']->getParams() ];
                } else {
                    $result[] = [ $error['message'], ...$error['params'] ];
                }
            }
        }

        return $result;
    }

    /**
     * @param MessageSpecifier|MessageValue|string $message
     *
     * @return MessageSpecifier|string
     */
    private function normalizeMessage( $message ) {
        if ( $message instanceof MessageValue ) {
            $converter = new Converter();
            return $converter->convertMessageValue( $message );
        }

        return $message;
    }
}