DecimalValue   B
last analyzed

Complexity

Total Complexity 52

Size/Duplication

Total Lines 361
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 2

Test Coverage

Coverage 96.19%

Importance

Changes 0
Metric Value
wmc 52
lcom 1
cbo 2
dl 0
loc 361
ccs 101
cts 105
cp 0.9619
rs 7.44
c 0
b 0
f 0

19 Methods

Rating   Name   Duplication   Size   Complexity  
B __construct() 0 25 10
B convertToDecimal() 0 31 6
C compare() 0 53 14
A serialize() 0 3 1
A unserialize() 0 3 1
A getType() 0 3 1
A getSortKey() 0 3 1
A getValue() 0 3 1
A getSign() 0 3 1
A isZero() 0 3 1
A computeComplement() 0 11 3
A computeAbsolute() 0 7 2
A getIntegerPart() 0 9 2
A getFractionalPart() 0 9 2
A getTrimmed() 0 10 2
A getValueFloat() 0 3 1
A getArrayValue() 0 3 1
A newFromArray() 0 3 1
A __toString() 0 3 1

How to fix   Complexity   

Complex Class

Complex classes like DecimalValue often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use DecimalValue, and based on these observations, apply Extract Interface, too.

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-or-later
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
	public 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 143
	public function __construct( $value ) {
54 143
		if ( is_int( $value ) || is_float( $value ) ) {
55 33
			$value = $this->convertToDecimal( $value );
56 110
		} elseif ( !is_string( $value ) ) {
57 3
			throw new IllegalValueException( '$number must be a numeric string.' );
58
		}
59
60 140
		$value = trim( $value );
61 140
		if ( $value !== '' && $value[0] !== '-' && $value[0] !== '+' ) {
62 9
			$value = '+' . $value;
63
		}
64 140
		if ( strlen( $value ) > 127 ) {
65 1
			throw new IllegalValueException( 'Value must be at most 127 characters long.' );
66
		}
67 139
		if ( !preg_match( self::QUANTITY_VALUE_PATTERN, $value ) ) {
68 18
			throw new IllegalValueException( "\"$value\" is not a well formed decimal value" );
69
		}
70
71 121
		$this->value = $value;
72
73
		// make "negative" zero positive
74 121
		if ( $this->isZero() ) {
75 32
			$this->value = '+' . substr( $this->value, 1 );
76
		}
77 121
	}
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 33
	private function convertToDecimal( $number ) {
90 33
		if ( $number === NAN || abs( $number ) === INF ) {
91
			throw new InvalidArgumentException( '$number must not be NAN or INF.' );
92
		}
93
94
		/**
95
		 * The 16 digits after the decimal point are derived from PHP's "serialize_precision"
96
		 * default of 17 significant digits (including 1 digit before the decimal point). This
97
		 * ensures a full float-string-float roundtrip.
98
		 * @see http://php.net/manual/en/ini.core.php#ini.serialize-precision
99
		 */
100 33
		$decimal = sprintf( '%.16e', abs( $number ) );
101 33
		list( $base, $exponent ) = explode( 'e', $decimal );
102 33
		list( $before, $after ) = explode( '.', $base );
103
104
		// Fill with as many zeros as necessary, and move the decimal point
105 33
		if ( $exponent < 0 ) {
106 6
			$before = str_repeat( '0', -$exponent - strlen( $before ) + 1 ) . $before;
107 6
			$before = substr_replace( $before, '.', $exponent, 0 );
108
		} else {
109 27
			$pad = $exponent - strlen( $after );
110 27
			if ( $pad > 0 ) {
111 1
				$after .= str_repeat( '0', $pad );
112
			}
113
			// Always add the decimal point back, even if the exponent is 0
114 27
			$after = substr_replace( $after, '.', $exponent, 0 );
115
		}
116
117
		// Remove not needed ".0" or just "." from the end
118 33
		return ( $number < 0 ? '-' : '+' ) . $before . rtrim( rtrim( $after, '0' ), '.' );
119
	}
120
121
	/**
122
	 * Compares this DecimalValue to another DecimalValue.
123
	 *
124
	 * @param self $that
125
	 *
126
	 * @throws LogicException
127
	 * @return int +1 if $this > $that, 0 if $this == $that, -1 if $this < $that
128
	 */
