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
21/**
22 * Generic operation result class
23 * Has warning/error list, boolean status and arbitrary value
24 *
25 * "Good" means the operation was completed with no warnings or errors.
26 *
27 * "OK" means the operation was partially or wholly completed.
28 *
29 * An operation which is not OK should have errors so that the user can be
30 * informed as to what went wrong. Calling the fatal() function sets an error
31 * message and simultaneously switches off the OK flag.
32 *
33 * The recommended pattern for Status objects is to return a StatusValue
34 * unconditionally, i.e. both on success and on failure -- so that the
35 * developer of the calling code is reminded that the function can fail, and
36 * so that a lack of error-handling will be explicit.
37 *
38 * The use of Message objects should be avoided when serializability is needed.
39 *
40 * @newable
41 * @since 1.25
42 */
43class StatusValue {
44
45	/** @var bool */
46	protected $ok = true;
47
48	/** @var array[] */
49	protected $errors = [];
50
51	/** @var mixed */
52	public $value;
53
54	/** @var bool[] Map of (key => bool) to indicate success of each part of batch operations */
55	public $success = [];
56
57	/** @var int Counter for batch operations */
58	public $successCount = 0;
59
60	/** @var int Counter for batch operations */
61	public $failCount = 0;
62
63	/**
64	 * Factory function for fatal errors
65	 *
66	 * @param string|MessageSpecifier $message Message key or object
67	 * @param mixed ...$parameters
68	 * @return static
69	 */
70	public static function newFatal( $message, ...$parameters ) {
71		$result = new static();
72		$result->fatal( $message, ...$parameters );
73		return $result;
74	}
75
76	/**
77	 * Factory function for good results
78	 *
79	 * @param mixed|null $value
80	 * @return static
81	 */
82	public static function newGood( $value = null ) {
83		$result = new static();
84		$result->value = $value;
85		return $result;
86	}
87
88	/**
89	 * Splits this StatusValue object into two new StatusValue objects, one which contains only
90	 * the error messages, and one that contains the warnings, only. The returned array is
91	 * defined as:
92	 * [
93	 *     0 => object(StatusValue) # the StatusValue with error messages, only
94	 *     1 => object(StatusValue) # The StatusValue with warning messages, only
95	 * ]
96	 *
97	 * @return static[]
98	 */
99	public function splitByErrorType() {
100		$errorsOnlyStatusValue = static::newGood();
101		$warningsOnlyStatusValue = static::newGood();
102		$warningsOnlyStatusValue->setResult( true, $this->getValue() );
103		$errorsOnlyStatusValue->setResult( $this->isOK(), $this->getValue() );
104
105		foreach ( $this->errors as $item ) {
106			if ( $item['type'] === 'warning' ) {
107				$warningsOnlyStatusValue->errors[] = $item;
108			} else {
109				$errorsOnlyStatusValue->errors[] = $item;
110			}
111		}
112
113		return [ $errorsOnlyStatusValue, $warningsOnlyStatusValue ];
114	}
115
116	/**
117	 * Returns whether the operation completed and didn't have any error or
118	 * warnings
119	 *
120	 * @return bool
121	 */
122	public function isGood() {
123		return $this->ok && !$this->errors;
124	}
125
126	/**
127	 * Returns whether the operation completed
128	 *
129	 * @return bool
130	 */
131	public function isOK() {
132		return $this->ok;
133	}
134
135	/**
136	 * @return mixed
137	 */
138	public function getValue() {
139		return $this->value;
140	}
141
142	/**
143	 * Get the list of errors
144	 *
145	 * Each error is a (message:string or MessageSpecifier,params:array) map
146	 *
147	 * @return array[]
148	 */
149	public function getErrors() {
150		return $this->errors;
151	}
152
153	/**
154	 * Change operation status
155	 *
156	 * @param bool $ok
157	 * @return $this
158	 */
159	public function setOK( $ok ) {
160		$this->ok = $ok;
161		return $this;
162	}
163
164	/**
165	 * Change operation result
166	 *
167	 * @param bool $ok Whether the operation completed
168	 * @param mixed|null $value
169	 * @return $this
170	 */
171	public function setResult( $ok, $value = null ) {
172		$this->ok = (bool)$ok;
173		$this->value = $value;
174		return $this;
175	}
176
177	/**
178	 * Add a new error to the error array ($this->errors) if that error is not already in the
179	 * error array. Each error is passed as an array with the following fields:
180	 *
181	 * - type: 'error' or 'warning'
182	 * - message: a string (message key) or MessageSpecifier
183	 * - params: an array of string parameters
184	 *
185	 * If the new error is of type 'error' and it matches an existing error of type 'warning',
186	 * the existing error is upgraded to type 'error'. An error provided as a MessageSpecifier
187	 * will successfully match an error provided as the same string message key and array of
188	 * parameters as separate array elements.
189	 *
190	 * @param array $newError
191	 * @return $this
192	 */
193	private function addError( array $newError ) {
194		if ( $newError[ 'message' ] instanceof MessageSpecifier ) {
195			$isEqual = static function ( $existingError ) use ( $newError ) {
196				if ( $existingError['message'] instanceof MessageSpecifier ) {
197					// compare attributes of both MessageSpecifiers
198					return $newError['message'] == $existingError['message'];
199				} else {
200					return $newError['message']->getKey() === $existingError['message'] &&
201						$newError['message']->getParams() === $existingError['params'];
202				}
203			};
204		} else {
205			$isEqual = static function ( $existingError ) use ( $newError ) {
206				if ( $existingError['message'] instanceof MessageSpecifier ) {
207					return $newError['message'] === $existingError['message']->getKey() &&
208						$newError['params'] === $existingError['message']->getParams();
209				} else {
210					return $newError['message'] === $existingError['message'] &&
211						$newError['params'] === $existingError['params'];
212				}
213			};
214		}
215		foreach ( $this->errors as $index => $existingError ) {
216			if ( $isEqual( $existingError ) ) {
217				if ( $newError[ 'type' ] === 'error' && $existingError[ 'type' ] === 'warning' ) {
218					$this->errors[ $index ][ 'type' ] = 'error';
219				}
220				return $this;
221			}
222		}
223		$this->errors[] = $newError;
224		return $this;
225	}
226
227	/**
228	 * Add a new warning
229	 *
230	 * @param string|MessageSpecifier $message Message key or object
231	 * @param mixed ...$parameters
232	 * @return $this
233	 */
234	public function warning( $message, ...$parameters ) {
235		return $this->addError( [
236			'type' => 'warning',
237			'message' => $message,
238			'params' => $parameters
239		] );
240	}
241
242	/**
243	 * Add an error, do not set fatal flag
244	 * This can be used for non-fatal errors
245	 *
246	 * @param string|MessageSpecifier $message Message key or object
247	 * @param mixed ...$parameters
248	 * @return $this
249	 */
250	public function error( $message, ...$parameters ) {
251		return $this->addError( [
252			'type' => 'error',
253			'message' => $message,
254			'params' => $parameters
255		] );
256	}
257
258	/**
259	 * Add an error and set OK to false, indicating that the operation
260	 * as a whole was fatal
261	 *
262	 * @param string|MessageSpecifier $message Message key or object
263	 * @param mixed ...$parameters
264	 * @return $this
265	 */
266	public function fatal( $message, ...$parameters ) {
267		$this->ok = false;
268		return $this->error( $message, ...$parameters );
269	}
270
271	/**
272	 * Merge another status object into this one
273	 *
274	 * @param StatusValue $other
275	 * @param bool $overwriteValue Whether to override the "value" member
276	 * @return $this
277	 */
278	public function merge( $other, $overwriteValue = false ) {
279		foreach ( $other->errors as $error ) {
280			$this->addError( $error );
281		}
282		$this->ok = $this->ok && $other->ok;
283		if ( $overwriteValue ) {
284			$this->value = $other->value;
285		}
286		$this->successCount += $other->successCount;
287		$this->failCount += $other->failCount;
288		return $this;
289	}
290
291	/**
292	 * Returns a list of status messages of the given type
293	 *
294	 * Each entry is a map of:
295	 *   - message: string message key or MessageSpecifier
296	 *   - params: array list of parameters
297	 *
298	 * @param string $type
299	 * @return array[]
300	 */
301	public function getErrorsByType( $type ) {
302		$result = [];
303		foreach ( $this->errors as $error ) {
304			if ( $error['type'] === $type ) {
305				$result[] = $error;
306			}
307		}
308
309		return $result;
310	}
311
312	/**
313	 * Returns true if the specified message is present as a warning or error
314	 *
315	 * @param string|MessageSpecifier $message Message key or object to search for
316	 *
317	 * @return bool
318	 */
319	public function hasMessage( $message ) {
320		if ( $message instanceof MessageSpecifier ) {
321			$message = $message->getKey();
322		}
323		foreach ( $this->errors as $error ) {
324			if ( $error['message'] instanceof MessageSpecifier
325				&& $error['message']->getKey() === $message
326			) {
327				return true;
328			} elseif ( $error['message'] === $message ) {
329				return true;
330			}
331		}
332
333		return false;
334	}
335
336	/**
337	 * If the specified source message exists, replace it with the specified
338	 * destination message, but keep the same parameters as in the original error.
339	 *
340	 * Note, due to the lack of tools for comparing IStatusMessage objects, this
341	 * function will not work when using such an object as the search parameter.
342	 *
343	 * @param MessageSpecifier|string $source Message key or object to search for
344	 * @param MessageSpecifier|string $dest Replacement message key or object
345	 * @return bool Return true if the replacement was done, false otherwise.
346	 */
347	public function replaceMessage( $source, $dest ) {
348		$replaced = false;
349
350		foreach ( $this->errors as $index => $error ) {
351			if ( $error['message'] === $source ) {
352				$this->errors[$index]['message'] = $dest;
353				$replaced = true;
354			}
355		}
356
357		return $replaced;
358	}
359
360	/**
361	 * @return string
362	 */
363	public function __toString() {
364		$status = $this->isOK() ? "OK" : "Error";
365		if ( count( $this->errors ) ) {
366			$errorcount = "collected " . ( count( $this->errors ) ) . " error(s) on the way";
367		} else {
368			$errorcount = "no errors detected";
369		}
370		if ( isset( $this->value ) ) {
371			$valstr = gettype( $this->value ) . " value set";
372			if ( is_object( $this->value ) ) {
373				$valstr .= "\"" . get_class( $this->value ) . "\" instance";
374			}
375		} else {
376			$valstr = "no value set";
377		}
378		$out = sprintf( "<%s, %s, %s>",
379			$status,
380			$errorcount,
381			$valstr
382		);
383		if ( count( $this->errors ) > 0 ) {
384			$hdr = sprintf( "+-%'-4s-+-%'-25s-+-%'-40s-+\n", "", "", "" );
385			$i = 1;
386			$out .= "\n";
387			$out .= $hdr;
388			foreach ( $this->errors as $error ) {
389				if ( $error['message'] instanceof MessageSpecifier ) {
390					$key = $error['message']->getKey();
391					$params = $error['message']->getParams();
392				} elseif ( $error['params'] ) {
393					$key = $error['message'];
394					$params = $error['params'];
395				} else {
396					$key = $error['message'];
397					$params = [];
398				}
399
400				$out .= sprintf( "| %4d | %-25.25s | %-40.40s |\n",
401					$i,
402					$key,
403					$this->flattenParams( $params )
404				);
405				$i++;
406			}
407			$out .= $hdr;
408		}
409
410		return $out;
411	}
412
413	/**
414	 * @param array $params Message parameters
415	 * @return string String representation
416	 */
417	private function flattenParams( array $params ): string {
418		$ret = [];
419		foreach ( $params as $p ) {
420			if ( is_array( $p ) ) {
421				$ret[] = '[ ' . self::flattenParams( $p ) . ' ]';
422			} elseif ( $p instanceof MessageSpecifier ) {
423				$ret[] = '{ ' . $p->getKey() . ': ' . self::flattenParams( $p->getParams() ) . ' }';
424			} else {
425				$ret[] = (string)$p;
426			}
427		}
428		return implode( ' ', $ret );
429	}
430
431	/**
432	 * Returns a list of status messages of the given type (or all if false)
433	 *
434	 * @note this handles RawMessage poorly
435	 *
436	 * @param string|bool $type
437	 * @return array[]
438	 */
439	protected function getStatusArray( $type = false ) {
440		$result = [];
441
442		foreach ( $this->getErrors() as $error ) {
443			if ( $type === false || $error['type'] === $type ) {
444				if ( $error['message'] instanceof MessageSpecifier ) {
445					$result[] = array_merge(
446						[ $error['message']->getKey() ],
447						$error['message']->getParams()
448					);
449				} elseif ( $error['params'] ) {
450					$result[] = array_merge( [ $error['message'] ], $error['params'] );
451				} else {
452					$result[] = [ $error['message'] ];
453				}
454			}
455		}
456
457		return $result;
458	}
459}
460