Skip to content

Commit e74a051

Browse files
authored
Snowflake helper class (#1488)
1 parent de40cda commit e74a051

2 files changed

Lines changed: 301 additions & 0 deletions

File tree

src/Discord/Helpers/Snowflake.php

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is a part of the DiscordPHP project.
7+
*
8+
* Copyright (c) 2015-2022 David Cole <david.cole1340@gmail.com>
9+
* Copyright (c) 2020-present Valithor Obsidion <valithor@discordphp.org>
10+
*
11+
* This file is subject to the MIT license that is bundled
12+
* with this source code in the LICENSE.md file.
13+
*/
14+
15+
namespace Discord\Helpers;
16+
17+
use Carbon\Carbon;
18+
use DateTimeInterface;
19+
use GMP;
20+
use InvalidArgumentException;
21+
use Stringable;
22+
23+
/**
24+
* Snowflake is a helper class for parsing Discord snowflake IDs into their component parts, and for generating snowflake IDs from a given timestamp.
25+
*
26+
* https://docs.discord.com/developers/reference#snowflakes
27+
*
28+
* @since 10.56.9
29+
*
30+
* @author Valithor Obsidion <valithor@discordphp.org>
31+
*
32+
* @property-read string $id The snowflake ID as a numeric string.
33+
* @property-read int|string $timestamp Milliseconds since the Unix Epoch that the snowflake was generated at. Returned as a numeric string on 32-bit PHP, where it does not fit in a native `int`.
34+
* @property-read Carbon $datetime The `Carbon` instance the snowflake was generated at.
35+
* @property-read int $worker_id Internal worker ID, 0-31.
36+
* @property-read int $process_id Internal process ID, 0-31.
37+
* @property-read int $increment Increment for the ID generated on that worker/process, 0-4095.
38+
*/
39+
class Snowflake implements Stringable
40+
{
41+
use DynamicPropertyMutatorTrait;
42+
43+
/** Discord Epoch (2015-01-01T00:00:00Z), in milliseconds since the Unix Epoch. */
44+
public const DISCORD_EPOCH = 1420070400000;
45+
46+
/** The snowflake ID as a numeric string. */
47+
protected string $id;
48+
49+
/**
50+
* @param Stringable|int|string $id The snowflake ID.
51+
*/
52+
public function __construct($id)
53+
{
54+
if (PHP_INT_SIZE === 4) {
55+
BigInt::init();
56+
}
57+
58+
$this->setId($id);
59+
}
60+
61+
/**
62+
* @param Stringable|int|string $id The snowflake ID.
63+
*/
64+
public static function new($id): self
65+
{
66+
return new self($id);
67+
}
68+
69+
/**
70+
* Creates a new Snowflake from a timestamp and optional internal fields.
71+
*
72+
* @param DateTimeInterface|int|string $timestamp A `DateTimeInterface` or a Unix timestamp in milliseconds.
73+
* @param int $workerId Internal worker ID, 0-31. Defaults to 0.
74+
* @param int $processId Internal process ID, 0-31. Defaults to 0.
75+
* @param int $increment Increment for the ID, 0-4095. Defaults to 0.
76+
*
77+
* @throws InvalidArgumentException
78+
*/
79+
public static function fromTimestamp($timestamp, int $workerId = 0, int $processId = 0, int $increment = 0): self
80+
{
81+
$timestamp = self::normalizeTimestampToMs($timestamp);
82+
83+
if ($timestamp < self::DISCORD_EPOCH) {
84+
throw new InvalidArgumentException('Timestamp cannot be before the Discord Epoch ('.self::DISCORD_EPOCH.').');
85+
}
86+
87+
if ($workerId < 0 || $workerId > 0x1F) {
88+
throw new InvalidArgumentException('Worker ID must be between 0 and 31.');
89+
}
90+
91+
if ($processId < 0 || $processId > 0x1F) {
92+
throw new InvalidArgumentException('Process ID must be between 0 and 31.');
93+
}
94+
95+
if ($increment < 0 || $increment > 0xFFF) {
96+
throw new InvalidArgumentException('Increment must be between 0 and 4095.');
97+
}
98+
99+
$id = BigInt::shiftLeft(BigInt::sub($timestamp, self::DISCORD_EPOCH), 22);
100+
$id = BigInt::or($id, BigInt::shiftLeft($workerId, 17));
101+
$id = BigInt::or($id, BigInt::shiftLeft($processId, 12));
102+
$id = BigInt::or($id, $increment);
103+
104+
return new self($id instanceof GMP ? gmp_strval($id) : (string) $id);
105+
}
106+
107+
/**
108+
* Normalizes a `DateTimeInterface` or numeric timestamp to milliseconds since the Unix Epoch.
109+
*
110+
* Returned as a numeric string on 32-bit PHP, since millisecond timestamps overflow a native `int` there.
111+
*
112+
* @param DateTimeInterface|int|string $timestamp
113+
*
114+
* @return int|string
115+
*/
116+
protected static function normalizeTimestampToMs($timestamp)
117+
{
118+
if ($timestamp instanceof DateTimeInterface) {
119+
$ms = round(((float) $timestamp->format('U.u')) * 1000);
120+
121+
return PHP_INT_SIZE === 4 ? sprintf('%.0f', $ms) : (int) $ms;
122+
}
123+
124+
if (! is_numeric($timestamp)) {
125+
throw new InvalidArgumentException('Timestamp must be a DateTimeInterface or a Unix timestamp in milliseconds.');
126+
}
127+
128+
return PHP_INT_SIZE === 4 ? sprintf('%.0f', (float) $timestamp) : (int) $timestamp;
129+
}
130+
131+
/**
132+
* @return string The snowflake ID as a numeric string.
133+
*/
134+
protected function getId(): string
135+
{
136+
return $this->id;
137+
}
138+
139+
/**
140+
* Normalizes and sets the `id` attribute.
141+
*
142+
* @param Stringable|int|string $id
143+
*/
144+
protected function setId($id): void
145+
{
146+
$id = (string) $id;
147+
148+
if (! ctype_digit($id)) {
149+
throw new InvalidArgumentException('Snowflake ID must be a numeric string or integer.');
150+
}
151+
152+
$this->id = $id;
153+
}
154+
155+
/**
156+
* Returned as a numeric string on 32-bit PHP, since the value overflows a native `int` there.
157+
*
158+
* @return int|string Milliseconds since the Unix Epoch that the snowflake was generated at.
159+
*/
160+
protected function getTimestamp()
161+
{
162+
$ms = BigInt::add(BigInt::shiftRight($this->id, 22), self::DISCORD_EPOCH);
163+
164+
return $ms instanceof GMP ? gmp_strval($ms) : (int) $ms;
165+
}
166+
167+
/**
168+
* @return Carbon The datetime the snowflake was generated at.
169+
*/
170+
protected function getDatetime(): Carbon
171+
{
172+
return Carbon::createFromTimestampMs($this->getTimestamp());
173+
}
174+
175+
/**
176+
* @return int Internal worker ID, 0-31.
177+
*/
178+
protected function getWorkerId(): int
179+
{
180+
return (int) BigInt::shiftRight(BigInt::and($this->id, 0x3E0000), 17);
181+
}
182+
183+
/**
184+
* @return int Internal process ID, 0-31.
185+
*/
186+
protected function getProcessId(): int
187+
{
188+
return (int) BigInt::shiftRight(BigInt::and($this->id, 0x1F000), 12);
189+
}
190+
191+
/**
192+
* @return int Increment for the ID generated on that worker/process, 0-4095.
193+
*/
194+
protected function getIncrement(): int
195+
{
196+
return (int) BigInt::and($this->id, 0xFFF);
197+
}
198+
199+
/**
200+
* @return string The snowflake ID.
201+
*/
202+
public function __toString(): string
203+
{
204+
return $this->id;
205+
}
206+
}

tests/SnowflakeTest.php

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is a part of the DiscordPHP project.
7+
*
8+
* Copyright (c) 2015-2022 David Cole <david.cole1340@gmail.com>
9+
* Copyright (c) 2020-present Valithor Obsidion <valithor@discordphp.org>
10+
*
11+
* This file is subject to the MIT license that is bundled
12+
* with this source code in the LICENSE.md file.
13+
*/
14+
15+
use Discord\Helpers\Snowflake;
16+
17+
final class SnowflakeTest extends DiscordTestCase
18+
{
19+
/**
20+
* @covers \Discord\Helpers\Snowflake::__construct
21+
* @covers \Discord\Helpers\Snowflake::__toString
22+
*/
23+
public function testParsesSnowflakeComponents(): void
24+
{
25+
$snowflake = new Snowflake('175928847299117063');
26+
27+
$this->assertSame('175928847299117063', $snowflake->id);
28+
$this->assertSame(1462015105796, $snowflake->timestamp);
29+
$this->assertSame('2016-04-30 11:18:25', $snowflake->datetime->format('Y-m-d H:i:s'));
30+
$this->assertSame(1, $snowflake->worker_id);
31+
$this->assertSame(0, $snowflake->process_id);
32+
$this->assertSame(7, $snowflake->increment);
33+
$this->assertSame('175928847299117063', (string) $snowflake);
34+
}
35+
36+
/**
37+
* @covers \Discord\Helpers\Snowflake::new
38+
*/
39+
public function testCreatesSnowflakeWithFactory(): void
40+
{
41+
$snowflake = Snowflake::new(175928847299117063);
42+
43+
$this->assertSame('175928847299117063', $snowflake->id);
44+
}
45+
46+
/**
47+
* @covers \Discord\Helpers\Snowflake::fromTimestamp
48+
*/
49+
public function testCreatesSnowflakeFromTimestamp(): void
50+
{
51+
$snowflake = Snowflake::fromTimestamp(1462015105796, 1, 0, 7);
52+
53+
$this->assertSame('175928847299117063', $snowflake->id);
54+
}
55+
56+
/**
57+
* @covers \Discord\Helpers\Snowflake::fromTimestamp
58+
*/
59+
public function testCreatesSnowflakeFromDateTime(): void
60+
{
61+
$snowflake = Snowflake::fromTimestamp(new DateTimeImmutable('@1462015105'));
62+
63+
$this->assertSame(1462015105000, $snowflake->timestamp);
64+
}
65+
66+
/**
67+
* @covers \Discord\Helpers\Snowflake::fromTimestamp
68+
*/
69+
public function testRejectsTimestampBeforeDiscordEpoch(): void
70+
{
71+
$this->expectException(InvalidArgumentException::class);
72+
73+
Snowflake::fromTimestamp(0);
74+
}
75+
76+
/**
77+
* @covers \Discord\Helpers\Snowflake::fromTimestamp
78+
*/
79+
public function testRejectsOutOfRangeWorkerId(): void
80+
{
81+
$this->expectException(InvalidArgumentException::class);
82+
83+
Snowflake::fromTimestamp(Snowflake::DISCORD_EPOCH, 32);
84+
}
85+
86+
/**
87+
* @covers \Discord\Helpers\Snowflake::setId
88+
*/
89+
public function testRejectsInvalidId(): void
90+
{
91+
$this->expectException(InvalidArgumentException::class);
92+
93+
new Snowflake('not-a-snowflake');
94+
}
95+
}

0 commit comments

Comments
 (0)