Skip to content

Commit

Permalink
feat: add factory to create version 1 UUIDs
Browse files Browse the repository at this point in the history
  • Loading branch information
ramsey committed Sep 26, 2022
1 parent 3e00b6e commit 5c574ce
Show file tree
Hide file tree
Showing 2 changed files with 350 additions and 0 deletions.
152 changes: 152 additions & 0 deletions src/Uuid/Factory/UuidV1Factory.php
@@ -0,0 +1,152 @@
<?php

/**
* This file is part of ramsey/identifier
*
* ramsey/identifier is open source software: you can distribute
* it and/or modify it under the terms of the MIT License
* (the "License"). You may not use this file except in
* compliance with the License.
*
* @copyright Copyright (c) Ben Ramsey <ben@benramsey.com>
* @license https://opensource.org/licenses/MIT MIT License
*/

declare(strict_types=1);

namespace Ramsey\Identifier\Uuid\Factory;

use Brick\Math\BigInteger;
use DateTimeInterface;
use Exception;
use Identifier\Uuid\UuidFactoryInterface;
use Identifier\Uuid\Version;
use Ramsey\Identifier\Service\ClockSequence\ClockSequenceServiceInterface;
use Ramsey\Identifier\Service\ClockSequence\RandomClockSequenceService;
use Ramsey\Identifier\Service\ClockSequence\StaticClockSequenceService;
use Ramsey\Identifier\Service\Node\NodeServiceInterface;
use Ramsey\Identifier\Service\Node\StaticNodeService;
use Ramsey\Identifier\Service\Node\SystemNodeService;
use Ramsey\Identifier\Service\Time\CurrentDateTimeService;
use Ramsey\Identifier\Service\Time\TimeServiceInterface;
use Ramsey\Identifier\Uuid\Util;
use Ramsey\Identifier\Uuid\UuidV1;

use function hex2bin;
use function pack;
use function sprintf;
use function str_pad;
use function substr;

use const PHP_INT_SIZE;
use const STR_PAD_LEFT;

/**
* A factory for creating version 1, Gregorian time UUIDs
*/
final class UuidV1Factory implements UuidFactoryInterface
{
use DefaultFactory;

public function __construct(
private readonly ClockSequenceServiceInterface $clockSequenceService = new RandomClockSequenceService(),
private readonly NodeServiceInterface $nodeService = new SystemNodeService(),
private readonly TimeServiceInterface $timeService = new CurrentDateTimeService(),
) {
}

/**
* @param int<0, max> | string | null $node A 48-bit integer or hexadecimal
* string representing the hardware address of the machine where this
* identifier was generated
* @param DateTimeInterface | null $dateTime A date-time to use when
* creating the identifier
* @param int<0, 16383> | null $clockSequence A 14-bit number used to help
* avoid duplicates that could arise when the clock is set backwards in
* time or if the node ID changes
*
* @throws Exception If a suitable source of randomness is not available
*
* @psalm-param int<0, max> | non-empty-string | null $node
*/
public function create(
int | string | null $node = null,
?DateTimeInterface $dateTime = null,
?int $clockSequence = null,
): UuidV1 {
/** @psalm-suppress ImpureMethodCall */
$node = $node !== null
? (new StaticNodeService($node))->getNode()
: $this->nodeService->getNode();

/** @psalm-suppress ImpureMethodCall */
$dateTime = $dateTime ?? $this->timeService->getDateTime();

/** @psalm-suppress ImpureMethodCall */
$clockSequence = $clockSequence !== null
? (new StaticClockSequenceService($clockSequence))->getClockSequence()
: $this->clockSequenceService->getClockSequence();

if (PHP_INT_SIZE >= 8) {
$timeBytes = pack('J*', (int) $dateTime->format('Uu0') + Util::GREGORIAN_OFFSET_INT);
} else {
/** @psalm-suppress ImpureMethodCall */
$timeBytes = str_pad(
BigInteger::of($dateTime->format('Uu0'))->plus(
BigInteger::fromBytes(Util::GREGORIAN_OFFSET_BIN),
)->toBytes(false),
8,
"\x00",
STR_PAD_LEFT,
);
}

/** @var non-empty-string $bytes */
$bytes = substr($timeBytes, -4)
. substr($timeBytes, 2, 2)
. substr($timeBytes, 0, 2)
. pack('n*', $clockSequence)
. hex2bin(sprintf('%012s', $node));

$bytes = Util::applyVersionAndVariant($bytes, Version::GregorianTime);

return new UuidV1($bytes);
}

public function createFromBytes(string $identifier): UuidV1
{
/** @var UuidV1 */
return $this->createFromBytesInternal($identifier);
}

public function createFromHexadecimal(string $identifier): UuidV1
{
/** @var UuidV1 */
return $this->createFromHexadecimalInternal($identifier);
}

public function createFromInteger(int | string $identifier): UuidV1
{
/** @var UuidV1 */
return $this->createFromIntegerInternal($identifier);
}

public function createFromString(string $identifier): UuidV1
{
/** @var UuidV1 */
return $this->createFromStringInternal($identifier);
}

/**
* @psalm-mutation-free
*/
protected function getVersion(): Version
{
return Version::GregorianTime;
}

protected function getUuidClass(): string
{
return UuidV1::class;
}
}
198 changes: 198 additions & 0 deletions tests/unit/Uuid/Factory/UuidV1FactoryTest.php
@@ -0,0 +1,198 @@
<?php

