Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 18 additions & 6 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,9 @@ Intercepts method calls on `#[DbQuery]` annotated methods:
2. Determine return type from method signature
3. If return type is `void` → do nothing, return null
4. Load `{fakeDir}/{queryId}.json`
5. If file not found → throw `FakeJsonNotFoundException("{queryId}.json not found in {fakeDir}")`
5. If file not found:
- nullable return type → return `null`
- non-nullable return type → throw `FakeJsonNotFoundException`
6. Hydrate JSON to return type and return

```php
Expand Down Expand Up @@ -141,12 +143,22 @@ Look at how Ray.MediaQuery handles hydration (`/Users/akihito/git/Ray.MediaQuery

## JSON File Conventions

- Filename: `{queryId}.json` (e.g., `#[DbQuery('todo_item')]` → `todo_item.json`)
- Single entity: JSON object `{}`
- Collection: JSON array `[{}, {}]`
- Nullable: `null` or missing file returns null for nullable return types
- Single entity (`?Entity`): `{queryId}.json` — single JSON object
- Collection (`array<Entity>`): `{queryId}.jsonl` — JSON Lines, one object per line
- Nullable: missing file or `null` content returns null for nullable return types
- void methods: no file needed
Comment thread
coderabbitai[bot] marked this conversation as resolved.

### Why JSONL for collections?

```jsonl
{"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

## Exception

```php
Expand All @@ -164,7 +176,7 @@ final class FakeJsonNotFoundException extends \RuntimeException
## Key Behaviors

1. **Commands are no-ops**: `void` return type → silently succeed
2. **Missing file throws**: Clear error message with queryId and directory
2. **Missing file handling**: Nullable returns `null`; non-nullable throws with queryId and directory
3. **snake_case → camelCase**: Automatic key conversion on hydration
4. **Nullable respected**: `?Entity` with null JSON returns null
5. **Array PHPDoc respected**: `@return array<Entity>` triggers array hydration
Expand Down
155 changes: 21 additions & 134 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,30 +1,12 @@
# Ray.FakeQuery

