TimeInterval   F
last analyzed

Complexity

Total Complexity 71

Size/Duplication

Total Lines 442
Duplicated Lines 0 %

Importance

Changes 2
Bugs 0 Features 1
Metric Value
eloc 260
dl 0
loc 442
rs 2.7199
c 2
b 0
f 1
wmc 71

15 Methods

Rating   Name   Duplication   Size   Complexity  
A parsePostgresqlStr() 0 55 4
A __construct() 0 5 1
B parseTimeStr() 0 28 7
A divide() 0 3 1
A multiply() 0 3 1
A parseQuantityUnitPairs() 0 12 2
A negate() 0 3 1
A toIsoString() 0 12 5
A fromDateInterval() 0 10 2
A parseIsoDateStr() 0 16 3
A subtract() 0 6 1
A add() 0 6 1
B fromString() 0 33 10
D fromParts() 0 87 24
B 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
        /** @noinspection PhpAssignmentInConditionInspection it's a pity explicit parentheses do not suffice */
88
        for (; ($pair = current($queue)) !== false; next($queue)) {
89
            [$unit, $quantity] = $pair;
90
            $intQuantity = (int)round($quantity, self::PRECISION);
91
            $fracQuantity = $quantity - $intQuantity;
92
93
            switch ($unit) {
94
                case self::MILLENNIUM:
95
                    $mon += $intQuantity * 12 * 1000;
96
                    if ($fracQuantity) {
97
                        $queue[] = [self::YEAR, $fracQuantity * 1000];
98
                    }
99
                    break;
100
                case self::CENTURY:
101
                    $mon += $intQuantity * 12 * 100;
102
                    if ($fracQuantity) {
103
                        $queue[] = [self::YEAR, $fracQuantity * 100];
104
                    }
105
                    break;
106
                case self::DECADE:
107
                    $mon += $intQuantity * 12 * 10;
108
                    if ($fracQuantity) {
109
                        $queue[] = [self::YEAR, $fracQuantity * 10];
110
                    }
111
                    break;
112
                case self::YEAR:
113
                    $mon += $intQuantity * 12;
114
                    if ($fracQuantity) {
115
                        $queue[] = [self::MONTH, $fracQuantity * 12];
116
                    }
117
                    break;
118
                case self::MONTH:
119
                    $mon += $intQuantity;
120
                    if ($fracQuantity) {
121
                        $queue[] = [self::DAY, $fracQuantity * 30];
122
                    }
123
                    break;
124
                case self::WEEK:
125
                    $day += $intQuantity * 7;
126
                    if ($fracQuantity) {
127
                        $queue[] = [self::DAY, $fracQuantity * 7];
128
                    }
129
                    break;
130
                case self::DAY:
131
                    $day += $intQuantity;
132
                    if ($fracQuantity) {
133
                        $queue[] = [self::SECOND, $fracQuantity * 24 * 60 * 60];
134
                    }
135
                    break;
136
                case self::HOUR:
137
                    $sec += $intQuantity * 60 * 60;
138
                    if ($fracQuantity) {
139
                        $queue[] = [self::SECOND, $fracQuantity * 60 * 60];
140
                    }
141
                    break;
142
                case self::MINUTE:
143
                    $sec += $intQuantity * 60;
144
                    if ($fracQuantity) {
145
                        $queue[] = [self::SECOND, $fracQuantity * 60];
146
                    }
147
                    break;
148
                case self::SECOND:
149
                    $sec += round($quantity, self::PRECISION);
150
                    break;
151
                case self::MILLISECOND:
152
                    $sec += round($quantity / 1000, self::PRECISION);
153
                    break;
154
                case self::MICROSECOND:
155
                    $sec += round($quantity / 100000, self::PRECISION);
156
                    break;
157
                default:
158
                    throw new \InvalidArgumentException("Undefined unit: '$unit'");
159
            }
160
        }
161
        return new TimeInterval($mon, $day, $sec);
162
    }
163
164
    /**
165
     * Creates a time interval from the PHP's standard {@link \DateInterval}.
166
     *
167
     * @param \DateInterval $dateInterval
168
     * @return TimeInterval
169
     */
170
    public static function fromDateInterval(\DateInterval $dateInterval): TimeInterval
