Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 3 additions & 10 deletions Couchbase/Collection.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
use Couchbase\Exception\TimeoutException;
use Couchbase\Exception\UnsupportedOperationException;
use Couchbase\Management\CollectionQueryIndexManager;
use Couchbase\Utilities\ExpiryHelper;
use DateTimeInterface;

/**
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
99 changes: 99 additions & 0 deletions Couchbase/Utilities/ExpiryHelper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php

namespace Couchbase\Utilities;

use Couchbase\Exception\InvalidArgumentException;
use DateTimeImmutable;
use DateTimeInterface;

class ExpiryHelper
{
private const THIRTY_DAYS_IN_SECONDS = 2592000;
private const FIFTY_YEARS_IN_SECONDS = 1576800000;
private const MAX_EXPIRY = 4294967295;

/**
* @throws InvalidArgumentException
*/
public static function parseExpiry($expiry): int
{
if ($expiry === null || $expiry === 0 || $expiry === '0') {
return 0;
}
if (!is_int($expiry) && !($expiry instanceof DateTimeInterface)) {
throw new InvalidArgumentException(
"Expected expiry to be an int or DateTimeInterface."
);
}

if ($expiry instanceof DateTimeInterface) {
$timestamp = $expiry->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');
}
}
100 changes: 100 additions & 0 deletions tests/ExpiryTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
<?php

/**
* Copyright 2014-Present Couchbase, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

declare(strict_types=1);

use Couchbase\Utilities\ExpiryHelper;
use Couchbase\Exception\InvalidArgumentException;

include_once __DIR__ . "/Helpers/CouchbaseTestCase.php";

class ExpiryTest extends Helpers\CouchbaseTestCase
{
public function testNullExpiryReturnsZero()
{
$this->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');
}
}
Loading