Passed
Push — master ( c327c2...0adcf0 )
by Ondřej
02:45
created

TimeInterval   F

Complexity

Total Complexity 71

Size/Duplication

Total Lines 444
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
dl 0
loc 444
rs 2.6315
c 0
b 0
f 0
wmc 71

15 Methods

Rating   Name   Duplication   Size   Complexity  
A parsePostgresqlStr() 0 55 4
A __construct() 0 5 1
C parseTimeStr() 0 28 7
A divide() 0 3 1
A multiply() 0 3 1
A parseQuantityUnitPairs() 0 12 2
A negate() 0 3 1
B toIsoString() 0 12 5
A fromDateInterval() 0 10 2
A parseIsoDateStr() 0 16 3
A subtract() 0 6 1
A add() 0 6 1
D fromString() 0 33 10
D fromParts() 0 86 24
C toParts() 0 28 8

How to fix   Complexity   

Complex Class

Complex classes like TimeInterval 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.

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 TimeInterval, and based on these observations, apply Extract Interface, too.

1
<?php
2
declare(strict_types=1);
3
namespace Ivory\Value;
4
5
use Ivory\Value\Alg\ComparableWithPhpOperators;
6
use Ivory\Value\Alg\IComparable;
7
8
/**
9
 * Representation of a time interval.
10
 *
11
 * The interval may be positive, negative ("ago"), or mixed (e.g., `'-1 year -2 mons +3 days'`).
12
 *
13
 * Internally, the interval is held as the number of months, days (both integers) and seconds (integer or float) - just
14
 * as in PostgreSQL. When creating an interval, the values are normalized - e.g., specifying an interval of 9 days is
15
 * effectively the same (and unrecognizable after creating the object) as specifying an interval of 1 week and 2 days.
16
 * Note that values are not carried over month-day or day-second borders - e.g., "36 hours" is different from
17
 * "1 day 12 hours".
18
 *
19
 * When specifying the interval, any quantity may be an arbitrary decimal number. Fractions are added to the lower-order
20
 * fields using the conversion factors 1 month = 30 days and 1 day = 24 hours.
21
 *
22
 * Besides being {@link IEqualable}, the {@link Timestamp} objects may safely be compared using the `<`, `==`, and `>`
23
 * operators with the expected results.
24
 *
25
 * The objects are immutable, i.e., once constructed, their value cannot be changed.
26
 */
