Skip to content

Commit

Permalink
refactor: ensure ULIDs are monotonically increasing
Browse files Browse the repository at this point in the history
  • Loading branch information
ramsey committed Oct 23, 2022
1 parent 78d9081 commit bcfb209
Show file tree
Hide file tree
Showing 7 changed files with 149 additions and 40 deletions.
12 changes: 11 additions & 1 deletion src/Service/BytesGenerator/FixedBytesGenerator.php
Expand Up @@ -18,23 +18,33 @@

use DateTimeInterface;

use function intdiv;
use function strlen;
use function substr;

/**
* A bytes generator that returns a pre-determined string of bytes
*/
final class FixedBytesGenerator implements BytesGenerator
{
private readonly int $bytesLength;

/**
* @param non-empty-string $bytes
*/
public function __construct(private readonly string $bytes)
{
$this->bytesLength = strlen($this->bytes);
}

public function bytes(int $length = 16, ?DateTimeInterface $dateTime = null): string
{
$bytes = '';
for ($i = 0; $i <= intdiv($length, $this->bytesLength); $i++) {
$bytes .= $this->bytes;
}

/** @var non-empty-string */
return substr($this->bytes, 0, $length);
return substr($bytes, 0, $length);
}
}
15 changes: 13 additions & 2 deletions src/Service/BytesGenerator/MonotonicBytesGenerator.php
Expand Up @@ -25,6 +25,7 @@

use function hash;
use function pack;
use function random_bytes;
use function str_pad;
use function strlen;
use function substr;
Expand Down Expand Up @@ -84,8 +85,18 @@ public function bytes(int $length = 16, ?DateTimeInterface $dateTime = null): st
$time = str_pad(BigInteger::of($time)->toBytes(false), 6, "\x00", STR_PAD_LEFT);
}

/** @var non-empty-string */
return $time . pack('n*', self::$rand[1], self::$rand[2], self::$rand[3], self::$rand[4], self::$rand[5]);
/** @var non-empty-string $bytes */
$bytes = $time . pack('n*', self::$rand[1], self::$rand[2], self::$rand[3], self::$rand[4], self::$rand[5]);

if ($length === 16) {
return $bytes;
} elseif ($length <= 16) {
/** @var non-empty-string */
return substr($bytes, 0, $length);
} else {
// If the caller requested more bytes, add more bytes.
return $bytes . random_bytes($length - 16);
}
}

private function randomize(string $time): void
Expand Down
26 changes: 7 additions & 19 deletions src/Ulid/DefaultUlidFactory.php
Expand Up @@ -22,14 +22,11 @@
use DateTimeInterface;
use Ramsey\Identifier\Exception\InvalidArgument;
use Ramsey\Identifier\Service\BytesGenerator\BytesGenerator;
use Ramsey\Identifier\Service\BytesGenerator\RandomBytesGenerator;
use Ramsey\Identifier\Service\Clock\SystemClock;
use Ramsey\Identifier\Service\BytesGenerator\MonotonicBytesGenerator;
use Ramsey\Identifier\Ulid\Utility\Validation;
use Ramsey\Identifier\UlidFactory;
use Ramsey\Identifier\UlidIdentifier;
use Ramsey\Identifier\Uuid\Utility\Format;
use Ramsey\Identifier\Uuid\Utility\Time;
use StellaMaris\Clock\ClockInterface as Clock;

