Passed
Pull Request — master (#71)
by ignace nyamagana
02:00
created

Period::around()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 8
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 2

Importance

Changes 0
Metric Value
cc 2
eloc 4
nc 2
nop 2
dl 0
loc 8
rs 10
c 0
b 0
f 0
ccs 4
cts 4
cp 1
crap 2
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
    /**
35
     * The starting included datepoint.
36
     *
37
     * @var DateTimeImmutable
38
     */
39
    private $startDate;
40
41
    /**
42
     * The ending excluded datepoint.
43
     *
44
     * @var DateTimeImmutable
45
     */
46
    private $endDate;
47
48
    /**
49
     * @inheritdoc
50
     */
51 3
    public static function __set_state(array $interval)
52
    {
53 3
        return new self($interval['startDate'], $interval['endDate']);
54
    }
55
56
    /**
57
     * Creates new instance from a starting datepoint and a duration.
58
     */
59
    public static function after($datepoint, $duration): self
60
    {
61
        if (!$datepoint instanceof DateTimeImmutable) {
62
            $datepoint = Datepoint::create($datepoint);
63
        }
64 255
        
65
        return new self($datepoint, $datepoint->add(Duration::create($duration)));
66 255
    }
67 255
68 255
    /**
69 39
     * Creates new instance from a ending datepoint and a duration.
70
     */
71 246
    public static function before($datepoint, $duration): self
72 246
    {
73 246
        if (!$datepoint instanceof DateTimeImmutable) {
74
            $datepoint = Datepoint::create($datepoint);
75
        }
76
77
        return new self($datepoint->sub(Duration::create($duration)), $datepoint);
78
    }
79
80
    /**
81
     * Creates new instance where the given duration is simultaneously
82 282
     * substracted from and added to the datepoint.
83
     */
84 282
    public static function around($datepoint, $duration): self
85 246
    {
86
        if (!$datepoint instanceof DateTimeImmutable) {
87
            $datepoint = Datepoint::create($datepoint);
88 222
        }
89 78
        $duration = Duration::create($duration);
90
91
        return new self($datepoint->sub($duration), $datepoint->add($duration));
92 153
    }
93
94
    /**
95
     * Creates new instance for a specific year.
96
     */
97
    public static function fromYear(int $year): self
98
    {
99
        $startDate = (new Datepoint())->setDate($year, 1, 1)->setTime(0, 0);
100
101
        return new self($startDate, $startDate->add(new DateInterval('P1Y')));
102
    }
103
104 6
    /**
105
     * Creates new instance for a specific ISO year.
106 6
     */
107 3
    public static function fromIsoYear(int $year): self
108
    {
109
        return new self(
110 3
            (new Datepoint())->setISODate($year, 1)->setTime(0, 0),
111
            (new Datepoint())->setISODate(++$year, 1)->setTime(0, 0)
112
        );
113
    }
114
115
    /**
116
     * Creates new instance for a specific year and semester.
117
     */
118
    public static function fromSemester(int $year, int $semester = 1): self
119
    {
120
        $month = (($semester - 1) * 6) + 1;
121
        $startDate = (new Datepoint())->setDate($year, $month, 1)->setTime(0, 0);
122
123
        return new self($startDate, $startDate->add(new DateInterval('P6M')));
124
    }
125
126
    /**
127
     * Creates new instance for a specific year and quarter.
128 138
     */
129
    public static function fromQuarter(int $year, int $quarter = 1): self
130 138
    {
131
        $month = (($quarter - 1) * 3) + 1;
132 138
        $startDate = (new Datepoint())->setDate($year, $month, 1)->setTime(0, 0);
133
134
        return new self($startDate, $startDate->add(new DateInterval('P3M')));
135
    }
136
137
    /**
138
     * Creates new instance for a specific year and month.
139
     */
140
    public static function fromMonth(int $year, int $month = 1): self
141
    {
142
        $startDate = (new Datepoint())->setDate($year, $month, 1)->setTime(0, 0);
143
144
        return new self($startDate, $startDate->add(new DateInterval('P1M')));
145
    }
146
147
    /**
148
     * Creates new instance for a specific ISO8601 week.
149 177
     */
150
    public static function fromIsoWeek(int $year, int $week = 1): self
151 177
    {
152 27
        $startDate = (new Datepoint())->setISODate($year, $week, 1)->setTime(0, 0);
153
154
        return new self($startDate, $startDate->add(new DateInterval('P7D')));
155 165
    }
156 30
157
    /**
158
     * Creates new instance for a specific year, month and day.
159 153
     */
160
    public static function fromDay(int $year, int $month = 1, int $day = 1): self
161
    {
162
        $startDate = (new Datepoint())->setDate($year, $month, $day)->setTime(0, 0);
163
164
        return new self($startDate, $startDate->add(new DateInterval('P1D')));
165
    }
166
167
    /**
168
     * Creates new instance from a DatePeriod.
169
     */
170
    public static function fromDatePeriod(DatePeriod $datePeriod): self
171
    {
172
        return new self($datePeriod->getStartDate(), $datePeriod->getEndDate());
173
    }
174
175
    /**
176
     * Creates a new instance.
177 24
     *
178
     * @param mixed $startDate the starting included datepoint
179 24
     * @param mixed $endDate   the ending excluded datepoint
180
     *
181 24
     * @throws Exception If $startDate is greater than $endDate
182
     */
183
    public function __construct($startDate, $endDate)
184
    {
185
        if (!$startDate instanceof DateTimeImmutable) {
186
            $startDate = Datepoint::create($startDate);
187
        }
188
189
        if (!$endDate instanceof DateTimeImmutable) {
190
            $endDate = Datepoint::create($endDate);
191 18
        }
192
193 18
        if ($startDate > $endDate) {
194 15
            throw new Exception('The ending datepoint must be greater or equal to the starting datepoint');
195
        }
196 15
        $this->startDate = $startDate;
197
        $this->endDate = $endDate;
198
    }
199 6
200
    /**
201 6
     * Returns the starting included datepoint.
202
     */
203
    public function getStartDate(): DateTimeImmutable
204
    {
205
        return $this->startDate;
206
    }
207
208
    /**
209
     * Returns the ending excluded datepoint.
210
     */
211
    public function getEndDate(): DateTimeImmutable
212
    {
213 21
        return $this->endDate;
214
    }
215 21
216
    /**
217
     * Returns the instance duration as expressed in seconds.
218
     */
219
    public function getTimestampInterval(): float
220
    {
221
        return $this->endDate->getTimestamp() - $this->startDate->getTimestamp();
222
    }
223
224
    /**
225
     * Returns the instance duration as a DateInterval object.
226 12
     */
227
    public function getDateInterval(): DateInterval
228 12
    {
229 3
        return $this->startDate->diff($this->endDate);
230 3
    }
231 3
232
    /**
233 3
     * Allows iteration over a set of dates and times,
234
     * recurring at regular intervals, over the instance.
235
     *
236 12
     * @see http://php.net/manual/en/dateperiod.construct.php
237 6
     */
238
    public function getDatePeriod($duration, int $option = 0): DatePeriod
239 6
    {
240
        return new DatePeriod($this->startDate, Duration::create($duration), $this->endDate, $option);
241
    }
242
243
    /**
244
     * Allows iteration over a set of dates and times,
245
     * recurring at regular intervals, over the instance backwards starting from
246
     * the instance ending datepoint.
247
     */
248
    public function getDatePeriodBackwards($duration, int $option = 0): iterable
249
    {
250
        $duration = Duration::create($duration);
251
        $date = $this->endDate;
252
        if ((bool) ($option & DatePeriod::EXCLUDE_START_DATE)) {
253 75
            $date = $this->endDate->sub($duration);
254
        }
255 75
256 75
        while ($date > $this->startDate) {
257 51
            yield $date;
258
            $date = $date->sub($duration);
259
        }
260 24
    }
261
262
    /**
263
     * Returns the string representation as a ISO8601 interval format.
264
     *
265
     * @see https://en.wikipedia.org/wiki/ISO_8601#Time_intervals
266
     *
267
     * @return string
268
     */
269
    public function __toString()
270
    {
271 12
        $interval = $this->jsonSerialize();
272
273 12
        return $interval['startDate'].'/'.$interval['endDate'];
274 3
    }
275 3
276 3
    /**
277
     * Returns the JSON representation of an instance.
278 3
     *
279
     * Based on the JSON representation of dates as
280
     * returned by Javascript Date.toJSON() method.
281 12
     *
282 6
     * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toJSON
283
     * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString
284 6
     *
285
     * @return array<string>
286
     */
287
    public function jsonSerialize()
288
    {
289
        $utc = new DateTimeZone('UTC');
290
291
        return [
292
            'startDate' => $this->startDate->setTimezone($utc)->format(self::ISO8601_FORMAT),
293
            'endDate' => $this->endDate->setTimezone($utc)->format(self::ISO8601_FORMAT),
294
        ];
295 39
    }
296
297 39
    /**
298 6
     * Returns the mathematical representation of an instance as a left close, right open interval.
299
     *
300 6
     * @see https://en.wikipedia.org/wiki/Interval_(mathematics)#Notations_for_intervals
301
     * @see https://php.net/manual/en/function.date.php
302
     * @see https://www.postgresql.org/docs/9.3/static/rangetypes.html
303 36
     *
304
     * @param string $format the format of the outputted date string
305 30
     */
306
    public function format(string $format): string
307
    {
308
        return '['.$this->startDate->format($format).', '.$this->endDate->format($format).')';
309
    }
310
311
    /**
312
     * Compares two instances according to their duration.
313
     *
314
     * Returns:
315
     * <ul>
316 24
     * <li> -1 if the current Interval is lesser than the submitted Interval object</li>
317
     * <li>  1 if the current Interval is greater than the submitted Interval object</li>
318 24
     * <li>  0 if both Interval objects have the same duration</li>
319 3
     * </ul>
320 3
     */
321 3
    public function durationCompare(self $interval): int
322 3
    {
323
        return $this->endDate <=> $this->startDate->add($interval->getDateInterval());
324
    }
325 3
326
    /**
327
     * Tells whether the current instance duration is equal to the submitted one.
328 24
     */
329 24
    public function durationEquals(self $interval): bool
330 18
    {
331
        return 0 === $this->durationCompare($interval);
332
    }
333 18
334
    /**
335
     * Tells whether the current instance duration is greater than the submitted one.
336
     */
337
    public function durationGreaterThan(self $interval): bool
338
    {
339
        return 1 === $this->durationCompare($interval);
340
    }
341
342
    /**
343
     * Tells whether the current instance duration is less than the submitted one.
344
     */
345
    public function durationLessThan(self $interval): bool
346 3
    {
347
        return -1 === $this->durationCompare($interval);
348 3
    }
349
350 3
    /**
351
     * Tells whether two intervals share the same datepoints.
352
     *
353
     * [--------------------)
354
     * [--------------------)
355
     */
356
    public function equals(self $interval): bool
357
    {
358
        return $this->startDate == $interval->startDate
359
            && $this->endDate == $interval->endDate;
360
    }
361
362
    /**
363 3
     * Tells whether two intervals abuts.
364
     *
365 3
     * [--------------------)
366
     *                      [--------------------)
367 3
     * or
368
     *                      [--------------------)
369
     * [--------------------)
370
     */
371
    public function abuts(self $interval): bool
372
    {
373
        return $this->startDate == $interval->endDate
374
            || $this->endDate == $interval->startDate;
375
    }
376
377
    /**
378
     * Tells whether two intervals overlaps.
379
     *
380 3
     * [--------------------)
381
     *          [--------------------)
382 3
     */
383
    public function overlaps(self $interval): bool
384 3
    {
385
        return $this->startDate < $interval->endDate
386
            && $this->endDate > $interval->startDate;
387
    }
388
389
    /**
390
     * Tells whether an interval is entirely after the specified index.
391
     * The index can be a DateTimeInterface object or another Period object.
392
     *
393
     *                          [--------------------)
394
     * [--------------------)
395
     */
396
    public function isAfter($index): bool
397 3
    {
398
        if ($index instanceof self) {
399 3
            return $this->startDate >= $index->endDate;
400
        }
401 3
402
        return $this->startDate > Datepoint::create($index);
403
    }
404
405
    /**
406
     * Tells whether an instance is entirely before the specified index.
407
     *
408
     * The index can be a DateTimeInterface object or another Period object.
409
     *
410
     * [--------------------)
411
     *                          [--------------------)
412 177
     */
413
    public function isBefore($index): bool
414 177
    {
415
        if ($index instanceof self) {
416
            return $this->endDate <= $index->startDate;
417
        }
418
419
        return $this->endDate <= Datepoint::create($index);
420
    }
421
422
    /**
423
     * Tells whether an instance fully contains the specified index.
424
     *
425 162
     * The index can be a DateTimeInterface object or another Period object.
426
     *
427 162
     */
428
    public function contains($index): bool
429
    {
430
        if ($index instanceof self) {
431
            return $this->containsInterval($index);
432
        }
433
434
        return $this->containsDatepoint(Datepoint::create($index));
435 18
    }
436
437 18
    /**
438
     * Tells whether an instance fully contains another instance.
439
     *
440
     * [--------------------)
441
     *     [----------)
442
     */
443
    private function containsInterval(self $interval): bool
444
    {
445 33
        return $this->containsDatepoint($interval->startDate)
446
            && ($interval->endDate >= $this->startDate && $interval->endDate <= $this->endDate);
447 33
    }
448
449
    /**
450
     * Tells whether an instance contains a datepoint.
451
     *
452
     * [------|------------)
453
     */
454
    private function containsDatepoint(DateTimeInterface $datepoint): bool
455
    {
456
        return $datepoint >= $this->startDate && $datepoint < $this->endDate;
457
    }
458
459
    /**
460
     * Allows splitting an instance in smaller Period objects according to a given interval.
461
     *
462
     * The returned iterable Interval set is ordered so that:
463
     * <ul>
464
     * <li>The first returned object MUST share the starting datepoint of the parent object.</li>
465
     * <li>The last returned object MUST share the ending datepoint of the parent object.</li>
466
     * <li>The last returned object MUST have a duration equal or lesser than the submitted interval.</li>
467
     * <li>All returned objects except for the first one MUST start immediately after the previously returned object</li>
468
     * </ul>
469 24
     *
470
     * @return iterable<Period>
471 24
     */
472
    public function split($duration): iterable
473
    {
474
        $duration = Duration::create($duration);
475
        foreach ($this->getDatePeriod($duration) as $startDate) {
476
            $endDate = $startDate->add($duration);
477
            if ($endDate > $this->endDate) {
478
                $endDate = $this->endDate;
479
            }
480
481
            yield new self($startDate, $endDate);
482
        }
483
    }
484
485
    /**
486
     * Allows splitting an instance in smaller Period objects according to a given interval.
487
     *
488
     * The returned iterable Period set is ordered so that:
489
     * <ul>
490
     * <li>The first returned object MUST share the ending datepoint of the parent object.</li>
491
     * <li>The last returned object MUST share the starting datepoint of the parent object.</li>
492
     * <li>The last returned object MUST have a duration equal or lesser than the submitted interval.</li>
493
     * <li>All returned objects except for the first one MUST end immediately before the previously returned object</li>
494
     * </ul>
495
     *
496
     * @return iterable<Period>
497 12
     */
498
    public function splitBackwards($duration): iterable
499 12
    {
500 12
        $endDate = $this->endDate;
501
        $duration = Duration::create($duration);
502 12
        do {
503 12
            $startDate = $endDate->sub($duration);
504 6
            if ($startDate < $this->startDate) {
505
                $startDate = $this->startDate;
506 12
            }
507
            yield new self($startDate, $endDate);
508 9
509 9
            $endDate = $startDate;
510 9
        } while ($endDate > $this->startDate);
511
    }
512
513
    /**
514
     * Returns the computed intersection between two instances as a new instance.
515
     *
516
     * [--------------------)
517
     *          ∩
518
     *                 [----------)
519
     *          =
520
     *                 [----)
521
     *
522
     * @throws Exception If both objects do not overlaps
523
     */
524
    public function intersect(self $interval): self
525
    {
526
        if (!$this->overlaps($interval)) {
527
            throw new Exception('Both '.self::class.' objects should overlaps');
528
        }
529
530
        return new self(
531
            ($interval->startDate > $this->startDate) ? $interval->startDate : $this->startDate,
532
            ($interval->endDate < $this->endDate) ? $interval->endDate : $this->endDate
533
        );
534
    }
535 6
536
    /**
537 6
     * Returns the computed difference between two overlapping instances as
538 6
     * an array containing Period objects or the null value.
539
     *
540 6
     * The array will always contains 2 elements:
541 6
     *
542 3
     * <ul>
543
     * <li>an NULL filled array if both objects have the same datepoints</li>
544 6
     * <li>one Period object and NULL if both objects share one datepoint</li>
545
     * <li>two Period objects if both objects share no datepoint</li>
546 6
     * </ul>
547 6
     *
548 6
     * [--------------------)
549
     *          \
550
     *                [-----------)
551
     *          =
552
     * [--------------)  +  [-----)
553
     *
554
     * @return array<null|Period>
555
     */
556
    public function diff(self $interval): array
557
    {
558 3
        if ($interval->equals($this)) {
559
            return [null, null];
560 3
        }
561
562 3
        $intersect = $this->intersect($interval);
563
        $merge = $this->merge($interval);
564
        if ($merge->startDate == $intersect->startDate) {
565
            return [$merge->startingOn($intersect->endDate), null];
566
        }
567
568
        if ($merge->endDate == $intersect->endDate) {
569
            return [$merge->endingOn($intersect->startDate), null];
570
        }
571
572
        return [
573 6
            $merge->endingOn($intersect->startDate),
574
            $merge->startingOn($intersect->endDate),
575 6
        ];
576 6
    }
577 6
578
    /**
579
     * Returns the computed gap between two instances as a new instance.
580 6
     *
581 6
     * [--------------------)
582
     *          +
583
     *                          [----------)
584
     *          =
585
     *                      [---)
586
     *
587
     * @throws Exception If both instance overlaps
588
     */
589
    public function gap(self $interval): self
590
    {
591
        if ($this->overlaps($interval)) {
592
            throw new Exception('Both '.self::class.' objects must not overlaps');
593
        }
594
595
        if ($interval->startDate > $this->startDate) {
596
            return new self($this->endDate, $interval->startDate);
597
        }
598
599 21
        return new self($interval->endDate, $this->startDate);
600
    }
601 21
602
    /**
603
     * Returns the difference between two instances expressed in seconds.
604
     */
605
    public function timestampIntervalDiff(self $interval): float
606
    {
607
        return $this->getTimestampInterval() - $interval->getTimestampInterval();
608
    }
609
610
    /**
611
     * Returns the difference between two instances expressed with a DateInterval object.
612 9
     */
613
    public function dateIntervalDiff(self $interval): DateInterval
614 9
    {
615
        return $this->endDate->diff($this->startDate->add($interval->getDateInterval()));
616
    }
617
618
    /**
619
     * Returns an instance with the specified starting datepoint.
620
     *
621
     * This method MUST retain the state of the current instance, and return
622
     * an instance that contains the specified starting datepoint.
623
     */
624
    public function startingOn($datepoint): self
625 9
    {
626
        $startDate = Datepoint::create($datepoint);
627 9
        if ($startDate == $this->startDate) {
628
            return $this;
629
        }
630
631
        return new self($startDate, $this->endDate);
632
    }
633
634
    /**
635
     * Returns an instance with the specified ending datepoint.
636
     *
637
     * This method MUST retain the state of the current instance, and return
638 3
     * an instance that contains the specified ending datepoint.
639
     */
640 3
    public function endingOn($datepoint): self
641
    {
642
        $endDate = Datepoint::create($datepoint);
643
        if ($endDate == $this->endDate) {
644
            return $this;
645
        }
646
647
        return new self($this->startDate, $endDate);
648
    }
649
650 18
    /**
651
     * Returns a new instance with a new ending datepoint.
652 18
     *
653 18
     * This method MUST retain the state of the current instance, and return
654
     * an instance that contains the specified ending datepoint.
655
     */
656
    public function withDurationAfterStart($duration): self
657
    {
658
        return $this->endingOn($this->startDate->add(Duration::create($duration)));
659
    }
660
661
    /**
662
     * Returns a new instance with a new starting datepoint.
663 42
     *
664
     * This method MUST retain the state of the current instance, and return
665 42
     * an instance that contains the specified starting datepoint.
666 42
     */
667
    public function withDurationBeforeEnd($duration): self
668
    {
669
        return $this->startingOn($this->endDate->sub(Duration::create($duration)));
670
    }
671
672
    /**
673
     * Returns a new instance with a new starting datepoint
674
     * moved forward or backward by the given interval.
675
     *
676 36
     * This method MUST retain the state of the current instance, and return
677
     * an instance that contains the specified starting datepoint.
678 36
     */
679 36
    public function moveStartDate($duration): self
680 36
    {
681
        return $this->startingOn($this->startDate->add(Duration::create($duration)));
682
    }
683
684
    /**
685
     * Returns a new instance with a new ending datepoint
686
     * moved forward or backward by the given interval.
687
     *
688
     * This method MUST retain the state of the current instance, and return
689
     * an instance that contains the specified ending datepoint.
690
     */
691
    public function moveEndDate($duration): self
692
    {
693
        return $this->endingOn($this->endDate->add(Duration::create($duration)));
694
    }
695
696
    /**
697 9
     * Returns a new instance where the datepoints
698
     * are moved forwards or backward simultaneously by the given DateInterval.
699 9
     *
700 3
     * This method MUST retain the state of the current instance, and return
701
     * an instance that contains the specified new datepoints.
702
     */
703 6
    public function move($duration): self
704
    {
705
        $duration = Duration::create($duration);
706
        $interval = new self($this->startDate->add($duration), $this->endDate->add($duration));
707
        if ($this->equals($interval)) {
708
            return $this;
709
        }
710
711
        return $interval;
712
    }
713
714
    /**
715
     * Returns an instance where the given DateInterval is simultaneously
716
     * substracted from the starting datepoint and added to the ending datepoint.
717
     *
718
     * Depending on the duration value, the resulting instance duration will be expanded or shrinked.
719
     *
720 12
     * This method MUST retain the state of the current instance, and return
721
     * an instance that contains the specified new datepoints.
722 12
     */
723 6
    public function expand($duration): self
724
    {
725
        $duration = Duration::create($duration);
726 6
        $interval = new self($this->startDate->sub($duration), $this->endDate->add($duration));
727
        if ($this->equals($interval)) {
728
            return $this;
729
        }
730
731
        return $interval;
732
    }
733
734
    /**
735
     * Merges one or more instances to return a new instance.
736
     * The resulting instance represents the largest duration possible.
737
     *
738
     * This method MUST retain the state of the current instance, and return
739
     * an instance that contains the specified new datepoints.
740
     *
741
     * [--------------------)
742
     *          U
743
     *                 [----------)
744 24
     *          =
745
     * [--------------------------)
746 24
     *
747 9
     *
748
     * @param Period ...$intervals
749
     */
750 24
    public function merge(self $interval, self ...$intervals): self
751
    {
752
        $intervals[] = $interval;
753
        $carry = $this;
754
        foreach ($intervals as $interval) {
755
            if ($carry->startDate > $interval->startDate) {
756
                $carry = $carry->startingOn($interval->startDate);
757
            }
758
759
            if ($carry->endDate < $interval->endDate) {
760
                $carry = $carry->endingOn($interval->endDate);
761 9
            }
762
        }
763 9
764 9
        return $carry;
765
    }
766
}
767