Skip to content

Commit 6f49475

Browse files
committed
feat: timestamp binning
1 parent 0f6d359 commit 6f49475

File tree

5 files changed

+173
-0
lines changed

5 files changed

+173
-0
lines changed

README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,33 @@ Schema::table('users', function (Blueprint $table): void {
262262
> The `Uuid4` expression is not available for all database versions.
263263
> With PostgreSQL you need at least v13 and with MariaDB at least v10.10.
264264
265+
#### Time
266+
```php
267+
use Tpetry\QueryExpressions\Function\Time\Now;
268+
use Tpetry\QueryExpressions\Function\Time\TimestampBin;
269+
270+
new Now();
271+
new TimestampBin(string|Expression $expression, DateInterval $step, ?DateTimeInterface $origin = null);
272+
273+
BlogVisit::select([
274+
'url',
275+
new TimestampBin('created_at', DateInterval::createFromDateString('5 minutes')),
276+
new Count('*'),
277+
])->groupBy(
278+
'url',
279+
new TimestampBin('created_at', DateInterval::createFromDateString('5 minutes'))
280+
)->get();
281+
// | url | timestamp | count |
282+
// |-----------|---------------------|-------|
283+
// | /example1 | 2023-05-16 09:50:00 | 2 |
284+
// | /example1 | 2023-05-16 09:55:00 | 1 |
285+
// | /example1 | 2023-05-16 09:50:00 | 1 |
286+
287+
Schema::table('users', function (Blueprint $table): void {
288+
$table->uuid()->default(new Uuid4())->unique();
289+
});
290+
```
291+
265292
## Changelog
266293

267294
Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently.

src/Function/Time/Now.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tpetry\QueryExpressions\Function\Time;
6+
7+
use Illuminate\Contracts\Database\Query\Expression;
8+
use Illuminate\Database\Grammar;
9+
use Tpetry\QueryExpressions\Concerns\IdentifiesDriver;
10+
11+
class Now implements Expression
12+
{
13+
use IdentifiesDriver;
14+
15+
public function getValue(Grammar $grammar)
16+
{
17+
// MySQL: The expression needs to be enclosed by parentheses to be used as a default value in create table statements.
18+
// PostgreSQL: The CURRENT_TIMESTAMP constant is frozen within transactions.
19+
// SQLite: The expression needs to be enclosed by parentheses to be used as a default value in create table statements.
20+
return match ($this->identify($grammar)) {
21+
'mysql', 'sqlite' => '(current_timestamp)',
22+
'pgsql' => 'statement_timestamp()',
23+
'sqlsrv' => 'current_timestamp',
24+
};
25+
}
26+
}

src/Function/Time/TimestampBin.php

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tpetry\QueryExpressions\Function\Time;
6+
7+
use Carbon\CarbonInterval;
8+
use DateInterval;
9+
use DateTimeInterface;
10+
use Illuminate\Contracts\Database\Query\Expression;
11+
use Illuminate\Database\Grammar;
12+
use RuntimeException;
13+
use Tpetry\QueryExpressions\Concerns\IdentifiesDriver;
14+
use Tpetry\QueryExpressions\Concerns\StringizeExpression;
15+
16+
class TimestampBin implements Expression
17+
{
18+
use IdentifiesDriver;
19+
use StringizeExpression;
20+
21+
public function __construct(
22+
private readonly string|Expression $expression,
23+
private readonly DateInterval $step,
24+
private readonly ?DateTimeInterface $origin = null,
25+
) {
26+
if ($this->step->f > 0) {
27+
throw new RuntimeException('timestamp binning with millisecond resolution is not supported');
28+
}
29+
if ($this->origin?->getTimestamp() < 0) {
30+
throw new RuntimeException('timestamp binning with an origin before 1970-01-01 is not supported');
31+
}
32+
}
33+
34+
public function getValue(Grammar $grammar)
35+
{
36+
$expression = $this->stringize($grammar, $this->expression);
37+
$step = (int) CarbonInterval::instance($this->step)->totalSeconds;
38+
$origin = $this->origin?->getTimestamp() ?? 0;
39+
40+
// MySQL: The expression needs to be enclosed by parentheses to be used as a default value in create table statements.
41+
// SQLite: The expression needs to be enclosed by parentheses to be used as a default value in create table statements.
42+
return match ($this->identify($grammar)) {
43+
'mysql' => "(from_unixtime(floor((unix_timestamp({$expression})-{$origin})/{$step})*{$step}+{$origin}))",
44+
'pgsql' => "to_timestamp(floor((extract(epoch from {$expression})-{$origin})/{$step})*{$step}+{$origin})",
45+
'sqlite' => "(datetime((strftime('%s',{$expression})-{$origin})/{$step}*{$step}+{$origin},'unixepoch'))",
46+
'sqlsrv' => "dateadd(s,(datediff(s,'1970-01-01',{$expression})-{$origin})/{$step}*{$step}+{$origin},'1970-01-01')",
47+
};
48+
}
49+
}

tests/Function/Time/NowTest.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use Tpetry\QueryExpressions\Function\Time\Now;
6+
7+
it('can generate the current time')
8+
->expect(new Now())
9+
->toBeExecutable()
10+
->toBeMysql('(current_timestamp)')
11+
->toBePgsql('statement_timestamp()')
12+
->toBeSqlite('(current_timestamp)')
13+
->toBeSqlsrv('current_timestamp');
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use Illuminate\Database\Query\Expression;
6+
use Illuminate\Support\Facades\DB;
7+
use Tpetry\QueryExpressions\Function\Time\TimestampBin;
8+
9+
it('can bin the time of a column')
10+
->expect(new TimestampBin('val', DateInterval::createFromDateString('5 minutes')))
11+
->toBeExecutable(['val timestamp'])
12+
->toBeMysql('(from_unixtime(floor((unix_timestamp(`val`)-0)/300)*300+0))')
13+
->toBePgsql('to_timestamp(floor((extract(epoch from "val")-0)/300)*300+0)')
14+
->toBeSqlite('(datetime((strftime(\'%s\',"val")-0)/300*300+0,\'unixepoch\'))')
15+
->toBeSqlsrv('dateadd(s,(datediff(s,\'1970-01-01\',[val])-0)/300*300+0,\'1970-01-01\')');
16+
17+
it('can bin the time of an expression')
18+
->expect(new TimestampBin(new Expression('current_timestamp'), DateInterval::createFromDateString('1 minute')))
19+
->toBeExecutable(['val timestamp'])
20+
->toBeMysql('(from_unixtime(floor((unix_timestamp(current_timestamp)-0)/60)*60+0))')
21+
->toBePgsql('to_timestamp(floor((extract(epoch from current_timestamp)-0)/60)*60+0)')
22+
->toBeSqlite('(datetime((strftime(\'%s\',current_timestamp)-0)/60*60+0,\'unixepoch\'))')
23+
->toBeSqlsrv('dateadd(s,(datediff(s,\'1970-01-01\',current_timestamp)-0)/60*60+0,\'1970-01-01\')');
24+
25+
it('can bin the time of a column with an origin')
26+
->expect(new TimestampBin('val', DateInterval::createFromDateString('90 seconds'), DateTime::createFromFormat('Y-m-d H:i:s', '2022-02-02 22:22:22')))
27+
->toBeExecutable(['val timestamp'])
28+
->toBeMysql('(from_unixtime(floor((unix_timestamp(`val`)-1643840542)/90)*90+1643840542))')
29+
->toBePgsql('to_timestamp(floor((extract(epoch from "val")-1643840542)/90)*90+1643840542)')
30+
->toBeSqlite('(datetime((strftime(\'%s\',"val")-1643840542)/90*90+1643840542,\'unixepoch\'))')
31+
->toBeSqlsrv('dateadd(s,(datediff(s,\'1970-01-01\',[val])-1643840542)/90*90+1643840542,\'1970-01-01\')');
32+
33+
it('can bin the time of an expression with an origin')
34+
->expect(new TimestampBin(new Expression('current_timestamp'), DateInterval::createFromDateString('1 hour'), DateTime::createFromFormat('Y-m-d H:i:s', '2000-01-01 00:00:00')))
35+
->toBeExecutable(['val timestamp'])
36+
->toBeMysql('(from_unixtime(floor((unix_timestamp(current_timestamp)-946684800)/3600)*3600+946684800))')
37+
->toBePgsql('to_timestamp(floor((extract(epoch from current_timestamp)-946684800)/3600)*3600+946684800)')
38+
->toBeSqlite('(datetime((strftime(\'%s\',current_timestamp)-946684800)/3600*3600+946684800,\'unixepoch\'))')
39+
->toBeSqlsrv('dateadd(s,(datediff(s,\'1970-01-01\',current_timestamp)-946684800)/3600*3600+946684800,\'1970-01-01\')');
40+
41+
it('does not support millisecond steps', function () {
42+
$expression = new TimestampBin(
43+
expression: new Expression('current_timestamp'),
44+
step: DateInterval::createFromDateString('500 milliseconds'),
45+
);
46+
47+
$expression->getValue(DB::connection()->getQueryGrammar());
48+
})->throws(RuntimeException::class, 'timestamp binning with millisecond resolution is not supported');
49+
50+
it('does not support origins before 1970-01-01', function () {
51+
$expression = new TimestampBin(
52+
expression: new Expression('current_timestamp'),
53+
step: DateInterval::createFromDateString('1 second'),
54+
origin: DateTime::createFromFormat('Y-m-d', '1900-01-01'),
55+
);
56+
57+
$expression->getValue(DB::connection()->getQueryGrammar());
58+
})->throws(RuntimeException::class, 'timestamp binning with an origin before 1970-01-01 is not supported');

0 commit comments

Comments
 (0)