use function is_int;
use function is_string;
Expand All @@ -50,33 +47,23 @@ final class DefaultUlidFactory implements UlidFactory
{
use Validation;

private readonly Time $time;

/**
* Constructs a factory for creating ULIDs
*
* @param Clock $clock A clock used to provide a date-time instance;
* defaults to {@see SystemClock}
* @param BytesGenerator $bytesGenerator A random generator used to
* generate bytes; defaults to {@see RandomBytesGenerator}
*/
public function __construct(
private readonly Clock $clock = new SystemClock(),
private readonly BytesGenerator $bytesGenerator = new RandomBytesGenerator(),
private readonly BytesGenerator $bytesGenerator = new MonotonicBytesGenerator(),
) {
$this->time = new Time();
}

/**
* @throws InvalidArgument
*/
public function create(): UlidIdentifier
{
$dateTime = $this->clock->now();
$bytes = $this->time->getTimeBytesForUnixEpoch($dateTime)
. $this->bytesGenerator->bytes(10);

return new Ulid($bytes);
return new Ulid($this->bytesGenerator->bytes());
}

/**
Expand Down Expand Up @@ -104,10 +91,11 @@ public function createFromBytes(string $identifier): UlidIdentifier
*/
public function createFromDateTime(DateTimeInterface $dateTime): UlidIdentifier
{
$bytes = $this->time->getTimeBytesForUnixEpoch($dateTime)
. $this->bytesGenerator->bytes(10);
if ($dateTime->getTimestamp() < 0) {
throw new InvalidArgument('Timestamp may not be earlier than the Unix Epoch');
}

return new Ulid($bytes);
return new Ulid($this->bytesGenerator->bytes(dateTime: $dateTime));
}

/**
Expand Down
56 changes: 40 additions & 16 deletions tests/unit/Service/BytesGenerator/FixedBytesGeneratorTest.php
Expand Up @@ -9,27 +9,51 @@

class FixedBytesGeneratorTest extends TestCase
{
public function testGetBytesWithLengthExactlyAsValueProvided(): void
/**
* @param non-empty-string $bytes
* @param int<1, max> $length
* @param non-empty-string $expectedBytes
*
* @dataProvider bytesProvider
*/
public function testBytes(string $bytes, int $length, string $expectedBytes): void
{
$bytes = "\xab\xcd\xef\x01\x23\x45\x67\x89";
$bytesGenerator = new FixedBytesGenerator($bytes);

$this->assertSame($bytes, $bytesGenerator->bytes(8));
$this->assertSame($expectedBytes, $bytesGenerator->bytes($length));
}

public function testGetBytesWithLengthGreaterThanValueProvided(): void
/**
* @return array<array{bytes: non-empty-string, length: int<1, max>, expectedBytes: non-empty-string}>
*/
public function bytesProvider(): array
{
$bytes = "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff";
$bytesGenerator = new FixedBytesGenerator($bytes);

$this->assertSame($bytes, $bytesGenerator->bytes(20));
}

public function testGetBytesWithLengthLessThanValueProvided(): void
{
$bytes = "\xff\xff\xff\xff\xab\xcd\xef\x01\x23\x45\x67\x89\xff\xff\xff\xff";
$bytesGenerator = new FixedBytesGenerator($bytes);

$this->assertSame("\xff\xff\xff\xff\xab\xcd\xef\x01", $bytesGenerator->bytes(8));
return [
[
'bytes' => "\xab\xcd\xef\x01\x23\x45\x67\x89",
'length' => 8,
'expectedBytes' => "\xab\xcd\xef\x01\x23\x45\x67\x89",
],
[
'bytes' => "\x00\x11\x22\x33\x44\x55\x66\x77\x88\x99\xaa\xbb\xcc\xdd\xee\xff",
'length' => 20,
'expectedBytes' => "\x00\x11\x22\x33\x44\x55\x66\x77\x88\x99\xaa\xbb\xcc\xdd\xee\xff\x00\x11\x22\x33",
],
[
'bytes' => "\x00\x11\x22",
'length' => 15,
'expectedBytes' => "\x00\x11\x22\x00\x11\x22\x00\x11\x22\x00\x11\x22\x00\x11\x22",
],
[
'bytes' => "\x00\x11\x22\33",
'length' => 17,
'expectedBytes' => "\x00\x11\x22\33\x00\x11\x22\33\x00\x11\x22\33\x00\x11\x22\33\x00",
],
[
'bytes' => "\xff\xff\xff\xff\xab\xcd\xef\x01\x23\x45\x67\x89\xff\xff\xff\xff",
'length' => 8,
'expectedBytes' => "\xff\xff\xff\xff\xab\xcd\xef\x01",
],
];
}
}
27 changes: 27 additions & 0 deletions tests/unit/Service/BytesGenerator/MonotonicBytesGeneratorTest.php
Expand Up @@ -74,6 +74,33 @@ public function testBytesAreMonotonicallyIncreasing(): void
}
}

public function testLongerLengthBytesRequestedAreMonotonicallyIncreasing(): void
{
$bytesGenerator = new MonotonicBytesGenerator();

$previous = $bytesGenerator->bytes(29);
$this->assertSame(29, strlen($previous));

for ($i = 0; $i < 25; $i++) {
$bytes = $bytesGenerator->bytes(29);
$this->assertSame(29, strlen($bytes));
$this->assertTrue($previous < $bytes);
$previous = $bytes;
}
}

