Added Snowflake ID generators.
This commit is contained in:
parent
60c2130182
commit
ea549dd0eb
7 changed files with 341 additions and 2 deletions
2
VERSION
2
VERSION
|
@ -1 +1 @@
|
|||
0.2503.251852
|
||||
0.2503.260138
|
||||
|
|
60
src/Snowflake/BinarySnowflake.php
Normal file
60
src/Snowflake/BinarySnowflake.php
Normal file
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
// BinarySnowflake.php
|
||||
// Created: 2025-03-26
|
||||
// Updated: 2025-03-26
|
||||
|
||||
namespace Index\Snowflake;
|
||||
|
||||
use DateTimeInterface;
|
||||
|
||||
/**
|
||||
* Implements a snowflake generator wrapper that takes user provided data.
|
||||
*/
|
||||
class BinarySnowflake {
|
||||
/**
|
||||
* Reference to the underlying generator.
|
||||
*/
|
||||
public private(set) SnowflakeGenerator $generator;
|
||||
|
||||
/**
|
||||
* @param bool $littleEndian Whether to convert the given data to a little endian integer or big endian.
|
||||
* @param ?SnowflakeGenerator $generator Snowflake generator instance.
|
||||
*/
|
||||
public function __construct(
|
||||
public private(set) bool $littleEndian = false,
|
||||
?SnowflakeGenerator $generator = null,
|
||||
) {
|
||||
$this->generator = $generator ?? new SnowflakeGenerator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the next snowflake.
|
||||
*
|
||||
* @param string $data Data to shove in.
|
||||
* @param DateTimeInterface|string|int<0, max>|null $at Timestamp to base at.
|
||||
* @return int
|
||||
*/
|
||||
public function next(string $data, DateTimeInterface|string|int|null $at = null): int {
|
||||
$length = strlen($data);
|
||||
if($length <= 1) {
|
||||
$format = 'C';
|
||||
$data = str_pad($data, 1, "\0", STR_PAD_LEFT);
|
||||
} elseif($length === 2) {
|
||||
$format = $this->littleEndian ? 'v' : 'n';
|
||||
} elseif($length <= 4) {
|
||||
$format = $this->littleEndian ? 'V' : 'N';
|
||||
$data = str_pad($data, 4, "\0", STR_PAD_LEFT);
|
||||
} else {
|
||||
$format = $this->littleEndian ? 'P' : 'J';
|
||||
if($length < 8)
|
||||
$data = str_pad($data, 8, "\0", STR_PAD_LEFT);
|
||||
elseif($length > 8)
|
||||
$data = substr($data, 0, 8);
|
||||
}
|
||||
|
||||
$data = unpack(sprintf('%sdata', $format), $data);
|
||||
$data = is_array($data) && isset($data['data']) && is_int($data['data']) && $data['data'] >= 0 ? $data['data'] : 0;
|
||||
|
||||
return $this->generator->next($data, $at);
|
||||
}
|
||||
}
|
53
src/Snowflake/IncrementalSnowflake.php
Normal file
53
src/Snowflake/IncrementalSnowflake.php
Normal file
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
// IncrementalSnowflake.php
|
||||
// Created: 2025-03-26
|
||||
// Updated: 2025-03-26
|
||||
|
||||
namespace Index\Snowflake;
|
||||
|
||||
use DateTimeInterface;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Implements a snowflake generator wrapper that just counts up.
|
||||
*/
|
||||
class IncrementalSnowflake {
|
||||
/**
|
||||
* Reference to the underlying generator.
|
||||
*/
|
||||
public private(set) SnowflakeGenerator $generator;
|
||||
|
||||
/**
|
||||
* @param int<0, max> $counter Incremental counter value.
|
||||
* @param ?SnowflakeGenerator $generator Snowflake generator instance.
|
||||
* @throws InvalidArgumentException if $counter is less than 0.
|
||||
*/
|
||||
public function __construct(
|
||||
public private(set) int $counter = 0,
|
||||
?SnowflakeGenerator $generator = null,
|
||||
) {
|
||||
if($counter < 0)
|
||||
throw new InvalidArgumentException('$counter must be a positive integer');
|
||||
|
||||
$this->generator = $generator ?? new SnowflakeGenerator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Increments the counter, with overflow protection.
|
||||
*
|
||||
* @return int<0, max>
|
||||
*/
|
||||
public function increment(): int {
|
||||
return $this->counter = ($this->counter >= PHP_INT_MAX ? 0 : ($this->counter + 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the next snowflake.
|
||||
*
|
||||
* @param DateTimeInterface|string|int<0, max>|null $at Timestamp to base at.
|
||||
* @return int
|
||||
*/
|
||||
public function next(DateTimeInterface|string|int|null $at = null): int {
|
||||
return $this->generator->next($this->increment(), $at);
|
||||
}
|
||||
}
|
46
src/Snowflake/RandomSnowflake.php
Normal file
46
src/Snowflake/RandomSnowflake.php
Normal file
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
// RandomSnowflake.php
|
||||
// Created: 2025-03-26
|
||||
// Updated: 2025-03-26
|
||||
|
||||
namespace Index\Snowflake;
|
||||
|
||||
use DateTimeInterface;
|
||||
use Random\{Engine,Randomizer};
|
||||
|
||||
/**
|
||||
* Implements a snowflake generator wrapper that uses a randomizer engine.
|
||||
*/
|
||||
class RandomSnowflake {
|
||||
/**
|
||||
* Underlying randomness source.
|
||||
*/
|
||||
public private(set) Randomizer $randomizer;
|
||||
|
||||
/**
|
||||
* Reference to the underlying generator.
|
||||
*/
|
||||
public private(set) SnowflakeGenerator $generator;
|
||||
|
||||
/**
|
||||
* @param ?Engine $engine Randomness engine to use.
|
||||
* @param ?SnowflakeGenerator $generator Snowflake generator instance.
|
||||
*/
|
||||
public function __construct(
|
||||
?Engine $engine = null,
|
||||
?SnowflakeGenerator $generator = null,
|
||||
) {
|
||||
$this->randomizer = new Randomizer($engine);
|
||||
$this->generator = $generator ?? new SnowflakeGenerator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the next snowflake.
|
||||
*
|
||||
* @param DateTimeInterface|string|int<0, max>|null $at Timestamp to base at.
|
||||
* @return int
|
||||
*/
|
||||
public function next(DateTimeInterface|string|int|null $at = null): int {
|
||||
return $this->generator->next(abs($this->randomizer->nextInt()), $at);
|
||||
}
|
||||
}
|
83
src/Snowflake/SnowflakeGenerator.php
Normal file
83
src/Snowflake/SnowflakeGenerator.php
Normal file
|
@ -0,0 +1,83 @@
|
|||
<?php
|
||||
// SnowflakeGenerator.php
|
||||
// Created: 2025-03-26
|
||||
// Updated: 2025-03-26
|
||||
|
||||
namespace Index\Snowflake;
|
||||
|
||||
use DateTimeInterface;
|
||||
use InvalidArgumentException;
|
||||
use Index\XDateTime;
|
||||
|
||||
/**
|
||||
* Base snowflake generator.
|
||||
*/
|
||||
final class SnowflakeGenerator {
|
||||
/**
|
||||
* Masks the output for a signed 64-bit integer.
|
||||
*/
|
||||
public const int MASK = 0x7FFFFFFFFFFFFFFF;
|
||||
|
||||
/**
|
||||
* Default epoch value, 1st of January 2013 at 0:00.
|
||||
*/
|
||||
public const int EPOCH = 1356998400000;
|
||||
|
||||
/**
|
||||
* Amount by which to shift the timestamp left and clear space for sequence data.
|
||||
*/
|
||||
public const int SHIFT = 16;
|
||||
|
||||
/**
|
||||
* Cached mask for timestamps.
|
||||
*/
|
||||
public private(set) int $tsMask;
|
||||
|
||||
/**
|
||||
* Cached mask for sequence data.
|
||||
*/
|
||||
public private(set) int $sqMask;
|
||||
|
||||
/**
|
||||
* @param int<0, max> $epoch Timestamp epoch.
|
||||
* @param int<0, 63> $shift Amount to shift the timestamp left by.
|
||||
* @throws InvalidArgumentException if $epoch or $shift is not within the given boundaries.
|
||||
*/
|
||||
public function __construct(
|
||||
public private(set) int $epoch = self::EPOCH,
|
||||
public private(set) int $shift = self::SHIFT,
|
||||
) {
|
||||
if($epoch < 0 || $epoch > self::MASK)
|
||||
throw new InvalidArgumentException('$epoch is not a valid positive integer');
|
||||
if($shift < 1 || $shift > 63)
|
||||
throw new InvalidArgumentException('$shift must be between 0 and 63 inclusive');
|
||||
|
||||
$this->tsMask = ~(~0 << (63 - $shift));
|
||||
$this->sqMask = ~(~0 << $shift);
|
||||
}
|
||||
|
||||
/**
|
||||
* Current timestamp with the epoch subtracted.
|
||||
*
|
||||
* @param DateTimeInterface|string|int<0, max>|null $at Timestamp to base at.
|
||||
* @return int<0, max>
|
||||
*/
|
||||
public function nowMs(DateTimeInterface|string|int|null $at = null): int {
|
||||
return max(0, XDateTime::toUnixMilliseconds($at ?? XDateTime::now(), true) - $this->epoch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a snowflake.
|
||||
*
|
||||
* @param int<0, max> $sequence Sequence data, will be masked to fit within $shift in the constructor.
|
||||
* @param DateTimeInterface|string|int<0, max>|null $at Timestamp to base at.
|
||||
* @throws InvalidArgumentException if $sequence is not a positive integer.
|
||||
* @return int
|
||||
*/
|
||||
public function next(int $sequence = 0, DateTimeInterface|string|int|null $at = null): int {
|
||||
if($sequence < 0)
|
||||
throw new InvalidArgumentException('$sequence must be a positive integer');
|
||||
|
||||
return (($this->nowMs($at) & $this->tsMask) << $this->shift) | ($sequence & $this->sqMask);
|
||||
}
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
<?php
|
||||
// XDateTime.php
|
||||
// Created: 2024-07-31
|
||||
// Updated: 2025-01-18
|
||||
// Updated: 2025-03-26
|
||||
|
||||
namespace Index;
|
||||
|
||||
|
@ -24,6 +24,30 @@ final class XDateTime {
|
|||
return new DateTimeImmutable('now');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current date and time in seconds since the 0:00 on January 1st, 1970.
|
||||
*
|
||||
* @param DateTimeInterface|string|int $dt
|
||||
* @return int
|
||||
*/
|
||||
public static function toUnixSeconds(DateTimeInterface|string|int|null $dt): int {
|
||||
return (int)self::format($dt, 'U');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current date and time in milliseconds since the 0:00 on January 1st, 1970.
|
||||
*
|
||||
* @param DateTimeInterface|string|int $dt
|
||||
* @param bool $intDtAsMilliseconds Whether to treat $dt as milliseconds instead of seconds if it is an integer.
|
||||
* @return int
|
||||
*/
|
||||
public static function toUnixMilliseconds(DateTimeInterface|string|int|null $dt, bool $intDtAsMilliseconds = false): int {
|
||||
if($intDtAsMilliseconds && is_int($dt))
|
||||
$dt = new DateTimeImmutable(sprintf('@%.3F', (float)$dt / 1000));
|
||||
|
||||
return (int)self::format($dt, 'Uv');
|
||||
}
|
||||
|
||||
private static function toCompareFormat(DateTimeInterface|string|int $dt): string {
|
||||
if($dt instanceof DateTimeInterface)
|
||||
return $dt->format(self::COMPARE_FORMAT);
|
||||
|
|
73
tests/SnowflakeTest.php
Normal file
73
tests/SnowflakeTest.php
Normal file
|
@ -0,0 +1,73 @@
|
|||
<?php
|
||||
// SnowflakeTest.php
|
||||
// Created: 2025-03-26
|
||||
// Updated: 2025-03-26
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use PHPUnit\Framework\Attributes\{CoversClass,UsesClass};
|
||||
use Index\XDateTime;
|
||||
use Index\Snowflake\{BinarySnowflake,IncrementalSnowflake,RandomSnowflake,SnowflakeGenerator};
|
||||
use Random\Engine\Mt19937;
|
||||
|
||||
#[CoversClass(IncrementalSnowflake::class)]
|
||||
#[CoversClass(SnowflakeGenerator::class)]
|
||||
#[UsesClass(XDateTime::class)]
|
||||
final class SnowflakeTest extends TestCase {
|
||||
public function testSnowflakeGenerator(): void {
|
||||
// default epoch and shift
|
||||
$generator = new SnowflakeGenerator;
|
||||
$this->assertSame(SnowflakeGenerator::EPOCH, $generator->epoch);
|
||||
$this->assertSame(SnowflakeGenerator::SHIFT, $generator->shift);
|
||||
$this->assertSame(0x7FFFFFFFFFFF, $generator->tsMask);
|
||||
$this->assertSame(0xFFFF, $generator->sqMask);
|
||||
$snowflake = $generator->next(0x123456, 1742946997548);
|
||||
$this->assertSame(0x59DC543D2C3456, $snowflake);
|
||||
|
||||
// copy date exactly
|
||||
$generator = new SnowflakeGenerator(epoch: 0);
|
||||
$this->assertSame(0, $generator->epoch);
|
||||
$this->assertSame(SnowflakeGenerator::SHIFT, $generator->shift);
|
||||
$this->assertSame(0x7FFFFFFFFFFF, $generator->tsMask);
|
||||
$this->assertSame(0xFFFF, $generator->sqMask);
|
||||
$snowflake = $generator->next(0x654321, 1742946997548);
|
||||
$this->assertSame(0x195CFBC952C4321, $snowflake);
|
||||
|
||||
// quirky shift
|
||||
$generator = new SnowflakeGenerator(shift: 14);
|
||||
$this->assertSame(SnowflakeGenerator::EPOCH, $generator->epoch);
|
||||
$this->assertSame(14, $generator->shift);
|
||||
$this->assertSame(0x1FFFFFFFFFFFF, $generator->tsMask);
|
||||
$this->assertSame(0x3FFF, $generator->sqMask);
|
||||
$snowflake = $generator->next(0xFDE9, 1742947403098);
|
||||
$this->assertSame(0x1677169B56BDE9, $snowflake);
|
||||
|
||||
// incremental
|
||||
$incremental = new IncrementalSnowflake(0x10001, $generator);
|
||||
$this->assertSame(0x10002, $incremental->increment());
|
||||
$this->assertSame(0x16771F5C1C4003, $incremental->next(1742949697649));
|
||||
|
||||
// big endian binary
|
||||
$big = new BinarySnowflake(generator: new SnowflakeGenerator(shift: 20));
|
||||
$this->assertSame(0x59DC920D23000DE, $big->next("\xDE", 1742951048483));
|
||||
$this->assertSame(0x59DC920D230DEAD, $big->next("\xDE\xAD", 1742951048483));
|
||||
$this->assertSame(0x59DC920D23EADBE, $big->next("\xDE\xAD\xBE", 1742951048483));
|
||||
$this->assertSame(0x59DC920D23FBEAD, $big->next("\xDE\xAF\xBE\xAD", 1742951048483));
|
||||
|
||||
// little endian binary
|
||||
$little = new BinarySnowflake(littleEndian: true, generator: new SnowflakeGenerator(shift: 20));
|
||||
$this->assertSame(0x59DC920D23000DE, $little->next("\xDE", 1742951048483));
|
||||
$this->assertSame(0x59DC920D230ADDE, $little->next("\xDE\xAD", 1742951048483));
|
||||
$this->assertSame(0x59DC920D23DDE00, $little->next("\xDE\xAD\xBE", 1742951048483));
|
||||
$this->assertSame(0x59DC920D23EAFDE, $little->next("\xDE\xAF\xBE\xAD", 1742951048483));
|
||||
|
||||
// random
|
||||
$random = new RandomSnowflake(new Mt19937(2525));
|
||||
$this->assertSame(0x59DCAD88D22D3E, $random->next(1742952849618));
|
||||
$this->assertSame(0x59DCAD88D2CFDC, $random->next(1742952849618));
|
||||
$this->assertSame(0x59DCAD88D27EA2, $random->next(1742952849618));
|
||||
$this->assertSame(0x59DCAD88D26E0A, $random->next(1742952849618));
|
||||
$this->assertSame(0x59DCAD88D24C90, $random->next(1742952849618));
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue