Implement Ray.FakeQuery as a companion package to Ray.MediaQuery.
When FakeQueryModule is installed instead of MediaQuerySqlModule, all #[DbQuery] annotated interface methods return data from JSON fixture files instead of executing SQL.
- Ray.MediaQuery source:
/Users/akihito/git/Ray.MediaQuery - BEAR.FakeJson (same concept for BEAR.Sunday resources):
https://github.com/bearsunday/BEAR.FakeJson - README:
README.mdin this repo
{
"name": "ray/fake-query",
"description": "Replace Ray.MediaQuery SQL execution with JSON fixtures",
"require": {
"php": "^8.1",
"ray/di": "^2.18",
"ray/media-query": "^1.0"
}
}src/
├── FakeQueryModule.php
├── FakeQueryConfig.php
├── Interceptor/
│ └── FakeQueryInterceptor.php
└── Hydrator/
└── FakeJsonHydrator.php
tests/
├── FakeQueryModuleTest.php
└── Fake/
├── todo_item.json
└── todo_list.json
var/fake/ (convention, not in src)
Simple value object holding configuration:
final class FakeQueryConfig
{
public function __construct(
public readonly string $fakeDir, // path to JSON fixture directory
) {}
}Ray.Di module. Binds all interfaces that have #[DbQuery] methods to a proxy that intercepts calls and returns JSON data.
Should work similarly to how MediaQuerySqlModule binds interfaces — scan the interface directory, find all interfaces with #[DbQuery] methods, and bind them to fake implementations.
final class FakeQueryModule extends AbstractModule
{
public function __construct(
private readonly string $fakeDir,
private readonly string $interfaceDir, // same as MediaQuerySqlModule
) {}
protected function configure(): void
{
// bind FakeQueryConfig
// scan interfaceDir for interfaces with #[DbQuery] methods
// for each interface, bind to a generated fake implementation
// the fake implementation uses FakeQueryInterceptor
}
}Intercepts method calls on #[DbQuery] annotated methods:
- Get the query ID from
#[DbQuery('query_id')] - Determine return type from method signature
- If return type is
void→ do nothing, return null - Load
{fakeDir}/{queryId}.json - If file not found:
- nullable return type → return
null - non-nullable return type → throw
FakeJsonNotFoundException
- nullable return type → return
- Hydrate JSON to return type and return
final class FakeQueryInterceptor implements MethodInterceptor
{
public function __construct(
private readonly FakeQueryConfig $config,
private readonly FakeJsonHydrator $hydrator,
) {}
public function invoke(MethodInvocation $invocation): mixed
{
$method = $invocation->getMethod();
$dbQuery = $method->getAttributes(DbQuery::class)[0]->newInstance();
$queryId = $dbQuery->id; // check actual property name in Ray.MediaQuery
$returnType = $method->getReturnType();
// void → no-op
if ($returnType instanceof \ReflectionNamedType && $returnType->getName() === 'void') {
return null;
}
$jsonFile = $this->config->fakeDir . '/' . $queryId . '.json';
if (! file_exists($jsonFile)) {
throw new FakeJsonNotFoundException($queryId, $this->config->fakeDir);
}
$data = json_decode(file_get_contents($jsonFile), true);
return $this->hydrator->hydrate($data, $method);
}
}Hydrates JSON array to the declared return type:
?SomeEntity→ hydrate single object or return null if JSON is nullarray<SomeEntity>(from PHPDoc@return) → hydrate array of objectsarray→ return raw array- snake_case keys → camelCase properties (same as Ray.MediaQuery)
Look at how Ray.MediaQuery handles hydration (/Users/akihito/git/Ray.MediaQuery/src/) and reuse or replicate the same logic.
- Single entity (
?Entity):{queryId}.json— single JSON object - Collection (
array<Entity>):{queryId}.jsonl— JSON Lines, one object per line - Nullable: missing file or
nullcontent returns null for nullable return types - void methods: no file needed
{"todoId": "01HVXXXXXX0007", "todoTitle": "ALPSプロファイルを設計する", "isCompleted": true}
{"todoId": "01HVXXXXXX0008", "todoTitle": "Beフレームワークのチュートリアルを書く", "isCompleted": false}- Adding a record = adding a line (no array syntax, no trailing comma issues)
- Git diffs are clean
- Each line is independently valid JSON
final class FakeJsonNotFoundException extends \RuntimeException
{
public function __construct(string $queryId, string $fakeDir)
{
parent::__construct(
"Fake JSON file not found: {$queryId}.json in {$fakeDir}"
);
}
}- Commands are no-ops:
voidreturn type → silently succeed - Missing file handling: Nullable returns
null; non-nullable throws with queryId and directory - snake_case → camelCase: Automatic key conversion on hydration
- Nullable respected:
?Entitywith null JSON returns null - Array PHPDoc respected:
@return array<Entity>triggers array hydration
Write tests that:
- Install
FakeQueryModulewith a test fake directory - Get interface instance from injector
- Assert that method returns hydrated entity from JSON
final class FakeQueryModuleTest extends TestCase
{
public function testItemQuery(): void
{
$injector = new Injector(new class extends AbstractModule {
protected function configure(): void
{
$this->install(new FakeQueryModule(
fakeDir: __DIR__ . '/Fake',
interfaceDir: __DIR__ . '/Fake/Interface',
));
}
});
$query = $injector->getInstance(TodoQueryInterface::class);
$todo = $query->item('todo-123');
$this->assertInstanceOf(TodoEntity::class, $todo);
$this->assertSame('Beフレームワークのチュートリアルを書く', $todo->todoTitle);
}
}- Study how
MediaQuerySqlModulescans interfaces and binds them — replicate the same pattern for fake binding - The
DbQueryattribute class lives inRay\MediaQuery\Annotation\DbQuery— import from there - Use
ray/diinterceptor binding:$this->bindInterceptor(...)for methods with#[DbQuery] - PHP 8.1+ only — use readonly properties, enums where appropriate