/**
* We support fewer bytes returned, but when you truncate the bytes, you
* lose the monotonicity, since the monotonicity is based on a 48-bit
* timestamp.
*/
public function testShorterLengthBytesRequested(): void
{
$bytesGenerator = new MonotonicBytesGenerator();

$this->assertSame(5, strlen($bytesGenerator->bytes(5)));
}

/**
* @runInSeparateProcess since values are stored statically on the class
* @preserveGlobalState disabled
Expand Down
39 changes: 37 additions & 2 deletions tests/unit/Ulid/DefaultUlidFactoryTest.php
Expand Up @@ -7,6 +7,7 @@
use DateTimeImmutable;
use Ramsey\Identifier\Exception\InvalidArgument;
use Ramsey\Identifier\Service\BytesGenerator\FixedBytesGenerator;
use Ramsey\Identifier\Service\BytesGenerator\MonotonicBytesGenerator;
use Ramsey\Identifier\Service\Clock\FrozenClock;
use Ramsey\Identifier\Ulid\DefaultUlidFactory;
use Ramsey\Identifier\Ulid\MaxUlid;
Expand All @@ -15,6 +16,7 @@
use Ramsey\Identifier\UlidFactory;
use Ramsey\Test\Identifier\TestCase;

use function gmdate;
use function sprintf;
use function substr;

Expand All @@ -36,11 +38,17 @@ public function testCreate(): void
$this->assertInstanceOf(Ulid::class, $ulid);
}

/**
* @runInSeparateProcess since values are stored statically on the MonotonicBytesGenerator
* @preserveGlobalState disabled
*/
public function testCreateWithFactoryDeterministicValues(): void
{
$factory = new DefaultUlidFactory(
new FrozenClock(new DateTimeImmutable('1970-01-01 00:00:00')),
new FixedBytesGenerator("\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"),
new MonotonicBytesGenerator(
new FixedBytesGenerator("\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"),
new FrozenClock(new DateTimeImmutable('1970-01-01 00:00:00')),
),
);

$ulid = $factory->create();
Expand Down Expand Up @@ -285,4 +293,31 @@ public function testNil(): void
{
$this->assertInstanceOf(NilUlid::class, $this->factory->nil());
}

public function testCreateEachUlidIsMonotonicallyIncreasing(): void
{
$previous = $this->factory->create();

for ($i = 0; $i < 25; $i++) {
$ulid = $this->factory->create();
$now = gmdate('Y-m-d H:i');
$this->assertTrue($ulid->compareTo($previous) > 0);
$this->assertSame($now, $ulid->getDateTime()->format('Y-m-d H:i'));
$previous = $ulid;
}
}

public function testCreateEachUlidFromSameDateTimeIsMonotonicallyIncreasing(): void
{
$dateTime = new DateTimeImmutable();

$previous = $this->factory->createFromDateTime($dateTime);

for ($i = 0; $i < 25; $i++) {
$ulid = $this->factory->createFromDateTime($dateTime);
$this->assertTrue($ulid->compareTo($previous) > 0);
$this->assertSame($dateTime->format('Y-m-d H:i'), $ulid->getDateTime()->format('Y-m-d H:i'));
$previous = $ulid;
}
}
}
14 changes: 14 additions & 0 deletions tests/unit/Uuid/UuidV7FactoryTest.php
Expand Up @@ -184,4 +184,18 @@ public function testCreateEachUuidIsMonotonicallyIncreasing(): void
$previous = $uuid;
}
}

public function testCreateEachUuidFromSameDateTimeIsMonotonicallyIncreasing(): void
{
$dateTime = new DateTimeImmutable();

$previous = $this->factory->create($dateTime);

for ($i = 0; $i < 25; $i++) {
$uuid = $this->factory->create($dateTime);
$this->assertGreaterThan(0, $uuid->compareTo($previous));
$this->assertSame($dateTime->format('Y-m-d H:i'), $uuid->getDateTime()->format('Y-m-d H:i'));
$previous = $uuid;
}
}
}

0 comments on commit bcfb209

Please sign in to comment.