Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.47% covered (success)
97.47%
154 / 158
87.50% covered (warning)
87.50%
21 / 24
CRAP
0.00% covered (danger)
0.00%
0 / 1
StatusValue
97.47% covered (success)
97.47%
154 / 158
87.50% covered (warning)
87.50%
21 / 24
76
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 newFatal
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 newGood
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 cast
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
2.00
 splitByErrorType
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 isGood
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isOK
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getValue
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getErrors
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setOK
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setResult
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 addError
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
8
 warning
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 error
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 fatal
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 merge
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
6.03
 getErrorsByType
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 getMessages
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
7
 hasMessage
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 hasMessagesExcept
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 replaceMessage
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 __toString
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
8
 flattenParams
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
6.22
 getStatusArray
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7use Wikimedia\Assert\Assert;
8use Wikimedia\Message\MessageParam;
9use Wikimedia\Message\MessageSpecifier;
10use Wikimedia\Message\MessageValue;
11
12/**
13 * Generic operation result class
14 * Has warning/error list, boolean status and arbitrary value
15 *
16 * "Good" means the operation was completed with no warnings or errors.
17 *
18 * "OK" means the operation was partially or wholly completed.
19 *
20 * An operation which is not OK should have errors so that the user can be
21 * informed as to what went wrong. Calling the fatal() function sets an error
22 * message and simultaneously switches off the OK flag.
23 *
24 * The recommended pattern for functions returning StatusValue objects is
25 * to return a StatusValue unconditionally, both on success and on failure
26 * (similarly to Option, Maybe, Promise etc. objects in other languages) --
27 * so that the developer of the calling code is reminded that the function
28 * can fail, and so that a lack of error-handling will be explicit.
29 *
30 * This class accepts any MessageSpecifier objects. The use of Message objects
31 * should be avoided when serializability is needed. Use MessageValue in that
32 * case instead.
33 *
34 * @newable
35 * @stable to extend
36 * @since 1.25
37 * @template T Type of the value stored in the status when the operation result is OK.
38 *   May be 'never' to indicate that there's no meaningful value, and that
39 *   this status is only used to keep track of errors and warnings.
40 */
41class StatusValue implements Stringable {
42
43    /**
44     * @var bool
45     * @internal Only for use by Status. Use {@link self::isOK()} or {@link self::setOK()}.
46     */
47    protected $ok = true;
48
49    /**
50     * @var array[]
51     * @internal Only for use by Status. Use {@link self::getErrors()} (get full list),
52     * {@link self::splitByErrorType()} (get errors/warnings), or
53     * {@link self::fatal()}, {@link self::error()} or {@link self::warning()} (add error/warning).
54     */
55    protected $errors = [];
56
57    /** @var T */
58    public $value;
59
60    /** @var bool[] Map of (key => bool) to indicate success of each part of batch operations */
61    public $success = [];
62
63    /** @var int Counter for batch operations */
64    public $successCount = 0;
65
66    /** @var int Counter for batch operations */
67    public $failCount = 0;
68
69    /** @var mixed arbitrary extra data about the operation */
70    public $statusData;
71
72    /**
73     * @suppress PhanGenericConstructorTypes
74     */
75    public function __construct() {
76    }
77
78    /**
79     * Factory function for fatal errors
80     *
81     * @param string|MessageSpecifier $message Message key or object
82     * @phpcs:ignore Generic.Files.LineLength
83     * @param MessageParam|MessageSpecifier|string|int|float|list<MessageParam|MessageSpecifier|string|int|float> ...$parameters
84     *   See Message::params()
85     */
86    public static function newFatal( $message, ...$parameters ): static {
87        $result = new static();
88        $result->fatal( $message, ...$parameters );
89        return $result;
90    }
91
92    /**
93     * Factory function for good results
94     *
95     * @param mixed|null $value
96     */
97    public static function newGood( $value = null ): static {
98        $result = new static();
99        $result->value = $value;
100        return $result;
101    }
102
103    /**
104     * Succinct helper method to wrap a StatusValue in some other specific subclass.
105     *
106     * One place where this is useful is when formatting StatusValue objects:
107     * @code
108     *     $this->getOutput()->addHtml( Status::cast( $sv )->getHTML() );
109     * @endcode
110     *
111     * Also, several code paths in MediaWiki core use {@link Status::wrap()}
112     * to turn a returned StatusValue into a Status (e.g. {@link \MediaWiki\HTMLForm\HTMLForm::trySubmit());
113     * in such cases, cast() can be used to "restore" the original type.
114     * @code
115     *     $form = HTMLForm::factory( 'ooui', [ ... ], $this->getContext() )
116     *         ->setSubmitCallback( function ( array $data, HTMLForm $form ) {
117     *             return CustomStatusValue::newGood( $data );
118     *         } );
119     *     $result = $form->tryAuthorizedSubmit();
120     *     // $result is a generic Status
121     *     if ( $result && $result->isGood() ) {
122     *         $result = CustomStatusValue::cast( $result );
123     *         // $result is a CustomStatusValue again
124     *     }
125     * @endcode
126     * To support this pattern, StatusValue subclasses should not add any new properties:
127     * all data should be stored in the standard fields, chiefly {@link self::$value} and {@link self::$statusData}.
128     * (Making them associative arrays can help to keep the data extensible.)
129     *
130     * @param StatusValue $sv
131     * @return static
132     */
133    public static function cast( StatusValue $sv ) {
134        if ( $sv instanceof static ) {
135            return $sv;
136        }
137
138        $result = new static();
139        $result->ok = $sv->ok;
140        $result->errors = $sv->errors;
141        $result->value = $sv->value;
142        $result->successCount = $sv->successCount;
143        $result->failCount = $sv->failCount;
144        $result->success = $sv->success;
145        $result->statusData = $sv->statusData;
146
147        return $result;
148    }
149
150    /**
151     * Splits this StatusValue object into two new StatusValue objects, one which contains only
152     * the error messages, and one that contains the warnings, only. The returned array is
153     * defined as:
154     * [
155     *     0 => object(StatusValue) # the StatusValue with error messages, only
156     *     1 => object(StatusValue) # The StatusValue with warning messages, only
157     * ]
158     *
159     * @return static[]
160     */
161    public function splitByErrorType() {
162        $errorsOnlyStatusValue = static::newGood();
163        $warningsOnlyStatusValue = static::newGood();
164        $warningsOnlyStatusValue->setResult( true, $this->getValue() );
165        $errorsOnlyStatusValue->setResult( $this->isOK(), $this->getValue() );
166
167        foreach ( $this->errors as $item ) {
168            if ( $item['type'] === 'warning' ) {
169                $warningsOnlyStatusValue->errors[] = $item;
170            } else {
171                $errorsOnlyStatusValue->errors[] = $item;
172            }
173        }
174
175        return [ $errorsOnlyStatusValue, $warningsOnlyStatusValue ];
176    }
177
178    /**
179     * Returns whether the operation completed and didn't have any error or
180     * warnings
181     *
182     * @return bool
183     */
184    public function isGood() {
185        return $this->ok && !$this->errors;
186    }
187
188    /**
189     * Returns whether the operation completed
190     *
191     * @return bool
192     */
193    public function isOK() {
194        return $this->ok;
195    }
196
197    /**
198     * @return T
199     */
200    public function getValue() {
201        return $this->value;
202    }
203
204    /**
205     * Get the list of errors
206     *
207     * Each error is a (message:string or MessageSpecifier,params:array) map
208     *
209     * @deprecated since 1.43 Use `->getMessages()` instead
210     * @return array[]
211     * @phan-return array{type:'warning'|'error', message:string|MessageSpecifier, params:array}[]
212     */
213    public function getErrors() {
214        return $this->errors;
215    }
216
217    /**
218     * Change operation status
219     *
220     * @param bool $ok
221     * @return $this
222     */
223    public function setOK( $ok ) {
224        $this->ok = $ok;
225        return $this;
226    }
227
228    /**
229     * Change operation result
230     *
231     * @param bool $ok Whether the operation completed
232     * @phpcs:ignore MediaWiki.Commenting.FunctionComment.DefaultNullTypeParam -- `T|null` causes false Phan warnings
233     * @param T $value If `$ok` is true, this should be a value of the template type `T`.
234     *   Otherwise it may be null or omitted.
235     * @return $this
236     */
237    public function setResult( $ok, $value = null ) {
238        $this->ok = (bool)$ok;
239        $this->value = $value;
240        return $this;
241    }
242
243    /**
244     * Add a new error to the error array ($this->errors) if that error is not already in the
245     * error array. Each error is passed as an array with the following fields:
246     *
247     * - type: 'error' or 'warning'
248     * - message: a string (message key) or MessageSpecifier
249     * - params: an array of string parameters
250     *
251     * If the new error is of type 'error' and it matches an existing error of type 'warning',
252     * the existing error is upgraded to type 'error'. An error provided as a MessageSpecifier
253     * will successfully match an error provided as the same string message key and array of
254     * parameters as separate array elements.
255     *
256     * @param array $newError
257     * @phan-param array{type:'warning'|'error', message:string|MessageSpecifier, params:array} $newError
258     * @return $this
259     */
260    private function addError( array $newError ) {
261        [ 'type' => $newType, 'message' => $newKey, 'params' => $newParams ] = $newError;
262        if ( $newKey instanceof MessageSpecifier ) {
263            Assert::parameter( $newParams === [],
264                '$parameters', "must be empty when using a MessageSpecifier" );
265            $newParams = $newKey->getParams();
266            $newKey = $newKey->getKey();
267        }
268
269        foreach ( $this->errors as [ 'type' => &$type, 'message' => $key, 'params' => $params ] ) {
270            if ( $key instanceof MessageSpecifier ) {
271                $params = $key->getParams();
272                $key = $key->getKey();
273            }
274
275            // This uses loose equality as we must support equality between MessageParam objects
276            // (e.g. ScalarParam), including when they are created separate and not by-ref equal.
277            if ( $newKey === $key && $newParams == $params ) {
278                if ( $type === 'warning' && $newType === 'error' ) {
279                    $type = 'error';
280                }
281                return $this;
282            }
283        }
284
285        $this->errors[] = $newError;
286
287        return $this;
288    }
289
290    /**
291     * Add a new warning
292     *
293     * @param string|MessageSpecifier $message Message key or object
294     * @phpcs:ignore Generic.Files.LineLength
295     * @param MessageParam|MessageSpecifier|string|int|float|list<MessageParam|MessageSpecifier|string|int|float> ...$parameters
296     *   See Message::params()
297     * @return $this
298     */
299    public function warning( $message, ...$parameters ) {
300        return $this->addError( [
301            'type' => 'warning',
302            'message' => $message,
303            'params' => $parameters
304        ] );
305    }
306
307    /**
308     * Add an error, do not set fatal flag
309     * This can be used for non-fatal errors
310     *
311     * @param string|MessageSpecifier $message Message key or object
312     * @phpcs:ignore Generic.Files.LineLength
313     * @param MessageParam|MessageSpecifier|string|int|float|list<MessageParam|MessageSpecifier|string|int|float> ...$parameters
314     *   See Message::params()
315     * @return $this
316     */
317    public function error( $message, ...$parameters ) {
318        return $this->addError( [
319            'type' => 'error',
320            'message' => $message,
321            'params' => $parameters
322        ] );
323    }
324
325    /**
326     * Add an error and set OK to false, indicating that the operation
327     * as a whole was fatal
328     *
329     * @param string|MessageSpecifier $message Message key or object
330     * @phpcs:ignore Generic.Files.LineLength
331     * @param MessageParam|MessageSpecifier|string|int|float|list<MessageParam|MessageSpecifier|string|int|float> ...$parameters
332     *   See Message::params()
333     * @return $this
334     */
335    public function fatal( $message, ...$parameters ) {
336        $this->ok = false;
337        return $this->error( $message, ...$parameters );
338    }
339
340    /**
341     * Merge another status object into this one
342     *
343     * @param StatusValue $other
344     * @param bool $overwriteValue Whether to override the "value" member
345     * @return $this
346     */
347    public function merge( $other, $overwriteValue = false ) {
348        if ( $this->statusData !== null && $other->statusData !== null ) {
349            throw new RuntimeException( "Status cannot be merged, because they both have \$statusData" );
350        } else {
351            $this->statusData ??= $other->statusData;
352        }
353
354        foreach ( $other->errors as $error ) {
355            $this->addError( $error );
356        }
357        $this->ok = $this->ok && $other->ok;
358        if ( $overwriteValue ) {
359            $this->value = $other->value;
360        }
361        $this->successCount += $other->successCount;
362        $this->failCount += $other->failCount;
363
364        return $this;
365    }
366
367    /**
368     * Returns a list of status messages of the given type
369     *
370     * Each entry is a map of:
371     *   - message: string message key or MessageSpecifier
372     *   - params: array list of parameters
373     *
374     * @deprecated since 1.43 Use `->getMessages( $type )` instead
375     * @param string $type
376     * @return array[]
377     * @phan-return array{type:'warning'|'error', message:string|MessageSpecifier, params:array}[]
378     */
379    public function getErrorsByType( $type ) {
380        $result = [];
381        foreach ( $this->errors as $error ) {
382            if ( $error['type'] === $type ) {
383                $result[] = $error;
384            }
385        }
386
387        return $result;
388    }
389
390    /**
391     * Returns a list of error messages, optionally only those of the given type
392     *
393     * If the `warning()` or `error()` method was called with a MessageSpecifier object,
394     * this method is guaranteed to return the same object.
395     *
396     * @since 1.43
397     * @param ?string $type If provided, only return messages of the type 'warning' or 'error'
398     * @phan-param null|'warning'|'error' $type
399     * @return MessageSpecifier[]
400     */
401    public function getMessages( ?string $type = null ): array {
402        Assert::parameter( $type === null || $type === 'warning' || $type === 'error',
403            '$type', "must be null, 'warning', or 'error'" );
404        $result = [];
405        foreach ( $this->errors as $error ) {
406            if ( $type === null || $error['type'] === $type ) {
407                [ 'message' => $key, 'params' => $params ] = $error;
408                if ( $key instanceof MessageSpecifier ) {
409                    $result[] = $key;
410                } else {
411                    $result[] = new MessageValue( $key, $params );
412                }
413            }
414        }
415
416        return $result;
417    }
418
419    /**
420     * Returns true if the specified message is present as a warning or error.
421     * Any message using the same key will be found (ignoring the message parameters).
422     *
423     * @param string $message Message key to search for
424     * @return bool
425     */
426    public function hasMessage( string $message ) {
427        foreach ( $this->errors as [ 'message' => $key ] ) {
428            if ( ( $key instanceof MessageSpecifier && $key->getKey() === $message ) ||
429                $key === $message
430            ) {
431                return true;
432            }
433        }
434
435        return false;
436    }
437
438    /**
439     * Returns true if any other message than the specified ones is present as a warning or error.
440     * Any messages using the same keys will be found (ignoring the message parameters).
441     *
442     * @param string ...$messages Message keys to search for
443     * @return bool
444     */
445    public function hasMessagesExcept( string ...$messages ) {
446        foreach ( $this->errors as [ 'message' => $key ] ) {
447            if ( $key instanceof MessageSpecifier ) {
448                $key = $key->getKey();
449            }
450            if ( !in_array( $key, $messages, true ) ) {
451                return true;
452            }
453        }
454
455        return false;
456    }
457
458    /**
459     * If the specified source message exists, replace it with the specified
460     * destination message, but keep the same parameters as in the original error.
461     *
462     * Any message using the same key will be replaced (ignoring the message parameters).
463     *
464     * @param string $source Message key to search for
465     * @param MessageSpecifier|string $dest Replacement message key or object
466     * @return bool Return true if the replacement was done, false otherwise.
467     */
468    public function replaceMessage( string $source, $dest ) {
469        $replaced = false;
470
471        foreach ( $this->errors as [ 'message' => &$message, 'params' => &$params ] ) {
472            if ( $message === $source ||
473                ( $message instanceof MessageSpecifier && $message->getKey() === $source )
474            ) {
475                $message = $dest;
476                if ( $dest instanceof MessageSpecifier ) {
477                    // 'params' will be ignored now, so remove them from the internal array
478                    $params = [];
479                }
480                $replaced = true;
481            }
482        }
483
484        return $replaced;
485    }
486
487    /**
488     * Returns a string representation of the status for debugging.
489     * This is fairly verbose and may change without notice.
490     *
491     * @return string
492     */
493    public function __toString() {
494        $status = $this->isOK() ? "OK" : "Error";
495        if ( count( $this->errors ) ) {
496            $errorcount = "collected " . ( count( $this->errors ) ) . " message(s) on the way";
497        } else {
498            $errorcount = "no errors detected";
499        }
500        if ( $this->value !== null ) {
501            $valstr = get_debug_type( $this->value ) . " value set";
502        } else {
503            $valstr = "no value set";
504        }
505        $out = sprintf( "<%s, %s, %s>",
506            $status,
507            $errorcount,
508            $valstr
509        );
510        if ( count( $this->errors ) > 0 ) {
511            $hdr = sprintf( "+-%'-8s-+-%'-25s-+-%'-36s-+\n", "", "", "" );
512            $out .= "\n" . $hdr;
513            foreach ( $this->errors as [ 'type' => $type, 'message' => $key, 'params' => $params ] ) {
514                if ( $key instanceof MessageSpecifier ) {
515                    $params = $key->getParams();
516                    $key = $key->getKey();
517                }
518
519                $keyChunks = mb_str_split( $key, 25 );
520                $paramsChunks = mb_str_split( $this->flattenParams( $params, " | " ), 36 );
521
522                // array_map(null,...) is like Python's zip()
523                foreach ( array_map( null, [ $type ], $keyChunks, $paramsChunks )
524                    as [ $typeChunk, $keyChunk, $paramsChunk ]
525                ) {
526                    $out .= sprintf( "| %-8s | %-25s | %-36s |\n",
527                        $typeChunk,
528                        $keyChunk,
529                        $paramsChunk
530                    );
531                }
532            }
533            $out .= $hdr;
534        }
535
536        return $out;
537    }
538
539    /**
540     * @param array $params Message parameters
541     * @param string $joiner
542     *
543     * @return string String representation
544     */
545    private function flattenParams( array $params, string $joiner = ', ' ): string {
546        $ret = [];
547        foreach ( $params as $p ) {
548            if ( is_array( $p ) ) {
549                $r = '[ ' . self::flattenParams( $p ) . ' ]';
550            } elseif ( $p instanceof MessageSpecifier ) {
551                $r = '{ ' . $p->getKey() . ': ' . self::flattenParams( $p->getParams() ) . ' }';
552            } elseif ( $p instanceof MessageParam ) {
553                $r = $p->dump();
554            } else {
555                $r = (string)$p;
556            }
557
558            $ret[] = mb_strlen( $r ) > 100 ? mb_substr( $r, 0, 99 ) . "..." : $r;
559        }
560        return implode( $joiner, $ret );
561    }
562
563    /**
564     * Returns a list of status messages of the given type (or all if false)
565     *
566     * @internal Only for use by Status.
567     *
568     * @param string|bool $type
569     * @return array[]
570     */
571    protected function getStatusArray( $type = false ) {
572        $result = [];
573
574        foreach ( $this->getErrors() as $error ) {
575            if ( !$type || $error['type'] === $type ) {
576                if ( $error['message'] instanceof MessageSpecifier ) {
577                    $result[] = [ $error['message']->getKey(), ...$error['message']->getParams() ];
578                } else {
579                    $result[] = [ $error['message'], ...$error['params'] ];
580                }
581            }
582        }
583
584        return $result;
585    }
586}