Skip to content

Commit 9c9f946

Browse files
committed
uuid7 expression
1 parent 86be8c1 commit 9c9f946

File tree

5 files changed

+161
-6
lines changed

5 files changed

+161
-6
lines changed

README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ composer require tpetry/laravel-postgresql-enhanced
6868
- [Casts](#casts)
6969
- [Refresh Data on Save](#refresh-data-on-save)
7070
- [Date Formats](#date-formats)
71+
- [Expressions](#expressions)
7172

7273
## IDE Autocomplete
7374

@@ -1221,6 +1222,42 @@ class Example extends Model
12211222
> Instead of truncating the milliseconds, they are rounded by PostgreSQL.
12221223
> When the value is rounded up, your timestamp will be in the future.
12231224
1225+
# Expressions
1226+
1227+
Laravel 10 added the functionality to use pre-made expressions with the query builder like this that generate vendor-specific SQL for complex operations:
1228+
1229+
```php
1230+
BlogVisit::select([
1231+
'url',
1232+
new TimestampBin('created_at', DateInterval::createFromDateString('5 minutes')),
1233+
new Count('*'),
1234+
])->groupBy(
1235+
'url',
1236+
new TimestampBin('created_at', DateInterval::createFromDateString('5 minutes'))
1237+
);
1238+
```
1239+
1240+
I've already released a lot of expressions with my package [tpetry/laravel-query-expressions](https://github.com/tpetry/laravel-query-expressions) that are usable on all databases supported by Laravel.
1241+
But some functionality just can't be built for all database.
1242+
So here are some PostgreSQL specific ones:
1243+
1244+
## Uuid7
1245+
1246+
You can now generate time-sorted UUIDv7 IDs directly in the database.
1247+
The drawback of using `Str::orderedUuid()` is that inserting new rows can only be done from Laravel:
1248+
You loose the ability to insert new rows manually with a GUI, simple `INSERT` queries or efficient approaches like `INSERT INTO ... SELECT`.
1249+
But all of these are still possible with IDs generated at the database.
1250+
1251+
```php
1252+
use Tpetry\PostgresqlEnhanced\Expressions\Uuid7;
1253+
1254+
Schema::create('comments', function (Blueprint $table) {
1255+
$table->id();
1256+
$table->uuid()->default(new Uuid7())->unique();
1257+
$table->text('text');
1258+
});
1259+
```
1260+
12241261
# Breaking Changes
12251262

12261263
* 0.35.0 -> 0.36.0

composer.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,13 @@
2525
"require-dev": {
2626
"composer/semver": "^3.4",
2727
"friendsofphp/php-cs-fixer": "^2.19.3|^3.5.0",
28+
"nesbot/carbon": "^2.7|^3.3",
2829
"nunomaduro/larastan": "^1.0|^2.1",
2930
"orchestra/testbench": "^4.0|^5.0|^6.0|^7.0|^8.0|^9.0",
3031
"phpstan/extension-installer": "^1.1",
3132
"phpstan/phpstan": "^1.5",
32-
"phpunit/phpunit": "^8.5.23|^9.5.13|^10.5"
33+
"phpunit/phpunit": "^8.5.23|^9.5.13|^10.5",
34+
"ramsey/uuid": "^3.9|^4.7"
3335
},
3436
"autoload": {
3537
"psr-4": {

phpunit.xml.dist

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,6 @@
2626
<include>
2727
<directory suffix=".php">./src</directory>
2828
</include>
29-
<report>
30-
<html outputDirectory="build/coverage"/>
31-
<text outputFile="build/coverage.txt"/>
32-
<clover outputFile="build/logs/clover.xml"/>
33-
</report>
3429
</coverage>
3530
<logging>
3631
<junit outputFile="build/report.junit.xml"/>

src/Expressions/Uuid7.php

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tpetry\PostgresqlEnhanced\Expressions;
6+
7+
use DateTimeInterface;
8+
use Illuminate\Contracts\Database\Query\Expression;
9+
use Illuminate\Database\Grammar;
10+
11+
class Uuid7 implements Expression
12+
{
13+
public function __construct(
14+
private readonly ?DateTimeInterface $time = null,
15+
) {
16+
}
17+
18+
public function getValue(Grammar $grammar): string
19+
{
20+
// The UUIDv7 algorithm in pure PostgreSQL SQL is copied from:
21+
// https://gist.github.com/fabiolimace/515a0440e3e40efeb234e12644a6a346#file-uuidv7-sql
22+
23+
$c_milli_factor = '10^3::numeric'; // 1000
24+
$c_micro_factor = '10^6::numeric'; // 1000000
25+
$c_scale_factor = '4.096::numeric'; // 4.0 * (1024 / 1000)
26+
$c_version = "x'0000000000007000'::bit(64)"; // RFC-4122 version: b'0111...'
27+
$c_variant = "x'8000000000000000'::bit(64)"; // RFC-4122 variant: b'10xx...'
28+
29+
$v_time = match ($this->time) {
30+
null => 'extract(epoch from statement_timestamp())',
31+
default => "extract(epoch from '{$this->time->format('Y-m-d H:i:s.uP')}'::timestamptz)",
32+
};
33+
34+
$v_unix_t = "trunc({$v_time} * {$c_milli_factor})";
35+
$v_unix_t_hex = "lpad(to_hex({$v_unix_t}::bigint), 12, '0')";
36+
37+
$v_rand_a = "((({$v_time} * {$c_micro_factor}) - ({$v_unix_t} * {$c_milli_factor})) * {$c_scale_factor})";
38+
$v_rand_a_hex = "lpad(to_hex(({$v_rand_a}::bigint::bit(64) | {$c_version})::bigint), 4, '0')";
39+
40+
$v_rand_b = '(random()::numeric * 2^62::numeric)';
41+
$v_rand_b_hex = "lpad(to_hex(({$v_rand_b}::bigint::bit(64) | {$c_variant})::bigint), 16, '0')";
42+
43+
$v_output_bytes = "decode({$v_unix_t_hex} || {$v_rand_a_hex} || {$v_rand_b_hex}, 'hex')";
44+
45+
return "encode({$v_output_bytes}, 'hex')::uuid";
46+
}
47+
}

tests/Expressions/Uuid7Test.php

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tpetry\PostgresqlEnhanced\Tests\Expressions;
6+
7+
use Carbon\CarbonImmutable;
8+
use Composer\Semver\Comparator;
9+
use Illuminate\Support\Arr;
10+
use Ramsey\Uuid\UuidFactory;
11+
use Tpetry\PostgresqlEnhanced\Expressions\Uuid7;
12+
use Tpetry\PostgresqlEnhanced\Tests\TestCase;
13+
14+
class Uuid7Test extends TestCase
15+
{
16+
protected function setUp(): void
17+
{
18+
parent::setUp();
19+
20+
if (Comparator::lessThan($this->app->version(), '10.0.0')) {
21+
$this->markTestSkipped('Expression support has been added in Laravel 10.x.');
22+
}
23+
}
24+
25+
public function testIncludesTimestampOrClock(): void
26+
{
27+
$uuidNow = (new Uuid7())->getValue($this->getConnection()->getQueryGrammar());
28+
$this->assertStringContainsString('statement_timestamp()', $uuidNow);
29+
30+
$time = CarbonImmutable::now();
31+
$uuidSpecific = (new Uuid7($time))->getValue($this->getConnection()->getQueryGrammar());
32+
$this->assertStringContainsString($time->format('Y-m-d H:i:s.uP'), $uuidSpecific);
33+
}
34+
35+
/**
36+
* Two different expression invocations would always be unique because of different time.
37+
* So the time is fixed to check for the randomness.
38+
*/
39+
public function testIsRandom(): void
40+
{
41+
$uuid = new Uuid7(CarbonImmutable::now());
42+
43+
$value1 = $this->executeExpression($uuid)->value;
44+
$value2 = $this->executeExpression($uuid)->value;
45+
46+
$this->assertNotEquals($value1, $value2);
47+
}
48+
49+
public function testTimeIncreases(): void
50+
{
51+
$uuid = new Uuid7();
52+
53+
$time1 = $this->executeExpression($uuid)->time;
54+
usleep(50000);
55+
$time2 = $this->executeExpression($uuid)->time;
56+
57+
$this->assertGreaterThanOrEqual(50.0, $time1->diffInMilliseconds($time2));
58+
}
59+
60+
/**
61+
* @return object{time: CarbonImmutable, value: string}
62+
*/
63+
private function executeExpression(Uuid7 $expression): object
64+
{
65+
$row = $this->getConnection()->query()->select($expression)->first();
66+
$value = Arr::first((array) $row);
67+
68+
$uuid = (new UuidFactory())->fromString($value);
69+
throw_unless($uuid instanceof \Ramsey\Uuid\Rfc4122\UuidV7, message: "{$uuid} is not a UUIDv7");
70+
$time = CarbonImmutable::instance($uuid->getDateTime());
71+
72+
return (object) compact('value', 'time');
73+
}
74+
}

0 commit comments

Comments
 (0)