27
class TimeInterval implements IComparable
28
{
29
    use ComparableWithPhpOperators;
30
31
    const MILLENNIUM = 'millennium';
32
    const CENTURY = 'century';
33
    const DECADE = 'decade';
34
    const YEAR = 'year';
35
    const MONTH = 'month';
36
    const WEEK = 'week';
37
    const DAY = 'day';
38
    const HOUR = 'hour';
39
    const MINUTE = 'minute';
40
    const SECOND = 'second';
41
    const MILLISECOND = 'millisecond';
42
    const MICROSECOND = 'microsecond';
43
44
    private const PRECISION = 7;
45
    private const UNIT_ISO_ABBR = [
46
        self::YEAR => 'Y',
47
        self::MONTH => 'M',
48
        self::WEEK => 'W',
49
        self::DAY => 'D',
50
        self::HOUR => 'H',
51
        self::MINUTE => 'M',
52
        self::SECOND => 'S',
53
    ];
54
    private const TIME_UNIT_HASH = [
55
        self::HOUR => true,
56
        self::MINUTE => true,
57
        self::SECOND => true,
58
        self::MILLISECOND => true,
59
        self::MICROSECOND => true,
60
    ];
61
62
    // NOTE: the order of fields is important so that comparison with operators <, ==, and > works
63
    /** @var int */
64
    private $mon;
65
    /** @var int */
66
    private $day;
67
    /** @var int|float */
68
    private $sec;
69
70
71
    /**
72
     * @param number[] $parts map of units (any of {@link TimeInterval} constants) to the corresponding quantity
73
     * @return TimeInterval
74
     */
75
    public static function fromParts(array $parts): TimeInterval
76
    {
77
        $mon = 0;
78
        $day = 0;
79
        $sec = 0;
80
81
        // fractional quantities might lead to some units to be added repetitively
82
        $queue = [];
83
        foreach ($parts as $unit => $quantity) {
84
            $queue[] = [$unit, $quantity];
85
        }
86
87
        for (; ($pair = current($queue)) !== false; next($queue)) {
88
            list($unit, $quantity) = $pair;
89
            $intQuantity = (int)round($quantity, self::PRECISION);
90
            $fracQuantity = $quantity - $intQuantity;
91
92
            switch ($unit) {
93
                case self::MILLENNIUM:
94
                    $mon += $intQuantity * 12 * 1000;
95
                    if ($fracQuantity) {
96
                        $queue[] = [self::YEAR, $fracQuantity * 1000];
97
                    }
98
                    break;
99
                case self::CENTURY:
100
                    $mon += $intQuantity * 12 * 100;
101
                    if ($fracQuantity) {
102
                        $queue[] = [self::YEAR, $fracQuantity * 100];
103
                    }
104
                    break;
105
                case self::DECADE:
106
                    $mon += $intQuantity * 12 * 10;
107
                    if ($fracQuantity) {
108
                        $queue[] = [self::YEAR, $fracQuantity * 10];
109
                    }
110
                    break;
111
                case self::YEAR:
112
                    $mon += $intQuantity * 12;
113
                    if ($fracQuantity) {
114
                        $queue[] = [self::MONTH, $fracQuantity * 12];
115
                    }
116
                    break;
117
                case self::MONTH:
118
                    $mon += $intQuantity;
119
                    if ($fracQuantity) {
120
                        $queue[] = [self::DAY, $fracQuantity * 30];
121
                    }
122
                    break;
123
                case self::WEEK:
124
                    $day += $intQuantity * 7;
125
                    if ($fracQuantity) {
126
                        $queue[] = [self::DAY, $fracQuantity * 7];
127
                    }
128
                    break;
129
                case self::DAY:
130
                    $day += $intQuantity;
131
                    if ($fracQuantity) {
132
                        $queue[] = [self::SECOND, $fracQuantity * 24 * 60 * 60];
133
                    }
134
                    break;
135
                case self::HOUR:
136
                    $sec += $intQuantity * 60 * 60;
137
                    if ($fracQuantity) {
138
                        $queue[] = [self::SECOND, $fracQuantity * 60 * 60];
139
                    }
140
                    break;
141
                case self::MINUTE:
142
                    $sec += $intQuantity * 60;
143
                    if ($fracQuantity) {
144
                        $queue[] = [self::SECOND, $fracQuantity * 60];
145
                    }
146
                    break;
147
                case self::SECOND:
148
                    $sec += round($quantity, self::PRECISION);
149
                    break;
150
                case self::MILLISECOND:
151
                    $sec += round($quantity / 1000, self::PRECISION);
152
                    break;
153
                case self::MICROSECOND:
154
                    $sec += round($quantity / 100000, self::PRECISION);
155
                    break;
156
                default:
157
                    throw new \InvalidArgumentException("Undefined unit: '$unit'");
158
            }
159
        }
160
        return new TimeInterval($mon, $day, $sec);
161
    }
162
163
    /**
164
     * Creates a time interval from the PHP's standard {@link \DateInterval}.
165
     *
166
     * @param \DateInterval $dateInterval
167
     * @return TimeInterval
168
     */
169
    public static function fromDateInterval(\DateInterval $dateInterval): TimeInterval
170
    {
171
        $sgn = ($dateInterval->invert ? -1 : 1);
172
        return self::fromParts([
173
            self::YEAR => $sgn * $dateInterval->y,
174
            self::MONTH => $sgn * $dateInterval->m,
175
            self::DAY => $sgn * $dateInterval->d,
176
            self::HOUR => $sgn * $dateInterval->h,
177
            self::MINUTE => $sgn * $dateInterval->i,
178
            self::SECOND => $sgn * $dateInterval->s,
179
        ]);
180
    }
181
182
    /**
183
     * Creates a time interval from a string specification.
184
     *
185
     * Several formats are supported:
186
     * - ISO 8601 (e.g., `'P4DT5H'`, `'P1.5Y'`, `'P1,5Y'`, `'P0001-02-03'`, or `'P0001-02-03T04:05:06.7'`);
187
     * - SQL (e.g., `'200-10'` for 200 years and 10 months, or `'1 12:59:10'`);
188
     * - PostgreSQL (e.g., `'1 year 4.5 months 8 sec'`, `'@ 3 days 04:05:06'`, or just `'2'` for 2 seconds).
189
     *
190
     * @see http://www.postgresql.org/docs/9.4/static/datatype-datetime.html#DATATYPE-INTERVAL-INPUT
191
     *
192
     * @param string $str interval specification in the ISO 8601, SQL, or PostgreSQL format
193
     * @return TimeInterval
194
     */
195
    public static function fromString(string $str): TimeInterval
196
    {
197
        if ($str[0] == 'P') {
198
            // ISO format
199
            $timeDelimPos = strpos($str, 'T');
200
            if (!$timeDelimPos) {
201
                $parts = self::parseIsoDateStr(substr($str, 1));
202
            } elseif ($timeDelimPos == 1) {
203
                $parts = self::parseTimeStr($str, 2);
204
            } else {
205
                $parts = self::parseIsoDateStr(substr($str, 1, $timeDelimPos - 1)) +
206
                    self::parseTimeStr($str, $timeDelimPos + 1);
207
            }
208
        } elseif ($str[0] == '@') {
209
            // verbose PostgreSQL format
210
            $parts = self::parsePostgresqlStr($str, 1);
211
        } elseif (preg_match('~^(?:(-)?(\d+)-(\d+))?\s*(-?\d+)??\s*(-?\d+(?::\d+(?::\d+(?:\.\d+)?)?)?)?$~', $str, $m)) {
212
            // sql format
213
            $parts = (isset($m[5]) ? self::parseTimeStr($m[5], 0, false) : []);
214
            if (!empty($m[2]) || !empty($m[3])) {
215
                $sgn = $m[1] . '1';
216
                $parts[self::YEAR] = $sgn * $m[2];
217
                $parts[self::MONTH] = $sgn * $m[3];
218
            }
219
            if (!empty($m[4])) {
220
                $parts[self::DAY] = (int)$m[4];
221
            }
222
        } else {
223
            // PostgreSQL format
224
            $parts = self::parsePostgresqlStr($str);
225
        }
226
227
        return self::fromParts($parts);
228
    }
229
230
    private static function parseIsoDateStr(string $str): array
231
    {
232
        if (preg_match('~^(-?\d+)-(-?\d+)-(-?\d+)$~', $str, $m)) {
233
            return [
234
                self::YEAR => (int)$m[1],
235
                self::MONTH => (int)$m[2],
236
                self::DAY => (int)$m[3],
237
            ];
238
        } else {
239
            $parts = [];
240
            preg_match_all('~(-?\d+(?:\.\d+)?)([YMDW])~', $str, $matches, PREG_SET_ORDER);
241
            static $units = ['Y' => self::YEAR, 'M' => self::MONTH, 'D' => self::DAY, 'W' => self::WEEK];
242
            foreach ($matches as $m) {
243
                $parts[$units[$m[2]]] = (float)$m[1];
244
            }
245
            return $parts;
246
        }
247
    }
248
249
    private static function parseTimeStr(string $str, int $offset = 0, bool $separateMinuteSigns = true): array
250
    {
251
        $timeRe = '~^
252
                     ( -? \d+ (?: \.\d+ )? )
253
                     (?: : ( -? \d+ (?:\.\d+)? ) )?
254
                     (?: : ( -? \d+ (?:\.\d+)? ) )?
255
                    $~x';
256
        if (preg_match($timeRe, substr($str, $offset), $m)) {
257
            if (isset($m[2])) {
258
                $sgn = ($separateMinuteSigns || $m[1] >= 0 ? 1 : -1);
259
                return [
260
                    self::HOUR => (float)$m[1],
261
                    self::MINUTE => $sgn * $m[2],
262
                    self::SECOND => $sgn * (isset($m[3]) ? (float)$m[3] : 0),
263
                ];
264
            } else {
265
                return [
266
                    self::SECOND => (float)$m[1],
267
                ];
268
            }
269
        } else {
270
            $parts = [];
271
            preg_match_all('~(-?\d+(?:\.\d+)?)([HMS])~', $str, $matches, PREG_SET_ORDER, $offset);
272
            static $units = ['H' => self::HOUR, 'M' => self::MINUTE, 'S' => self::SECOND];
273
            foreach ($matches as $m) {
274
                $parts[$units[$m[2]]] = (float)$m[1];
275
            }
276
            return $parts;
277
        }
278
    }
279
280
    private static function parsePostgresqlStr(string $str, int $offset = 0): array
281
    {
282
        static $pgUnits = [
283
            'millennium' => self::MILLENNIUM,
284
            'millenniums' => self::MILLENNIUM,
285
            'mil' => self::MILLENNIUM,
286
            'mils' => self::MILLENNIUM,
287
            'century' => self::CENTURY,
288
            'centuries' => self::CENTURY,
289
            'cent' => self::CENTURY,
290
            'decade' => self::DECADE,
291
            'decades' => self::DECADE,
292
            'dec' => self::DECADE,
293
            'decs' => self::DECADE,
294
            'year' => self::YEAR,
295
            'years' => self::YEAR,
296
            'y' => self::YEAR,
297
            'month' => self::MONTH,
298
            'months' => self::MONTH,
299
            'mon' => self::MONTH,
300
            'mons' => self::MONTH,
301
            'week' => self::WEEK,
302
            'weeks' => self::WEEK,
303
            'w' => self::WEEK,
304
            'day' => self::DAY,
305
            'days' => self::DAY,
306
            'd' => self::DAY,
307
            'hour' => self::HOUR,
308
            'hours' => self::HOUR,
309
            'h' => self::HOUR,
310
            'minute' => self::MINUTE,
311
            'minutes' => self::MINUTE,
312
            'min' => self::MINUTE,
313
            'mins' => self::MINUTE,
314
            'm' => self::MINUTE,
315
            'second' => self::SECOND,
316
            'seconds' => self::SECOND,
317
            'sec' => self::SECOND,
318
            'secs' => self::SECOND,
319
            's' => self::SECOND,
320
            'millisecond' => self::MILLISECOND,
321
            'milliseconds' => self::MILLISECOND,
322
            'microsecond' => self::MICROSECOND,
323
            'microseconds' => self::MICROSECOND,
324
        ];
325
        $parts = self::parseQuantityUnitPairs($str, $offset, $pgUnits);
326
        if (preg_match('~-?\d+:\d+:\d+(?:\.\d+)?~', $str, $m, 0, $offset)) {
327
            $parts += self::parseTimeStr($m[0]);
328
        }
329
        if (stripos($str, 'ago', $offset)) {
330
            foreach ($parts as &$quantity) {
331
                $quantity *= -1;
332
            } unset($quantity);
333
        }
334
        return $parts;
335
    }
336
337
    private static function parseQuantityUnitPairs(string $str, int $offset, array $units): array
338
    {
339
        $result = [];
340
        // OPT: the regular expression might be cached
341
        $re = '~(-?\d+(?:\.\d+)?)\s*(' . implode('|', array_map('preg_quote', array_keys($units))) . ')\b~i';
342
        preg_match_all($re, $str, $matches, PREG_SET_ORDER, $offset);
343
        $unitsLower = array_change_key_case($units, CASE_LOWER); // OPT: keys $units might be required to be lower-case
344
        foreach ($matches as $m) {
345
            $unit = $unitsLower[strtolower($m[2])];
346
            $result[$unit] = (float)$m[1];
347
        }
348
        return $result;
349
    }
350
351
    private function __construct(int $mon, int $day, $sec)
352
    {
353
        $this->mon = $mon;
354
        $this->day = $day;
355
        $this->sec = $sec;
356
    }
357
358
    /**
359
     * @return number[] map: unit => quantity, the sum of which equals to the represented interval;
360
     *                  the output will consist of number of years, months, days, hours, minutes (all of which will
361
     *                    always be non-zero integers) and seconds (which may be fractional, and will be zero iff the
362
     *                    interval is zero);
363
     *                  the order of parts is guaranteed to be as mentioned in the previous sentence, i.e., from years
364
     *                    to seconds
365
     */
366
    public function toParts(): array
367
    {
368
        $result = [];
369
        $yr = (int)($this->mon / 12);
370
        $mon = $this->mon % 12;
371
        if ($yr) {
372
            $result[self::YEAR] = $yr;
373
        }
374
        if ($mon) {
375
            $result[self::MONTH] = $mon;
376
        }
377
        if ($this->day) {
378
            $result[self::DAY] = $this->day;
379
        }
380
        $hr = (int)($this->sec / (60 * 60));
381
        $sec = $this->sec - $hr * 60 * 60;
382
        $min = (int)($sec / 60);
383
        $sec -= $min * 60;
384
        if ($hr) {
385
            $result[self::HOUR] = $hr;
386
        }
387
        if ($min) {
388
            $result[self::MINUTE] = $min;
389
        }
390
        if ($sec || !$result) {
0 ignored issues
show
introduced by
The condition $sec || ! $result can never be false.
Loading history...
391
            $result[self::SECOND] = $sec;
392
        }
393
        return $result;
394
    }
395
396
    /**
397
     * @return string
398
     */
399
    public function toIsoString(): string
400
    {
401
        $str = '';
402
        $inDatePart = true;
403
        foreach ($this->toParts() as $unit => $quantity) {
404
            if ($inDatePart && isset(self::TIME_UNIT_HASH[$unit])) {
405
                $str .= 'T';
406
                $inDatePart = false;
407
            }
408
            $str .= $quantity . self::UNIT_ISO_ABBR[$unit];
409
        }
410
        return ($str ? "P$str" : 'PT0S');
411
    }
412
413
    /**
414
     * Adds a time interval to this interval and returns the result as a new time interval object.
415
     *
416
     * @param TimeInterval $addend
417
     * @return TimeInterval
418
     */
419
    public function add(TimeInterval $addend): TimeInterval
420
    {
421
        return new TimeInterval(
422
            $this->mon + $addend->mon,
423
            $this->day + $addend->day,
424
            $this->sec + $addend->sec
425
        );
426
    }
427
428
    /**
429
     * Subtracts a time interval from this interval and returns the result as a new time interval object.
430
     *
431
     * @param TimeInterval $subtrahend
432
     * @return TimeInterval
433
     */
434
    public function subtract(TimeInterval $subtrahend): TimeInterval
435
    {
436
        return new TimeInterval(
437
            $this->mon - $subtrahend->mon,
438
            $this->day - $subtrahend->day,
439
            $this->sec - $subtrahend->sec
440
        );
441
    }
442
443
    /**
444
     * Multiplies this time interval with a scalar and returns the result as a new time interval object.
445
     *
446
     * @param number $multiplier
447
     * @return TimeInterval
448
     */
449
    public function multiply($multiplier): TimeInterval
450
    {
451
        return new TimeInterval($multiplier * $this->mon, $multiplier * $this->day, $multiplier * $this->sec);
0 ignored issues
show
Bug introduced by
$multiplier * $this->mon of type double is incompatible with the type integer expected by parameter $mon of Ivory\Value\TimeInterval::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

451
        return new TimeInterval(/** @scrutinizer ignore-type */ $multiplier * $this->mon, $multiplier * $this->day, $multiplier * $this->sec);
Loading history...
Bug introduced by
$multiplier * $this->day of type double is incompatible with the type integer expected by parameter $day of Ivory\Value\TimeInterval::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

451
        return new TimeInterval($multiplier * $this->mon, /** @scrutinizer ignore-type */ $multiplier * $this->day, $multiplier * $this->sec);
Loading history...
452
    }
453
454
    /**
455
     * Divides this time interval with a scalar and returns the result as a new time interval object.
456
     *
457
     * @param number $divisor
458
     * @return TimeInterval
459
     */
460
    public function divide($divisor): TimeInterval
461
    {
462
        return new TimeInterval($this->mon / $divisor, $this->day / $divisor, $this->sec / $divisor);
0 ignored issues
show
Bug introduced by
$this->mon / $divisor of type double is incompatible with the type integer expected by parameter $mon of Ivory\Value\TimeInterval::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

462
        return new TimeInterval(/** @scrutinizer ignore-type */ $this->mon / $divisor, $this->day / $divisor, $this->sec / $divisor);
Loading history...
Bug introduced by
$this->day / $divisor of type double is incompatible with the type integer expected by parameter $day of Ivory\Value\TimeInterval::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

462
        return new TimeInterval($this->mon / $divisor, /** @scrutinizer ignore-type */ $this->day / $divisor, $this->sec / $divisor);
Loading history...
463
    }
464
465
    /**
466
     * @return TimeInterval time interval negative to this one
467
     */
468
    public function negate(): TimeInterval
469
    {
470
        return $this->multiply(-1);
471
    }
472
}
473