171
    {
172
        $sgn = ($dateInterval->invert ? -1 : 1);
173
        return self::fromParts([
174
            self::YEAR => $sgn * $dateInterval->y,
175
            self::MONTH => $sgn * $dateInterval->m,
176
            self::DAY => $sgn * $dateInterval->d,
177
            self::HOUR => $sgn * $dateInterval->h,
178
            self::MINUTE => $sgn * $dateInterval->i,
179
            self::SECOND => $sgn * $dateInterval->s,
180
        ]);
181
    }
182
183
    /**
184
     * Creates a time interval from a string specification.
185
     *
186
     * Several formats are supported:
187
     * - ISO 8601 (e.g., `'P4DT5H'`, `'P1.5Y'`, `'P1,5Y'`, `'P0001-02-03'`, or `'P0001-02-03T04:05:06.7'`);
188
     * - SQL (e.g., `'200-10'` for 200 years and 10 months, or `'1 12:59:10'`);
189
     * - PostgreSQL (e.g., `'1 year 4.5 months 8 sec'`, `'@ 3 days 04:05:06'`, or just `'2'` for 2 seconds).
190
     *
191
     * @see https://www.postgresql.org/docs/11/datatype-datetime.html#DATATYPE-INTERVAL-INPUT
192
     *
193
     * @param string $str interval specification in the ISO 8601, SQL, or PostgreSQL format
194
     * @return TimeInterval
195
     */
196
    public static function fromString(string $str): TimeInterval
197
    {
198
        if ($str[0] == 'P') {
199
            // ISO format
200
            $timeDelimPos = strpos($str, 'T');
201
            if (!$timeDelimPos) {
202
                $parts = self::parseIsoDateStr(substr($str, 1));
203
            } elseif ($timeDelimPos == 1) {
204
                $parts = self::parseTimeStr($str, 2);
205
            } else {
206
                $parts = self::parseIsoDateStr(substr($str, 1, $timeDelimPos - 1)) +
207
                    self::parseTimeStr($str, $timeDelimPos + 1);
208
            }
209
        } elseif ($str[0] == '@') {
210
            // verbose PostgreSQL format
211
            $parts = self::parsePostgresqlStr($str, 1);
212
        } elseif (preg_match('~^(?:(-)?(\d+)-(\d+))?\s*(-?\d+)??\s*(-?\d+(?::\d+(?::\d+(?:\.\d+)?)?)?)?$~', $str, $m)) {
213
            // sql format
214
            $parts = (isset($m[5]) ? self::parseTimeStr($m[5], 0, false) : []);
215
            if (!empty($m[2]) || !empty($m[3])) {
216
                $sgn = $m[1] . '1';
217
                $parts[self::YEAR] = $sgn * $m[2];
218
                $parts[self::MONTH] = $sgn * $m[3];
219
            }
220
            if (!empty($m[4])) {
221
                $parts[self::DAY] = (int)$m[4];
222
            }
223
        } else {
224
            // PostgreSQL format
225
            $parts = self::parsePostgresqlStr($str);
226
        }
227
228
        return self::fromParts($parts);
229
    }
230
231
    private static function parseIsoDateStr(string $str): array
232
    {
233
        if (preg_match('~^(-?\d+)-(-?\d+)-(-?\d+)$~', $str, $m)) {
234
            return [
235
                self::YEAR => (int)$m[1],
236
                self::MONTH => (int)$m[2],
237
                self::DAY => (int)$m[3],
238
            ];
239
        } else {
240
            $parts = [];
241
            preg_match_all('~(-?\d+(?:\.\d+)?)([YMDW])~', $str, $matches, PREG_SET_ORDER);
242
            static $units = ['Y' => self::YEAR, 'M' => self::MONTH, 'D' => self::DAY, 'W' => self::WEEK];
243
            foreach ($matches as $m) {
244
                $parts[$units[$m[2]]] = (float)$m[1];
245
            }
246
            return $parts;
247
        }
248
    }
249
250
    private static function parseTimeStr(string $str, int $offset = 0, bool $separateMinuteSigns = true): array