129 19
	public function compare( self $that ) {
130 19
		if ( $this === $that ) {
131
			return 0;
132
		}
133
134 19
		$a = $this->value;
135 19
		$b = $that->value;
136
137 19
		if ( $a === $b ) {
138 3
			return 0;
139
		}
140
141 16
		if ( $a[0] === '+' && $b[0] === '-' ) {
142 2
			return 1;
143
		}
144
145 16
		if ( $a[0] === '-' && $b[0] === '+' ) {
146 2
			return -1;
147
		}
148
149
		// compare the integer parts
150 14
		$aInt = ltrim( $this->getIntegerPart(), '0' );
151 14
		$bInt = ltrim( $that->getIntegerPart(), '0' );
152
153 14
		$sense = $a[0] === '+' ? 1 : -1;
154
155
		// per precondition, there are no leading zeros, so the longer nummber is greater
156 14
		if ( strlen( $aInt ) > strlen( $bInt ) ) {
157 2
			return $sense;
158
		}
159
160 14
		if ( strlen( $aInt ) < strlen( $bInt ) ) {
161 2
			return -$sense;
162
		}
163
164
		// if both have equal length, compare alphanumerically
165 12
		$cmp = strcmp( $aInt, $bInt );
166 12
		if ( $cmp > 0 ) {
167 5
			return $sense;
168
		}
169
170 12
		if ( $cmp < 0 ) {
171 5
			return -$sense;
172
		}
173
174
		// compare fractional parts
175 7
		$aFract = rtrim( $this->getFractionalPart(), '0' );
176 7
		$bFract = rtrim( $that->getFractionalPart(), '0' );
177
178
		// the fractional part is left-aligned, so just check alphanumeric ordering
179 7
		$cmp = strcmp( $aFract, $bFract );
180 7
		return $cmp === 0 ? 0 : ( $cmp < 0 ? -$sense : $sense );
181
	}
182
183
	/**
184
	 * @see Serializable::serialize
185
	 *
186
	 * @return string
187
	 */
188 58
	public function serialize() {
189 58
		return serialize( $this->value );
190
	}
191
192
	/**
193
	 * @see Serializable::unserialize
194
	 *
195
	 * @param string $data
196
	 */
197 57
	public function unserialize( $data ) {
198 57
		$this->__construct( unserialize( $data ) );
199 57
	}
200
201
	/**
202
	 * @see DataValue::getType
203
	 *
204
	 * @return string
205
	 */
206 38
	public static function getType() {
207 38
		return 'decimal';
208
	}
209
210
	/**
211
	 * @deprecated Kept for compatibility with older DataValues versions.
212
	 * Do not use.
213
	 *
214
	 * @return float
215
	 */
216
	public function getSortKey() {
217
		return $this->getValueFloat();
218
	}
219
220
	/**
221
	 * Returns the value as a decimal string, using the format described in the class level
222
	 * documentation of @see DecimalValue and matching @see DecimalValue::QUANTITY_VALUE_PATTERN.
223
	 * In particular, the string always starts with a sign (either '+' or '-')
224
	 * and has no leading zeros (except immediately before the decimal point). The decimal point is
225
	 * optional, but must not be the last character. Trailing zeros are significant.
226
	 *
227
	 * @see DataValue::getValue
228
	 *
229
	 * @return string
230 76
	 */
231 76
	public function getValue() {
232
		return $this->value;
233
	}
234
235
	/**
236
	 * Returns the sign of the amount (+ or -).
237
	 *
238
	 * @return string "+" or "-".
239 17
	 */
240 17
	public function getSign() {
241
		return substr( $this->value, 0, 1 );
242
	}
243
244
	/**
245
	 * Determines whether this DecimalValue is zero.
246
	 *
247
	 * @return bool
248 128
	 */
249 128
	public function isZero() {
250
		return (bool)preg_match( '/^[-+]0+(\.0+)?$/', $this->value );
251
	}
252
253
	/**
254
	 * Returns a new DecimalValue that represents the complement of this DecimalValue.
255
	 * That is, it constructs a new DecimalValue with the same digits as this,
256
	 * but with the sign inverted.
257
	 *
258
	 * Note that if isZero() returns true, this method returns this
259
	 * DecimalValue itself (because zero is it's own complement).
260
	 *
261
	 * @return self
262 8
	 */
