Completed
Pull Request — master (#93)
by ignace nyamagana
03:37
created

Period::fromISO8601()   A

Complexity

Conditions 5
Paths 5

Size

Total Lines 30
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 15
CRAP Score 5

Importance

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

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