251
    {
252
        $timeRe = '~^
253
                     ( -? \d+ (?: \.\d+ )? )
254
                     (?: : ( -? \d+ (?:\.\d+)? ) )?
255
                     (?: : ( -? \d+ (?:\.\d+)? ) )?
256
                    $~x';
257
        if (preg_match($timeRe, substr($str, $offset), $m)) {
258
            if (isset($m[2])) {
259
                $sgn = ($separateMinuteSigns || $m[1] >= 0 ? 1 : -1);
260
                return [
261
                    self::HOUR => (float)$m[1],
262
                    self::MINUTE => $sgn * $m[2],
263
                    self::SECOND => $sgn * (isset($m[3]) ? (float)$m[3] : 0),
264
                ];
265
            } else {
266
                return [
267
                    self::SECOND => (float)$m[1],
268
                ];
269
            }
270
        } else {
271
            $parts = [];
272
            preg_match_all('~(-?\d+(?:\.\d+)?)([HMS])~', $str, $matches, PREG_SET_ORDER, $offset);
273
            static $units = ['H' => self::HOUR, 'M' => self::MINUTE, 'S' => self::SECOND];
274
            foreach ($matches as $m) {
275
                $parts[$units[$m[2]]] = (float)$m[1];
276
            }
277
            return $parts;
278
        }
279
    }
280
281
    private static function parsePostgresqlStr(string $str, int $offset = 0): array
282
    {
283
        static $pgUnits = [
284
            'millennium' => self::MILLENNIUM,
285
            'millenniums' => self::MILLENNIUM,
286
            'mil' => self::MILLENNIUM,
287
            'mils' => self::MILLENNIUM,
288
            'century' => self::CENTURY,
289
            'centuries' => self::CENTURY,
290
            'cent' => self::CENTURY,
291
            'decade' => self::DECADE,
292
            'decades' => self::DECADE,
293
            'dec' => self::DECADE,
294
            'decs' => self::DECADE,
295
            'year' => self::YEAR,
296
            'years' => self::YEAR,
297
            'y' => self::YEAR,
298
            'month' => self::MONTH,
299
            'months' => self::MONTH,
300
            'mon' => self::MONTH,
301
            'mons' => self::MONTH,
302
            'week' => self::WEEK,
303
            'weeks' => self::WEEK,
304
            'w' => self::WEEK,
305
            'day' => self::DAY,
306
            'days' => self::DAY,
307
            'd' => self::DAY,
308
            'hour' => self::HOUR,
309
            'hours' => self::HOUR,
310
            'h' => self::HOUR,
311
            'minute' => self::MINUTE,
312
            'minutes' => self::MINUTE,
313
            'min' => self::MINUTE,
314
            'mins' => self::MINUTE,
315
            'm' => self::MINUTE,
316
            'second' => self::SECOND,
317
            'seconds' => self::SECOND,
318
            'sec' => self::SECOND,
319
            'secs' => self::SECOND,
320
            's' => self::SECOND,
321
            'millisecond' => self::MILLISECOND,
322
            'milliseconds' => self::MILLISECOND,
323
            'microsecond' => self::MICROSECOND,
324
            'microseconds' => self::MICROSECOND,
325
        ];
326
        $parts = self::parseQuantityUnitPairs($str, $offset, $pgUnits);
327
        if (preg_match('~-?\d+:\d+:\d+(?:\.\d+)?~', $str, $m, 0, $offset)) {
328
            $parts += self::parseTimeStr($m[0]);
329
        }
330
        if (stripos($str, 'ago', $offset)) {
331
            foreach ($parts as &$quantity) {
332
                $quantity *= -1;
333
            } unset($quantity);
334
        }
335
        return $parts;
336
    }
337
338
    private static function parseQuantityUnitPairs(string $str, int $offset, array $units): array
339
    {
340
        $result = [];
341
        // OPT: the regular expression might be cached
342
        $re = '~(-?\d+(?:\.\d+)?)\s*(' . implode('|', array_map('preg_quote', array_keys($units))) . ')\b~i';
343
        preg_match_all($re, $str, $matches, PREG_SET_ORDER, $offset);
344
        $unitsLower = array_change_key_case($units, CASE_LOWER); // OPT: keys $units might be required to be lower-case
345
        foreach ($matches as $m) {
346
            $unit = $unitsLower[strtolower($m[2])];
347
            $result[$unit] = (float)$m[1];
348
        }
349
        return $result;
350
    }
351
352
    private function __construct(int $mon, int $day, $sec)