263 8
	public function computeComplement() {
264 2
		if ( $this->isZero() ) {
265
			return $this;
266
		}
267 6
268 6
		$sign = $this->getSign();
269
		$invertedSign = ( $sign === '+' ? '-' : '+' );
270 6
271 6
		$inverseDigits = $invertedSign . substr( $this->value, 1 );
272
		return new self( $inverseDigits );
273
	}
274
275
	/**
276
	 * Returns a new DecimalValue that represents the absolute (positive) value
277
	 * of this DecimalValue. That is, it constructs a new DecimalValue with the
278
	 * same digits as this, but with the positive sign.
279
	 *
280
	 * Note that if getSign() returns "+", this method returns this
281
	 * DecimalValue itself (because a positive value is its own absolute value).
282
	 *
283
	 * @return self
284 7
	 */
285 7
	public function computeAbsolute() {
286 7
		if ( $this->getSign() === '+' ) {
287
			return $this;
288
		}
289 3
290
		return $this->computeComplement();
291
	}
292
293
	/**
294
	 * Returns the integer part of the value, that is, the part before the decimal point,
295
	 * without the sign.
296
	 *
297
	 * @return string
298 28
	 */
299 28
	public function getIntegerPart() {
300
		$n = strpos( $this->value, '.' );
301 28
302 15
		if ( $n === false ) {
303
			$n = strlen( $this->value );
304
		}
305 28
306
		return substr( $this->value, 1, $n - 1 );
307
	}
308
309
	/**
310
	 * Returns the fractional part of the value, that is, the part after the decimal point,
311
	 * if any.
312
	 *
313
	 * @return string
314 8
	 */
315 8
	public function getFractionalPart() {
316
		$n = strpos( $this->value, '.' );
317 8
318 2
		if ( $n === false ) {
319
			return '';
320
		}
321 8
322
		return substr( $this->value, $n + 1 );
323
	}
324
325
	/**
326
	 * Returns a DecimalValue with the same digits as this one, but with any trailing zeros
327
	 * after the decimal point removed. If there are no trailing zeros after the decimal
328
	 * point, this method will return $this.
329
	 *
330
	 * @return self
331 10
	 */
332 10
	public function getTrimmed() {
333 10
		$trimmed = preg_replace( '/(\.\d+?)0+$/', '$1', $this->value );
334
		$trimmed = preg_replace( '/(?<=\d)\.0*$/', '', $trimmed );
335 10
336 6
		if ( $trimmed === $this->value ) {
337
			return $this;
338
		}
339 4
340
		return new self( $trimmed );
341
	}
342
343
	/**
344
	 * Returns the value held by this object, as a float.
345
	 * Equivalent to floatval( $this->getvalue() ).
346
	 *
347
	 * @return float
348 13
	 */
349 13
	public function getValueFloat() {
350
		return floatval( $this->value );
351
	}
352
353
	/**
354
	 * @see DataValue::getArrayValue
355
	 *
356
	 * @return string
357 39
	 */
358 39
	public function getArrayValue() {
359
		return $this->value;
360
	}
361
362
	/**
363
	 * Constructs a new instance from the provided data. Required for @see DataValueDeserializer.
364
	 * This is expected to round-trip with @see getArrayValue.
365
	 *
366
	 * @deprecated since 0.8.3. Static DataValue::newFromArray constructors like this are
367
	 *  underspecified (not in the DataValue interface), and misleadingly named (should be named
368
	 *  newFromArrayValue). Instead, use DataValue builder callbacks in @see DataValueDeserializer.
369
	 *
370
	 * @param mixed $data Warning! Even if this is expected to be a value as returned by
371
	 *  @see getArrayValue, callers of this specific newFromArray implementation can not guarantee
372
	 *  this. This is not guaranteed to be a string!
373
	 *
374
	 * @throws InvalidArgumentException if $data is not in the expected format. Subclasses of
375
	 *  InvalidArgumentException are expected and properly handled by @see DataValueDeserializer.
376
	 * @return self
377
	 */
378
	public static function newFromArray( $data ) {
379
		return new static( $data );
380
	}
381
382
	/**
383
	 * @return string
384 1
	 */
385 1
	public function __toString() {
386
		return $this->value;
387
	}
388
389
}
390