Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
18.28% covered (danger)
18.28%
17 / 93
10.00% covered (danger)
10.00%
2 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
FormOptions
18.28% covered (danger)
18.28%
17 / 93
10.00% covered (danger)
10.00%
2 / 20
1767.46
0.00% covered (danger)
0.00%
0 / 1
 add
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 delete
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 guessType
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 validateName
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 setValue
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 getValue
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getValueReal
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 reset
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 consumeValue
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 consumeValues
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 validateIntBounds
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 validateBounds
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
42
 getUnconsumedValues
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
30
 getChangedValues
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 getAllValues
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 fetchValuesFromRequest
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
182
 offsetExists
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 offsetGet
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 offsetSet
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 offsetUnset
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Helper class to keep track of options when mixing links and form elements.
4 *
5 * Copyright © 2008, Niklas Laxström
6 * Copyright © 2011, Antoine Musso
7 * Copyright © 2013, Bartosz Dziewoński
8 *
9 * @license GPL-2.0-or-later
10 * @file
11 * @author Niklas Laxström
12 * @author Antoine Musso
13 */
14
15namespace MediaWiki\Html;
16
17use ArrayAccess;
18use InvalidArgumentException;
19use MediaWiki\Request\WebRequest;
20use UnexpectedValueException;
21
22/**
23 * Helper class to keep track of options when mixing links and form elements.
24 *
25 * @todo This badly needs some examples and tests :) The usage in SpecialRecentchanges class is a
26 *     good ersatz in the meantime.
27 */
28class FormOptions implements ArrayAccess {
29    /** @name Type constants
30     * Used internally to map an option value to a WebRequest accessor
31     * @{
32     */
33    /** Mark value for automatic detection (for simple data types only) */
34    public const AUTO = -1;
35    /** String type, maps guessType() to WebRequest::getText() */
36    public const STRING = 0;
37    /** Integer type, maps guessType() to WebRequest::getInt() */
38    public const INT = 1;
39    /** Float type, maps guessType() to WebRequest::getFloat()
40     * @since 1.23
41     */
42    public const FLOAT = 4;
43    /** Boolean type, maps guessType() to WebRequest::getBool() */
44    public const BOOL = 2;
45    /** Integer type or null, maps to WebRequest::getIntOrNull()
46     * This is useful for the namespace selector.
47     */
48    public const INTNULL = 3;
49    /** Array type, maps guessType() to WebRequest::getArray()
50     * @since 1.29
51     */
52    public const ARR = 5;
53    /** @} */
54
55    /**
56     * Map of known option names to information about them.
57     *
58     * Each value is an array with the following keys:
59     * - 'default' - the default value as passed to add()
60     * - 'value' - current value, start with null, can be set by various functions
61     * - 'consumed' - true/false, whether the option was consumed using
62     *   consumeValue() or consumeValues()
63     * - 'type' - one of the type constants (but never AUTO)
64     * @var array
65     */
66    protected $options = [];
67
68    # Setting up
69
70    /**
71     * Add an option to be handled by this FormOptions instance.
72     *
73     * @param string $name Request parameter name
74     * @param mixed $default Default value when the request parameter is not present
75     * @param int $type One of the type constants (optional, defaults to AUTO)
76     */
77    public function add( $name, $default, $type = self::AUTO ) {
78        $this->options[$name] = [
79            'default' => $default,
80            'value' => null,
81            'consumed' => false,
82            'type' => $type === self::AUTO ? self::guessType( $default ) : $type,
83        ];
84    }
85
86    /**
87     * Remove an option being handled by this FormOptions instance. This is the inverse of add().
88     *
89     * @param string $name Request parameter name
90     */
91    public function delete( $name ) {
92        $this->validateName( $name, true );
93        unset( $this->options[$name] );
94    }
95
96    /**
97     * Used to find out which type the data is. All types are defined in the 'Type constants' section
98     * of this class.
99     *
100     * Detection of the INTNULL type is not supported; INT will be assumed if the data is an integer.
101     *
102     * @param mixed $data Value to guess the type for
103     * @return int Type constant
104     */
105    public static function guessType( $data ) {
106        if ( is_bool( $data ) ) {
107            return self::BOOL;
108        } elseif ( is_int( $data ) ) {
109            return self::INT;
110        } elseif ( is_float( $data ) ) {
111            return self::FLOAT;
112        } elseif ( is_string( $data ) ) {
113            return self::STRING;
114        } elseif ( is_array( $data ) ) {
115            return self::ARR;
116        } else {
117            throw new InvalidArgumentException( 'Unsupported datatype' );
118        }
119    }
120
121    # Handling values
122
123    /**
124     * Verify that the given option name exists.
125     *
126     * @param string $name Option name
127     * @param bool $strict Throw an exception when the option doesn't exist instead of returning false
128     * @return bool True if the option exists, false otherwise
129     */
130    public function validateName( $name, $strict = false ) {
131        if ( !isset( $this->options[$name] ) ) {
132            if ( $strict ) {
133                throw new InvalidArgumentException( "Invalid option $name" );
134            } else {
135                return false;
136            }
137        }
138
139        return true;
140    }
141
142    /**
143     * Use to set the value of an option.
144     *
145     * @param string $name Option name
146     * @param mixed $value Value for the option
147     * @param bool $force Whether to set the value when it is equivalent to the default value for this
148     *     option (default false).
149     */
150    public function setValue( $name, $value, $force = false ) {
151        $this->validateName( $name, true );
152
153        if ( !$force && $value === $this->options[$name]['default'] ) {
154            // null default values as unchanged
155            $this->options[$name]['value'] = null;
156        } else {
157            $this->options[$name]['value'] = $value;
158        }
159    }
160
161    /**
162     * Get the value for the given option name. Uses getValueReal() internally.
163     *
164     * @param string $name Option name
165     * @return mixed
166     * @return-taint tainted This actually depends on the type of the option, but there's no way to determine that
167     * statically.
168     */
169    public function getValue( $name ) {
170        $this->validateName( $name, true );
171
172        return $this->getValueReal( $this->options[$name] );
173    }
174
175    /**
176     * Return current option value, based on a structure taken from $options.
177     *
178     * @param array $option Array structure describing the option
179     * @return mixed Value, or the default value if it is null
180     */
181    protected function getValueReal( $option ) {
182        if ( $option['value'] !== null ) {
183            return $option['value'];
184        } else {
185            return $option['default'];
186        }
187    }
188
189    /**
190     * Delete the option value.
191     * This will make future calls to getValue() return the default value.
192     * @param string $name Option name
193     */
194    public function reset( $name ) {
195        $this->validateName( $name, true );
196        $this->options[$name]['value'] = null;
197    }
198
199    /**
200     * Get the value of given option and mark it as 'consumed'. Consumed options are not returned
201     * by getUnconsumedValues(). Callers should verify that the given option exists.
202     *
203     * @see consumeValues()
204     * @param string $name Option name
205     * @return mixed Value, or the default value if it is null
206     */
207    public function consumeValue( $name ) {
208        $this->validateName( $name, true );
209        $this->options[$name]['consumed'] = true;
210
211        return $this->getValueReal( $this->options[$name] );
212    }
213
214    /**
215     * Get the values of given options and mark them as 'consumed'. Consumed options are not returned
216     * by getUnconsumedValues(). Callers should verify that all the given options exist.
217     *
218     * @see consumeValue()
219     * @param string[] $names List of option names
220     * @return array Array of option values, or the default values if they are null
221     */
222    public function consumeValues( $names ) {
223        $out = [];
224
225        foreach ( $names as $name ) {
226            $this->validateName( $name, true );
227            $this->options[$name]['consumed'] = true;
228            $out[] = $this->getValueReal( $this->options[$name] );
229        }
230
231        return $out;
232    }
233
234    /**
235     * @see validateBounds()
236     * @param string $name
237     * @param int $min
238     * @param int $max
239     */
240    public function validateIntBounds( $name, $min, $max ) {
241        $this->validateBounds( $name, $min, $max );
242    }
243
244    /**
245     * Constrain a numeric value for a given option to a given range. The value will be altered to fit
246     * in the range.
247     *
248     * @since 1.23
249     *
250     * @param string $name Option name. Must be of numeric type.
251     * @param int|float $min Minimum value
252     * @param int|float $max Maximum value
253     */
254    public function validateBounds( $name, $min, $max ) {
255        $this->validateName( $name, true );
256        $type = $this->options[$name]['type'];
257
258        if ( $type !== self::INT && $type !== self::INTNULL && $type !== self::FLOAT ) {
259            throw new InvalidArgumentException( "Type of option $name is not numeric" );
260        }
261
262        $value = $this->getValueReal( $this->options[$name] );
263        if ( $type !== self::INTNULL || $value !== null ) {
264            $this->setValue( $name, max( $min, min( $max, $value ) ) );
265        }
266    }
267
268    /**
269     * Get all remaining values which have not been consumed by consumeValue() or consumeValues().
270     *
271     * @param bool $all Whether to include unchanged options (default: false)
272     * @return array
273     */
274    public function getUnconsumedValues( $all = false ) {
275        $values = [];
276
277        foreach ( $this->options as $name => $data ) {
278            if ( !$data['consumed'] ) {
279                if ( $all || $data['value'] !== null ) {
280                    $values[$name] = $this->getValueReal( $data );
281                }
282            }
283        }
284
285        return $values;
286    }
287
288    /**
289     * Return options modified as an array ( name => value )
290     * @return array
291     */
292    public function getChangedValues() {
293        $values = [];
294
295        foreach ( $this->options as $name => $data ) {
296            if ( $data['value'] !== null ) {
297                $values[$name] = $data['value'];
298            }
299        }
300
301        return $values;
302    }
303
304    /**
305     * Format options to an array ( name => value )
306     * @return array
307     */
308    public function getAllValues() {
309        $values = [];
310
311        foreach ( $this->options as $name => $data ) {
312            $values[$name] = $this->getValueReal( $data );
313        }
314
315        return $values;
316    }
317
318    # Reading values
319
320    /**
321     * Fetch values for all options (or selected options) from the given WebRequest, making them
322     * available for accessing with getValue() or consumeValue() etc.
323     *
324     * @param WebRequest $r The request to fetch values from
325     * @param array|null $optionKeys Which options to fetch the values for (default:
326     *     all of them). Note that passing an empty array will also result in
327     *     values for all keys being fetched.
328     */
329    public function fetchValuesFromRequest( WebRequest $r, $optionKeys = null ) {
330        if ( !$optionKeys ) {
331            $optionKeys = array_keys( $this->options );
332        }
333
334        foreach ( $optionKeys as $name ) {
335            $default = $this->options[$name]['default'];
336            $type = $this->options[$name]['type'];
337
338            switch ( $type ) {
339                case self::BOOL:
340                    $value = $r->getBool( $name, $default );
341                    break;
342                case self::INT:
343                    $value = $r->getInt( $name, $default );
344                    break;
345                case self::FLOAT:
346                    $value = $r->getFloat( $name, $default );
347                    break;
348                case self::STRING:
349                    $value = $r->getText( $name, $default );
350                    break;
351                case self::INTNULL:
352                    $value = $r->getIntOrNull( $name );
353                    break;
354                case self::ARR:
355                    $value = $r->getArray( $name );
356
357                    if ( $value !== null ) {
358                        // Reject nested arrays (T344931)
359                        $value = array_filter( $value, 'is_scalar' );
360                    }
361                    break;
362                default:
363                    throw new UnexpectedValueException( "Unsupported datatype $type" );
364            }
365
366            if ( $value !== null ) {
367                $this->options[$name]['value'] = $value === $default ? null : $value;
368            }
369        }
370    }
371
372    /***************************************************************************/
373    // region   ArrayAccess functions
374    /** @name   ArrayAccess functions
375     * These functions implement the ArrayAccess PHP interface.
376     * @see https://www.php.net/manual/en/class.arrayaccess.php
377     */
378
379    /**
380     * Whether the option exists.
381     * @param string $name
382     * @return bool
383     */
384    public function offsetExists( $name ): bool {
385        return isset( $this->options[$name] );
386    }
387
388    /**
389     * Retrieve an option value.
390     * @param string $name
391     * @return mixed
392     */
393    #[\ReturnTypeWillChange]
394    public function offsetGet( $name ) {
395        return $this->getValue( $name );
396    }
397
398    /**
399     * Set an option to given value.
400     * @param string $name
401     * @param mixed $value
402     */
403    public function offsetSet( $name, $value ): void {
404        $this->setValue( $name, $value );
405    }
406
407    /**
408     * Delete the option.
409     * @param string $name
410     */
411    public function offsetUnset( $name ): void {
412        $this->delete( $name );
413    }
414
415    // endregion -- end of ArrayAccess functions
416}