Completed
Pull Request — master (#93)
by ignace nyamagana
01:54
created

Period::fromISO8601()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 26
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 12
CRAP Score 4

Importance

Changes 0
Metric Value
cc 4
eloc 11
c 0
b 0
f 0
nc 4
nop 3
dl 0
loc 26
ccs 12
cts 12
cp 1
crap 4
rs 9.9
1
<?php
2
3
/**
4
 * League.Period (https://period.thephpleague.com)
5
 *
6
 * (c) Ignace Nyamagana Butera <[email protected]>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11
12
declare(strict_types=1);
13
14
namespace League\Period;
15
16
use DateInterval;
17
use DatePeriod;
18
use DateTimeImmutable;
19
use DateTimeInterface;
20
use DateTimeZone;
21
use JsonSerializable;
22
use function array_filter;
23
use function array_keys;
24
use function implode;
25
use function sprintf;
26
use function str_replace;
27
use function strlen;
28
use function substr;
29
30
/**
31
 * A immutable value object class to manipulate Time interval.
32
 *
33
 * @package League.period
34
 * @author  Ignace Nyamagana Butera <[email protected]>
35
 * @since   1.0.0
36
 */
37
final class Period implements JsonSerializable
38
{
39
    private const ISO8601_FORMAT = 'Y-m-d\TH:i:s.u\Z';
40
41
    private const BOUNDARY_TYPE = [
42
        self::INCLUDE_START_EXCLUDE_END => 1,
43
        self::INCLUDE_ALL => 1,
44
        self::EXCLUDE_START_INCLUDE_END => 1,
45
        self::EXCLUDE_ALL => 1,
46
    ];
47
48
    public const INCLUDE_START_EXCLUDE_END = '[)';
49
50
    public const EXCLUDE_START_INCLUDE_END = '(]';
51
52
    public const EXCLUDE_ALL = '()';
53
54
    public const INCLUDE_ALL = '[]';
55
56
    /**
57
     * The starting datepoint.
58
     *
59
     * @var DateTimeImmutable
60
     */
61
    private $startDate;
62
63
    /**
64
     * The ending datepoint.
65
     *
66
     * @var DateTimeImmutable
67
     */
68
    private $endDate;
69
70
    /**
71
     * The boundary type.
72
     *
73
     * @var string
74
     */
75
    private $boundaryType;
76
77
    /**
78
     * Creates a new instance.
79
     *
80
     * @param mixed $startDate the starting datepoint
81
     * @param mixed $endDate   the ending datepoint
82
     *
83
     * @throws Exception If $startDate is greater than $endDate
84
     */
85 897
    public function __construct($startDate, $endDate, string $boundaryType = self::INCLUDE_START_EXCLUDE_END)
86
    {
87 897
        $startDate = self::getDatepoint($startDate);
88 897
        $endDate = self::getDatepoint($endDate);
89 885
        if ($startDate > $endDate) {
90 66
            throw new Exception('The ending datepoint must be greater or equal to the starting datepoint');
91
        }
92
93 861
        if (!isset(self::BOUNDARY_TYPE[$boundaryType])) {
94 6
            throw new Exception(sprintf(
95 6
                'The boundary type `%s` is invalid. The only valid values are %s',
96 6
                $boundaryType,
97 6
                '`'.implode('`, `', array_keys(self::BOUNDARY_TYPE)).'`'
98
            ));
99
        }
100
101 858
        $this->startDate = $startDate;
102 858
        $this->endDate = $endDate;
103 858
        $this->boundaryType = $boundaryType;
104 858
    }
105
106
    /**
107
     * Returns a DateTimeImmutable instance.
108
     *
109
     * @param mixed $datepoint a Datepoint
110
     */
111 1062
    private static function getDatepoint($datepoint): DateTimeImmutable
112
    {
113 1062
        if ($datepoint instanceof DateTimeImmutable) {
114 840
            return $datepoint;
115
        }
116
117 720
        return Datepoint::create($datepoint);
118
    }
119
120
    /**
121
     * Returns a DateInterval instance.
122
     *
123
     * @param mixed $duration a Duration
124
     */
125 357
    private static function getDuration($duration): DateInterval
126
    {
127 357
        if ($duration instanceof DateInterval) {
128 150
            return $duration;
129
        }
130
131 207
        return Duration::create($duration);
132
    }
133
134
    /**************************************************
135
     * Named constructors
136
     **************************************************/
137
138
    /**
139
     * @inheritDoc
140
     */
141 6
    public static function __set_state(array $interval)
142
    {
143 6
        return new self($interval['startDate'], $interval['endDate'], $interval['boundaryType'] ?? self::INCLUDE_START_EXCLUDE_END);
144
    }
145
146
    /**
147
     * Creates new instance from a starting datepoint and a duration.
148
     *
149
     * @param mixed $startDate the starting datepoint
150
     * @param mixed $duration  a Duration
151
     */
152 132
    public static function after($startDate, $duration, string $boundaryType = self::INCLUDE_START_EXCLUDE_END): self
153
    {
154 132
        $startDate = self::getDatepoint($startDate);
155
156 132
        return new self($startDate, $startDate->add(self::getDuration($duration)), $boundaryType);
157
    }
158
159
    /**
160
     * Creates new instance from a ending datepoint and a duration.
161
     *
162
     * @param mixed $endDate  the ending datepoint
163
     * @param mixed $duration a Duration
164
     */
165 27
    public static function before($endDate, $duration, string $boundaryType = self::INCLUDE_START_EXCLUDE_END): self
166
    {
167 27
        $endDate = self::getDatepoint($endDate);
168
169 27
        return new self($endDate->sub(self::getDuration($duration)), $endDate, $boundaryType);
170
    }
171
172
    /**
173
     * Creates new instance where the given duration is simultaneously
174
     * subtracted from and added to the datepoint.
175
     *
176
     * @param mixed $datepoint a Datepoint
177
     * @param mixed $duration  a Duration
178
     */
179 21
    public static function around($datepoint, $duration, string $boundaryType = self::INCLUDE_START_EXCLUDE_END): self
180
    {
181 21
        $datepoint = self::getDatepoint($datepoint);
182 21
        $duration = self::getDuration($duration);
183
184 21
        return new self($datepoint->sub($duration), $datepoint->add($duration), $boundaryType);
185
    }
186
187
    /**
188
     * Creates new instance from a DatePeriod.
189
     */
190 12
    public static function fromDatePeriod(DatePeriod $datePeriod, string $boundaryType = self::INCLUDE_START_EXCLUDE_END): self
191
    {
192 12
        return new self($datePeriod->getStartDate(), $datePeriod->getEndDate(), $boundaryType);
193
    }
194
195
    /**
196
     * Creates new instance for a specific year.
197
     */
198 12
    public static function fromYear(int $year, string $boundaryType = self::INCLUDE_START_EXCLUDE_END): self
199
    {
200 12
        $startDate = (new DateTimeImmutable())->setDate($year, 1, 1)->setTime(0, 0);
201
202 12
        return new self($startDate, $startDate->add(new DateInterval('P1Y')), $boundaryType);
203
    }
204
205
    /**
206
     * Creates new instance for a specific ISO year.
207
     */
208 6
    public static function fromIsoYear(int $year, string $boundaryType = self::INCLUDE_START_EXCLUDE_END): self
209
    {
210 6
        return new self(
211 6
            (new DateTimeImmutable())->setISODate($year, 1)->setTime(0, 0),
212 6
            (new DateTimeImmutable())->setISODate(++$year, 1)->setTime(0, 0),
213 4
            $boundaryType
214
        );
215
    }
216
217
    /**
218
     * Creates new instance for a specific year and semester.
219
     */
220 18
    public static function fromSemester(int $year, int $semester = 1, string $boundaryType = self::INCLUDE_START_EXCLUDE_END): self
221
    {
222 18
        $month = (($semester - 1) * 6) + 1;
223 18
        $startDate = (new DateTimeImmutable())->setDate($year, $month, 1)->setTime(0, 0);
224
225 18
        return new self($startDate, $startDate->add(new DateInterval('P6M')), $boundaryType);
226
    }
227
228
    /**
229
     * Creates new instance for a specific year and quarter.
230
     */
231 18
    public static function fromQuarter(int $year, int $quarter = 1, string $boundaryType = self::INCLUDE_START_EXCLUDE_END): self
232
    {
233 18
        $month = (($quarter - 1) * 3) + 1;
234 18
        $startDate = (new DateTimeImmutable())->setDate($year, $month, 1)->setTime(0, 0);
235
236 18
        return new self($startDate, $startDate->add(new DateInterval('P3M')), $boundaryType);
237
    }
238
239
    /**
240
     * Creates new instance for a specific year and month.
241
     */
242 75
    public static function fromMonth(int $year, int $month = 1, string $boundaryType = self::INCLUDE_START_EXCLUDE_END): self
243
    {
244 75
        $startDate = (new DateTimeImmutable())->setDate($year, $month, 1)->setTime(0, 0);
245
246 75
        return new self($startDate, $startDate->add(new DateInterval('P1M')), $boundaryType);
247
    }
248
249
    /**
250
     * Creates new instance for a specific ISO8601 week.
251
     */
252 21
    public static function fromIsoWeek(int $year, int $week = 1, string $boundaryType = self::INCLUDE_START_EXCLUDE_END): self
253
    {
254 21
        $startDate = (new DateTimeImmutable())->setISODate($year, $week, 1)->setTime(0, 0);
255
256 21
        return new self($startDate, $startDate->add(new DateInterval('P7D')), $boundaryType);
257
    }
258
259
    /**
260
     * Creates new instance for a specific year, month and day.
261
     */
262 51
    public static function fromDay(int $year, int $month = 1, int $day = 1, string $boundaryType = self::INCLUDE_START_EXCLUDE_END): self
263
    {
264 51
        $startDate = (new DateTimeImmutable())->setDate($year, $month, $day)->setTime(0, 0);
265
266 51
        return new self($startDate, $startDate->add(new DateInterval('P1D')), $boundaryType);
267
    }
268
269
    /**
270
     * Creates new instance from an ISO 8601 time interval.
271
     * This named constructor only support full datepoint and interval.
272
     *
273
     * @see https://en.wikipedia.org/wiki/ISO_8601#Time_intervals
274
     *
275
     * @throws Exception
276
     */
277 21
    public static function fromISO8601(
278
        string $isoFormat,
279
        string $separator = '/',
280
        string $boundaryType = self::INCLUDE_START_EXCLUDE_END
281
    ): self {
282
        /** @var string[] $parts */
283 21
        $parts = explode($separator, $isoFormat);
284 21
        if (2 !== count($parts)) {
285 3
            throw new Exception('The submitted format and/or the separator are not valid. Please review your parameters against the ISO8601 interval format.');
286
        }
287
288 18
        if ('P' === $parts[0][0]) {
289 3
            $endDate = str_replace('T', ' ', $parts[1]);
290
291 3
            return self::before($endDate, new DateInterval($parts[0]), $boundaryType);
292
        }
293
294 15
        if ('P' === $parts[1][0]) {
295 3
            $startDate = str_replace('T', ' ', $parts[0]);
296
297 3
            return self::after($startDate, new DateInterval($parts[1]), $boundaryType);
298
        }
299
300 12
        [$startDate, $endDate] = self::normalizeISO8601($parts);
301
302 9
        return new self($startDate, $endDate, $boundaryType);
303
    }
304
305
    /**
306
     * @param string[] $iso8601String
307
     *
308
     * @throws Exception
309
     *
310
     * @return string[]
311
     */
312 12
    private static function normalizeISO8601(array $iso8601String): array
313
    {
314
        $formatter = static function (string $datepoint): string {
315 12
            return str_replace('T', ' ', $datepoint);
316 12
        };
317
318 12
        $iso8601String = array_map($formatter, $iso8601String);
319 12
        [$startDate, $endDate] = $iso8601String;
320 12
        $startLength = strlen($startDate);
321 12
        $endLength = strlen($endDate);
322 12
        $diff = $startLength <=> $endLength;
323 12
        if (1 === $diff) {
324 6
            return [$startDate, substr($startDate, 0, - $endLength).$endDate];
325
        }
326
327 6
        if (-1 === $diff) {
328 3
            throw new Exception('The string format is not valid. Please review your submitted ISO8601 Interval format.');
329
        }
330
331 3
        return $iso8601String;
332
    }
333
334
    /**************************************************
335
     * Basic getters
336
     **************************************************/
337
338
    /**
339
     * Returns the starting datepoint.
340
     */
341 243
    public function getStartDate(): DateTimeImmutable
342
    {
343 243
        return $this->startDate;
344
    }
345
346
    /**
347
     * Returns the ending datepoint.
348
     */
349 219
    public function getEndDate(): DateTimeImmutable
350
    {
351 219
        return $this->endDate;
352
    }
353
354
    /**
355
     * Returns the instance boundary type.
356
     */
357 159
    public function getBoundaryType(): string
358
    {
359 159
        return $this->boundaryType;
360
    }
361
362
    /**
363
     * Returns the instance duration as expressed in seconds.
364
     */
365 36
    public function getTimestampInterval(): float
366
    {
367 36
        return $this->endDate->getTimestamp() - $this->startDate->getTimestamp();
368
    }
369
370
    /**
371
     * Returns the instance duration as a DateInterval object.
372
     */
373 129
    public function getDateInterval(): DateInterval
374
    {
375 129
        return $this->startDate->diff($this->endDate);
376
    }
377
378
    /**************************************************
379
     * String representation
380
     **************************************************/
381
382
    /**
383
     * Returns the string representation as a ISO8601 interval format.
384
     *
385
     * @see Period::toISO8601()
386
     */
387 6
    public function __toString()
388
    {
389 6
        return $this->toISO8601();
390
    }
391
392
    /**
393
     * Returns the string representation as a ISO8601 interval format.
394
     *
395
     * @see https://en.wikipedia.org/wiki/ISO_8601#Time_intervals
396
     *
397
     */
398 6
    public function toISO8601(): string
399
    {
400 6
        $interval = $this->jsonSerialize();
401
402 6
        return $interval['startDate'].'/'.$interval['endDate'];
403
    }
404
405
    /**
406
     * Returns the JSON representation of an instance.
407
     *
408
     * Based on the JSON representation of dates as
409
     * returned by Javascript Date.toJSON() method.
410
     *
411
     * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toJSON
412
     * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString
413
     *
414
     * @return array<string>
415
     */
416 15
    public function jsonSerialize()
417
    {
418 15
        $utc = new DateTimeZone('UTC');
419
420
        return [
421 15
            'startDate' => $this->startDate->setTimezone($utc)->format(self::ISO8601_FORMAT),
422 15
            'endDate' => $this->endDate->setTimezone($utc)->format(self::ISO8601_FORMAT),
423
        ];
424
    }
425
426
    /**
427
     * Returns the mathematical representation of an instance as a left close, right open interval.
428
     *
429
     * @see https://en.wikipedia.org/wiki/Interval_(mathematics)#Notations_for_intervals
430
     * @see https://php.net/manual/en/function.date.php
431
     * @see https://www.postgresql.org/docs/9.3/static/rangetypes.html
432
     *
433
     * @param string $format the format of the outputted date string
434
     */
435 24
    public function format(string $format): string
436
    {
437 24
        return $this->boundaryType[0]
438 24
            .$this->startDate->format($format)
439 24
            .', '
440 24
            .$this->endDate->format($format)
441 24
            .$this->boundaryType[1];
442
    }
443
444
    /**************************************************
445
     * Boundary related methods
446
     **************************************************/
447
448
    /**
449
     * Tells whether the start datepoint is included in the boundary.
450
     */
451 12
    public function isStartIncluded(): bool
452
    {
453 12
        return '[' === $this->boundaryType[0];
454
    }
455
456
    /**
457
     * Tells whether the start datepoint is excluded from the boundary.
458
     */
459 81
    public function isStartExcluded(): bool
460
    {
461 81
        return '(' === $this->boundaryType[0];
462
    }
463
464
    /**
465
     * Tells whether the end datepoint is included in the boundary.
466
     */
467 12
    public function isEndIncluded(): bool
468
    {
469 12
        return ']' === $this->boundaryType[1];
470
    }
471
472
    /**
473
     * Tells whether the end datepoint is excluded from the boundary.
474
     */
475 81
    public function isEndExcluded(): bool
476
    {
477 81
        return ')' === $this->boundaryType[1];
478
    }
479
480
    /**************************************************
481
     * Duration comparison methods
482
     **************************************************/
483
484
    /**
485
     * Compares two instances according to their duration.
486
     *
487
     * Returns:
488
     * <ul>
489
     * <li> -1 if the current Interval is lesser than the submitted Interval object</li>
490
     * <li>  1 if the current Interval is greater than the submitted Interval object</li>
491
     * <li>  0 if both Interval objects have the same duration</li>
492
     * </ul>
493
     */
494 60
    public function durationCompare(self $interval): int
495
    {
496 60
        return $this->startDate->add($this->getDateInterval())
497 60
            <=> $this->startDate->add($interval->getDateInterval());
498
    }
499
500
    /**
501
     * Tells whether the current instance duration is equal to the submitted one.
502
     */
503 6
    public function durationEquals(self $interval): bool
504
    {
505 6
        return 0 === $this->durationCompare($interval);
506
    }
507
508
    /**
509
     * Tells whether the current instance duration is greater than the submitted one.
510
     */
511 18
    public function durationGreaterThan(self $interval): bool
512
    {
513 18
        return 1 === $this->durationCompare($interval);
514
    }
515
516
    /**
517
     * Tells whether the current instance duration is less than the submitted one.
518
     */
519 12
    public function durationLessThan(self $interval): bool
520
    {
521 12
        return -1 === $this->durationCompare($interval);
522
    }
523
524
    /**************************************************
525
     * Relation methods
526
     **************************************************/
527
528
    /**
529
     * Tells whether an instance is entirely before the specified index.
530
     *
531
     * The index can be a DateTimeInterface object or another Period object.
532
     *
533
     * [--------------------)
534
     *                          [--------------------)
535
     *
536
     * @param mixed $index a datepoint or a Period object
537
     */
538 90
    public function isBefore($index): bool
539
    {
540 90
        if ($index instanceof self) {
541 48
            return $this->endDate < $index->startDate
542 48
                || ($this->endDate == $index->startDate && $this->boundaryType[1] !== $index->boundaryType[0]);
543
        }
544
545 42
        $datepoint = self::getDatepoint($index);
546 42
        return $this->endDate < $datepoint
547 42
            || ($this->endDate == $datepoint && ')' === $this->boundaryType[1]);
548
    }
549
550
    /**
551
     * Tells whether the current instance end date meets the interval start date.
552
     *
553
     * [--------------------)
554
     *                      [--------------------)
555
     */
556 309
    public function bordersOnStart(self $interval): bool
557
    {
558 309
        return $this->endDate == $interval->startDate
559 309
            && '][' !== $this->boundaryType[1].$interval->boundaryType[0];
560
    }
561
562
    /**
563
     * Tells whether two intervals share the same start datepoint
564
     * and the same starting boundary type.
565
     *
566
     *    [----------)
567
     *    [--------------------)
568
     *
569
     * or
570
     *
571
     *    [--------------------)
572
     *    [---------)
573
     *
574
     * @param mixed $index a datepoint or a Period object
575
     */
576 27
    public function isStartedBy($index): bool
577
    {
578 27
        if ($index instanceof self) {
579 15
            return $this->startDate == $index->startDate
580 15
                && $this->boundaryType[0] === $index->boundaryType[0];
581
        }
582
583 12
        $index = self::getDatepoint($index);
584
585 12
        return $index == $this->startDate && '[' === $this->boundaryType[0];
586
    }
587
588
    /**
589
     * Tells whether an instance is fully contained in the specified interval.
590
     *
591
     *     [----------)
592
     * [--------------------)
593
     */
594 39
    public function isDuring(self $interval): bool
595
    {
596 39
        return $interval->containsInterval($this);
597
    }
598
599
    /**
600
     * Tells whether an instance fully contains the specified index.
601
     *
602
     * The index can be a DateTimeInterface object or another Period object.
603
     *
604
     * @param mixed $index a datepoint or a Period object
605
     */
606 144
    public function contains($index): bool
607
    {
608 144
        if ($index instanceof self) {
609 66
            return $this->containsInterval($index);
610
        }
611
612 78
        return $this->containsDatepoint(self::getDatepoint($index), $this->boundaryType);
613
    }
614
615
    /**
616
     * Tells whether an instance fully contains another instance.
617
     *
618
     * [--------------------)
619
     *     [----------)
620
     */
621 66
    private function containsInterval(self $interval): bool
622
    {
623 66
        if ($this->startDate < $interval->startDate && $this->endDate > $interval->endDate) {
624 18
            return true;
625
        }
626
627 63
        if ($this->startDate == $interval->startDate && $this->endDate == $interval->endDate) {
628 21
            return $this->boundaryType === $interval->boundaryType || '[]' === $this->boundaryType;
629
        }
630
631 42
        if ($this->startDate == $interval->startDate) {
632 12
            return ($this->boundaryType[0] === $interval->boundaryType[0] || '[' === $this->boundaryType[0])
633 12
                && $this->containsDatepoint($this->startDate->add($interval->getDateInterval()), $this->boundaryType);
634
        }
635
636 30
        if ($this->endDate == $interval->endDate) {
637 18
            return ($this->boundaryType[1] === $interval->boundaryType[1] || ']' === $this->boundaryType[1])
638 18
                && $this->containsDatepoint($this->endDate->sub($interval->getDateInterval()), $this->boundaryType);
0 ignored issues
show
Bug introduced by
It seems like $this->endDate->sub($interval->getDateInterval()) can also be of type false; however, parameter $datepoint of League\Period\Period::containsDatepoint() does only seem to accept DateTimeInterface, maybe add an additional type check? ( Ignorable by Annotation )

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

638
                && $this->containsDatepoint(/** @scrutinizer ignore-type */ $this->endDate->sub($interval->getDateInterval()), $this->boundaryType);
Loading history...
639
        }
640
641 12
        return false;
642
    }
643
644
    /**
645
     * Tells whether an instance contains a datepoint.
646
     *
647
     * [------|------------)
648
     */
649 108
    private function containsDatepoint(DateTimeInterface $datepoint, string $boundaryType): bool
650
    {
651
        switch ($boundaryType) {
652 108
            case self::EXCLUDE_ALL:
653 9
                return $datepoint > $this->startDate && $datepoint < $this->endDate;
654 99
            case self::INCLUDE_ALL:
655 3
                return $datepoint >= $this->startDate && $datepoint <= $this->endDate;
656 96
            case self::EXCLUDE_START_INCLUDE_END:
657 9
                return $datepoint > $this->startDate && $datepoint <= $this->endDate;
658 87
            case self::INCLUDE_START_EXCLUDE_END:
659
            default:
660 87
                return $datepoint >= $this->startDate && $datepoint < $this->endDate;
661
        }
662
    }
663
664
    /**
665
     * Tells whether two intervals share the same datepoints.
666
     *
667
     * [--------------------)
668
     * [--------------------)
669
     */
670 285
    public function equals(self $interval): bool
671
    {
672 285
        return $this->startDate == $interval->startDate
673 285
            && $this->endDate == $interval->endDate
674 285
            && $this->boundaryType === $interval->boundaryType;
675
    }
676
677
    /**
678
     * Tells whether two intervals share the same end datepoint
679
     * and the same ending boundary type.
680
     *
681
     *              [----------)
682
     *    [--------------------)
683
     *
684
     * or
685
     *
686
     *    [--------------------)
687
     *               [---------)
688
     *
689
     * @param mixed $index a datepoint or a Period object
690
     */
691 24
    public function isEndedBy($index): bool
692
    {
693 24
        if ($index instanceof self) {
694 12
            return $this->endDate == $index->endDate
695 12
                && $this->boundaryType[1] === $index->boundaryType[1];
696
        }
697
698 12
        $index = self::getDatepoint($index);
699
700 12
        return $index == $this->endDate && ']' === $this->boundaryType[1];
701
    }
702
703
    /**
704
     * Tells whether the current instance start date meets the interval end date.
705
     *
706
     *                      [--------------------)
707
     * [--------------------)
708
     */
709 291
    public function bordersOnEnd(self $interval): bool
710
    {
711 291
        return $interval->bordersOnStart($this);
712
    }
713
714
    /**
715
     * Tells whether an interval is entirely after the specified index.
716
     * The index can be a DateTimeInterface object or another Period object.
717
     *
718
     *                          [--------------------)
719
     * [--------------------)
720
     *
721
     * @param mixed $index a datepoint or a Period object
722
     */
723 54
    public function isAfter($index): bool
724
    {
725 54
        if ($index instanceof self) {
726 24
            return $index->isBefore($this);
727
        }
728
729 30
        $datepoint = self::getDatepoint($index);
730 30
        return $this->startDate > $datepoint
731 30
            || ($this->startDate == $datepoint && '(' === $this->boundaryType[0]);
732
    }
733
734
    /**
735
     * Tells whether two intervals abuts.
736
     *
737
     * [--------------------)
738
     *                      [--------------------)
739
     * or
740
     *                      [--------------------)
741
     * [--------------------)
742
     */
743 309
    public function abuts(self $interval): bool
744
    {
745 309
        return $this->bordersOnStart($interval) || $this->bordersOnEnd($interval);
746
    }
747
748
    /**
749
     * Tells whether two intervals overlaps.
750
     *
751
     * [--------------------)
752
     *          [--------------------)
753
     */
754 291
    public function overlaps(self $interval): bool
755
    {
756 291
        return !$this->abuts($interval)
757 291
            && $this->startDate < $interval->endDate
758 291
            && $this->endDate > $interval->startDate;
759
    }
760
761
    /**************************************************
762
     * Manipulating instance duration
763
     **************************************************/
764
765
    /**
766
     * Returns the difference between two instances expressed in seconds.
767
     */
768 6
    public function timestampIntervalDiff(self $interval): float
769
    {
770 6
        return $this->getTimestampInterval() - $interval->getTimestampInterval();
771
    }
772
773
    /**
774
     * Returns the difference between two instances expressed with a DateInterval object.
775
     */
776 12
    public function dateIntervalDiff(self $interval): DateInterval
777
    {
778 12
        return $this->endDate->diff($this->startDate->add($interval->getDateInterval()));
779
    }
780
781
    /**
782
     * Allows iteration over a set of dates and times,
783
     * recurring at regular intervals, over the instance.
784
     *
785
     * @see http://php.net/manual/en/dateperiod.construct.php
786
     *
787
     * @param mixed $duration a Duration
788
     */
789 54
    public function getDatePeriod($duration, int $option = 0): DatePeriod
790
    {
791 54
        return new DatePeriod($this->startDate, self::getDuration($duration), $this->endDate, $option);
792
    }
793
794
    /**
795
     * Allows iteration over a set of dates and times,
796
     * recurring at regular intervals, over the instance backwards starting from
797
     * the instance ending datepoint.
798
     *
799
     * @param mixed $duration a Duration
800
     */
801 24
    public function getDatePeriodBackwards($duration, int $option = 0): iterable
802
    {
803 24
        $duration = self::getDuration($duration);
804 24
        $date = $this->endDate;
805 24
        if ((bool) ($option & DatePeriod::EXCLUDE_START_DATE)) {
806 12
            $date = $this->endDate->sub($duration);
807
        }
808
809 24
        while ($date > $this->startDate) {
810 24
            yield $date;
811 24
            $date = $date->sub($duration);
812
        }
813 24
    }
814
815
    /**
816
     * Allows splitting an instance in smaller Period objects according to a given interval.
817
     *
818
     * The returned iterable Interval set is ordered so that:
819
     * <ul>
820
     * <li>The first returned object MUST share the starting datepoint of the parent object.</li>
821
     * <li>The last returned object MUST share the ending datepoint of the parent object.</li>
822
     * <li>The last returned object MUST have a duration equal or lesser than the submitted interval.</li>
823
     * <li>All returned objects except for the first one MUST start immediately after the previously returned object</li>
824
     * </ul>
825
     *
826
     * @param mixed $duration a Duration
827
     *
828
     * @return iterable<Period>
829
     */
830 30
    public function split($duration): iterable
831
    {
832 30
        $duration = self::getDuration($duration);
833 30
        foreach ($this->getDatePeriod($duration) as $startDate) {
834 30
            $endDate = $startDate->add($duration);
835 30
            if ($endDate > $this->endDate) {
836 12
                $endDate = $this->endDate;
837
            }
838
839 30
            yield new self($startDate, $endDate, $this->boundaryType);
840
        }
841 30
    }
842
843
    /**
844
     * Allows splitting an instance in smaller Period objects according to a given interval.
845
     *
846
     * The returned iterable Period set is ordered so that:
847
     * <ul>
848
     * <li>The first returned object MUST share the ending datepoint of the parent object.</li>
849
     * <li>The last returned object MUST share the starting datepoint of the parent object.</li>
850
     * <li>The last returned object MUST have a duration equal or lesser than the submitted interval.</li>
851
     * <li>All returned objects except for the first one MUST end immediately before the previously returned object</li>
852
     * </ul>
853
     *
854
     * @param mixed $duration a Duration
855
     *
856
     * @return iterable<Period>
857
     */
858 18
    public function splitBackwards($duration): iterable
859
    {
860 18
        $endDate = $this->endDate;
861 18
        $duration = self::getDuration($duration);
862
        do {
863 18
            $startDate = $endDate->sub($duration);
864 18
            if ($startDate < $this->startDate) {
865 6
                $startDate = $this->startDate;
866
            }
867 18
            yield new self($startDate, $endDate, $this->boundaryType);
868
869 18
            $endDate = $startDate;
870 18
        } while ($endDate > $this->startDate);
871 18
    }
872
873
    /**************************************************
874
     * Manipulation instance endpoints and boundaries
875
     **************************************************/
876
877
    /**
878
     * Returns the computed intersection between two instances as a new instance.
879
     *
880
     * [--------------------)
881
     *          ∩
882
     *                 [----------)
883
     *          =
884
     *                 [----)
885
     *
886
     * @throws Exception If both objects do not overlaps
887
     */
888 153
    public function intersect(self $interval): self
889
    {
890 153
        if (!$this->overlaps($interval)) {
891 12
            throw new Exception('Both '.self::class.' objects should overlaps');
892
        }
893
894 141
        $startDate = $this->startDate;
895 141
        $endDate = $this->endDate;
896 141
        $boundaryType = $this->boundaryType;
897 141
        if ($interval->startDate > $this->startDate) {
898 132
            $boundaryType[0] = $interval->boundaryType[0];
899 132
            $startDate = $interval->startDate;
900
        }
901
902 141
        if ($interval->endDate < $this->endDate) {
903 33
            $boundaryType[1] = $interval->boundaryType[1];
904 33
            $endDate = $interval->endDate;
905
        }
906
907 141
        $intersect = new self($startDate, $endDate, $boundaryType);
908 141
        if ($intersect->equals($this)) {
909 21
            return $this;
910
        }
911
912 138
        return $intersect;
913
    }
914
915
    /**
916
     * Returns the computed difference between two overlapping instances as
917
     * an array containing Period objects or the null value.
918
     *
919
     * The array will always contains 2 elements:
920
     *
921
     * <ul>
922
     * <li>an NULL filled array if both objects have the same datepoints</li>
923
     * <li>one Period object and NULL if both objects share one datepoint</li>
924
     * <li>two Period objects if both objects share no datepoint</li>
925
     * </ul>
926
     *
927
     * [--------------------)
928
     *          \
929
     *                [-----------)
930
     *          =
931
     * [--------------)  +  [-----)
932
     *
933
     * @return array<null|Period>
934
     */
935 99
    public function diff(self $interval): array
936
    {
937 99
        if ($interval->equals($this)) {
938 12
            return [null, null];
939
        }
940
941 87
        $intersect = $this->intersect($interval);
942 81
        $merge = $this->merge($interval);
943 81
        if ($merge->startDate == $intersect->startDate) {
944 9
            $first = ')' === $intersect->boundaryType[1] ? '[' : '(';
945 9
            $boundary = $first.$merge->boundaryType[1];
946
947 9
            return [$merge->startingOn($intersect->endDate)->withBoundaryType($boundary), null];
948
        }
949
950 75
        if ($merge->endDate == $intersect->endDate) {
951 6
            $last = '(' === $intersect->boundaryType[0] ? ']' : ')';
952 6
            $boundary = $merge->boundaryType[0].$last;
953
954 6
            return [$merge->endingOn($intersect->startDate)->withBoundaryType($boundary), null];
955
        }
956
957 69
        $last = '(' === $intersect->boundaryType[0] ? ']' : ')';
958 69
        $lastBoundary = $merge->boundaryType[0].$last;
959
960 69
        $first = ')' === $intersect->boundaryType[1] ? '[' : '(';
961 69
        $firstBoundary = $first.$merge->boundaryType[1];
962
963
        return [
964 69
            $merge->endingOn($intersect->startDate)->withBoundaryType($lastBoundary),
965 69
            $merge->startingOn($intersect->endDate)->withBoundaryType($firstBoundary),
966
        ];
967
    }
968
969
    /**
970
     * DEPRECATION WARNING! This method will be removed in the next major point release.
971
     *
972
     * @deprecated since version 4.9.0
973
     * @see ::subtract
974
     */
975 3
    public function substract(self $interval): Sequence
976
    {
977 3
        return $this->subtract($interval);
978
    }
979
980
    /**
981
     * Returns the difference set operation between two intervals as a Sequence.
982
     * The Sequence can contain from 0 to 2 Periods depending on the result of
983
     * the operation.
984
     *
985
     * [--------------------)
986
     *          -
987
     *                [-----------)
988
     *          =
989
     * [--------------)
990
     */
991 24
    public function subtract(self $interval): Sequence
992
    {
993 24
        if (!$this->overlaps($interval)) {
994 9
            return new Sequence($this);
995
        }
996
997
        $filter = function ($item): bool {
998 18
            return null !== $item && $this->overlaps($item);
999 18
        };
1000
1001 18
        return new Sequence(...array_filter($this->diff($interval), $filter));
1002
    }
1003
1004
    /**
1005
     * Returns the computed gap between two instances as a new instance.
1006
     *
1007
     * [--------------------)
1008
     *          +
1009
     *                          [----------)
1010
     *          =
1011
     *                      [---)
1012
     *
1013
     * @throws Exception If both instance overlaps
1014
     */
1015 84
    public function gap(self $interval): self
1016
    {
1017 84
        if ($this->overlaps($interval)) {
1018 18
            throw new Exception('Both '.self::class.' objects must not overlaps');
1019
        }
1020
1021 66
        $boundaryType = $this->isEndExcluded() ? '[' : '(';
1022 66
        $boundaryType .= $interval->isStartExcluded() ? ']' : ')';
1023 66
        if ($interval->startDate > $this->startDate) {
1024 66
            return new self($this->endDate, $interval->startDate, $boundaryType);
1025
        }
1026
1027 6
        return new self($interval->endDate, $this->startDate, $this->boundaryType);
1028
    }
1029
1030
    /**
1031
     * Merges one or more instances to return a new instance.
1032
     * The resulting instance represents the largest duration possible.
1033
     *
1034
     * This method MUST retain the state of the current instance, and return
1035
     * an instance that contains the specified new datepoints.
1036
     *
1037
     * [--------------------)
1038
     *          +
1039
     *                 [----------)
1040
     *          =
1041
     * [--------------------------)
1042
     *
1043
     *
1044
     * @param Period ...$intervals
1045
     */
1046 105
    public function merge(self ...$intervals): self
1047
    {
1048 105
        $carry = $this;
1049 105
        foreach ($intervals as $period) {
1050 99
            if ($carry->startDate > $period->startDate) {
1051 33
                $carry = new self(
1052 33
                    $period->startDate,
1053 33
                    $carry->endDate,
1054 33
                    $period->boundaryType[0].$carry->boundaryType[1]
1055
                );
1056
            }
1057
1058 99
            if ($carry->endDate < $period->endDate) {
1059 87
                $carry = new self(
1060 87
                    $carry->startDate,
1061 87
                    $period->endDate,
1062 91
                    $carry->boundaryType[0].$period->boundaryType[1]
1063
                );
1064
            }
1065
        }
1066
1067 105
        return $carry;
1068
    }
1069
1070
1071
    /**************************************************
1072
     * Mutation methods
1073
     **************************************************/
1074
1075
    /**
1076
     * Returns an instance with the specified starting datepoint.
1077
     *
1078
     * This method MUST retain the state of the current instance, and return
1079
     * an instance that contains the specified starting datepoint.
1080
     *
1081
     * @param mixed $startDate the new starting datepoint
1082
     */
1083 120
    public function startingOn($startDate): self
1084
    {
1085 120
        $startDate = self::getDatepoint($startDate);
1086 120
        if ($startDate == $this->startDate) {
1087 6
            return $this;
1088
        }
1089
1090 120
        return new self($startDate, $this->endDate, $this->boundaryType);
1091
    }
1092
1093
    /**
1094
     * Returns an instance with the specified ending datepoint.
1095
     *
1096
     * This method MUST retain the state of the current instance, and return
1097
     * an instance that contains the specified ending datepoint.
1098
     *
1099
     * @param mixed $endDate the new ending datepoint
1100
     */
1101 120
    public function endingOn($endDate): self
1102
    {
1103 120
        $endDate = self::getDatepoint($endDate);
1104 120
        if ($endDate == $this->endDate) {
1105 6
            return $this;
1106
        }
1107
1108 120
        return new self($this->startDate, $endDate, $this->boundaryType);
1109
    }
1110
1111
    /**
1112
     * Returns an instance with the specified boundary type.
1113
     *
1114
     * This method MUST retain the state of the current instance, and return
1115
     * an instance with the specified range type.
1116
     */
1117 87
    public function withBoundaryType(string $boundaryType): self
1118
    {
1119 87
        if ($boundaryType === $this->boundaryType) {
1120 72
            return $this;
1121
        }
1122
1123 45
        return new self($this->startDate, $this->endDate, $boundaryType);
1124
    }
1125
1126
    /**
1127
     * Returns a new instance with a new ending datepoint.
1128
     *
1129
     * This method MUST retain the state of the current instance, and return
1130
     * an instance that contains the specified ending datepoint.
1131
     *
1132
     * @param mixed $duration a Duration
1133
     */
1134 12
    public function withDurationAfterStart($duration): self
1135
    {
1136 12
        return $this->endingOn($this->startDate->add(self::getDuration($duration)));
1137
    }
1138
1139
    /**
1140
     * Returns a new instance with a new starting datepoint.
1141
     *
1142
     * This method MUST retain the state of the current instance, and return
1143
     * an instance that contains the specified starting datepoint.
1144
     *
1145
     * @param mixed $duration a Duration
1146
     */
1147 12
    public function withDurationBeforeEnd($duration): self
1148
    {
1149 12
        return $this->startingOn($this->endDate->sub(self::getDuration($duration)));
1150
    }
1151
1152
    /**
1153
     * Returns a new instance with a new starting datepoint
1154
     * moved forward or backward by the given interval.
1155
     *
1156
     * This method MUST retain the state of the current instance, and return
1157
     * an instance that contains the specified starting datepoint.
1158
     *
1159
     * @param mixed $duration a Duration
1160
     */
1161 18
    public function moveStartDate($duration): self
1162
    {
1163 18
        return $this->startingOn($this->startDate->add(self::getDuration($duration)));
1164
    }
1165
1166
    /**
1167
     * Returns a new instance with a new ending datepoint
1168
     * moved forward or backward by the given interval.
1169
     *
1170
     * This method MUST retain the state of the current instance, and return
1171
     * an instance that contains the specified ending datepoint.
1172
     *
1173
     * @param mixed $duration a Duration
1174
     */
1175 15
    public function moveEndDate($duration): self
1176
    {
1177 15
        return $this->endingOn($this->endDate->add(self::getDuration($duration)));
1178
    }
1179
1180
    /**
1181
     * Returns a new instance where the datepoints
1182
     * are moved forwards or backward simultaneously by the given DateInterval.
1183
     *
1184
     * This method MUST retain the state of the current instance, and return
1185
     * an instance that contains the specified new datepoints.
1186
     *
1187
     * @param mixed $duration a Duration
1188
     */
1189 24
    public function move($duration): self
1190
    {
1191 24
        $duration = self::getDuration($duration);
1192 24
        $interval = new self($this->startDate->add($duration), $this->endDate->add($duration), $this->boundaryType);
1193 24
        if ($this->equals($interval)) {
1194 6
            return $this;
1195
        }
1196
1197 24
        return $interval;
1198
    }
1199
1200
    /**
1201
     * Returns an instance where the given DateInterval is simultaneously
1202
     * subtracted from the starting datepoint and added to the ending datepoint.
1203
     *
1204
     * Depending on the duration value, the resulting instance duration will be expanded or shrinked.
1205
     *
1206
     * This method MUST retain the state of the current instance, and return
1207
     * an instance that contains the specified new datepoints.
1208
     *
1209
     * @param mixed $duration a Duration
1210
     */
1211 24
    public function expand($duration): self
1212
    {
1213 24
        $duration = self::getDuration($duration);
1214 24
        $interval = new self($this->startDate->sub($duration), $this->endDate->add($duration), $this->boundaryType);
1215 18
        if ($this->equals($interval)) {
1216 6
            return $this;
1217
        }
1218
1219 12
        return $interval;
1220
    }
1221
}
1222