353
    {
354
        $this->mon = $mon;
355
        $this->day = $day;
356
        $this->sec = $sec;
357
    }
358
359
    /**
360
     * @return number[] map: unit => quantity, the sum of which equals to the represented interval;
361
     *                  the output will consist of number of years, months, days, hours, minutes (all of which will
362
     *                    always be non-zero integers) and seconds (which may be fractional, and will be zero iff the
363
     *                    interval is zero);
364
     *                  the order of parts is guaranteed to be as mentioned in the previous sentence, i.e., from years
365
     *                    to seconds
366
     */
367
    public function toParts(): array
368
    {
369
        $result = [];
370
        $yr = (int)($this->mon / 12);
371
        $mon = $this->mon % 12;
372
        if ($yr != 0) {
373
            $result[self::YEAR] = $yr;
374
        }
375
        if ($mon != 0) {
376
            $result[self::MONTH] = $mon;
377
        }
378
        if ($this->day != 0) {
379
            $result[self::DAY] = $this->day;
380
        }
381
        $hr = (int)($this->sec / (60 * 60));
382
        $sec = $this->sec - $hr * 60 * 60;
383
        $min = (int)($sec / 60);
384
        $sec -= $min * 60;
385
        if ($hr != 0) {
386
            $result[self::HOUR] = $hr;
387
        }
388
        if ($min != 0) {
389
            $result[self::MINUTE] = $min;
390
        }
391
        if ($sec != 0 || !$result) {
392
            $result[self::SECOND] = $sec;
393
        }
394
        return $result;
395
    }
396
397
    public function toIsoString(): string
398
    {
399
        $str = '';
400
        $inDatePart = true;
401
        foreach ($this->toParts() as $unit => $quantity) {
402
            if ($inDatePart && isset(self::TIME_UNIT_HASH[$unit])) {
403
                $str .= 'T';
404
                $inDatePart = false;
405
            }
406
            $str .= $quantity . self::UNIT_ISO_ABBR[$unit];
407
        }
408
        return ($str ? "P$str" : 'PT0S');
409
    }
410
411
    /**
412
     * Adds a time interval to this interval and returns the result as a new time interval object.
413
     *
414
     * @param TimeInterval $addend
415
     * @return TimeInterval
416
     */
417
    public function add(TimeInterval $addend): TimeInterval
418
    {
419
        return new TimeInterval(
420
            $this->mon + $addend->mon,
421
            $this->day + $addend->day,
422
            $this->sec + $addend->sec
423
        );
424
    }
425
426
    /**
427
     * Subtracts a time interval from this interval and returns the result as a new time interval object.
428
     *
429
     * @param TimeInterval $subtrahend
430
     * @return TimeInterval
431
     */
432
    public function subtract(TimeInterval $subtrahend): TimeInterval
433
    {
434
        return new TimeInterval(
435
            $this->mon - $subtrahend->mon,
436
            $this->day - $subtrahend->day,
437
            $this->sec - $subtrahend->sec
438
        );
439
    }
440
441
    /**
442
     * Multiplies this time interval with a scalar and returns the result as a new time interval object.
443
     *
444
     * @param number $multiplier
445
     * @return TimeInterval
446
     */
447
    public function multiply($multiplier): TimeInterval
448
    {
449
        return new TimeInterval($multiplier * $this->mon, $multiplier * $this->day, $multiplier * $this->sec);
0 ignored issues
show
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

449
        return new TimeInterval($multiplier * $this->mon, /** @scrutinizer ignore-type */ $multiplier * $this->day, $multiplier * $this->sec);
Loading history...
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

449
        return new TimeInterval(/** @scrutinizer ignore-type */ $multiplier * $this->mon, $multiplier * $this->day, $multiplier * $this->sec);
Loading history...
450
    }
451
452
    /**
453
     * Divides this time interval with a scalar and returns the result as a new time interval object.
454
     *
455
     * @param number $divisor
456
     * @return TimeInterval
457
     */
458
    public function divide($divisor): TimeInterval
459
    {
460
        return new TimeInterval($this->mon / $divisor, $this->day / $divisor, $this->sec / $divisor);
0 ignored issues
show
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

460
        return new TimeInterval($this->mon / $divisor, /** @scrutinizer ignore-type */ $this->day / $divisor, $this->sec / $divisor);
Loading history...
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

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