1
|
|
|
<?php |
2
|
|
|
|
3
|
|
|
namespace Spatie\Period; |
4
|
|
|
|
5
|
|
|
use DateTime; |
6
|
|
|
use DateInterval; |
7
|
|
|
use DateTimeImmutable; |
8
|
|
|
use DateTimeInterface; |
9
|
|
|
use Spatie\Period\Exceptions\InvalidDate; |
10
|
|
|
use Spatie\Period\Exceptions\InvalidPeriod; |
11
|
|
|
use Spatie\Period\Exceptions\CannotComparePeriods; |
12
|
|
|
|
13
|
|
|
class Period |
14
|
|
|
{ |
15
|
|
|
/** @var \DateTimeImmutable */ |
16
|
|
|
protected $start; |
17
|
|
|
|
18
|
|
|
/** @var \DateTimeImmutable */ |
19
|
|
|
protected $end; |
20
|
|
|
|
21
|
|
|
/** @var \DateInterval */ |
22
|
|
|
protected $interval; |
23
|
|
|
|
24
|
|
|
/** @var \DateTimeImmutable */ |
25
|
|
|
private $includedStart; |
26
|
|
|
|
27
|
|
|
/** @var \DateTimeImmutable */ |
28
|
|
|
private $includedEnd; |
29
|
|
|
|
30
|
|
|
/** @var int */ |
31
|
|
|
private $boundaryExclusionMask; |
32
|
|
|
|
33
|
|
|
/** @var int */ |
34
|
|
|
private $precisionMask; |
35
|
|
|
|
36
|
|
|
public function __construct( |
37
|
|
|
DateTimeImmutable $start, |
38
|
|
|
DateTimeImmutable $end, |
39
|
|
|
?int $precisionMask = null, |
40
|
|
|
?int $boundaryExclusionMask = null |
41
|
|
|
) { |
42
|
|
|
if ($start > $end) { |
43
|
|
|
throw InvalidPeriod::endBeforeStart($start, $end); |
44
|
|
|
} |
45
|
|
|
|
46
|
|
|
$this->boundaryExclusionMask = $boundaryExclusionMask ?? Boundaries::EXCLUDE_NONE; |
47
|
|
|
$this->precisionMask = $precisionMask ?? Precision::DAY; |
48
|
|
|
|
49
|
|
|
$this->start = $this->roundDate($start, $this->precisionMask); |
50
|
|
|
$this->end = $this->roundDate($end, $this->precisionMask); |
51
|
|
|
$this->interval = $this->createDateInterval($this->precisionMask); |
52
|
|
|
|
53
|
|
|
$this->includedStart = $this->startIncluded() |
54
|
|
|
? $this->start |
55
|
|
|
: $this->start->add($this->interval); |
56
|
|
|
|
57
|
|
|
$this->includedEnd = $this->endIncluded() |
|
|
|
|
58
|
|
|
? $this->end |
59
|
|
|
: $this->end->sub($this->interval); |
60
|
|
|
} |
61
|
|
|
|
62
|
|
|
/** |
63
|
|
|
* @param string|DateTimeInterface $start |
64
|
|
|
* @param string|DateTimeInterface $end |
65
|
|
|
* @param int|null $precisionMask |
66
|
|
|
* @param int|null $boundaryExclusionMask |
67
|
|
|
* @param string|null $format |
68
|
|
|
* |
69
|
|
|
* @return \Spatie\Period\Period|static |
70
|
|
|
*/ |
71
|
|
|
public static function make( |
72
|
|
|
$start, |
73
|
|
|
$end, |
74
|
|
|
?int $precisionMask = null, |
75
|
|
|
?int $boundaryExclusionMask = null, |
76
|
|
|
?string $format = null |
77
|
|
|
): Period { |
78
|
|
|
if ($start === null) { |
79
|
|
|
throw InvalidDate::cannotBeNull('Start date'); |
80
|
|
|
} |
81
|
|
|
|
82
|
|
|
if ($end === null) { |
83
|
|
|
throw InvalidDate::cannotBeNull('End date'); |
84
|
|
|
} |
85
|
|
|
|
86
|
|
|
return new static( |
87
|
|
|
self::resolveDate($start, $format), |
88
|
|
|
self::resolveDate($end, $format), |
89
|
|
|
$precisionMask, |
90
|
|
|
$boundaryExclusionMask |
91
|
|
|
); |
92
|
|
|
} |
93
|
|
|
|
94
|
|
|
public function startIncluded(): bool |
95
|
|
|
{ |
96
|
|
|
return ! $this->startExcluded(); |
97
|
|
|
} |
98
|
|
|
|
99
|
|
|
public function startExcluded(): bool |
100
|
|
|
{ |
101
|
|
|
return Boundaries::EXCLUDE_START & $this->boundaryExclusionMask; |
102
|
|
|
} |
103
|
|
|
|
104
|
|
|
public function endIncluded(): bool |
105
|
|
|
{ |
106
|
|
|
return ! $this->endExcluded(); |
107
|
|
|
} |
108
|
|
|
|
109
|
|
|
public function endExcluded(): bool |
110
|
|
|
{ |
111
|
|
|
return Boundaries::EXCLUDE_END & $this->boundaryExclusionMask; |
112
|
|
|
} |
113
|
|
|
|
114
|
|
|
public function getStart(): DateTimeImmutable |
115
|
|
|
{ |
116
|
|
|
return $this->start; |
117
|
|
|
} |
118
|
|
|
|
119
|
|
|
public function getIncludedStart(): DateTimeImmutable |
120
|
|
|
{ |
121
|
|
|
return $this->includedStart; |
122
|
|
|
} |
123
|
|
|
|
124
|
|
|
public function getEnd(): DateTimeImmutable |
125
|
|
|
{ |
126
|
|
|
return $this->end; |
127
|
|
|
} |
128
|
|
|
|
129
|
|
|
public function getIncludedEnd(): DateTimeImmutable |
130
|
|
|
{ |
131
|
|
|
return $this->includedEnd; |
132
|
|
|
} |
133
|
|
|
|
134
|
|
|
public function length(): int |
135
|
|
|
{ |
136
|
|
|
$length = $this->getIncludedStart()->diff($this->getIncludedEnd())->days + 1; |
137
|
|
|
|
138
|
|
|
return $length; |
139
|
|
|
} |
140
|
|
|
|
141
|
|
|
public function overlapsWith(Period $period): bool |
142
|
|
|
{ |
143
|
|
|
$this->ensurePrecisionMatches($period); |
144
|
|
|
|
145
|
|
|
if ($this->getIncludedStart() > $period->getIncludedEnd()) { |
146
|
|
|
return false; |
147
|
|
|
} |
148
|
|
|
|
149
|
|
|
if ($period->getIncludedStart() > $this->getIncludedEnd()) { |
150
|
|
|
return false; |
151
|
|
|
} |
152
|
|
|
|
153
|
|
|
return true; |
154
|
|
|
} |
155
|
|
|
|
156
|
|
|
public function touchesWith(Period $period): bool |
157
|
|
|
{ |
158
|
|
|
$this->ensurePrecisionMatches($period); |
159
|
|
|
|
160
|
|
|
if ($this->getIncludedEnd()->diff($period->getIncludedStart())->days <= 1) { |
161
|
|
|
return true; |
162
|
|
|
} |
163
|
|
|
|
164
|
|
|
if ($this->getIncludedStart()->diff($period->getIncludedEnd())->days <= 1) { |
165
|
|
|
return true; |
166
|
|
|
} |
167
|
|
|
|
168
|
|
|
return false; |
169
|
|
|
} |
170
|
|
|
|
171
|
|
|
public function startsBefore(DateTimeInterface $date): bool |
172
|
|
|
{ |
173
|
|
|
return $this->getIncludedStart() < $date; |
174
|
|
|
} |
175
|
|
|
|
176
|
|
|
public function startsBeforeOrAt(DateTimeInterface $date): bool |
177
|
|
|
{ |
178
|
|
|
return $this->getIncludedStart() <= $date; |
179
|
|
|
} |
180
|
|
|
|
181
|
|
|
public function startsAfter(DateTimeInterface $date): bool |
182
|
|
|
{ |
183
|
|
|
return $this->getIncludedStart() > $date; |
184
|
|
|
} |
185
|
|
|
|
186
|
|
|
public function startsAfterOrAt(DateTimeInterface $date): bool |
187
|
|
|
{ |
188
|
|
|
return $this->getIncludedStart() >= $date; |
189
|
|
|
} |
190
|
|
|
|
191
|
|
|
public function startsAt(DateTimeInterface $date): bool |
192
|
|
|
{ |
193
|
|
|
return $this->getIncludedStart()->getTimestamp() === $this->roundDate( |
194
|
|
|
$date, |
195
|
|
|
$this->precisionMask |
196
|
|
|
)->getTimestamp(); |
197
|
|
|
} |
198
|
|
|
|
199
|
|
|
public function endsBefore(DateTimeInterface $date): bool |
200
|
|
|
{ |
201
|
|
|
return $this->getIncludedEnd() < $this->roundDate( |
202
|
|
|
$date, |
203
|
|
|
$this->precisionMask |
204
|
|
|
); |
205
|
|
|
} |
206
|
|
|
|
207
|
|
|
public function endsBeforeOrAt(DateTimeInterface $date): bool |
208
|
|
|
{ |
209
|
|
|
return $this->getIncludedEnd() <= $this->roundDate( |
210
|
|
|
$date, |
211
|
|
|
$this->precisionMask |
212
|
|
|
); |
213
|
|
|
} |
214
|
|
|
|
215
|
|
|
public function endsAfter(DateTimeInterface $date): bool |
216
|
|
|
{ |
217
|
|
|
return $this->getIncludedEnd() > $this->roundDate( |
218
|
|
|
$date, |
219
|
|
|
$this->precisionMask |
220
|
|
|
); |
221
|
|
|
} |
222
|
|
|
|
223
|
|
|
public function endsAfterOrAt(DateTimeInterface $date): bool |
224
|
|
|
{ |
225
|
|
|
return $this->getIncludedEnd() >= $this->roundDate( |
226
|
|
|
$date, |
227
|
|
|
$this->precisionMask |
228
|
|
|
); |
229
|
|
|
} |
230
|
|
|
|
231
|
|
|
public function endsAt(DateTimeInterface $date): bool |
232
|
|
|
{ |
233
|
|
|
return $this->getIncludedEnd()->getTimestamp() === $this->roundDate( |
234
|
|
|
$date, |
235
|
|
|
$this->precisionMask |
236
|
|
|
)->getTimestamp(); |
237
|
|
|
} |
238
|
|
|
|
239
|
|
|
public function contains(DateTimeInterface $date): bool |
240
|
|
|
{ |
241
|
|
|
if ($this->roundDate($date, $this->precisionMask) < $this->getIncludedStart()) { |
242
|
|
|
return false; |
243
|
|
|
} |
244
|
|
|
|
245
|
|
|
if ($this->roundDate($date, $this->precisionMask) > $this->getIncludedEnd()) { |
246
|
|
|
return false; |
247
|
|
|
} |
248
|
|
|
|
249
|
|
|
return true; |
250
|
|
|
} |
251
|
|
|
|
252
|
|
|
public function equals(Period $period): bool |
253
|
|
|
{ |
254
|
|
|
$this->ensurePrecisionMatches($period); |
255
|
|
|
|
256
|
|
|
if ($period->getIncludedStart()->getTimestamp() !== $this->getIncludedStart()->getTimestamp()) { |
257
|
|
|
return false; |
258
|
|
|
} |
259
|
|
|
|
260
|
|
|
if ($period->getIncludedEnd()->getTimestamp() !== $this->getIncludedEnd()->getTimestamp()) { |
261
|
|
|
return false; |
262
|
|
|
} |
263
|
|
|
|
264
|
|
|
return true; |
265
|
|
|
} |
266
|
|
|
|
267
|
|
|
/** |
268
|
|
|
* @param \Spatie\Period\Period $period |
269
|
|
|
* |
270
|
|
|
* @return \Spatie\Period\Period|static|null |
271
|
|
|
* @throws \Exception |
272
|
|
|
*/ |
273
|
|
|
public function gap(Period $period): ?Period |
274
|
|
|
{ |
275
|
|
|
$this->ensurePrecisionMatches($period); |
276
|
|
|
|
277
|
|
|
if ($this->overlapsWith($period)) { |
278
|
|
|
return null; |
279
|
|
|
} |
280
|
|
|
|
281
|
|
|
if ($this->touchesWith($period)) { |
282
|
|
|
return null; |
283
|
|
|
} |
284
|
|
|
|
285
|
|
|
if ($this->getIncludedStart() >= $period->getIncludedEnd()) { |
286
|
|
|
return static::make( |
287
|
|
|
$period->getIncludedEnd()->add($this->interval), |
288
|
|
|
$this->getIncludedStart()->sub($this->interval) |
|
|
|
|
289
|
|
|
); |
290
|
|
|
} |
291
|
|
|
|
292
|
|
|
return static::make( |
293
|
|
|
$this->getIncludedEnd()->add($this->interval), |
294
|
|
|
$period->getIncludedStart()->sub($this->interval) |
|
|
|
|
295
|
|
|
); |
296
|
|
|
} |
297
|
|
|
|
298
|
|
|
/** |
299
|
|
|
* @param \Spatie\Period\Period $period |
300
|
|
|
* |
301
|
|
|
* @return \Spatie\Period\Period|static|null |
302
|
|
|
*/ |
303
|
|
|
public function overlapSingle(Period $period): ?Period |
304
|
|
|
{ |
305
|
|
|
$this->ensurePrecisionMatches($period); |
306
|
|
|
|
307
|
|
|
$start = $this->getIncludedStart() > $period->getIncludedStart() |
308
|
|
|
? $this->getIncludedStart() |
309
|
|
|
: $period->getIncludedStart(); |
310
|
|
|
|
311
|
|
|
$end = $this->getIncludedEnd() < $period->getIncludedEnd() |
312
|
|
|
? $this->getIncludedEnd() |
313
|
|
|
: $period->getIncludedEnd(); |
314
|
|
|
|
315
|
|
|
if ($start > $end) { |
316
|
|
|
return null; |
317
|
|
|
} |
318
|
|
|
|
319
|
|
|
return static::make($start, $end); |
320
|
|
|
} |
321
|
|
|
|
322
|
|
|
/** |
323
|
|
|
* @param \Spatie\Period\Period ...$periods |
324
|
|
|
* |
325
|
|
|
* @return \Spatie\Period\PeriodCollection|static[] |
326
|
|
|
*/ |
327
|
|
|
public function overlap(Period ...$periods): PeriodCollection |
328
|
|
|
{ |
329
|
|
|
$overlapCollection = new PeriodCollection(); |
330
|
|
|
|
331
|
|
|
foreach ($periods as $period) { |
332
|
|
|
$overlap = $this->overlapSingle($period); |
333
|
|
|
|
334
|
|
|
if ($overlap === null) { |
335
|
|
|
continue; |
336
|
|
|
} |
337
|
|
|
|
338
|
|
|
$overlapCollection[] = $overlap; |
339
|
|
|
} |
340
|
|
|
|
341
|
|
|
return $overlapCollection; |
342
|
|
|
} |
343
|
|
|
|
344
|
|
|
/** |
345
|
|
|
* @param \Spatie\Period\Period ...$periods |
346
|
|
|
* |
347
|
|
|
* @return \Spatie\Period\Period|static |
348
|
|
|
*/ |
349
|
|
|
public function overlapAll(Period ...$periods): Period |
350
|
|
|
{ |
351
|
|
|
$overlap = clone $this; |
352
|
|
|
|
353
|
|
|
if (! count($periods)) { |
354
|
|
|
return $overlap; |
355
|
|
|
} |
356
|
|
|
|
357
|
|
|
foreach ($periods as $period) { |
358
|
|
|
$overlap = $overlap->overlapSingle($period); |
359
|
|
|
} |
360
|
|
|
|
361
|
|
|
return $overlap; |
362
|
|
|
} |
363
|
|
|
|
364
|
|
|
public function diffSingle(Period $period): PeriodCollection |
365
|
|
|
{ |
366
|
|
|
$periodCollection = new PeriodCollection(); |
367
|
|
|
|
368
|
|
|
if (! $this->overlapsWith($period)) { |
369
|
|
|
$periodCollection[] = clone $this; |
370
|
|
|
$periodCollection[] = clone $period; |
371
|
|
|
|
372
|
|
|
return $periodCollection; |
373
|
|
|
} |
374
|
|
|
|
375
|
|
|
$overlap = $this->overlapSingle($period); |
376
|
|
|
|
377
|
|
|
$start = $this->getIncludedStart() < $period->getIncludedStart() |
378
|
|
|
? $this->getIncludedStart() |
379
|
|
|
: $period->getIncludedStart(); |
380
|
|
|
|
381
|
|
|
$end = $this->getIncludedEnd() > $period->getIncludedEnd() |
382
|
|
|
? $this->getIncludedEnd() |
383
|
|
|
: $period->getIncludedEnd(); |
384
|
|
|
|
385
|
|
|
if ($overlap->getIncludedStart() > $start) { |
386
|
|
|
$periodCollection[] = static::make( |
387
|
|
|
$start, |
388
|
|
|
$overlap->getIncludedStart()->sub($this->interval) |
389
|
|
|
); |
390
|
|
|
} |
391
|
|
|
|
392
|
|
|
if ($overlap->getIncludedEnd() < $end) { |
393
|
|
|
$periodCollection[] = static::make( |
394
|
|
|
$overlap->getIncludedEnd()->add($this->interval), |
395
|
|
|
$end |
396
|
|
|
); |
397
|
|
|
} |
398
|
|
|
|
399
|
|
|
return $periodCollection; |
400
|
|
|
} |
401
|
|
|
|
402
|
|
|
/** |
403
|
|
|
* @param \Spatie\Period\Period ...$periods |
404
|
|
|
* |
405
|
|
|
* @return \Spatie\Period\PeriodCollection|static[] |
406
|
|
|
*/ |
407
|
|
|
public function diff(Period ...$periods): PeriodCollection |
408
|
|
|
{ |
409
|
|
|
if (count($periods) === 1 && ! $this->overlapsWith($periods[0])) { |
410
|
|
|
$collection = new PeriodCollection(); |
411
|
|
|
|
412
|
|
|
$collection[] = $this->gap($periods[0]); |
413
|
|
|
|
414
|
|
|
return $collection; |
415
|
|
|
} |
416
|
|
|
|
417
|
|
|
$diffs = []; |
418
|
|
|
|
419
|
|
|
foreach ($periods as $period) { |
420
|
|
|
$diffs[] = $this->diffSingle($period); |
421
|
|
|
} |
422
|
|
|
|
423
|
|
|
$collection = (new PeriodCollection($this))->overlap(...$diffs); |
424
|
|
|
|
425
|
|
|
return $collection; |
426
|
|
|
} |
427
|
|
|
|
428
|
|
|
public function getPrecisionMask(): int |
429
|
|
|
{ |
430
|
|
|
return $this->precisionMask; |
431
|
|
|
} |
432
|
|
|
|
433
|
|
|
protected static function resolveDate($date, ?string $format): DateTimeImmutable |
434
|
|
|
{ |
435
|
|
|
if ($date instanceof DateTimeImmutable) { |
436
|
|
|
return $date; |
437
|
|
|
} |
438
|
|
|
|
439
|
|
|
if ($date instanceof DateTime) { |
440
|
|
|
return DateTimeImmutable::createFromMutable($date); |
441
|
|
|
} |
442
|
|
|
|
443
|
|
|
$format = self::resolveFormat($date, $format); |
444
|
|
|
|
445
|
|
|
if (! is_string($date)) { |
446
|
|
|
throw InvalidDate::forFormat($date, $format); |
447
|
|
|
} |
448
|
|
|
|
449
|
|
|
$dateTime = DateTimeImmutable::createFromFormat($format, $date); |
450
|
|
|
|
451
|
|
|
if ($dateTime === false) { |
452
|
|
|
throw InvalidDate::forFormat($date, $format); |
453
|
|
|
} |
454
|
|
|
|
455
|
|
|
if (strpos($format, ' ') === false) { |
456
|
|
|
$dateTime = $dateTime->setTime(0, 0, 0); |
457
|
|
|
} |
458
|
|
|
|
459
|
|
|
return $dateTime; |
460
|
|
|
} |
461
|
|
|
|
462
|
|
|
protected static function resolveFormat($date, ?string $format): string |
463
|
|
|
{ |
464
|
|
|
if ($format !== null) { |
465
|
|
|
return $format; |
466
|
|
|
} |
467
|
|
|
|
468
|
|
|
if (strpos($format, ' ') === false && strpos($date, ' ') !== false) { |
469
|
|
|
return 'Y-m-d H:i:s'; |
470
|
|
|
} |
471
|
|
|
|
472
|
|
|
return 'Y-m-d'; |
473
|
|
|
} |
474
|
|
|
|
475
|
|
|
protected function roundDate(DateTimeInterface $date, int $precision): DateTimeImmutable |
476
|
|
|
{ |
477
|
|
|
[$year, $month, $day, $hour, $minute, $second] = explode(' ', $date->format('Y m d H i s')); |
|
|
|
|
478
|
|
|
|
479
|
|
|
$month = (Precision::MONTH & $precision) === Precision::MONTH ? $month : '01'; |
|
|
|
|
480
|
|
|
$day = (Precision::DAY & $precision) === Precision::DAY ? $day : '01'; |
|
|
|
|
481
|
|
|
$hour = (Precision::HOUR & $precision) === Precision::HOUR ? $hour : '00'; |
|
|
|
|
482
|
|
|
$minute = (Precision::MINUTE & $precision) === Precision::MINUTE ? $minute : '00'; |
|
|
|
|
483
|
|
|
$second = (Precision::SECOND & $precision) === Precision::SECOND ? $second : '00'; |
|
|
|
|
484
|
|
|
|
485
|
|
|
return DateTimeImmutable::createFromFormat( |
486
|
|
|
'Y m d H i s', |
487
|
|
|
implode(' ', [$year, $month, $day, $hour, $minute, $second]) |
488
|
|
|
); |
489
|
|
|
} |
490
|
|
|
|
491
|
|
|
protected function createDateInterval(int $precision): DateInterval |
492
|
|
|
{ |
493
|
|
|
$interval = [ |
494
|
|
|
Precision::SECOND => 'PT1S', |
495
|
|
|
Precision::MINUTE => 'PT1M', |
496
|
|
|
Precision::HOUR => 'PT1H', |
497
|
|
|
Precision::DAY => 'P1D', |
498
|
|
|
Precision::MONTH => 'P1M', |
499
|
|
|
Precision::YEAR => 'P1Y', |
500
|
|
|
][$precision]; |
501
|
|
|
|
502
|
|
|
return new DateInterval($interval); |
503
|
|
|
} |
504
|
|
|
|
505
|
|
|
protected function ensurePrecisionMatches(Period $period): void |
506
|
|
|
{ |
507
|
|
|
if ($this->precisionMask === $period->precisionMask) { |
508
|
|
|
return; |
509
|
|
|
} |
510
|
|
|
|
511
|
|
|
throw CannotComparePeriods::precisionDoesNotMatch(); |
512
|
|
|
} |
513
|
|
|
} |
514
|
|
|
|
Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.
For example, imagine you have a variable
$accountId
that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to theid
property of an instance of theAccount
class. This class holds a proper account, so the id value must no longer be false.Either this assignment is in error or a type check should be added for that assignment.