declare(strict_types=1);

namespace Ramsey\Test\Identifier\Uuid\Factory;

use DateTimeImmutable;
use Ramsey\Identifier\Exception\InvalidArgumentException;
use Ramsey\Identifier\Service\ClockSequence\StaticClockSequenceService;
use Ramsey\Identifier\Service\Node\StaticNodeService;
use Ramsey\Identifier\Service\Time\StaticDateTimeService;
use Ramsey\Identifier\Uuid\Factory\UuidV1Factory;
use Ramsey\Identifier\Uuid\UuidV1;
use Ramsey\Test\Identifier\TestCase;

use function substr;

class UuidV1FactoryTest extends TestCase
{
private UuidV1Factory $factory;

protected function setUp(): void
{
$this->factory = new UuidV1Factory();
}

public function testCreate(): void
{
$uuid = $this->factory->create();

$this->assertInstanceOf(UuidV1::class, $uuid);
}

public function testCreateWithFactoryDeterministicValues(): void
{
$factory = new UuidV1Factory(
new StaticClockSequenceService(0),
new StaticNodeService(0),
new StaticDateTimeService(new DateTimeImmutable('1582-10-15 00:00:00')),
);

$uuid = $factory->create();

$this->assertInstanceOf(UuidV1::class, $uuid);
$this->assertSame('00000000-0000-1000-8000-010000000000', $uuid->toString());
}

public function testCreateWithClockSequence(): void
{
$uuid = $this->factory->create(clockSequence: 0x3321);

$this->assertInstanceOf(UuidV1::class, $uuid);
$this->assertSame('321', substr($uuid->toString(), 20, 3));
}

public function testCreateWithNode(): void
{
$uuid = $this->factory->create(node: '3c1239b4f540');

$this->assertInstanceOf(UuidV1::class, $uuid);
$this->assertSame('3d1239b4f540', substr($uuid->toString(), -12));
}

public function testCreateWithDateTime(): void
{
$dateTime = new DateTimeImmutable('2022-09-25 17:32:12');
$uuid = $this->factory->create(dateTime: $dateTime);

$this->assertInstanceOf(UuidV1::class, $uuid);
$this->assertNotSame($dateTime, $uuid->getDateTime());
$this->assertSame('2022-09-25T17:32:12+00:00', $uuid->getDateTime()->format('c'));
$this->assertSame('fd24f600-3cf7-11ed', substr($uuid->toString(), 0, 18));
}

public function testCreateWithMethodDeterministicValues(): void
{
$dateTime = new DateTimeImmutable('2022-09-25 17:32:12');
$uuid = $this->factory->create('3c1239b4f540', $dateTime, 0x3321);

$this->assertInstanceOf(UuidV1::class, $uuid);
$this->assertSame('fd24f600-3cf7-11ed-b321-3d1239b4f540', $uuid->toString());
}

public function testCreateFromBytes(): void
{
$uuid = $this->factory->createFromBytes("\xff\xff\xff\xff\xff\xff\x1f\xff\x8f\xff\xff\xff\xff\xff\xff\xff");

$this->assertInstanceOf(UuidV1::class, $uuid);
$this->assertSame('ffffffff-ffff-1fff-8fff-ffffffffffff', $uuid->toString());
}

public function testCreateFromBytesThrowsException(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Identifier must be a 16-byte string');

$this->factory->createFromBytes("\xff\xff\xff\xff\xff\xff\x1f\xff\x8f\xff\xff\xff\xff\xff\xff");
}

public function testCreateFromBytesThrowsExceptionForNonVersion1Uuid(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage(
"Invalid version 1 UUID: \"\xff\xff\xff\xff\xff\xff\x2f\xff\x8f\xff\xff\xff\xff\xff\xff\xff\"",
);

$this->factory->createFromBytes("\xff\xff\xff\xff\xff\xff\x2f\xff\x8f\xff\xff\xff\xff\xff\xff\xff");
}

public function testCreateFromHexadecimal(): void
{
$uuid = $this->factory->createFromHexadecimal('ffffffffffff1fff8fffffffffffffff');

$this->assertInstanceOf(UuidV1::class, $uuid);
$this->assertSame('ffffffff-ffff-1fff-8fff-ffffffffffff', $uuid->toString());
}

public function testCreateFromHexadecimalThrowsExceptionForWrongLength(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Identifier must be a 32-character hexadecimal string');

$this->factory->createFromHexadecimal('ffffffffffff1fff8ffffffffffffffff');
}

public function testCreateFromHexadecimalThrowsExceptionForNonHexadecimal(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Identifier must be a 32-character hexadecimal string');

$this->factory->createFromHexadecimal('ffffffffffff1fff8ffffffffffffffg');
}

public function testCreateFromHexadecimalThrowsExceptionForNonVersion1Uuid(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Invalid version 1 UUID: "ffffffffffff2fff8fffffffffffffff"');

$this->factory->createFromHexadecimal('ffffffffffff2fff8fffffffffffffff');
}

public function testCreateFromIntegerWithMaxInteger(): void
{
$uuid = $this->factory->createFromInteger('340282366920937405648670758612812955647');

$this->assertInstanceOf(UuidV1::class, $uuid);
$this->assertSame('ffffffff-ffff-1fff-bfff-ffffffffffff', $uuid->toString());
}

public function testCreateFromIntegerWithMinInteger(): void
{
$uuid = $this->factory->createFromInteger('75567087097951178194944');

$this->assertInstanceOf(UuidV1::class, $uuid);
$this->assertSame('00000000-0000-1000-8000-000000000000', $uuid->toString());
}

public function testCreateFromIntegerThrowsExceptionForInvalidInteger(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Invalid integer: "foobar"');

/** @phpstan-ignore-next-line */
$this->factory->createFromInteger('foobar');
}

public function testCreateFromIntegerThrowsExceptionForNonVersion1Uuid(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Invalid version 1 UUID: 0');

$this->factory->createFromInteger(0);
}

public function testCreateFromString(): void
{
$uuid = $this->factory->createFromString('ffffffff-ffff-1fff-8fff-ffffffffffff');

$this->assertInstanceOf(UuidV1::class, $uuid);
$this->assertSame('ffffffff-ffff-1fff-8fff-ffffffffffff', $uuid->toString());
}

public function testCreateFromStringThrowsExceptionForWrongLength(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Identifier must be a UUID in string standard representation');

$this->factory->createFromString('ffffffff-ffff-1fff-8fff-fffffffffffff');
}

public function testCreateFromStringThrowsExceptionForWrongFormat(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Identifier must be a UUID in string standard representation');

$this->factory->createFromString('ffff-ffffffff-1fff-8fff-ffffffffffff');
}
}

0 comments on commit 5c574ce

Please sign in to comment.