Test Failed
Push — 100decimalPlaces ( aadcfb )
by no
01:59
created

DecimalValue::getTrimmed()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 10
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
c 0
b 0
f 0
dl 0
loc 10
rs 9.4285
cc 2
eloc 6
nc 2
nop 0
1
<?php
2
3
namespace DataValues;
4
5
use InvalidArgumentException;
6
use LogicException;
7
8
/**
9
 * Class representing a decimal number with (nearly) arbitrary precision.
10
 *
11
 * For simple numeric values use @see NumberValue.
12
 *
13
 * The decimal notation for the value follows ISO 31-0, with some additional restrictions:
14
 * - the decimal separator is '.' (period). Comma is not used anywhere.
15
 * - no spacing or other separators are included for groups of digits.
16
 * - the first character in the string always gives the sign, either plus (+) or minus (-).
17
 * - scientific (exponential) notation is not used.
18
 * - the decimal point must not be the last character nor the fist character after the sign.
19
 * - no leading zeros, except one directly before the decimal point
20
 * - zero is always positive.
21
 *
22
 * These rules are enforced by @see QUANTITY_VALUE_PATTERN
23
 *
24
 * @since 0.1
25
 *
26
 * @license GPL-2.0+
27
 * @author Daniel Kinzler
28
 */
