Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add factory to create version 1 UUIDs
- Loading branch information
Showing
2 changed files
with
350 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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'); | ||
} | ||
} |