From 8e6414425881a5956b3dc8cccf96153c8370c40a Mon Sep 17 00:00:00 2001 From: Greg Bowler Date: Wed, 13 Sep 2023 19:17:26 +0100 Subject: [PATCH 1/3] feature: construct from other ulid closes #55 --- src/Base32.php | 51 ++++++++++++++++++++++++++++++ src/Extractor.php | 58 ++++++++++++++++++++++++++++++++++ src/Ulid.php | 56 +++++++++++++++----------------- test/phpunit/Base32Test.php | 34 ++++++++++++++++++++ test/phpunit/ExtractorTest.php | 49 ++++++++++++++++++++++++++++ test/phpunit/UlidTest.php | 17 ++++++++-- 6 files changed, 231 insertions(+), 34 deletions(-) create mode 100644 src/Base32.php create mode 100644 src/Extractor.php create mode 100644 test/phpunit/Base32Test.php create mode 100644 test/phpunit/ExtractorTest.php diff --git a/src/Base32.php b/src/Base32.php new file mode 100644 index 0000000..90b0a40 --- /dev/null +++ b/src/Base32.php @@ -0,0 +1,51 @@ +skipCharacters); + + $base10 = abs($base10); + $converted = base_convert((string)$base10, 10, 32); + + for($i = 0, $len = strlen($converted); $i < $len; $i++) { + $ord = ord($converted[$i]); + foreach($skipCharacters as $skip) { + $skipOrd = ord($skip); + if($ord >= $skipOrd) { + $ord++; + } + $converted[$i] = chr($ord); + } + } + + return strtoupper($converted); + } + + public function toDec(string $base32): int { + $skipCharacters = str_split($this->skipCharacters); + $base32 = strtoupper($base32); + + for ($i = 0, $len = strlen($base32); $i < $len; $i++) { + $ord = ord($base32[$i]); + $adjustment = 0; + + foreach ($skipCharacters as $skip) { + $skipOrd = ord(strtoupper($skip)); + if ($ord > $skipOrd) { + $adjustment++; + } + } + + $ord -= $adjustment; + $base32[$i] = chr($ord); + } + + return intval(base_convert($base32, 32, 10)); + } + +} diff --git a/src/Extractor.php b/src/Extractor.php new file mode 100644 index 0000000..81e017d --- /dev/null +++ b/src/Extractor.php @@ -0,0 +1,58 @@ +base32 = $base32 ?? new Base32(); + } + + public function extractPrefix(string $initString):?string { + $underscorePos = strpos($initString, "_"); + if($underscorePos === false) { + return null; + } + + return substr($initString, 0, $underscorePos); + } + + public function extractTimestamp(string $initString):int { + $underscorePos = $this->positionAfterUnderscore($initString); + + $timestampString = substr( + $initString, + $underscorePos, + $this->timestampLength, + ); + return $this->base32->toDec($timestampString); + } + + public function extractRandomString(string $initString):string { + $underscorePos = $this->positionAfterUnderscore($initString); + + $initString = substr( + $initString, + $underscorePos, + ); + + return substr( + $initString, + $this->timestampLength, + $this->length, + ); + } + + protected function positionAfterUnderscore(string $initString):int { + $underscorePos = strpos($initString, "_"); + if($underscorePos) { + $underscorePos += 1; + } + + return $underscorePos ?: 0; + } +} diff --git a/src/Ulid.php b/src/Ulid.php index 007bc33..880d844 100644 --- a/src/Ulid.php +++ b/src/Ulid.php @@ -7,17 +7,29 @@ class Ulid implements Stringable { const DEFAULT_TOTAL_LENGTH = 20; const DEFAULT_TIMESTAMP_LENGTH = 10; - private float $timestamp; + private int $timestamp; private string $randomString; + private Base32 $base32; public function __construct( private ?string $prefix = null, - float|int $timestamp = null, + int $timestamp = null, private int $length = self::DEFAULT_TOTAL_LENGTH, private int $timestampLength = self::DEFAULT_TIMESTAMP_LENGTH, + string $init = null, ) { + $this->base32 = new Base32(); + + if($init) { + $extractor = new Extractor(); + $this->prefix = $extractor->extractPrefix($init); + $this->timestamp = $extractor->extractTimestamp($init); + $this->randomString = $extractor->extractRandomString($init); + return; + } + if(is_null($timestamp)) { - $timestamp = microtime(true); + $timestamp = (int)round(microtime(true) * 1000); } $this->timestamp = $timestamp; @@ -25,7 +37,7 @@ public function __construct( $this->randomString = ""; for($i = 0; $i < $this->length - $this->timestampLength; $i++) { $rnd = random_int(0, 31); - $this->randomString .= $this->base32($rnd); + $this->randomString .= $this->base32->fromDec($rnd); } } @@ -38,9 +50,9 @@ public function __toString():string { $randomString, ]); - if($this->prefix) { + if($prefix = $this->getPrefix()) { $string = implode("_", [ - $this->prefix, + $prefix, $string, ]); } @@ -49,16 +61,19 @@ public function __toString():string { } public function getPrefix():?string { - return $this->prefix; + if(!$this->prefix) { + return null; + } + + return strtoupper($this->prefix); } - public function getTimestamp():float { + public function getTimestamp():int { return $this->timestamp; } public function getTimestampString():string { - $timestamp = round($this->timestamp * 1000); - $base32Timestamp = $this->base32((int)$timestamp); + $base32Timestamp = $this->base32->fromDec($this->timestamp); return substr( str_pad( $base32Timestamp, @@ -74,25 +89,4 @@ public function getTimestampString():string { public function getRandomString():string { return $this->randomString; } - - private function base32(int $number):string { - $skipCharacters = ["i", "l", "o", "u"]; - if($number < 0) { - $number = -$number; - } - $converted = base_convert((string)$number, 10, 32); - - for($i = 0, $len = strlen($converted); $i < $len; $i++) { - $ord = ord($converted[$i]); - foreach($skipCharacters as $skip) { - $skipOrd = ord($skip); - if($ord >= $skipOrd) { - $ord++; - } - $converted[$i] = chr($ord); - } - } - - return strtoupper($converted); - } } diff --git a/test/phpunit/Base32Test.php b/test/phpunit/Base32Test.php new file mode 100644 index 0000000..e973cf9 --- /dev/null +++ b/test/phpunit/Base32Test.php @@ -0,0 +1,34 @@ +fromDec(105); + self::assertSame(105, $sut->toDec($b32)); + } + + public function testFromDec_timestamp():void { + $timestamp = strtotime("5th April 1988 17:24:00"); + $sut = new Base32(); + $b32 = $sut->fromDec($timestamp); + self::assertSame($timestamp, $sut->toDec($b32)); + } + + public function testFromDec_timestampWayInTheFuture():void { + $timestamp = strtotime("31st December 9999 23:59:59"); + $sut = new Base32(); + $b32 = $sut->fromDec($timestamp); + self::assertSame($timestamp, $sut->toDec($b32)); + } + + public function testFromDec_timestampWayInThePast():void { + $timestamp = strtotime("1st January -5000 13:05:00"); + $sut = new Base32(); + $b32 = $sut->fromDec($timestamp); + self::assertSame($timestamp, $sut->toDec($b32)); + } +} diff --git a/test/phpunit/ExtractorTest.php b/test/phpunit/ExtractorTest.php new file mode 100644 index 0000000..6c17c94 --- /dev/null +++ b/test/phpunit/ExtractorTest.php @@ -0,0 +1,49 @@ +extractTimestamp($ulid); + self::assertSame($currentTimestamp, $timestamp); + } + + public function testExtractTimestamp_specificKnownDate():void { + $knownTimestamp = strtotime("5th April 1988 17:24:00"); + + $ulid = self::createMock(Ulid::class); + $ulid->method("__toString") + ->willReturn("0000H5J61G" . "DYSS02F0S3"); + + $sut = new Extractor(); + $timestamp = $sut->extractTimestamp($ulid); + self::assertSame($knownTimestamp, $timestamp); + } + + public function testExtractPrefix():void { + $ulid = "example_1234567890"; + $sut = new Extractor(); + self::assertSame("example", $sut->extractPrefix($ulid)); + } + + public function testExtractPrefix_noPrefix():void { + $ulid = "1234567890"; + $sut = new Extractor(); + self::assertNull($sut->extractPrefix($ulid)); + } + + public function testExtractRandomString():void { + $random = "AABBCCDDEE"; + $timestamp = "0000000000"; + $ulid = "example_" . $timestamp . $random; + $sut = new Extractor(); + self::assertSame($random, $sut->extractRandomString($ulid)); + } +} diff --git a/test/phpunit/UlidTest.php b/test/phpunit/UlidTest.php index 074dffc..eac5e51 100644 --- a/test/phpunit/UlidTest.php +++ b/test/phpunit/UlidTest.php @@ -5,14 +5,18 @@ use PHPUnit\Framework\TestCase; class UlidTest extends TestCase { + public function testGetPrefix():void { + $sut = new Ulid("customer"); + self::assertStringStartsWith("CUSTOMER_", $sut); + } public function testGetTimestamp():void { $sut = new Ulid(); - $timestamp = microtime(true); + $timestamp = round(microtime(true) * 1000); self::assertSame(round($timestamp), round($sut->getTimestamp())); } public function testGetTimestamp_setInConstructor():void { - $timestamp = (float)strtotime("5th April 1988"); + $timestamp = strtotime("5th April 1988"); $sut = new Ulid(timestamp: $timestamp); self::assertSame($timestamp, $sut->getTimestamp()); } @@ -82,7 +86,14 @@ public function testConstruct_setTimestampLength():void { public function testConstruct_prefix():void { $sut = new Ulid("customer"); - self::assertStringStartsWith("customer_", $sut); + self::assertStringStartsWith("CUSTOMER_", $sut); self::assertGreaterThan(strlen("customer_") + 10, strlen($sut)); } + + public function testConstruct_existingUlid():void { + $existingUlid = new Ulid(); + $existingString = (string)$existingUlid; + $sut = new Ulid(init: $existingString); + self::assertSame($existingString, (string)$sut); + } } From c4e0e7fb72316087ea16603fe2cc1144f48fbe52 Mon Sep 17 00:00:00 2001 From: Greg Bowler Date: Wed, 13 Sep 2023 19:22:10 +0100 Subject: [PATCH 2/3] tweak: make timestamp comparison less accurate to allow for time spent generating ulid --- test/phpunit/UlidTest.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/phpunit/UlidTest.php b/test/phpunit/UlidTest.php index eac5e51..3673083 100644 --- a/test/phpunit/UlidTest.php +++ b/test/phpunit/UlidTest.php @@ -9,10 +9,11 @@ public function testGetPrefix():void { $sut = new Ulid("customer"); self::assertStringStartsWith("CUSTOMER_", $sut); } + public function testGetTimestamp():void { $sut = new Ulid(); - $timestamp = round(microtime(true) * 1000); - self::assertSame(round($timestamp), round($sut->getTimestamp())); + $now = round(microtime(true) * 1000); + self::assertSame(round($now / 10), round($sut->getTimestamp() / 10)); } public function testGetTimestamp_setInConstructor():void { From 71bf4c98dc4c40f5c8ccbb52f491653d2d47a710 Mon Sep 17 00:00:00 2001 From: Greg Bowler Date: Wed, 13 Sep 2023 19:24:11 +0100 Subject: [PATCH 3/3] tweak: make timestamp comparison less accurate to allow for time spent generating ulid --- test/phpunit/UlidTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/phpunit/UlidTest.php b/test/phpunit/UlidTest.php index 3673083..d880d15 100644 --- a/test/phpunit/UlidTest.php +++ b/test/phpunit/UlidTest.php @@ -13,7 +13,7 @@ public function testGetPrefix():void { public function testGetTimestamp():void { $sut = new Ulid(); $now = round(microtime(true) * 1000); - self::assertSame(round($now / 10), round($sut->getTimestamp() / 10)); + self::assertSame(round($now / 1000), round($sut->getTimestamp() / 1000)); } public function testGetTimestamp_setInConstructor():void {