Passed
Pull Request — master (#93)
by ignace nyamagana
03:15
created

Period::extractDateTimeString()   B

Complexity

Conditions 7
Paths 7

Size

Total Lines 19
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

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

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