diff --git a/Couchbase/Collection.php b/Couchbase/Collection.php index a5560ecf..81c93de8 100644 --- a/Couchbase/Collection.php +++ b/Couchbase/Collection.php @@ -29,6 +29,7 @@ use Couchbase\Exception\TimeoutException; use Couchbase\Exception\UnsupportedOperationException; use Couchbase\Management\CollectionQueryIndexManager; +use Couchbase\Utilities\ExpiryHelper; use DateTimeInterface; /** @@ -196,11 +197,7 @@ public function getAndLock(string $id, int $lockTimeSeconds, ?GetAndLockOptions */ public function getAndTouch(string $id, $expiry, ?GetAndTouchOptions $options = null): GetResult { - if ($expiry instanceof DateTimeInterface) { - $expirySeconds = $expiry->getTimestamp(); - } else { - $expirySeconds = (int)$expiry; - } + $expirySeconds = ExpiryHelper::parseExpiry($expiry); $function = COUCHBASE_EXTENSION_NAMESPACE . '\\documentGetAndTouch'; $response = $function( $this->core, @@ -434,11 +431,7 @@ public function unlock(string $id, string $cas, ?UnlockOptions $options = null): */ public function touch(string $id, $expiry, ?TouchOptions $options = null): MutationResult { - if ($expiry instanceof DateTimeInterface) { - $expirySeconds = $expiry->getTimestamp(); - } else { - $expirySeconds = (int)$expiry; - } + $expirySeconds = ExpiryHelper::parseExpiry($expiry); $function = COUCHBASE_EXTENSION_NAMESPACE . '\\documentTouch'; $response = $function( $this->core, diff --git a/Couchbase/Utilities/ExpiryHelper.php b/Couchbase/Utilities/ExpiryHelper.php new file mode 100644 index 00000000..8af949b9 --- /dev/null +++ b/Couchbase/Utilities/ExpiryHelper.php @@ -0,0 +1,99 @@ +getTimestamp(); + + if ($timestamp === self::zeroSecondDate()->getTimestamp()) { + return 0; + } + + if ( + $timestamp < self::minExpiryDate()->getTimestamp() || + $timestamp > self::maxExpiryDate()->getTimestamp() + ) { + throw new InvalidArgumentException( + "Expiry date is out of range. Must be between " . + self::minExpiryDate()->format(DateTimeInterface::ATOM) . " and " . + self::maxExpiryDate()->format(DateTimeInterface::ATOM) . " But got " . + $expiry->format(DateTimeInterface::ATOM) + ); + } + return $timestamp; + } + + if ($expiry < 0) { + throw new InvalidArgumentException("Expiry cannot be negative, got $expiry"); + } + + if ($expiry > self::MAX_EXPIRY) { + throw new InvalidArgumentException("Expiry cannot be greater than " . self::MAX_EXPIRY . ", got $expiry"); + } + + if ($expiry > self::FIFTY_YEARS_IN_SECONDS) { + trigger_error(sprintf( + "The specified expiry (%d) is greater than 50 years in seconds. " + . "Unix timestamps passed directly as a number are not supported. " + . "If you want an absolute expiry, construct a DateTime from the timestamp.", + $expiry + ), E_USER_WARNING); + } + + if ($expiry < self::THIRTY_DAYS_IN_SECONDS) { + return $expiry; + } + + // Relative expiry >= 30 days, convert to absolute expiry + $unixTimeSecs = time(); + $maxExpiryDuration = self::MAX_EXPIRY - $unixTimeSecs; + if ($expiry > $maxExpiryDuration) { + throw new InvalidArgumentException( + "Expected expiry duration to be less than " . $maxExpiryDuration . + " but got $expiry" + ); + } + return $expiry + $unixTimeSecs; + } + + // The server treats values <= 259200 (30 days) as relative to the current time. + // So, the minimum expiry date is 259201 which corresponds to 1970-01-31T00:00:01Z + private static function minExpiryDate(): DateTimeImmutable + { + return new DateTimeImmutable('1970-01-31T00:00:01Z'); + } + + private static function maxExpiryDate(): DateTimeImmutable + { + return new DateTimeImmutable('2106-02-07T06:28:15Z'); + } + + private static function zeroSecondDate(): DateTimeImmutable + { + return new DateTimeImmutable('1970-01-31T00:00:00Z'); + } +} diff --git a/tests/ExpiryTest.php b/tests/ExpiryTest.php new file mode 100644 index 00000000..78bc469c --- /dev/null +++ b/tests/ExpiryTest.php @@ -0,0 +1,100 @@ +assertEquals(0, ExpiryHelper::parseExpiry(null)); + } + + public function testZeroExpiryReturnsZero() + { + $this->assertEquals(0, ExpiryHelper::parseExpiry(0)); + $this->assertEquals(0, ExpiryHelper::parseExpiry('0')); + } + + public function testNegativeExpiryThrows() + { + $this->expectException(InvalidArgumentException::class); + ExpiryHelper::parseExpiry(-1); + } + + public function testExpiryGreaterThanMaxThrows() + { + $this->expectException(InvalidArgumentException::class); + ExpiryHelper::parseExpiry(4294967296); // MAX_EXPIRY + 1 + } + + public function testRelativeExpiryUnderThirtyDays() + { + $expiry = 60; // 1 minute + $result = ExpiryHelper::parseExpiry($expiry); + $this->assertEquals($expiry, $result); + } + + public function testRelativeExpiryOverThirtyDaysIsConverted() + { + $expiry = 2592000 + 1; // 30 days + 1 second + $before = time(); + $result = ExpiryHelper::parseExpiry($expiry); + $after = time(); + $this->assertGreaterThanOrEqual($before + $expiry, $result); + $this->assertLessThanOrEqual($after + $expiry, $result); + } + + public function testAbsoluteDateWithinRangeReturnsTimestamp() + { + $dt = new DateTimeImmutable('2025-01-01T00:00:00Z'); + $result = ExpiryHelper::parseExpiry($dt); + $this->assertEquals($dt->getTimestamp(), $result); + } + + public function testAbsoluteDateBelowMinThrows() + { + $dt = new DateTimeImmutable('1969-12-31T23:59:59Z'); + $this->expectException(InvalidArgumentException::class); + ExpiryHelper::parseExpiry($dt); + } + + public function testAbsoluteDateAboveMaxThrows() + { + $dt = new DateTimeImmutable('2200-01-01T00:00:00Z'); + $this->expectException(InvalidArgumentException::class); + ExpiryHelper::parseExpiry($dt); + } + + public function testZeroSecondDateReturnsZero() + { + $dt = new DateTimeImmutable('1970-01-31T00:00:00Z'); + $this->assertEquals(0, ExpiryHelper::parseExpiry($dt)); + } + + public function testInvalidTypeThrows() + { + $this->expectException(InvalidArgumentException::class); + ExpiryHelper::parseExpiry('foo'); + } +}