Replace SQL execution with JSON fixtures for testing and frontend development.
A companion to [Ray.MediaQuery](https://github.com/ray-di/Ray.MediaQuery) that replaces SQL execution with JSON fixture files — no database required. Designed for testing and frontend development.

## Overview

Ray.FakeQuery is a companion package to [Ray.MediaQuery](https://github.com/ray-di/Ray.MediaQuery) that replaces SQL execution with JSON fixture files — no database required.

```
var/
├── sql/
│ └── todo_item.sql ← production: SQL executed against DB
└── fake/
└── todo_item.json ← test/dev: JSON returned directly
```text
Ray.MediaQuery SQL files → DB execution → Entity
Ray.FakeQuery JSON files → hydration → Entity (same interface)
```

The query ID defined in `#[DbQuery('todo_item')]` maps directly to the filename. Switch contexts, switch behavior.

## Why Ray.FakeQuery?

**The same philosophy as BEAR.FakeJson** — JSON files become the contract between teams.

- Frontend development proceeds without a database
- Tests run without SQL or migrations
- Fake data from [Semantic-Ex Method](https://koriym.github.io/blog/2025/08/10/semantic-method-en) becomes test fixtures naturally
- Realistic data, zero infrastructure

## Installation

```bash
Expand All @@ -33,7 +15,7 @@ composer require ray/fake-query

## Usage

Define your interfaces as usual with Ray.MediaQuery:
Define query interfaces with `#[DbQuery]` as usual:

```php
interface TodoQueryInterface
Expand All @@ -44,135 +26,40 @@ interface TodoQueryInterface
#[DbQuery('todo_list')]
/** @return array<TodoEntity> */
public function list(string $filterStatus = 'all'): array;

#[DbQuery('todo_add')]
public function add(string $todoId, string $title): void;
}
```

In production, install `MediaQuerySqlModule`. In tests or frontend development, install `FakeQueryModule`:
Swap modules to switch between real SQL and fake JSON:

```php
// Production
protected function configure(): void
{
$this->install(new MediaQuerySqlModule($sqlDir, $interfaceDir));
}
$this->install(new MediaQuerySqlModule($sqlDir, $interfaceDir));

// Test / Frontend development
protected function configure(): void
{
$this->install(new FakeQueryModule($fakeDir, $interfaceDir));
}
$this->install(new FakeQueryModule($fakeDir, $interfaceDir));
```

Create JSON files matching the query ID:
Create JSON files matching the query ID in `#[DbQuery]`:

```
var/fake/
├── todo_item.json
├── todo_list.json
├── todo_add.json (void → empty or {})
└── todo_complete.json
```
`var/fake/todo_item.json` — single entity (`?Entity`):

```json
// var/fake/todo_item.json
{
"todoId": "01HVXXXXXX0008",
"todoTitle": "Beフレームワークのチュートリアルを書く",
"todoMemo": "ALPSから始めて、JSONスキーマ、Beの実装まで",
"isCompleted": false,
"createdAt": "2026-03-02T08:00:00+09:00"
}
```

```json
// var/fake/todo_list.json
[
{
"todoId": "01HVXXXXXX0008",
"todoTitle": "Beフレームワークのチュートリアルを書く",
"isCompleted": false,
"createdAt": "2026-03-02T08:00:00+09:00"
},
{
"todoId": "01HVXXXXXX0007",
"todoTitle": "ALPSプロファイルを設計する",
"isCompleted": true,
"createdAt": "2026-03-01T10:30:00+09:00"
}
]
```

## How It Works

`FakeQueryModule` binds each interface method to a JSON-backed implementation:

1. Scan interfaces annotated with `#[DbQuery]`
2. Map query ID → `{fakeDir}/{queryId}.json`
3. Load JSON and hydrate to the declared return type (Entity, array, null)
4. For `void` methods (Commands), do nothing

The hydration follows the same rules as Ray.MediaQuery:
- Single entity: `?Entity` return type
- Collection: `array<Entity>` return type (PHPDoc)
- Raw array: `array` return type

## Command Interfaces

For write operations (`void` return), `FakeQueryModule` performs no-ops by default:

```php
interface TodoCommandInterface
{
#[DbQuery('todo_add')]
public function add(string $todoId, string $todoTitle, ?string $todoMemo, DateTimeInterface $createdAt = null): void;

#[DbQuery('todo_complete')]
public function complete(string $todoId): void;

#[DbQuery('todo_delete')]
public function delete(string $todoId): void;
"todo_id": "01HVXXXXXX0008",
"todo_title": "Buy groceries"
}
```

No JSON file needed for void methods. They simply succeed silently.
`var/fake/todo_list.jsonl` — collection (`array<Entity>`), one object per line:

## Project Structure

```
src/
├── FakeQueryModule.php Ray.Di module
├── FakeQueryInterceptor.php Intercepts #[DbQuery] calls
├── JsonHydrator.php JSON → Entity hydration
└── FakeQueryConfig.php Configuration (fakeDir, interfaceDir)
```

## Relation to Ray.MediaQuery

```
Ray.MediaQuery SQL files → DB execution → Entity
Ray.FakeQuery JSON files → hydration → Entity (same interface)
```

Both implement the same interface contracts. Swap modules, swap behavior.

## Design Decisions

- **File naming**: `{queryId}.json` — direct mapping from `#[DbQuery('queryId')]`
- **Hydration**: Reuses Ray.MediaQuery's hydration logic where possible
- **Commands**: void methods are no-ops (fake commands always succeed)
- **Missing files**: If JSON file not found, throw `FakeJsonNotFoundException` with clear message
- **snake_case → camelCase**: Same automatic conversion as Ray.MediaQuery

## Integration with Be Framework

[Be Framework](https://github.com/be-framework/be-framework) uses Ray.Di for DI. Ray.FakeQuery fits naturally:

```
Phase 1: Be + InMemory / FakeQuery ← develop domain logic, no DB needed
Phase 2: Be + Ray.MediaQuery ← add SQL, swap module
Phase 3: BEAR.Sunday + Be ← HTTP layer wraps domain
```jsonl
{"todo_id": "01HVXXXXXX0008", "todo_title": "Buy groceries"}
{"todo_id": "01HVXXXXXX0007", "todo_title": "Call dentist"}
```

## License
`void` methods require no file — they succeed silently as no-ops.

MIT
JSON keys use `snake_case`; entity properties use `camelCase`. Conversion is automatic, matching Ray.MediaQuery behavior.
8 changes: 4 additions & 4 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,10 @@
},
"scripts": {
"bin": "echo 'bin not installed'",
"test": "phpunit",
"coverage": "php -dzend_extension=xdebug.so -dxdebug.mode=coverage phpunit --coverage-text --coverage-html=build/coverage",
"phpdbg": "phpdbg -qrr phpunit --coverage-text --coverage-html ./build/coverage --coverage-clover=build/coverage.xml",
"pcov": "php -dextension=pcov.so -d pcov.enabled=1 phpunit --coverage-text --coverage-html=build/coverage --coverage-clover=build/coverage.xml",
"test": "./vendor/bin/phpunit",
"coverage": "php -dzend_extension=xdebug.so -dxdebug.mode=coverage ./vendor/bin/phpunit --coverage-text --coverage-html=build/coverage",
"phpdbg": "phpdbg -qrr ./vendor/bin/phpunit --coverage-text --coverage-html ./build/coverage --coverage-clover=build/coverage.xml",
"pcov": "php -dextension=pcov.so -d pcov.enabled=1 ./vendor/bin/phpunit --coverage-text --coverage-html=build/coverage --coverage-clover=build/coverage.xml",
"cs": "phpcs",
"cs-fix": "phpcbf src tests",
"phpstan": "phpstan analyse -c phpstan.neon",
Expand Down
3 changes: 3 additions & 0 deletions phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@ parameters:
paths:
- src
- tests
excludePaths:
analyseAndScan:
- tests/tmp/*
32 changes: 14 additions & 18 deletions phpunit.xml.dist
Original file line number Diff line number Diff line change
@@ -1,20 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="./vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
>
<testsuites>
<testsuite name="Ray.FakeQuery test suite">
<directory>tests</directory>
</testsuite>
</testsuites>
<coverage processUncoveredFiles="true">
<include>
<directory suffix=".php">src</directory>
</include>
</coverage>
<php>
<ini name="error_reporting" value="-1"/>
</php>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd" bootstrap="vendor/autoload.php" cacheDirectory=".phpunit.cache">
<testsuites>
<testsuite name="Ray.FakeQuery test suite">
<directory>tests</directory>
</testsuite>
</testsuites>
<php>
<ini name="error_reporting" value="-1"/>
</php>
<source>
<include>
<directory suffix=".php">src</directory>
</include>
</source>
</phpunit>
13 changes: 13 additions & 0 deletions src/Exception/FakeJsonNotFoundException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace Ray\FakeQuery\Exception;

final class FakeJsonNotFoundException extends RuntimeException
{
public function __construct(string $filename, string $fakeDir)
{
parent::__construct("Fake JSON file not found: {$filename} in {$fakeDir}");
}
}
13 changes: 13 additions & 0 deletions src/Exception/InvalidFakeDirException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace Ray\FakeQuery\Exception;

final class InvalidFakeDirException extends LogicException
{
public function __construct(string $fakeDir)
{
parent::__construct("Invalid fake directory: {$fakeDir}");
}
}
2 changes: 1 addition & 1 deletion src/Exception/LogicException.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@

namespace Ray\FakeQuery\Exception;

final class LogicException extends \LogicException
class LogicException extends \LogicException
{
}
2 changes: 1 addition & 1 deletion src/Exception/RuntimeException.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@

namespace Ray\FakeQuery\Exception;

final class RuntimeException extends \RuntimeException
class RuntimeException extends \RuntimeException
{
}
13 changes: 13 additions & 0 deletions src/Exception/UnknownFakeJsonException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace Ray\FakeQuery\Exception;

final class UnknownFakeJsonException extends RuntimeException
{
public function __construct(string $filename, string $fakeDir)
{
parent::__construct("Unknown fake JSON file: {$filename} in {$fakeDir} has no matching #[DbQuery] id");
}
}
9 changes: 0 additions & 9 deletions src/FakeQuery.php

This file was deleted.

21 changes: 21 additions & 0 deletions src/FakeQueryConfig.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace Ray\FakeQuery;

use Ray\FakeQuery\Exception\InvalidFakeDirException;

use function is_dir;
use function is_readable;

final class FakeQueryConfig
{
public function __construct(
public readonly string $fakeDir,
) {
if (! is_dir($fakeDir) || ! is_readable($fakeDir)) {
throw new InvalidFakeDirException($fakeDir);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Loading