From a8bbb9bc9ebde26b7851847ca098cb63200c6973 Mon Sep 17 00:00:00 2001 From: mscherer Date: Wed, 28 Jan 2026 17:53:17 +0100 Subject: [PATCH 1/2] Add ChronosInterval class Implements a DateInterval wrapper using the decorator pattern as discussed. This addresses the need for a proper interval class with: - ISO 8601 duration string formatting via __toString() - Factory methods: create(), createFromValues(), instance() - toNative() for compatibility with code expecting DateInterval - Convenience methods: totalSeconds(), totalDays(), isNegative(), isZero() - Property access proxy to underlying DateInterval Related to #444 --- src/ChronosInterval.php | 306 +++++++++++++++++++++++++ tests/TestCase/ChronosIntervalTest.php | 195 ++++++++++++++++ 2 files changed, 501 insertions(+) create mode 100644 src/ChronosInterval.php create mode 100644 tests/TestCase/ChronosIntervalTest.php diff --git a/src/ChronosInterval.php b/src/ChronosInterval.php new file mode 100644 index 0000000..79e3062 --- /dev/null +++ b/src/ChronosInterval.php @@ -0,0 +1,306 @@ +interval = $interval; + } + + /** + * Create an interval from a specification string. + * + * @param string $spec An interval specification (e.g., 'P1Y2M3D'). + * @return static + */ + public static function create(string $spec): static + { + return new static(new DateInterval($spec)); + } + + /** + * Create an interval from individual components. + * + * @param int|null $years Years + * @param int|null $months Months + * @param int|null $weeks Weeks (converted to days) + * @param int|null $days Days + * @param int|null $hours Hours + * @param int|null $minutes Minutes + * @param int|null $seconds Seconds + * @param int|null $microseconds Microseconds + * @return static + */ + public static function createFromValues( + ?int $years = null, + ?int $months = null, + ?int $weeks = null, + ?int $days = null, + ?int $hours = null, + ?int $minutes = null, + ?int $seconds = null, + ?int $microseconds = null, + ): static { + $interval = Chronos::createInterval( + $years, + $months, + $weeks, + $days, + $hours, + $minutes, + $seconds, + $microseconds, + ); + + return new static($interval); + } + + /** + * Create an interval from a DateInterval instance. + * + * @param \DateInterval $interval The interval to wrap. + * @return static + */ + public static function instance(DateInterval $interval): static + { + return new static($interval); + } + + /** + * Get the underlying DateInterval instance. + * + * Use this when you need to pass the interval to code that expects + * a native DateInterval. + * + * @return \DateInterval + */ + public function toNative(): DateInterval + { + return $this->interval; + } + + /** + * Format the interval as an ISO 8601 duration string. + * + * @return string + */ + public function toIso8601String(): string + { + $spec = 'P'; + + if ($this->interval->y) { + $spec .= $this->interval->y . 'Y'; + } + if ($this->interval->m) { + $spec .= $this->interval->m . 'M'; + } + if ($this->interval->d) { + $spec .= $this->interval->d . 'D'; + } + + if ($this->interval->h || $this->interval->i || $this->interval->s || $this->interval->f) { + $spec .= 'T'; + + if ($this->interval->h) { + $spec .= $this->interval->h . 'H'; + } + if ($this->interval->i) { + $spec .= $this->interval->i . 'M'; + } + if ($this->interval->s || $this->interval->f) { + $seconds = (string)$this->interval->s; + if ($this->interval->f) { + $fraction = rtrim(sprintf('%06d', (int)($this->interval->f * 1000000)), '0'); + if ($fraction !== '') { + $seconds .= '.' . $fraction; + } + } + $spec .= $seconds . 'S'; + } + } + + // Handle empty interval + if ($spec === 'P') { + $spec = 'PT0S'; + } + + return ($this->interval->invert ? '-' : '') . $spec; + } + + /** + * Format the interval using DateInterval::format(). + * + * @param string $format The format string. + * @return string + * @see https://www.php.net/manual/en/dateinterval.format.php + */ + public function format(string $format): string + { + return $this->interval->format($format); + } + + /** + * Get the total number of seconds in the interval. + * + * Note: This calculation assumes 30 days per month and 365 days per year, + * which is an approximation. For precise calculations, use diff() between + * specific dates. + * + * @return int + */ + public function totalSeconds(): int + { + $seconds = $this->interval->s; + $seconds += $this->interval->i * 60; + $seconds += $this->interval->h * 3600; + $seconds += $this->interval->d * 86400; + $seconds += $this->interval->m * 30 * 86400; + $seconds += $this->interval->y * 365 * 86400; + + return $this->interval->invert ? -$seconds : $seconds; + } + + /** + * Get the total number of days in the interval. + * + * If the interval was created from a diff(), this returns the exact + * total days. Otherwise, it approximates using 30 days per month + * and 365 days per year. + * + * @return int + */ + public function totalDays(): int + { + if ($this->interval->days !== false) { + return $this->interval->invert ? -$this->interval->days : $this->interval->days; + } + + $days = $this->interval->d; + $days += $this->interval->m * 30; + $days += $this->interval->y * 365; + + return $this->interval->invert ? -$days : $days; + } + + /** + * Check if this interval is negative. + * + * @return bool + */ + public function isNegative(): bool + { + return $this->interval->invert === 1; + } + + /** + * Check if this interval is zero (no duration). + * + * @return bool + */ + public function isZero(): bool + { + return $this->interval->y === 0 + && $this->interval->m === 0 + && $this->interval->d === 0 + && $this->interval->h === 0 + && $this->interval->i === 0 + && $this->interval->s === 0 + && $this->interval->f === 0.0; + } + + /** + * Return the interval as an ISO 8601 duration string. + * + * @return string + */ + public function __toString(): string + { + return $this->toIso8601String(); + } + + /** + * Allow read access to DateInterval properties. + * + * @param string $name Property name. + * @return mixed + */ + public function __get(string $name): mixed + { + return $this->interval->{$name}; + } + + /** + * Check if a DateInterval property exists. + * + * @param string $name Property name. + * @return bool + */ + public function __isset(string $name): bool + { + return isset($this->interval->{$name}); + } + + /** + * Debug info. + * + * @return array + */ + public function __debugInfo(): array + { + return [ + 'interval' => $this->toIso8601String(), + 'years' => $this->interval->y, + 'months' => $this->interval->m, + 'days' => $this->interval->d, + 'hours' => $this->interval->h, + 'minutes' => $this->interval->i, + 'seconds' => $this->interval->s, + 'microseconds' => $this->interval->f, + 'invert' => $this->interval->invert, + ]; + } +} diff --git a/tests/TestCase/ChronosIntervalTest.php b/tests/TestCase/ChronosIntervalTest.php new file mode 100644 index 0000000..94c79f9 --- /dev/null +++ b/tests/TestCase/ChronosIntervalTest.php @@ -0,0 +1,195 @@ +assertSame(1, $interval->y); + $this->assertSame(2, $interval->m); + $this->assertSame(3, $interval->d); + } + + public function testCreateFromValues(): void + { + $interval = ChronosInterval::createFromValues(1, 2, 0, 3, 4, 5, 6); + $this->assertSame(1, $interval->y); + $this->assertSame(2, $interval->m); + $this->assertSame(3, $interval->d); + $this->assertSame(4, $interval->h); + $this->assertSame(5, $interval->i); + $this->assertSame(6, $interval->s); + } + + public function testCreateFromValuesWithWeeks(): void + { + $interval = ChronosInterval::createFromValues(weeks: 2); + $this->assertSame(14, $interval->d); + } + + public function testInstance(): void + { + $native = new DateInterval('P1D'); + $interval = ChronosInterval::instance($native); + $this->assertSame(1, $interval->d); + } + + public function testToNative(): void + { + $interval = ChronosInterval::create('P1Y'); + $native = $interval->toNative(); + $this->assertInstanceOf(DateInterval::class, $native); + $this->assertSame(1, $native->y); + } + + public function testToIso8601String(): void + { + $interval = ChronosInterval::create('P1Y2M3DT4H5M6S'); + $this->assertSame('P1Y2M3DT4H5M6S', $interval->toIso8601String()); + } + + public function testToIso8601StringDateOnly(): void + { + $interval = ChronosInterval::create('P1Y2M3D'); + $this->assertSame('P1Y2M3D', $interval->toIso8601String()); + } + + public function testToIso8601StringTimeOnly(): void + { + $interval = ChronosInterval::create('PT4H5M6S'); + $this->assertSame('PT4H5M6S', $interval->toIso8601String()); + } + + public function testToIso8601StringEmpty(): void + { + $interval = ChronosInterval::create('P0D'); + $this->assertSame('PT0S', $interval->toIso8601String()); + } + + public function testToIso8601StringNegative(): void + { + $past = new Chronos('2020-01-01'); + $future = new Chronos('2021-02-02'); + $diff = $past->diff($future); + $diff->invert = 1; + + $interval = ChronosInterval::instance($diff); + $this->assertStringStartsWith('-P', $interval->toIso8601String()); + } + + public function testToString(): void + { + $interval = ChronosInterval::create('P1Y2M'); + $this->assertSame('P1Y2M', (string)$interval); + } + + public function testFormat(): void + { + $interval = ChronosInterval::create('P1Y2M3D'); + $this->assertSame('1 years, 2 months, 3 days', $interval->format('%y years, %m months, %d days')); + } + + public function testTotalSeconds(): void + { + $interval = ChronosInterval::create('PT1H30M'); + $this->assertSame(5400, $interval->totalSeconds()); + } + + public function testTotalDays(): void + { + $interval = ChronosInterval::create('P10D'); + $this->assertSame(10, $interval->totalDays()); + } + + public function testTotalDaysFromDiff(): void + { + $start = new Chronos('2020-01-01'); + $end = new Chronos('2020-01-11'); + $diff = $start->diff($end); + + $interval = ChronosInterval::instance($diff); + $this->assertSame(10, $interval->totalDays()); + } + + public function testIsNegative(): void + { + $interval = ChronosInterval::create('P1D'); + $this->assertFalse($interval->isNegative()); + + $past = new Chronos('2020-01-01'); + $future = new Chronos('2020-01-02'); + $diff = $future->diff($past); + + $interval = ChronosInterval::instance($diff); + $this->assertTrue($interval->isNegative()); + } + + public function testIsZero(): void + { + $interval = ChronosInterval::create('P0D'); + $this->assertTrue($interval->isZero()); + + $interval = ChronosInterval::create('P1D'); + $this->assertFalse($interval->isZero()); + } + + public function testPropertyAccess(): void + { + $interval = ChronosInterval::create('P1Y2M3DT4H5M6S'); + $this->assertSame(1, $interval->y); + $this->assertSame(2, $interval->m); + $this->assertSame(3, $interval->d); + $this->assertSame(4, $interval->h); + $this->assertSame(5, $interval->i); + $this->assertSame(6, $interval->s); + } + + public function testIsset(): void + { + $interval = ChronosInterval::create('P1Y'); + $this->assertTrue(isset($interval->y)); + $this->assertTrue(isset($interval->m)); + } + + public function testDebugInfo(): void + { + $interval = ChronosInterval::create('P1Y2M3D'); + $debug = $interval->__debugInfo(); + + $this->assertArrayHasKey('interval', $debug); + $this->assertArrayHasKey('years', $debug); + $this->assertArrayHasKey('months', $debug); + $this->assertArrayHasKey('days', $debug); + } + + public function testWithMicroseconds(): void + { + $interval = ChronosInterval::createFromValues(seconds: 1, microseconds: 500000); + $this->assertSame(0.5, $interval->f); + $this->assertSame('PT1.5S', $interval->toIso8601String()); + } + + public function testWithMicrosecondsPartial(): void + { + $interval = ChronosInterval::createFromValues(seconds: 1, microseconds: 123456); + $this->assertSame('PT1.123456S', $interval->toIso8601String()); + } +} From b60077f2aa5e665e216e63b96584ec39a2ce0666 Mon Sep 17 00:00:00 2001 From: mscherer Date: Wed, 28 Jan 2026 17:59:25 +0100 Subject: [PATCH 2/2] Add @phpstan-consistent-constructor annotation Fixes PHPStan level 8 'Unsafe usage of new static()' errors. --- src/ChronosInterval.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ChronosInterval.php b/src/ChronosInterval.php index 79e3062..5be6dea 100644 --- a/src/ChronosInterval.php +++ b/src/ChronosInterval.php @@ -31,6 +31,7 @@ * @property-read float $f Microseconds as a fraction of a second * @property-read int $invert 1 if the interval is negative * @property-read int|false $days Total days if created from diff(), false otherwise + * @phpstan-consistent-constructor */ class ChronosInterval implements Stringable {