29
class DecimalValue extends DataValueObject {
30
31
	/**
32
	 * The $value as a decimal string, in the format described in the class
33
	 * level documentation of @see DecimalValue, matching @see QUANTITY_VALUE_PATTERN.
34
	 *
35
	 * @var string
36
	 */
37
	private $value;
38
39
	/**
40
	 * Regular expression for matching decimal strings that conform to the format
41
	 * described in the class level documentation of @see DecimalValue.
42
	 */
43
	const QUANTITY_VALUE_PATTERN = '/^[-+]([1-9]\d*|\d)(\.\d+)?\z/';
44
45
	/**
46
	 * Constructs a new DecimalValue object, representing the given value.
47
	 *
48
	 * @param string|int|float $value If given as a string, the value must match
49
	 *  QUANTITY_VALUE_PATTERN. The leading plus sign is optional.
50
	 *
51
	 * @throws InvalidArgumentException
52
	 */
53
	public function __construct( $value ) {
54
		if ( is_int( $value ) || is_float( $value ) ) {
55
			$value = $this->convertToDecimal( $value );
56
		} elseif ( !is_string( $value ) ) {
57
			throw new IllegalValueException( '$number must be a numeric string.' );
58
		}
59
60
		$value = trim( $value );
61
		if ( $value !== '' && $value[0] !== '-' && $value[0] !== '+' ) {
62
			$value = '+' . $value;
63
		}
64
		if ( strlen( $value ) > 127 ) {
65
			throw new IllegalValueException( 'Value must be at most 127 characters long.' );
66
		}
67
		if ( !preg_match( self::QUANTITY_VALUE_PATTERN, $value ) ) {
68
			throw new IllegalValueException( "\"$value\" is not a well formed decimal value" );
69
		}
70
71
		$this->value = $value;
72
73
		// make "negative" zero positive
74
		if ( $this->isZero() ) {
75
			$this->value = '+' . substr( $this->value, 1 );
76
		}
77
	}
78
79
	/**
80
	 * Converts the given number to decimal notation. The resulting string conforms to the
81
	 * rules described in the class level documentation of @see DecimalValue and matches
82
	 * @see DecimalValue::QUANTITY_VALUE_PATTERN.
83
	 *
84
	 * @param int|float $number
85
	 *
86
	 * @return string
87
	 * @throws InvalidArgumentException
88
	 */
89
	private function convertToDecimal( $number ) {
90
		if ( $number === NAN || abs( $number ) === INF ) {
91
			throw new InvalidArgumentException( '$number must not be NAN or INF.' );
92
		}
93
94
		$decimal = strval( abs( $number ) );
95
		$decimal = preg_replace_callback(
96
			'/(\d*)\.(\d*)E([-+]\d+)/i',
97
			function ( $matches ) {
98
				list( , $before, $after, $exponent ) = $matches;
99
100
				// Fill with as many zeros as necessary, and move the decimal point
101
				if ( $exponent < 0 ) {
102
					$before = str_repeat( '0', -$exponent - strlen( $before ) + 1 ) . $before;
103
					$before = substr_replace( $before, '.', $exponent, 0 );
104
				} elseif ( $exponent > 0 ) {
105
					$after .= str_repeat( '0', $exponent - strlen( $after ) );
106
					$after = substr_replace( $after, '.', $exponent, 0 );
107
				}
108
109
				// Remove not needed ".0" or just "." from the end
110
				return $before . rtrim( rtrim( $after, '0' ), '.' );
111
			},
112
			$decimal,
113
			1
114
		);
115
		return ( $number < 0 ? '-' : '+' ) . $decimal;
116
	}
117
118
	/**
119
	 * Compares this DecimalValue to another DecimalValue.
120
	 *
121
	 * @param self $that
122
	 *
123
	 * @throws LogicException
124
	 * @return int +1 if $this > $that, 0 if $this == $that, -1 if $this < $that
125
	 */
126
	public function compare( self $that ) {
127
		if ( $this === $that ) {
128
			return 0;
129
		}
130
131
		$a = $this->value;
132
		$b = $that->value;
133
134
		if ( $a === $b ) {
135
			return 0;
136
		}
137
138
		if ( $a[0] === '+' && $b[0] === '-' ) {
139
			return 1;
140
		}
141
142
		if ( $a[0] === '-' && $b[0] === '+' ) {
143
			return -1;
144
		}
145
146
		// compare the integer parts
147
		$aInt = ltrim( $this->getIntegerPart(), '0' );
148
		$bInt = ltrim( $that->getIntegerPart(), '0' );
149
150
		$sense = $a[0] === '+' ? 1 : -1;
151
152
		// per precondition, there are no leading zeros, so the longer nummber is greater
153
		if ( strlen( $aInt ) > strlen( $bInt ) ) {
154
			return $sense;
155
		}
156
157
		if ( strlen( $aInt ) < strlen( $bInt ) ) {
158
			return -$sense;
159
		}
160
161
		// if both have equal length, compare alphanumerically
162
		$cmp = strcmp( $aInt, $bInt );
163
		if ( $cmp > 0 ) {
164
			return $sense;
165
		}
166
167
		if ( $cmp < 0 ) {
168
			return -$sense;
169
		}
170
171
		// compare fractional parts
172
		$aFract = rtrim( $this->getFractionalPart(), '0' );
173
		$bFract = rtrim( $that->getFractionalPart(), '0' );
174
175
		// the fractional part is left-aligned, so just check alphanumeric ordering
176
		$cmp = strcmp( $aFract, $bFract );
177
		return $cmp === 0 ? 0 : ( $cmp < 0 ? -$sense : $sense );
178
	}
179
180
	/**
181
	 * @see Serializable::serialize
182
	 *
183
	 * @return string
184
	 */
185
	public function serialize() {
186
		return serialize( $this->value );
187
	}
188
189
	/**
190
	 * @see Serializable::unserialize
191
	 *
192
	 * @param string $data
193
	 */
194
	public function unserialize( $data ) {
195
		$this->__construct( unserialize( $data ) );
196
	}
197
198
	/**
199
	 * @see DataValue::getType
200
	 *
201
	 * @return string
202
	 */
203
	public static function getType() {
204
		return 'decimal';
205
	}
206
207
	/**
208
	 * @see DataValue::getSortKey
209
	 *
210
	 * @return float
211
	 */
212
	public function getSortKey() {
213
		return $this->getValueFloat();
214
	}
215
216
	/**
217
	 * Returns the value as a decimal string, using the format described in the class level
218
	 * documentation of @see DecimalValue and matching @see DecimalValue::QUANTITY_VALUE_PATTERN.
219
	 * In particular, the string always starts with a sign (either '+' or '-')
220
	 * and has no leading zeros (except immediately before the decimal point). The decimal point is
221
	 * optional, but must not be the last character. Trailing zeros are significant.
222
	 *
223
	 * @see DataValue::getValue
224
	 *
225
	 * @return string
226
	 */
227
	public function getValue() {
228
		return $this->value;
229
	}
230
231
	/**
232
	 * Returns the sign of the amount (+ or -).
233
	 *
234
	 * @return string "+" or "-".
235
	 */
236
	public function getSign() {
237
		return substr( $this->value, 0, 1 );
238
	}
239
240
	/**
241
	 * Determines whether this DecimalValue is zero.
242
	 *
243
	 * @return bool
244
	 */
245
	public function isZero() {
246
		return (bool)preg_match( '/^[-+]0+(\.0+)?$/', $this->value );
247
	}
248
249
	/**
250
	 * Returns a new DecimalValue that represents the complement of this DecimalValue.
251
	 * That is, it constructs a new DecimalValue with the same digits as this,
252
	 * but with the sign inverted.
253
	 *
254
	 * Note that if isZero() returns true, this method returns this
255
	 * DecimalValue itself (because zero is it's own complement).
256
	 *
257
	 * @return self
258
	 */
259
	public function computeComplement() {
260
		if ( $this->isZero() ) {
261
			return $this;
262
		}
263
264
		$sign = $this->getSign();
265
		$invertedSign = ( $sign === '+' ? '-' : '+' );
266
267
		$inverseDigits = $invertedSign . substr( $this->value, 1 );
268
		return new self( $inverseDigits );
269
	}
270
271
	/**
272
	 * Returns a new DecimalValue that represents the absolute (positive) value
273
	 * of this DecimalValue. That is, it constructs a new DecimalValue with the
274
	 * same digits as this, but with the positive sign.
275
	 *
276
	 * Note that if getSign() returns "+", this method returns this
277
	 * DecimalValue itself (because a positive value is its own absolute value).
278
	 *
279
	 * @return self
280
	 */
281
	public function computeAbsolute() {
282
		if ( $this->getSign() === '+' ) {
283
			return $this;
284
		}
285
286
		return $this->computeComplement();
287
	}
288
289
	/**
290
	 * Returns the integer part of the value, that is, the part before the decimal point,
291
	 * without the sign.
292
	 *
293
	 * @return string
294
	 */
295
	public function getIntegerPart() {
296
		$n = strpos( $this->value, '.' );
297
298
		if ( $n === false ) {
299
			$n = strlen( $this->value );
300
		}
301
302
		return substr( $this->value, 1, $n - 1 );
303
	}
304
305
	/**
306
	 * Returns the fractional part of the value, that is, the part after the decimal point,
307
	 * if any.
308
	 *
309
	 * @return string
310
	 */
311
	public function getFractionalPart() {
312
		$n = strpos( $this->value, '.' );
313
314
		if ( $n === false ) {
315
			return '';
316
		}
317
318
		return substr( $this->value, $n + 1 );
319
	}
320
321
	/**
322
	 * Returns a DecimalValue with the same digits as this one, but with any trailing zeros
323
	 * after the decimal point removed. If there are no trailing zeros after the decimal
324
	 * point, this method will return $this.
325
	 *
326
	 * @return self
327
	 */
328
	public function getTrimmed() {
329
		$trimmed = preg_replace( '/(\.\d+?)0+$/', '$1', $this->value );
330
		$trimmed = preg_replace( '/(?<=\d)\.0*$/', '', $trimmed );
331
332
		if ( $trimmed === $this->value ) {
333
			return $this;
334
		}
335
336
		return new self( $trimmed );
337
	}
338
339
	/**
340
	 * Returns the value held by this object, as a float.
341
	 * Equivalent to floatval( $this->getvalue() ).
342
	 *
343
	 * @return float
344
	 */
345
	public function getValueFloat() {
346
		return floatval( $this->value );
347
	}
348
349
	/**
350
	 * @see DataValue::getArrayValue
351
	 *
352
	 * @return string
353
	 */
354
	public function getArrayValue() {
355
		return $this->value;
356
	}
357
358
	/**
359
	 * Constructs a new instance from the provided data. Required for @see DataValueDeserializer.
360
	 * This is expected to round-trip with @see getArrayValue.
361
	 *
362
	 * @deprecated since 0.8.3. Static DataValue::newFromArray constructors like this are
363
	 *  underspecified (not in the DataValue interface), and misleadingly named (should be named
364
	 *  newFromArrayValue). Instead, use DataValue builder callbacks in @see DataValueDeserializer.
365
	 *
366
	 * @param mixed $data Warning! Even if this is expected to be a value as returned by
367
	 *  @see getArrayValue, callers of this specific newFromArray implementation can not guarantee
368
	 *  this. This is not guaranteed to be a string!
369
	 *
370
	 * @throws InvalidArgumentException if $data is not in the expected format. Subclasses of
371
	 *  InvalidArgumentException are expected and properly handled by @see DataValueDeserializer.
372
	 * @return self
373
	 */
374
	public static function newFromArray( $data ) {
375
		return new static( $data );
376
	}
377
378
	/**
379
	 * @return string
380
	 */
381
	public function __toString() {
382
		return $this->value;
383
	}
384
385
}
386