Completed
Push — master ( 1c8858...c5150e )
by ignace nyamagana
18s queued 10s
created

Period::startsBy()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 2

Importance

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

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