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