Save to database #13
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
final class Contribution
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $provider,
|
||||||
|
public readonly int $date,
|
||||||
|
public readonly int $count,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @implements \IteratorAggregate<int, Contribution>
|
||||||
|
*/
|
||||||
|
final class ContributionCollection implements \IteratorAggregate, \Countable
|
||||||
|
{
|
||||||
|
/** @var array<int, Contribution> */
|
||||||
|
private readonly array $contributions;
|
||||||
|
|
||||||
|
public function __construct(Contribution ...$contributions)
|
||||||
|
{
|
||||||
|
$this->contributions = $contributions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getIterator(): \ArrayIterator
|
||||||
|
{
|
||||||
|
return new \ArrayIterator($this->contributions);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function count(): int
|
||||||
|
{
|
||||||
|
return count($this->contributions);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,24 +4,30 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Service;
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Entity\Contribution;
|
||||||
|
use App\Entity\ContributionCollection;
|
||||||
use PDO;
|
use PDO;
|
||||||
|
|
||||||
class ContributionStore {
|
class ContributionStore
|
||||||
|
{
|
||||||
private PDO $pdo;
|
private PDO $pdo;
|
||||||
|
private const int DAY_IN_SECONDS = 86400;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly string $dbPath = "%kernel.project_dir%/var/data/contributions.db",
|
private readonly string $dbPath = "%kernel.project_dir%/var/data/contributions.db",
|
||||||
private readonly ?int $retentionDays = null,
|
private readonly ?int $retentionDays = null,
|
||||||
){
|
) {
|
||||||
if(!is_dir(\dirname($this->dbPath))){
|
$dir = \dirname($this->dbPath);
|
||||||
mkdir(\dirname($this->dbPath), 0755, recursive: true);
|
if (!is_dir($dir)) {
|
||||||
|
mkdir($dir, 0755, recursive: true);
|
||||||
}
|
}
|
||||||
$this->pdo = new PDO('sqlite:' . $this->dbPath);
|
$this->pdo = new PDO('sqlite:' . $this->dbPath);
|
||||||
$this->pdo->setAttribute(PDO::ATTR_ERRMODE,PDO::ERRMODE_EXCEPTION);
|
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||||
$this->prepareSchema();
|
$this->prepareSchema();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function prepareSchema():void{
|
private function prepareSchema(): void
|
||||||
|
{
|
||||||
$this->pdo->exec('
|
$this->pdo->exec('
|
||||||
CREATE TABLE IF NOT EXISTS contributions (
|
CREATE TABLE IF NOT EXISTS contributions (
|
||||||
provider TEXT NOT NULL,
|
provider TEXT NOT NULL,
|
||||||
@@ -32,28 +38,74 @@ class ContributionStore {
|
|||||||
');
|
');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function add(string $provider, int $unixtime, int $count):void{
|
public function add(string $provider, int $unixtime, int $count): void
|
||||||
$stmt = $this->pdo->prepare('
|
{
|
||||||
INSERT INTO contributions (
|
$stmt = $this->pdo->prepare('
|
||||||
provider, date, count
|
INSERT INTO contributions (provider, date, count)
|
||||||
)
|
VALUES (?, ?, ?)
|
||||||
VALUES(
|
ON CONFLICT(provider, date) DO UPDATE SET count = excluded.count
|
||||||
?,?,?
|
|
||||||
)
|
|
||||||
');
|
');
|
||||||
$stmt->execute([$provider,$unixtime,$count]);
|
$stmt->execute([$provider, $unixtime, $count]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function remove(string $provider, int $unixtime):void{
|
public function remove(string $provider, int $unixtime): void
|
||||||
$stmt = $this->pdo->prepare('
|
{
|
||||||
DELETE contributions
|
$stmt = $this->pdo->prepare('
|
||||||
WHERE provider LIKE ? and date = ?
|
DELETE FROM contributions
|
||||||
|
WHERE provider = ? AND date = ?
|
||||||
');
|
');
|
||||||
$stmt->execute([$provider,$unixtime]);
|
$stmt->execute([$provider, $unixtime]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function all():void{
|
/**
|
||||||
|
* @param array<int, int> $dateCounts unix timestamp => count
|
||||||
|
*/
|
||||||
|
public function merge(string $provider, array $dateCounts): void
|
||||||
|
{
|
||||||
|
foreach ($dateCounts as $unixtime => $count) {
|
||||||
|
$this->add($provider, $unixtime, $count);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function latestDate(string $provider): ?int
|
||||||
|
{
|
||||||
|
$stmt = $this->pdo->prepare('SELECT MAX(date) FROM contributions WHERE provider = ?');
|
||||||
|
$stmt->execute([$provider]);
|
||||||
|
$result = $stmt->fetchColumn();
|
||||||
|
|
||||||
|
return $result !== null ? (int) $result : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function all(string $provider, ?int $sinceDays = null): ContributionCollection
|
||||||
|
{
|
||||||
|
$sql = 'SELECT provider, date, count FROM contributions WHERE provider = ?';
|
||||||
|
$params = [$provider];
|
||||||
|
|
||||||
|
if ($sinceDays !== null) {
|
||||||
|
$sql .= ' AND date >= ?';
|
||||||
|
$params[] = time() - $sinceDays * self::DAY_IN_SECONDS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $this->pdo->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
|
||||||
|
$contributions = array_map(
|
||||||
|
static fn (array $row): Contribution => new Contribution($row['provider'], (int) $row['date'], (int) $row['count']),
|
||||||
|
$stmt->fetchAll(PDO::FETCH_ASSOC),
|
||||||
|
);
|
||||||
|
|
||||||
|
return new ContributionCollection(...$contributions);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function prune(): void
|
||||||
|
{
|
||||||
|
if ($this->retentionDays === null || $this->retentionDays === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cutoff = time() - $this->retentionDays * self::DAY_IN_SECONDS;
|
||||||
|
|
||||||
|
$stmt = $this->pdo->prepare('DELETE FROM contributions WHERE date < ?');
|
||||||
|
$stmt->execute([$cutoff]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Entity;
|
||||||
|
|
||||||
|
use App\Entity\Contribution;
|
||||||
|
use App\Entity\ContributionCollection;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class ContributionCollectionTest extends TestCase
|
||||||
|
{
|
||||||
|
#[Test]
|
||||||
|
public function it_counts_zero_when_empty(): void
|
||||||
|
{
|
||||||
|
$collection = new ContributionCollection();
|
||||||
|
|
||||||
|
$this->assertCount(0, $collection);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function it_counts_the_contributions_it_wraps(): void
|
||||||
|
{
|
||||||
|
$collection = new ContributionCollection(
|
||||||
|
new Contribution('github', 1_700_000_000, 1),
|
||||||
|
new Contribution('github', 1_700_086_400, 2),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertCount(2, $collection);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function it_is_iterable_over_its_contributions(): void
|
||||||
|
{
|
||||||
|
$first = new Contribution('github', 1_700_000_000, 1);
|
||||||
|
$second = new Contribution('gitlab', 1_700_086_400, 2);
|
||||||
|
|
||||||
|
$collection = new ContributionCollection($first, $second);
|
||||||
|
|
||||||
|
$this->assertSame([$first, $second], iterator_to_array($collection));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Entity;
|
||||||
|
|
||||||
|
use App\Entity\Contribution;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class ContributionTest extends TestCase
|
||||||
|
{
|
||||||
|
#[Test]
|
||||||
|
public function it_exposes_provider_date_and_count(): void
|
||||||
|
{
|
||||||
|
$contribution = new Contribution('github', 1_700_000_000, 4);
|
||||||
|
|
||||||
|
$this->assertSame('github', $contribution->provider);
|
||||||
|
$this->assertSame(1_700_000_000, $contribution->date);
|
||||||
|
$this->assertSame(4, $contribution->count);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Service;
|
||||||
|
|
||||||
|
use App\Service\ContributionStore;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class ContributionStoreTest extends TestCase
|
||||||
|
{
|
||||||
|
#[Test]
|
||||||
|
public function it_stores_and_retrieves_a_contribution(): void
|
||||||
|
{
|
||||||
|
$store = new ContributionStore(':memory:');
|
||||||
|
|
||||||
|
$store->add('github', 1_700_000_000, 4);
|
||||||
|
$all = $store->all('github');
|
||||||
|
|
||||||
|
$this->assertCount(1, $all);
|
||||||
|
$this->assertSame(4, iterator_to_array($all)[0]->count);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function it_upserts_on_a_repeated_date(): void
|
||||||
|
{
|
||||||
|
$store = new ContributionStore(':memory:');
|
||||||
|
|
||||||
|
$store->add('github', 1_700_000_000, 4);
|
||||||
|
$store->add('github', 1_700_000_000, 9);
|
||||||
|
$all = $store->all('github');
|
||||||
|
|
||||||
|
$this->assertCount(1, $all);
|
||||||
|
$this->assertSame(9, iterator_to_array($all)[0]->count);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function it_removes_a_contribution(): void
|
||||||
|
{
|
||||||
|
$store = new ContributionStore(':memory:');
|
||||||
|
|
||||||
|
$store->add('github', 1_700_000_000, 4);
|
||||||
|
$store->remove('github', 1_700_000_000);
|
||||||
|
|
||||||
|
$this->assertCount(0, $store->all('github'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function it_merges_a_batch_of_date_counts(): void
|
||||||
|
{
|
||||||
|
$store = new ContributionStore(':memory:');
|
||||||
|
|
||||||
|
$store->merge('github', [1_700_000_000 => 1, 1_700_086_400 => 2]);
|
||||||
|
|
||||||
|
$this->assertCount(2, $store->all('github'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function it_reports_the_latest_date_for_a_provider(): void
|
||||||
|
{
|
||||||
|
$store = new ContributionStore(':memory:');
|
||||||
|
|
||||||
|
$store->add('github', 1_700_000_000, 1);
|
||||||
|
$store->add('github', 1_700_086_400, 2);
|
||||||
|
|
||||||
|
$this->assertSame(1_700_086_400, $store->latestDate('github'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function it_returns_null_latest_date_when_provider_has_no_rows(): void
|
||||||
|
{
|
||||||
|
$store = new ContributionStore(':memory:');
|
||||||
|
|
||||||
|
$this->assertNull($store->latestDate('github'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function it_filters_all_by_since_days(): void
|
||||||
|
{
|
||||||
|
$store = new ContributionStore(':memory:');
|
||||||
|
$now = time();
|
||||||
|
|
||||||
|
$store->add('github', $now - 10 * 86400, 1);
|
||||||
|
$store->add('github', $now - 400 * 86400, 2);
|
||||||
|
|
||||||
|
$this->assertCount(1, $store->all('github', sinceDays: 30));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function it_does_not_prune_when_retention_is_unset(): void
|
||||||
|
{
|
||||||
|
$store = new ContributionStore(':memory:', retentionDays: null);
|
||||||
|
|
||||||
|
$store->add('github', time() - 1_000 * 86400, 1);
|
||||||
|
$store->prune();
|
||||||
|
|
||||||
|
$this->assertCount(1, $store->all('github'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function it_prunes_rows_older_than_the_retention_window(): void
|
||||||
|
{
|
||||||
|
$store = new ContributionStore(':memory:', retentionDays: 30);
|
||||||
|
$now = time();
|
||||||
|
|
||||||
|
$store->add('github', $now - 10 * 86400, 1);
|
||||||
|
$store->add('github', $now - 40 * 86400, 2);
|
||||||
|
$store->prune();
|
||||||
|
|
||||||
|
$this->assertCount(1, $store->all('github'));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user