Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
77.78% covered (warning)
77.78%
119 / 153
81.82% covered (warning)
81.82%
18 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
StatusValue
77.78% covered (warning)
77.78%
119 / 153
81.82% covered (warning)
81.82%
18 / 22
134.09
0.00% covered (danger)
0.00%
0 / 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
 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
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
110
 warning
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 error
100.00% covered (success)
100.00%
6 / 6
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
 hasMessage
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
7
 hasMessagesExcept
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
7
 replaceMessage
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 __toString
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
9
 flattenParams
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
30
 getStatusArray
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 normalizeMessage
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
17 *
18 * @file
19 */
20
21use MediaWiki\Message\Converter;
22use Wikimedia\Message\MessageValue;
23
24/**
25 * Generic operation result class
26 * Has warning/error list, boolean status and arbitrary value
27 *
28 * "Good" means the operation was completed with no warnings or errors.
29 *
30 * "OK" means the operation was partially or wholly completed.
31 *
32 * An operation which is not OK should have errors so that the user can be
33 * informed as to what went wrong. Calling the fatal() function sets an error
34 * message and simultaneously switches off the OK flag.
35 *
36 * The recommended pattern for Status objects is to return a StatusValue
37 * unconditionally, i.e. both on success and on failure -- so that the
38 * developer of the calling code is reminded that the function can fail, and
39 * so that a lack of error-handling will be explicit.
40 *
41 * The use of Message objects should be avoided when serializability is needed.
42 *
43 * @newable
44 * @stable to extend
45 * @since 1.25
46 */
47class StatusValue {
48
49    /**
50     * @var bool
51     * @internal Only for use by Status. Use {@link self::isOK()} or {@link self::setOK()}.
52     */
53    protected $ok = true;
54
55    /**
56     * @var array[]
57     * @internal Only for use by Status. Use {@link self::getErrors()} (get full list),
58     * {@link self::splitByErrorType()} (get errors/warnings), or
59     * {@link self::fatal()}, {@link self::error()} or {@link self::warning()} (add error/warning).
60     */
61    protected $errors = [];
62
63    /** @var mixed */
64    public $value;
65
66    /** @var bool[] Map of (key => bool) to indicate success of each part of batch operations */
67    public $success = [];
68
69    /** @var int Counter for batch operations */
70    public $successCount = 0;
71
72    /** @var int Counter for batch operations */
73    public $failCount = 0;
74
75    /** @var mixed arbitrary extra data about the operation */
76    public $statusData;
77
78    /**
79     * Factory function for fatal errors
80     *
81     * @param string|MessageSpecifier $message Message key or object
82     * @param mixed ...$parameters
83     * @return static
84     */
85    public static function newFatal( $message, ...$parameters ) {
86        $result = new static();
87        $result->fatal( $message, ...$parameters );
88        return $result;
89    }
90
91    /**
92     * Factory function for good results
93     *
94     * @param mixed|null $value
95     * @return static
96     */
97    public static function newGood( $value = null ) {
98        $result = new static();
99        $result->value = $value;
100        return $result;
101    }
102
103    /**
104     * Splits this StatusValue object into two new StatusValue objects, one which contains only
105     * the error messages, and one that contains the warnings, only. The returned array is
106     * defined as:
107     * [
108     *     0 => object(StatusValue) # the StatusValue with error messages, only
109     *     1 => object(StatusValue) # The StatusValue with warning messages, only
110     * ]
111     *
112     * @return static[]
113     */
114    public function splitByErrorType() {
115        $errorsOnlyStatusValue = static::newGood();
116        $warningsOnlyStatusValue = static::newGood();
117        $warningsOnlyStatusValue->setResult( true, $this->getValue() );
118        $errorsOnlyStatusValue->setResult( $this->isOK(), $this->getValue() );
119
120        foreach ( $this->errors as $item ) {
121            if ( $item['type'] === 'warning' ) {
122                $warningsOnlyStatusValue->errors[] = $item;
123            } else {
124                $errorsOnlyStatusValue->errors[] = $item;
125            }
126        }
127
128        return [ $errorsOnlyStatusValue, $warningsOnlyStatusValue ];
129    }
130
131    /**
132     * Returns whether the operation completed and didn't have any error or
133     * warnings
134     *
135     * @return bool
136     */
137    public function isGood() {
138        return $this->ok && !$this->errors;
139    }
140
141    /**
142     * Returns whether the operation completed
143     *
144     * @return bool
145     */
146    public function isOK() {
147        return $this->ok;
148    }
149
150    /**
151     * @return mixed
152     */
153    public function getValue() {
154        return $this->value;
155    }
156
157    /**
158     * Get the list of errors
159     *
160     * Each error is a (message:string or MessageSpecifier,params:array) map
161     *
162     * @return array[]
163     * @phan-return array{type:'warning'|'error', message:string|MessageSpecifier, params:array}[]
164     */
165    public function getErrors() {
166        return $this->errors;
167    }
168
169    /**
170     * Change operation status
171     *
172     * @param bool $ok
173     * @return $this
174     */
175    public function setOK( $ok ) {
176        $this->ok = $ok;
177        return $this;
178    }
179
180    /**
181     * Change operation result
182     *
183     * @param bool $ok Whether the operation completed
184     * @param mixed|null $value
185     * @return $this
186     */
187    public function setResult( $ok, $value = null ) {
188        $this->ok = (bool)$ok;
189        $this->value = $value;
190        return $this;
191    }
192
193    /**
194     * Add a new error to the error array ($this->errors) if that error is not already in the
195     * error array. Each error is passed as an array with the following fields:
196     *
197     * - type: 'error' or 'warning'
198     * - message: a string (message key) or MessageSpecifier
199     * - params: an array of string parameters
200     *
201     * If the new error is of type 'error' and it matches an existing error of type 'warning',
202     * the existing error is upgraded to type 'error'. An error provided as a MessageSpecifier
203     * will successfully match an error provided as the same string message key and array of
204     * parameters as separate array elements.
205     *
206     * @param array $newError
207     * @phan-param array{type:'warning'|'error', message:string|MessageSpecifier, params:array} $newError
208     * @return $this
209     */
210    private function addError( array $newError ) {
211        if ( $newError[ 'message' ] instanceof MessageSpecifier ) {
212            $isEqual = static function ( $key, $params ) use ( $newError ) {
213                if ( $key instanceof MessageSpecifier ) {
214                    // compare attributes of both MessageSpecifiers
215                    return $newError['message'] == $key;
216                } else {
217                    return $newError['message']->getKey() === $key &&
218                        $newError['message']->getParams() === $params;
219                }
220            };
221        } else {
222            $isEqual = static function ( $key, $params ) use ( $newError ) {
223                if ( $key instanceof MessageSpecifier ) {
224                    $params = $key->getParams();
225                    $key = $key->getKey();
226                }
227                return $newError['message'] === $key && $newError['params'] === $params;
228            };
229        }
230        foreach ( $this->errors as [ 'type' => &$type, 'message' => $key, 'params' => $params ] ) {
231            if ( $isEqual( $key, $params ) ) {
232                if ( $type === 'warning' && $newError['type'] === 'error' ) {
233                    $type = 'error';
234                }
235                return $this;
236            }
237        }
238        $this->errors[] = $newError;
239        return $this;
240    }
241
242    /**
243     * Add a new warning
244     *
245     * @param string|MessageSpecifier|MessageValue $message Message key or object
246     * @param mixed ...$parameters
247     * @return $this
248     */
249    public function warning( $message, ...$parameters ) {
250        $message = $this->normalizeMessage( $message );
251
252        return $this->addError( [
253            'type' => 'warning',
254            'message' => $message,
255            'params' => $parameters
256        ] );
257    }
258
259    /**
260     * Add an error, do not set fatal flag
261     * This can be used for non-fatal errors
262     *
263     * @param string|MessageSpecifier|MessageValue $message Message key or object
264     * @param mixed ...$parameters
265     * @return $this
266     */
267    public function error( $message, ...$parameters ) {
268        $message = $this->normalizeMessage( $message );
269
270        return $this->addError( [
271            'type' => 'error',
272            'message' => $message,
273            'params' => $parameters
274        ] );
275    }
276
277    /**
278     * Add an error and set OK to false, indicating that the operation
279     * as a whole was fatal
280     *
281     * @param string|MessageSpecifier|MessageValue $message Message key or object
282     * @param mixed ...$parameters
283     * @return $this
284     */
285    public function fatal( $message, ...$parameters ) {
286        $this->ok = false;
287        return $this->error( $message, ...$parameters );
288    }
289
290    /**
291     * Merge another status object into this one
292     *
293     * @param StatusValue $other
294     * @param bool $overwriteValue Whether to override the "value" member
295     * @return $this
296     */
297    public function merge( $other, $overwriteValue = false ) {
298        if ( $this->statusData !== null && $other->statusData !== null ) {
299            throw new RuntimeException( "Status cannot be merged, because they both have \$statusData" );
300        } else {
301            $this->statusData ??= $other->statusData;
302        }
303
304        foreach ( $other->errors as $error ) {
305            $this->addError( $error );
306        }
307        $this->ok = $this->ok && $other->ok;
308        if ( $overwriteValue ) {
309            $this->value = $other->value;
310        }
311        $this->successCount += $other->successCount;
312        $this->failCount += $other->failCount;
313
314        return $this;
315    }
316
317    /**
318     * Returns a list of status messages of the given type
319     *
320     * Each entry is a map of:
321     *   - message: string message key or MessageSpecifier
322     *   - params: array list of parameters
323     *
324     * @param string $type
325     * @return array[]
326     * @phan-return array{type:'warning'|'error', message:string|MessageSpecifier, params:array}[]
327     */
328    public function getErrorsByType( $type ) {
329        $result = [];
330        foreach ( $this->errors as $error ) {
331            if ( $error['type'] === $type ) {
332                $result[] = $error;
333            }
334        }
335
336        return $result;
337    }
338
339    /**
340     * Returns true if the specified message is present as a warning or error
341     *
342     * @param string|MessageSpecifier|MessageValue $message Message key or object to search for
343     *
344     * @return bool
345     */
346    public function hasMessage( $message ) {
347        if ( $message instanceof MessageSpecifier || $message instanceof MessageValue ) {
348            $message = $message->getKey();
349        }
350
351        foreach ( $this->errors as [ 'message' => $key ] ) {
352            if ( ( $key instanceof MessageSpecifier && $key->getKey() === $message ) ||
353                $key === $message
354            ) {
355                return true;
356            }
357        }
358
359        return false;
360    }
361
362    /**
363     * Returns true if any other message than the specified ones  is present as a warning or error.
364     *
365     * @param string|MessageSpecifier|MessageValue ...$messages Messages to search for.
366     *
367     * @return bool
368     */
369    public function hasMessagesExcept( ...$messages ) {
370        $exceptedKeys = [];
371        foreach ( $messages as $message ) {
372            if ( $message instanceof MessageSpecifier || $message instanceof MessageValue ) {
373                $message = $message->getKey();
374            }
375            $exceptedKeys[] = $message;
376        }
377
378        foreach ( $this->errors as [ 'message' => $key ] ) {
379            if ( $key instanceof MessageSpecifier ) {
380                $key = $key->getKey();
381            }
382            if ( !in_array( $key, $exceptedKeys, true ) ) {
383                return true;
384            }
385        }
386
387        return false;
388    }
389
390    /**
391     * If the specified source message exists, replace it with the specified
392     * destination message, but keep the same parameters as in the original error.
393     *
394     * Note, due to the lack of tools for comparing IStatusMessage objects, this
395     * function will not work when using such an object as the search parameter.
396     *
397     * @param MessageSpecifier|MessageValue|string $source Message key or object to search for
398     * @param MessageSpecifier|MessageValue|string $dest Replacement message key or object
399     * @return bool Return true if the replacement was done, false otherwise.
400     */
401    public function replaceMessage( $source, $dest ) {
402        $replaced = false;
403
404        $source = $this->normalizeMessage( $source );
405        $dest = $this->normalizeMessage( $dest );
406
407        foreach ( $this->errors as [ 'message' => &$message ] ) {
408            if ( $message === $source ||
409                ( $message instanceof MessageSpecifier && $message->getKey() === $source )
410            ) {
411                $message = $dest;
412                $replaced = true;
413            }
414        }
415
416        return $replaced;
417    }
418
419    /**
420     * Returns a string representation of the status for debugging.
421     * This is fairly verbose and may change without notice.
422     *
423     * @return string
424     */
425    public function __toString() {
426        $status = $this->isOK() ? "OK" : "Error";
427        if ( count( $this->errors ) ) {
428            $errorcount = "collected " . ( count( $this->errors ) ) . " message(s) on the way";
429        } else {
430            $errorcount = "no errors detected";
431        }
432        if ( isset( $this->value ) ) {
433            $valstr = gettype( $this->value ) . " value set";
434            if ( is_object( $this->value ) ) {
435                $valstr .= "\"" . get_class( $this->value ) . "\" instance";
436            }
437        } else {
438            $valstr = "no value set";
439        }
440        $out = sprintf( "<%s, %s, %s>",
441            $status,
442            $errorcount,
443            $valstr
444        );
445        if ( count( $this->errors ) > 0 ) {
446            $hdr = sprintf( "+-%'-8s-+-%'-25s-+-%'-36s-+\n", "", "", "" );
447            $out .= "\n" . $hdr;
448            foreach ( $this->errors as [ 'type' => $type, 'message' => $key, 'params' => $params ] ) {
449                if ( $key instanceof MessageSpecifier ) {
450                    $params = $key->getParams();
451                    $key = $key->getKey();
452                }
453
454                $keyChunks = mb_str_split( $key, 25 );
455                $paramsChunks = mb_str_split( $this->flattenParams( $params, " | " ), 36 );
456
457                // array_map(null,...) is like Python's zip()
458                foreach ( array_map( null, [ $type ], $keyChunks, $paramsChunks )
459                    as [ $typeChunk, $keyChunk, $paramsChunk ]
460                ) {
461                    $out .= sprintf( "| %-8s | %-25s | %-36s |\n",
462                        $typeChunk,
463                        $keyChunk,
464                        $paramsChunk
465                    );
466                }
467            }
468            $out .= $hdr;
469        }
470
471        return $out;
472    }
473
474    /**
475     * @param array $params Message parameters
476     * @param string $joiner
477     *
478     * @return string String representation
479     */
480    private function flattenParams( array $params, string $joiner = ', ' ): string {
481        $ret = [];
482        foreach ( $params as $p ) {
483            if ( is_array( $p ) ) {
484                $r = '[ ' . self::flattenParams( $p ) . ' ]';
485            } elseif ( $p instanceof MessageSpecifier ) {
486                $r = '{ ' . $p->getKey() . ': ' . self::flattenParams( $p->getParams() ) . ' }';
487            } else {
488                $r = (string)$p;
489            }
490
491            $ret[] = mb_strlen( $r ) > 100 ? mb_substr( $r, 0, 99 ) . "..." : $r;
492        }
493        return implode( $joiner, $ret );
494    }
495
496    /**
497     * Returns a list of status messages of the given type (or all if false)
498     *
499     * @internal Only for use by Status.
500     * @note this handles RawMessage poorly
501     *
502     * @param string|bool $type
503     * @return array[]
504     */
505    protected function getStatusArray( $type = false ) {
506        $result = [];
507
508        foreach ( $this->getErrors() as $error ) {
509            if ( !$type || $error['type'] === $type ) {
510                if ( $error['message'] instanceof MessageSpecifier ) {
511                    $result[] = [ $error['message']->getKey(), ...$error['message']->getParams() ];
512                } else {
513                    $result[] = [ $error['message'], ...$error['params'] ];
514                }
515            }
516        }
517
518        return $result;
519    }
520
521    /**
522     * @param MessageSpecifier|MessageValue|string $message
523     *
524     * @return MessageSpecifier|string
525     */
526    private function normalizeMessage( $message ) {
527        if ( $message instanceof MessageValue ) {
528            $converter = new Converter();
529            return $converter->convertMessageValue( $message );
530        }
531
532        return $message;
533    }
534}