Completed
Pull Request — master (#92)
by ignace nyamagana
04:21 queued 02:43
created

Period::fromIsoString()   B

Complexity

Conditions 6
Paths 7

Size

Total Lines 35
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 21
CRAP Score 6

Importance

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

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