From 9087f9185513f7ac457e2767cc0081e41db87b00 Mon Sep 17 00:00:00 2001 From: Haylan Date: Sat, 11 Jul 2026 16:46:21 +0200 Subject: [PATCH] feat(store): finish ContributionStore and add typed entities - Fix add()'s upsert SQL (was invalid SQL from typos) and use it for the new merge() as well. - Fix remove()'s missing FROM keyword and drop the unindexed LIKE scan in favor of an exact match. - Add latestDate(), all() with optional sinceDays filtering, and prune() with a retention-day cutoff. - Add Contribution (immutable value object) and ContributionCollection (IteratorAggregate + Countable) so all() returns something typed instead of a raw date => count array. - Cover all of the above with unit tests against an in-memory SQLite database. --- src/Entity/Contribution.php | 15 +++ src/Entity/ContributionCollection.php | 29 +++++ src/Service/ContributionStore.php | 96 +++++++++++---- .../Entity/ContributionCollectionTest.php | 43 +++++++ tests/Unit/Entity/ContributionTest.php | 22 ++++ tests/Unit/Service/ContributionStoreTest.php | 113 ++++++++++++++++++ 6 files changed, 296 insertions(+), 22 deletions(-) create mode 100644 src/Entity/Contribution.php create mode 100644 src/Entity/ContributionCollection.php create mode 100644 tests/Unit/Entity/ContributionCollectionTest.php create mode 100644 tests/Unit/Entity/ContributionTest.php create mode 100644 tests/Unit/Service/ContributionStoreTest.php diff --git a/src/Entity/Contribution.php b/src/Entity/Contribution.php new file mode 100644 index 0000000..9e04820 --- /dev/null +++ b/src/Entity/Contribution.php @@ -0,0 +1,15 @@ + + */ +final class ContributionCollection implements \IteratorAggregate, \Countable +{ + /** @var array */ + 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); + } +} diff --git a/src/Service/ContributionStore.php b/src/Service/ContributionStore.php index 2c9f1ed..c12a32f 100644 --- a/src/Service/ContributionStore.php +++ b/src/Service/ContributionStore.php @@ -4,24 +4,30 @@ declare(strict_types=1); namespace App\Service; +use App\Entity\Contribution; +use App\Entity\ContributionCollection; use PDO; -class ContributionStore { +class ContributionStore +{ private PDO $pdo; + private const int DAY_IN_SECONDS = 86400; public function __construct( private readonly string $dbPath = "%kernel.project_dir%/var/data/contributions.db", private readonly ?int $retentionDays = null, - ){ - if(!is_dir(\dirname($this->dbPath))){ - mkdir(\dirname($this->dbPath), 0755, recursive: true); + ) { + $dir = \dirname($this->dbPath); + if (!is_dir($dir)) { + mkdir($dir, 0755, recursive: true); } $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(); } - private function prepareSchema():void{ + private function prepareSchema(): void + { $this->pdo->exec(' CREATE TABLE IF NOT EXISTS contributions ( provider TEXT NOT NULL, @@ -32,28 +38,74 @@ class ContributionStore { '); } - public function add(string $provider, int $unixtime, int $count):void{ - $stmt = $this->pdo->prepare(' - INSERT INTO contributions ( - provider, date, count - ) - VALUES( - ?,?,? - ) + public function add(string $provider, int $unixtime, int $count): void + { + $stmt = $this->pdo->prepare(' + INSERT INTO contributions (provider, date, count) + 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{ - $stmt = $this->pdo->prepare(' - DELETE contributions - WHERE provider LIKE ? and date = ? + public function remove(string $provider, int $unixtime): void + { + $stmt = $this->pdo->prepare(' + DELETE FROM contributions + WHERE provider = ? AND date = ? '); - $stmt->execute([$provider,$unixtime]); + $stmt->execute([$provider, $unixtime]); } - public function all():void{ + /** + * @param array $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]); } - } diff --git a/tests/Unit/Entity/ContributionCollectionTest.php b/tests/Unit/Entity/ContributionCollectionTest.php new file mode 100644 index 0000000..d6b4325 --- /dev/null +++ b/tests/Unit/Entity/ContributionCollectionTest.php @@ -0,0 +1,43 @@ +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)); + } +} diff --git a/tests/Unit/Entity/ContributionTest.php b/tests/Unit/Entity/ContributionTest.php new file mode 100644 index 0000000..e4f0899 --- /dev/null +++ b/tests/Unit/Entity/ContributionTest.php @@ -0,0 +1,22 @@ +assertSame('github', $contribution->provider); + $this->assertSame(1_700_000_000, $contribution->date); + $this->assertSame(4, $contribution->count); + } +} diff --git a/tests/Unit/Service/ContributionStoreTest.php b/tests/Unit/Service/ContributionStoreTest.php new file mode 100644 index 0000000..4b8af0e --- /dev/null +++ b/tests/Unit/Service/ContributionStoreTest.php @@ -0,0 +1,113 @@ +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')); + } +}