feat: add JsonFileRepository with lazy-loading

This commit is contained in:
Funky Waddle 2026-02-12 00:54:35 -06:00
parent 1951ce9c7d
commit a326084346
2 changed files with 189 additions and 0 deletions

View file

@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace FlagPole\Repository;
use FlagPole\Flag;
use FlagPole\FlagHydrator;
use FlagPole\Rule;
/**
* Loads flags from a JSON file.
*/
final class JsonFileRepository implements FlagRepositoryInterface
{
/** @var array<string, array<string, mixed>>|null */
private ?array $rawData = null;
/** @var array<string, Flag> */
private array $flags = [];
private ?FlagHydrator $hydrator = null;
public function __construct(
private readonly string $filePath
) {
}
public function get(string $name): ?Flag
{
$this->ensureLoaded();
if (isset($this->flags[$name])) {
return $this->flags[$name];
}
if (isset($this->rawData[$name])) {
$this->hydrator ??= new FlagHydrator();
$this->flags[$name] = $this->hydrator->hydrate($name, $this->rawData[$name]);
return $this->flags[$name];
}
return null;
}
/**
* @return iterable<string, Flag>
*/
public function all(): iterable
{
$this->ensureLoaded();
if ($this->rawData !== null) {
foreach (array_keys($this->rawData) as $name) {
$this->get((string)$name);
}
}
return $this->flags;
}
private function ensureLoaded(): void
{
if ($this->rawData !== null) {
return;
}
if (!file_exists($this->filePath)) {
$this->rawData = [];
return;
}
$content = file_get_contents($this->filePath);
if ($content === false) {
$this->rawData = [];
return;
}
try {
$data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
$this->rawData = [];
return;
}
if (!is_array($data)) {
$this->rawData = [];
return;
}
$this->rawData = $data;
}
}

View file

@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace FlagPole\Tests;
use FlagPole\Repository\JsonFileRepository;
use PHPUnit\Framework\TestCase;
final class JsonFileRepositoryTest extends TestCase
{
private string $tempFile;
protected function setUp(): void
{
$this->tempFile = tempnam(sys_get_temp_dir(), 'flagpole_test_');
}
protected function tearDown(): void
{
if (file_exists($this->tempFile)) {
unlink($this->tempFile);
}
}
public function testLoadsFlagsFromJson(): void
{
$json = json_encode([
'test-flag' => [
'enabled' => true,
'rules' => [
['attribute' => 'plan', 'operator' => 'eq', 'value' => 'pro']
]
]
]);
file_put_contents($this->tempFile, $json);
$repo = new JsonFileRepository($this->tempFile);
$flag = $repo->get('test-flag');
$this->assertNotNull($flag);
$this->assertEquals('test-flag', $flag->name);
$this->assertTrue($flag->enabled);
$this->assertCount(1, $flag->rules);
$this->assertEquals('plan', $flag->rules[0]->attribute);
}
public function testHandlesMissingFile(): void
{
$repo = new JsonFileRepository('/non/existent/file.json');
$this->assertNull($repo->get('any'));
$this->assertEmpty($repo->all());
}
public function testHandlesInvalidJson(): void
{
file_put_contents($this->tempFile, 'invalid json');
$repo = new JsonFileRepository($this->tempFile);
$this->assertNull($repo->get('any'));
}
public function testLazyHydration(): void
{
$json = json_encode([
'flag-1' => ['enabled' => true],
'flag-2' => ['enabled' => false],
]);
file_put_contents($this->tempFile, $json);
$repo = new JsonFileRepository($this->tempFile);
// Access internal property via reflection to verify it's NOT hydrated yet
$reflection = new \ReflectionClass($repo);
$flagsProp = $reflection->getProperty('flags');
$flagsProp->setAccessible(true);
$this->assertEmpty($flagsProp->getValue($repo));
// Hydrate flag-1
$flag1 = $repo->get('flag-1');
$this->assertNotNull($flag1);
$this->assertTrue($flag1->enabled);
$hydratedFlags = $flagsProp->getValue($repo);
$this->assertArrayHasKey('flag-1', $hydratedFlags);
$this->assertArrayNotHasKey('flag-2', $hydratedFlags);
// Hydrate all
$all = $repo->all();
$this->assertCount(2, $all);
$hydratedFlagsAfterAll = $flagsProp->getValue($repo);
$this->assertArrayHasKey('flag-1', $hydratedFlagsAfterAll);
$this->assertArrayHasKey('flag-2', $hydratedFlagsAfterAll);
}
}