Completed
Pull Request — master (#73)
by ignace nyamagana
03:48
created

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

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