Passed
Pull Request — master (#93)
by ignace nyamagana
12:07
created

Period::extractDateTimeString()   B

Complexity

Conditions 7
Paths 7

Size

Total Lines 18
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 7

Importance

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

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