Skip to content

Harden FakeQuery release baseline#2

Open
koriym wants to merge 6 commits into
1.xfrom
codex/fake-query-release-hardening
Open

Harden FakeQuery release baseline#2
koriym wants to merge 6 commits into
1.xfrom
codex/fake-query-release-hardening

Conversation

@koriym
Copy link
Copy Markdown
Member

@koriym koriym commented May 15, 2026

Summary

  • Add release-hardening planning docs and CI for PHP 8.2-8.5.
  • Update package metadata and dependency declarations around Ray.MediaQuery 1.1.
  • Add factory hydration coverage for static and injected #[DbQuery(factory: ...)] factories.
  • Validate nested fixture query IDs recursively.
  • Add select-side constructor-based PostQueryInterface result wrapper support.

Scope

  • Keeps Ray.FakeQuery focused on select fixtures.
  • Does not emulate PDO or PostQueryContext for DML metadata.
  • Leaves AffectedRows / InsertedRow support for a separate explicit metadata fixture design if needed.

Tests

  • composer tests
  • composer crc
  • composer validate --strict

Summary by CodeRabbit

  • New Features

    • Factory-based result hydration and PostQueryInterface post-query support
    • Recursive fixture discovery for nested query paths
  • Documentation

    • README updated with fixture format and Ray.MediaQuery 1.1 guidance
    • Planning documents rewritten into release-hardening checklist and progress logs
  • Tests

    • Added comprehensive factory-backed and post-query tests plus fixtures and test helpers
  • Chores

    • Added CI workflow and updated package metadata; expanded .gitignore to ignore test caches

Review Change Stack

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 15, 2026

Warning

Rate limit exceeded

@koriym has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 7 minutes before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 447596fa-e1ce-4066-8298-b1cd05399832

📥 Commits

Reviewing files that changed from the base of the PR and between 5d77462 and 77d6002.

📒 Files selected for processing (10)
  • DESIGN.md
  • README.md
  • src/Exception/InvalidFactoryException.php
  • src/FakeQueryConfig.php
  • src/JsonHydrator.php
  • tests/Fake/Factory/MissingMethodTodoFactory.php
  • tests/Fake/Query/FactoryTodoQueryInterface.php
  • tests/Fake/factory_missing_class_item.json
  • tests/Fake/factory_missing_method_item.json
  • tests/FakeQueryModuleTest.php
📝 Walkthrough

Walkthrough

This PR adds factory-backed hydration and PostQueryInterface (Ray.MediaQuery ^1.1) select-result support, makes JsonHydrator DbQuery-aware, implements recursive fixture validation, updates CI/composer/README, and adds tests/fixtures for static and injected factory flows and post-query selection hydration.

Changes

Factory hydration and PostQueryInterface result flow

Layer / File(s) Summary
Project metadata & CI
composer.json, README.md, .github/workflows/ci.yml, .gitignore
Composer metadata and runtime requirements updated (platform php 8.2, ray/media-query ^1.1, added support links); README documents fixture formats and select-result support; new GitHub Actions CI workflow and .phpunit.cache/ ignored.
Interceptor: dispatch and file loading
src/FakeQueryInterceptor.php
Invoke computes optional entityClass, detects PostQueryInterface return types, dispatches to selectPostQuery() for JSONL/post-query results, reads JSON/JSONL via readJsonl()/readContent(), and throws FakeJsonNotFoundException for missing files. Standard hydration now forwards DbQuery.
Hydrator: factory-aware hydration
src/JsonHydrator.php
Constructor added to accept FactoryMethod and InjectorInterface; hydrate() signature gains `DbQuery
Module wiring & fixture validation
src/FakeQueryModule.php
DI bind for FactoryMethod and interceptor wiring updated (DbQueryInterceptor::class target, singleton binding to FakeQueryInterceptor). validateFakeFiles() now recursively traverses $fakeDir, filters .json/.jsonl, derives normalized query ids via queryIdFromFile(), and throws UnknownFakeJsonException for stray files.
Test types, factories, and post-query result
tests/Fake/Entity/FactoryTodoEntity.php, tests/Fake/Factory/*, tests/Fake/Query/*, tests/Fake/Result/TodoSelection.php
Adds immutable FactoryTodoEntity, StaticTodoFactory (static), InjectedTodoFactory (instance), query interfaces wiring DbQuery(..., factory: ...), and TodoSelection implementing PostQueryInterface with fromContext(), counting, iteration, and titles().
Fixtures and tests
tests/Fake/*.json{,l}, tests/Fake/nested/*, tests/FakeUnknownNested/*, tests/FakeQueryModuleTest.php, tests/Fake/ThrowingSqlQuery.php
Adds JSON/JSONL fixtures for static/injected items and nested paths; test suite extended with cases for static/injected factory hydration, list hydration, nested query-id resolution, post-query selection hydration, and unknown-nested-fake exception. ThrowingSqlQuery stub prevents real SQL execution in tests.
sequenceDiagram
    participant Caller
    participant Interceptor
    participant Filesystem
    participant JsonHydrator
    participant Injector

    Caller->>Interceptor: invoke(queryMethod, DbQuery)
    Interceptor->>Filesystem: readContent/readJsonl(queryId)
    Filesystem-->>Interceptor: json/jsonl data
    Interceptor->>JsonHydrator: hydrate(data, entityClass, isRow?, DbQuery)
    JsonHydrator->>Injector: resolve factory (if configured)
    Injector-->>JsonHydrator: factory callable or null
    JsonHydrator-->>Interceptor: entity or PostQuery wrapper
    Interceptor-->>Caller: return result
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • ray-di/Ray.FakeQuery#1: Establishes the initial FakeQueryInterceptor, FakeQueryModule, and JsonHydrator implementations that this PR extends with factory-driven hydration and PostQueryInterface result wrapper support.

Poem

A rabbit found fixtures in a nested heap,
Factories stitch entities while tests count sheep,
PostQuery selections hop out in a row,
CI hums, composer aligns — ready to go! 🐇✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.81% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately summarizes the main change: hardening the FakeQuery package for release by adding CI, updating dependencies for Ray.MediaQuery 1.1, factory hydration support, and nested fixture validation.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/fake-query-release-hardening

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@koriym koriym marked this pull request as ready for review May 15, 2026 01:22
@koriym
Copy link
Copy Markdown
Member Author

koriym commented May 15, 2026

Downstream verification completed in koriym/BEAR.AppKata#2.

Verified usage:

  • Installed this branch as ray/fake-query in BEAR.AppKata.
  • First tested with a local Composer path repository symlink, then switched BEAR.AppKata to this GitHub VCS branch so CI can install it.
  • Replaced Admin read hard-coded fake classes with JSON/JSONL fixtures plus FakeQueryModule.
  • Confirmed #[DbQuery(factory: ...)] hydration, missing nullable row behavior, and constructor-based typed rowlist wrapper support.

BEAR.AppKata checks after push:

  • All tests: pass
  • Compile: pass
  • All Coverages: pass
  • apidoc: pass

Ray.FakeQuery checks are also passing for PHP 8.2, 8.3, 8.4, 8.5 and Quality.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (3)
composer.json (1)

106-108: ⚡ Quick win

Consider using "8.2" instead of "8.2.0" for the platform PHP version.

The platform.php constraint is set to the exact version 8.2.0. While this is functionally valid, it's less common than omitting the patch version (e.g., "php": "8.2"). Using the minor version only provides more flexibility with patch releases while still constraining the major and minor versions for dependency resolution.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@composer.json` around lines 106 - 108, The composer.json "platform" PHP
constraint currently pins the exact patch version ("php": "8.2.0"); update the
value to the minor-only constraint "8.2" (i.e., change platform -> php from
"8.2.0" to "8.2") so dependency resolution allows any 8.2.x patch release while
still constraining major/minor; locate the "platform" -> "php" entry in
composer.json to make this change.
src/FakeQueryModule.php (1)

51-51: 💤 Low value

Hard-coding 'factory' makes the factory method name a global, undocumented convention.

#[FactoryMethod] is bound to the literal string 'factory', which forces every DbQuery(factory: ...) target across the entire app to expose a method named exactly factory. This is fine as a v1.0 convention, but it's worth surfacing in the README/documentation and ideally driving it from a constant (e.g., JsonHydrator::DEFAULT_FACTORY_METHOD) so the string lives in exactly one place.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/FakeQueryModule.php` at line 51, The binding currently hard-codes the
factory method name ('factory') via FactoryMethod::class in FakeQueryModule
(->toInstance('factory')), which makes the convention global and undocumented;
change the literal to a single shared constant (e.g. replace 'factory' with
JsonHydrator::DEFAULT_FACTORY_METHOD or another appropriately named constant on
the hydrator/DbQuery target) and update the README/docs to document the
#[FactoryMethod] convention so the string/value lives in one place and the
convention is explicit.
src/FakeQueryInterceptor.php (1)

113-126: 💤 Low value

readContent() ignores the nullable-return convention used elsewhere.

The standard query path (lines 74–80) honours isNullable($returnType) and returns null when a fixture file is missing on a nullable return type, but selectPostQuery()readContent() unconditionally throws FakeJsonNotFoundException. If a method declares ?SomePostQuery as its return type, behaviour will diverge from regular row/list queries. If this asymmetry is intentional (containers are always required), please document it on readContent()/selectPostQuery() so future maintainers don't "fix" it inconsistently.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/FakeQueryInterceptor.php` around lines 113 - 126, readContent() currently
always throws FakeJsonNotFoundException when the fixture file is missing, which
diverges from the standard query path that uses isNullable($returnType) to
return null for nullable returns; update the code so selectPostQuery() (or
readContent()) checks the return type nullability via isNullable($returnType)
and returns null when the file is missing and the return type is nullable,
otherwise keep throwing FakeJsonNotFoundException; alternatively, if container
fixtures must always exist, add a clear docblock to readContent() and
selectPostQuery() stating that container post queries are required and will
always throw when missing to prevent future unintended changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.planning/progress.md:
- Line 43: Add a blank line between the "Test Results" heading and the table row
starting with "| Date | Command | Result | Notes |" so the table is surrounded
by a blank line per markdownlint MD058; locate the "Test Results" heading and
insert an empty line immediately above the table (the line containing "| Date |
Command | Result | Notes |") to satisfy the lint rule.

In @.planning/task_plan.md:
- Line 120: Add a single blank line immediately before the "Errors Encountered"
markdown table header (the line starting with "| Error | Attempt | Resolution
|") so the table is separated from preceding content and satisfies markdownlint
MD058; locate the table header in .planning/task_plan.md and insert one empty
line above it.

In `@src/FakeQueryInterceptor.php`:
- Around line 99-105: selectPostQuery currently instantiates the post-query via
Reflection and constructor args which breaks Ray.MediaQuery v1.1's contract;
instead build a PostQueryContext using the hydrated rows returned by
$this->hydrator->hydrate($this->readJsonl($dbQuery), $entityClass, false,
$dbQuery) plus the upstream context (statement, PDO, bind values) and then call
the static factory $postQueryClass::fromContext($context) (referencing
PostQueryInterface::fromContext and PostQueryContext) rather than newInstance;
update selectPostQuery to create and pass that PostQueryContext to the static
fromContext method so implementations like TodoSelection and others follow the
documented contract.

In `@src/FakeQueryModule.php`:
- Around line 72-91: The recursive directory iteration in configure() using new
RecursiveDirectoryIterator($this->fakeDir, FilesystemIterator::SKIP_DOTS) will
throw UnexpectedValueException when $this->fakeDir is missing or unreadable;
change configure() to first check is_dir($this->fakeDir) (or is_readable) and
either return early (skip scanning) or throw a clear, actionable exception that
references $this->fakeDir, so the failure mode matches the previous glob()
behavior and avoids confusing DI-time stack traces from
RecursiveDirectoryIterator; update any tests or callers that expect
UnknownFakeJsonException to still be thrown for missing files inside a valid
directory.
- Around line 94-100: queryIdFromFile() breaks when $this->fakeDir has a
trailing slash; normalize the stored fakeDir once in the class constructor
(e.g., trim trailing DIRECTORY_SEPARATOR and '/' from the promoted property or
remove readonly and rtrim in __construct) so later calls to queryIdFromFile()
can safely use substr($file->getPathname(), strlen($this->fakeDir) + 1) or,
better, compute the offset from strlen($this->fakeDir) and only add 1 when the
stored fakeDir does not end with a separator; ensure you use DIRECTORY_SEPARATOR
(and '/' for cross-platform) when trimming and keep the existing
str_replace(DIRECTORY_SEPARATOR, '/', $queryId) for final normalization.

In `@src/JsonHydrator.php`:
- Around line 117-119: The current check in JsonHydrator that returns null when
a configured factory can't be resolved hides misconfigurations; update the logic
so that when DbQuery::factory is non-empty but the referenced class or method
does not exist (the branch checking class_exists($factoryClass) ||
method_exists($factoryClass, $factoryMethod)), you throw a clear exception
(e.g., InvalidArgumentException or a small custom FactoryResolutionException)
instead of returning null; include the offending $factoryClass and
$factoryMethod in the exception message and reference DbQuery::factory and the
JsonHydrator::factory (or the factory-resolving method) so callers can diagnose
typos/refactors immediately.

---

Nitpick comments:
In `@composer.json`:
- Around line 106-108: The composer.json "platform" PHP constraint currently
pins the exact patch version ("php": "8.2.0"); update the value to the
minor-only constraint "8.2" (i.e., change platform -> php from "8.2.0" to "8.2")
so dependency resolution allows any 8.2.x patch release while still constraining
major/minor; locate the "platform" -> "php" entry in composer.json to make this
change.

In `@src/FakeQueryInterceptor.php`:
- Around line 113-126: readContent() currently always throws
FakeJsonNotFoundException when the fixture file is missing, which diverges from
the standard query path that uses isNullable($returnType) to return null for
nullable returns; update the code so selectPostQuery() (or readContent()) checks
the return type nullability via isNullable($returnType) and returns null when
the file is missing and the return type is nullable, otherwise keep throwing
FakeJsonNotFoundException; alternatively, if container fixtures must always
exist, add a clear docblock to readContent() and selectPostQuery() stating that
container post queries are required and will always throw when missing to
prevent future unintended changes.

In `@src/FakeQueryModule.php`:
- Line 51: The binding currently hard-codes the factory method name ('factory')
via FactoryMethod::class in FakeQueryModule (->toInstance('factory')), which
makes the convention global and undocumented; change the literal to a single
shared constant (e.g. replace 'factory' with
JsonHydrator::DEFAULT_FACTORY_METHOD or another appropriately named constant on
the hydrator/DbQuery target) and update the README/docs to document the
#[FactoryMethod] convention so the string/value lives in one place and the
convention is explicit.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3bf05c88-ccf7-4d1a-ab58-8b31a8d19dcf

📥 Commits

Reviewing files that changed from the base of the PR and between e9bf110 and c1bab11.

⛔ Files ignored due to path filters (3)
  • composer.lock is excluded by !**/*.lock
  • vendor-bin/require-checker/composer.lock is excluded by !**/*.lock
  • vendor-bin/tools/composer.lock is excluded by !**/*.lock
📒 Files selected for processing (22)
  • .github/workflows/ci.yml
  • .gitignore
  • .planning/findings.md
  • .planning/progress.md
  • .planning/task_plan.md
  • README.md
  • composer.json
  • src/FakeQueryInterceptor.php
  • src/FakeQueryModule.php
  • src/JsonHydrator.php
  • tests/Fake/Entity/FactoryTodoEntity.php
  • tests/Fake/Factory/InjectedTodoFactory.php
  • tests/Fake/Factory/StaticTodoFactory.php
  • tests/Fake/Query/FactoryTodoQueryInterface.php
  • tests/Fake/Query/TodoSelectionQueryInterface.php
  • tests/Fake/Result/TodoSelection.php
  • tests/Fake/factory_injected_item.json
  • tests/Fake/factory_static_item.json
  • tests/Fake/factory_static_list.jsonl
  • tests/Fake/nested/factory_static_item.json
  • tests/FakeQueryModuleTest.php
  • tests/FakeUnknownNested/nested/stray_query.json

Comment thread .planning/progress.md
- [ ] Phase 3: テスト実装
- [ ] Phase 4: 品質チェック
## Test Results
| Date | Command | Result | Notes |
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add a blank line before the table under “Test Results”.

Line 43 triggers markdownlint MD058 (“tables should be surrounded by blank lines”). Add an empty line between the heading and the table.

Suggested diff
 ## Test Results
+
 | Date | Command | Result | Notes |
 |------|---------|--------|-------|
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
| Date | Command | Result | Notes |
## Test Results
| Date | Command | Result | Notes |
|------|---------|--------|-------|
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 43-43: Tables should be surrounded by blank lines

(MD058, blanks-around-tables)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.planning/progress.md at line 43, Add a blank line between the "Test
Results" heading and the table row starting with "| Date | Command | Result |
Notes |" so the table is surrounded by a blank line per markdownlint MD058;
locate the "Test Results" heading and insert an empty line immediately above the
table (the line containing "| Date | Command | Result | Notes |") to satisfy the
lint rule.

Comment thread .planning/task_plan.md
- [ ] Create release PR.

## Errors Encountered
| Error | Attempt | Resolution |
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Insert a blank line before the “Errors Encountered” table.

Line 120 hits markdownlint MD058 because the table is not surrounded by blank lines.

Suggested diff
 ## Errors Encountered
+
 | Error | Attempt | Resolution |
 |-------|---------|------------|
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
| Error | Attempt | Resolution |
## Errors Encountered
| Error | Attempt | Resolution |
|-------|---------|------------|
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 120-120: Tables should be surrounded by blank lines

(MD058, blanks-around-tables)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.planning/task_plan.md at line 120, Add a single blank line immediately
before the "Errors Encountered" markdown table header (the line starting with "|
Error | Attempt | Resolution |") so the table is separated from preceding
content and satisfies markdownlint MD058; locate the table header in
.planning/task_plan.md and insert one empty line above it.

Comment on lines +99 to +105
private function selectPostQuery(string $postQueryClass, DbQuery $dbQuery, string|null $entityClass): PostQueryInterface
{
$rows = $this->hydrator->hydrate($this->readJsonl($dbQuery), $entityClass, false, $dbQuery);
assert(is_array($rows));

return (new ReflectionClass($postQueryClass))->newInstance($rows);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Ray.MediaQuery 1.1 PostQueryInterface constructor contract documentation

💡 Result:

Ray.MediaQuery is a PHP library (ray/media-query on Packagist, version 1.1.0 released 2026-05-06) for database query execution via interfaces with Ray DI/AOP. Ray\MediaQuery\Result\PostQueryInterface is a contract for typed post-execution results from #[DbQuery] methods, introduced in PR #85 (merged April 2026) and extended to SELECT in PR #89.[1][2][3][4] The interface defines a single static factory method: public static function fromContext(PostQueryContext $context): static; It has no constructor as it's an interface. Implementing classes (e.g., AffectedRows, InsertedRow, custom wrappers) provide their own constructor and the fromContext factory, which the DbQueryInterceptor calls after SQL execution.[5][1][6] Example implementation from docs: php use Ray\MediaQuery\Result\PostQueryContext; use Ray\MediaQuery\Result\PostQueryInterface; final class RowCountWithQuery implements PostQueryInterface { public function construct( public readonly int $count, public readonly string $queryString, ) {} public static function fromContext(PostQueryContext $context): static { return new static($context->statement->rowCount(), $context->statement->queryString); } } PostQueryContext constructor (from source diff in PRs): php final class PostQueryContext { / * `@param` array<string, mixed> $values * `@param` array<mixed> $rows Hydrated rows for SELECT; `[]` for DML. */ public function construct( public readonly PDOStatement $statement, public readonly ExtendedPdoInterface $pdo, public readonly array $values, public readonly array $rows = [], ) {} } For SELECT, $rows contains pre-hydrated results (entities or arrays); empty for DML. Custom classes own hydration/logic extraction from context.[4][6][1] Official docs are in README on GitHub (https://github.com/ray-di/Ray.MediaQuery) and Packagist.[7][1][8] No separate API docs site found; source/PRs provide authoritative details.[4][2][5][3][6]

Citations:


🏁 Script executed:

# Find and examine src/FakeQueryInterceptor.php lines 99-105
fd FakeQueryInterceptor.php

Repository: ray-di/Ray.FakeQuery

Length of output: 93


🏁 Script executed:

# Search for PostQueryInterface implementations
rg "implements PostQueryInterface|fromContext" --type php -A 3

Repository: ray-di/Ray.FakeQuery

Length of output: 695


🏁 Script executed:

# Check the TodoSelection fixture mentioned
fd -i todoselector
fd -i todo.*selection

Repository: ray-di/Ray.FakeQuery

Length of output: 149


🏁 Script executed:

# Read the exact code at src/FakeQueryInterceptor.php lines 99-105
sed -n '95,110p' src/FakeQueryInterceptor.php

Repository: ray-di/Ray.FakeQuery

Length of output: 718


🏁 Script executed:

# Check all PostQueryInterface implementations and their constructors
fd "\.php$" tests/ src/ | xargs grep -l "implements PostQueryInterface"

Repository: ray-di/Ray.FakeQuery

Length of output: 100


🏁 Script executed:

# Verify what PostQueryContext should contain and how fromContext should be called
rg "PostQueryContext|fromContext" src/FakeQueryInterceptor.php -B 5 -A 5

Repository: ray-di/Ray.FakeQuery

Length of output: 46


🏁 Script executed:

# Check if PostQueryContext is imported or available in FakeQueryInterceptor
head -40 src/FakeQueryInterceptor.php

Repository: ray-di/Ray.FakeQuery

Length of output: 1115


🏁 Script executed:

# Check if Ray.MediaQuery has PostQueryContext available and what selectPostQuery method should actually do
rg "PostQueryContext" --type php

Repository: ray-di/Ray.FakeQuery

Length of output: 254


🏁 Script executed:

# Look at the full selectPostQuery method and its context to understand what should be passed
sed -n '85,115p' src/FakeQueryInterceptor.php

Repository: ray-di/Ray.FakeQuery

Length of output: 1155


Fix selectPostQuery() to use Ray.MediaQuery's documented PostQueryInterface::fromContext() contract instead of direct constructor invocation.

The code calls (new ReflectionClass($postQueryClass))->newInstance($rows), which violates Ray.MediaQuery v1.1's documented interface contract. PostQueryInterface requires implementations to provide a static fromContext(PostQueryContext $context): static factory method — constructors are not part of the contract. TodoSelection works only because its constructor signature happens to match the assumption; any other implementation following Ray.MediaQuery's documented pattern will fail.

Instead, construct a PostQueryContext with the hydrated rows and upstream context (statement, PDO, bind values), then call $postQueryClass::fromContext($context).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/FakeQueryInterceptor.php` around lines 99 - 105, selectPostQuery
currently instantiates the post-query via Reflection and constructor args which
breaks Ray.MediaQuery v1.1's contract; instead build a PostQueryContext using
the hydrated rows returned by
$this->hydrator->hydrate($this->readJsonl($dbQuery), $entityClass, false,
$dbQuery) plus the upstream context (statement, PDO, bind values) and then call
the static factory $postQueryClass::fromContext($context) (referencing
PostQueryInterface::fromContext and PostQueryContext) rather than newInstance;
update selectPostQuery to create and pass that PostQueryContext to the static
fromContext method so implementations like TodoSelection and others follow the
documented contract.

Comment thread src/FakeQueryModule.php
Comment on lines +72 to 91
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($this->fakeDir, FilesystemIterator::SKIP_DOTS),
);
foreach ($files as $file) {
$basename = basename($file);
$ext = pathinfo($file, PATHINFO_EXTENSION);
$stem = $ext === 'jsonl' ? basename($file, '.jsonl') : basename($file, '.json');
assert($file instanceof SplFileInfo);
if (! $file->isFile()) {
continue;
}

$ext = pathinfo($file->getPathname(), PATHINFO_EXTENSION);
if ($ext !== 'json' && $ext !== 'jsonl') {
continue;
}

$basename = basename($file->getPathname());
$stem = $this->queryIdFromFile($file, $ext);
if (! isset($knownIds[$stem])) {
throw new UnknownFakeJsonException($basename, $this->fakeDir);
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Recursive iteration changes failure mode when fakeDir is missing or empty.

The previous glob() scan silently returned an empty array when fakeDir didn't exist or contained no matches. new RecursiveDirectoryIterator($this->fakeDir, FilesystemIterator::SKIP_DOTS) instead throws UnexpectedValueException if the directory is missing or unreadable, which now propagates out of configure() and turns a missing fixtures directory into a confusing DI-time stack trace. Consider guarding with is_dir($this->fakeDir) (early return or a dedicated, actionable exception) so module configuration fails with a message that points users at the misconfigured path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/FakeQueryModule.php` around lines 72 - 91, The recursive directory
iteration in configure() using new RecursiveDirectoryIterator($this->fakeDir,
FilesystemIterator::SKIP_DOTS) will throw UnexpectedValueException when
$this->fakeDir is missing or unreadable; change configure() to first check
is_dir($this->fakeDir) (or is_readable) and either return early (skip scanning)
or throw a clear, actionable exception that references $this->fakeDir, so the
failure mode matches the previous glob() behavior and avoids confusing DI-time
stack traces from RecursiveDirectoryIterator; update any tests or callers that
expect UnknownFakeJsonException to still be thrown for missing files inside a
valid directory.

Comment thread src/FakeQueryModule.php
Comment on lines +94 to +100
private function queryIdFromFile(SplFileInfo $file, string $ext): string
{
$relative = substr($file->getPathname(), strlen($this->fakeDir) + 1);
$queryId = substr($relative, 0, -strlen('.' . $ext));

return str_replace(DIRECTORY_SEPARATOR, '/', $queryId);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

queryIdFromFile() is fragile to trailing separators on fakeDir.

substr($file->getPathname(), strlen($this->fakeDir) + 1) assumes $this->fakeDir has no trailing / (or \). If a caller constructs FakeQueryModule with __DIR__ . '/fakes/' (a common pattern), the + 1 will eat the first character of the relative path and you'll either compute an off-by-one query id or, on Windows, get a mixed-separator result before the str_replace. Normalising $this->fakeDir once in the constructor (e.g., rtrim($fakeDir, DIRECTORY_SEPARATOR . '/')) — and using DIRECTORY_SEPARATOR length explicitly — would make this robust.

🛡️ Suggested fix
     public function __construct(
-        private readonly string $fakeDir,
+        string $fakeDir,
         private readonly string $interfaceDir,
         AbstractModule|null $module = null,
     ) {
+        $this->fakeDir = rtrim($fakeDir, '/' . DIRECTORY_SEPARATOR);
         parent::__construct($module);
     }

(Drop readonly from the promoted property, or normalize before passing in.)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/FakeQueryModule.php` around lines 94 - 100, queryIdFromFile() breaks when
$this->fakeDir has a trailing slash; normalize the stored fakeDir once in the
class constructor (e.g., trim trailing DIRECTORY_SEPARATOR and '/' from the
promoted property or remove readonly and rtrim in __construct) so later calls to
queryIdFromFile() can safely use substr($file->getPathname(),
strlen($this->fakeDir) + 1) or, better, compute the offset from
strlen($this->fakeDir) and only add 1 when the stored fakeDir does not end with
a separator; ensure you use DIRECTORY_SEPARATOR (and '/' for cross-platform)
when trimming and keep the existing str_replace(DIRECTORY_SEPARATOR, '/',
$queryId) for final normalization.

Comment thread src/JsonHydrator.php Outdated
@koriym
Copy link
Copy Markdown
Member Author

koriym commented May 15, 2026

@coderabbitai Follow-up fix added after downstream use in BEAR.AppKata exposed the important case: installing FakeQueryModule as an override while the application already has MediaQueryModule.

Root cause: Ray.Aop keys annotated pointcuts by annotation class, so the existing #[DbQuery] MediaQuery pointcut could shadow the FakeQuery pointcut.

Fix: FakeQuery now binds a priority interceptor using a non-deduplicated annotated matcher, so fake fixtures win in override contexts and the SQL interceptor is never reached.

Regression coverage added in Ray.FakeQuery: a module with both MediaQueryModule and FakeQueryModule plus a throwing SqlQueryInterface; the query must be served from JSON fixtures.

Re-verified:

  • Ray.FakeQuery CI: PHP 8.2, 8.3, 8.4, 8.5 and Quality all pass.
  • BEAR.AppKata#2 now uses direct install(new FakeQueryModule(...)) again, no nested injector workaround, and all checks pass.

@coderabbitai

@koriym
Copy link
Copy Markdown
Member Author

koriym commented May 15, 2026

Design correction: FakeQuery now replaces the Ray\MediaQuery\DbQueryInterceptor binding with FakeQueryInterceptor instead of trying to win annotated pointcut priority. This keeps existing MediaQuery #[DbQuery] pointcuts installed while DI resolves them to the fake interceptor. A regression test covers MediaQueryModule + throwing SqlQueryInterface + FakeQueryModule override. CI is passing for Quality and Unit PHP 8.2-8.5.

@koriym
Copy link
Copy Markdown
Member Author

koriym commented May 15, 2026

PR #2 レビュー: Harden FakeQuery release baseline

概要

リリース前のベースライン強化 PR。CI(PHP 8.2–8.5)、composer.json メタデータ整備、#[DbQuery(factory:)] 経由のファクトリ・ハイドレーション、ネストしたクエリID(nested/foo)対応、コンストラクタベースの PostQueryInterface 結果ラッパ対応など、機能追加と再設計が混在。


良い点

  • DbQueryInterceptor の alias 差し替え戦略 (FakeQueryModule.php:62-66)
    $this->bindInterceptor(..., [DbQueryInterceptor::class]);
    $this->bind(DbQueryInterceptor::class)->to(FakeQueryInterceptor::class)->in(Scope::SINGLETON);
    MediaQueryModuleoverride するだけで Fake 側が勝つ設計は良い。テスト testFakeQueryOverridesExistingMediaQueryInterceptors で動作検証されている。
  • ネストID対応 (FakeQueryModule.php:75-101): glob から RecursiveDirectoryIterator への置換は妥当。testUnknownNestedFakeJsonFileThrows で例外パスもカバー。
  • CI matrix (.github/workflows/ci.yml): 8.2–8.5 を unit/quality に分離。quality 側だけ composer tests + crc を回すのは健全。
  • composer.json: phpdocumentor/reflection-docblockray/aop を直接依存に明示。require-checker 通過のため必須。

指摘事項

重要: JsonHydrator::factory()is_callable チェック (src/JsonHydrator.php:113-125)

\$staticFactory = [\$factoryClass, \$factoryMethod];
if (is_callable(\$staticFactory)) {
    return static fn (array \$row): mixed => \$staticFactory(...array_values(\$row));
}

PHP 8 では is_callable(['Class', 'instanceMethod'])構文的に呼び出し可能なら true を返すInjectedTodoFactory::factory() は public な非 static メソッドなので、ここで true を返し、static 呼び出しを試みて non-static method called statically の致命的エラーとなる可能性が高い。

実際に CI が緑なのか、テスト経路を要確認:

  • testInjectedFactoryHydrationInjectedTodoFactory (非 static) を経由する
  • もし通っているなら、PHP のバージョン依存挙動が紛れている可能性あり

→ 推奨は (new ReflectionMethod(\$factoryClass, \$factoryMethod))->isStatic() で明示判定。

JsonHydrator::factory() のサイレント失敗 (src/JsonHydrator.php:118-120)

if (! class_exists(\$factoryClass) || ! method_exists(\$factoryClass, \$factoryMethod)) {
    return null;
}

#[DbQuery(factory: Typo::class)] 等で誤指定すると factory が無視され通常のエンティティ・ハイドレーションへフォールバック する。InvalidFactoryException 的な専用例外を投げるべき(汎用例外禁止 → 専用ドメイン例外の方針にも沿う)。

selectPostQuery.jsonl 固定 (src/FakeQueryInterceptor.php:91-99)

private function selectPostQuery(...): PostQueryInterface
{
    \$rows = \$this->hydrator->hydrate(\$this->readJsonl(\$dbQuery), \$entityClass, false, \$dbQuery);
    ...
    return (new ReflectionClass(\$postQueryClass))->newInstance(\$rows);
}
  • 単行の selection ラッパや、.json 配列形式を持つ fixture をサポートしない。README に「constructor-based select result wrappers」と限定明記されているので機能上は意図通りだが、PostQueryInterface 実装で .json を置いた場合は FakeJsonNotFoundException になり、ユーザにとってエラーメッセージから理由が読み取りにくい。
  • Ray.MediaQuery 本家は PostQueryInterface::fromContext(PostQueryContext) 規約。FakeQuery は コンストラクタ第1引数=rows 配列 を強制(newInstance(\$rows))。TodoSelection::fromContext を実装してもこちらは無視される。README に明記しておくこと(現状の "Constructor-based" 表記は読めるが、fromContext を期待した開発者には罠)。

FakeQueryInterceptor::invoke の Reflection 連打 (src/FakeQueryInterceptor.php:57-62)

if (\$returnType instanceof ReflectionNamedType) {
    \$typeName = \$returnType->getName();
    if (class_exists(\$typeName) && is_subclass_of(\$typeName, PostQueryInterface::class)) {
        return \$this->selectPostQuery(\$typeName, \$dbQuery, \$entityClass);
    }
}

メソッド呼び出しごとに class_exists + is_subclass_of。Reflection 結果は Module 構成時にキャッシュできる(Queries::fromDir の延長で post-query クラスを事前判定)が、現状のテスト規模では実害なし。最適化は不要だが、メソッド ID → strategy のメモ化を将来検討

軽微

  • FakeQueryModule.php:97: substr(\$file->getPathname(), strlen(\$this->fakeDir) + 1)\$this->fakeDir 末尾 /rtrim で正規化してから使うとロバスト。コンストラクタで一度 trim すれば足りる。
  • JsonHydrator::__construct#[FactoryMethod] を文字列としてインジェクトしているが、bind()->annotatedWith(FactoryMethod::class)->toInstance('factory') のハードコードは MediaQuery 本家との整合を要確認。Ray.MediaQuery 側の既定値とずれていないか。
  • assert(\$file instanceof SplFileInfo) (FakeQueryModule.php:81): 不要(常に SplFileInfo)。Psalm 抑止が目的なら /** @var SplFileInfo \$file */ で十分。
  • composer.jsonplatform.php: 8.2.0 固定は CI の 8.5 ジョブで意図せずダウンレベル解決を招く可能性。matrix CI 側で --ignore-platform-req=php 不要にするためなら明示理由をコメント。

ドキュメント

  • README に factory の挙動 (#[DbQuery(factory: ...)] 使用時の JSON フィールド → 引数マッピング)が未記載。array_values(\$row)JSON のキー順序依存 で位置引数として渡されるという仕様は重要なので明記すべき(snake_case → camelCase 変換が "factory パスでは効かない" 点も)。

テストカバレッジ

ファクトリ static/injected/list/nested、PostQuery、override、nested unknown まで網羅されており良好。ただし以下は未カバー:

  • factory 指定だが クラス不在/メソッド不在 のフォールバック経路 → 上記の「サイレント失敗」を例外化すれば自然にテスト追加可能
  • PostQueryInterface 実装クラスの コンストラクタが rows を受け取らない ケースのエラーメッセージ

まとめ

設計の方向性(DbQueryInterceptor alias 差し替え、ネストID、PostQuery 対応)は良い。最優先対応推奨は is_callable の static/instance 判定。次点でファクトリ誤指定時の専用例外化。README の factory フィールドマッピング仕様の追記も合わせて。

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 15, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

I'll look at the current implementation to review the fix properly.


🏁 Script executed:

#!/bin/bash
cat src/FakeQueryModule.php
echo "---"
cat src/FakeQueryInterceptor.php
echo "---"
# Find the regression test for MediaQuery override
rg -n "testFakeQueryOverrides\|ThrowingSql\|MediaQueryModule" tests/ -A5 -B2

Length of output: 9469


🏁 Script executed:

#!/bin/bash
cat src/JsonHydrator.php
echo "==="
rg -n "testFakeQueryOverrides\|ThrowingSql\|MediaQueryModule\|FakeQueryOverride" tests/ -A10 -B3
echo "==="
cat tests/Fake/ThrowingSqlQuery.php 2>/dev/null || echo "NOT FOUND"

Length of output: 7912


🏁 Script executed:

#!/bin/bash
# Find the test that uses ThrowingSqlQuery or covers the override scenario
rg -n "ThrowingSqlQuery\|override\|Override\|FakeQueryOverride\|MediaQuery" tests/FakeQueryModuleTest.php -A8 -B3
echo "==="
# Check for any test file that tests the override behavior
fd -e php . tests/ --exec grep -ln "ThrowingSqlQuery\|MediaQueryModule\|override"

Length of output: 66


🏁 Script executed:

#!/bin/bash
# Show the full test file to find the override regression test
cat tests/FakeQueryModuleTest.php

Length of output: 11137


@koriym: Thank you for the follow-up — the implementation looks solid. Here's a review of the current state:


Override mechanism ✅

The final design (as implemented) is the clean alias approach:

$this->bindInterceptor(
    $this->matcher->any(),
    $this->matcher->annotatedWith(DbQuery::class),
    [DbQueryInterceptor::class],
);
$this->bind(DbQueryInterceptor::class)->to(FakeQueryInterceptor::class)->in(Scope::SINGLETON);

This is better than the intermediate "priority interceptor" approach because it doesn't race against existing pointcuts at all — Ray.Di's singleton binding resolution just redirects DbQueryInterceptor to FakeQueryInterceptor transparently. The existing MediaQueryModule pointcuts remain wired, but DI resolves them to the fake.

testFakeQueryOverridesExistingMediaQueryInterceptors properly validates this scenario end-to-end with ThrowingSqlQuery as a sentinel — if real SQL were ever reached, the test would fail hard with a RuntimeException.


Two concerns from the earlier review — already resolved ✅

is_callable static/instance ambiguity — fixed correctly via ReflectionMethod::isStatic():

$method = new ReflectionMethod($factoryClass, $factoryMethod);
if ($method->isStatic()) {
    return static fn (array $row): mixed => $method->invokeArgs(null, array_values($row));
}
$factory = $this->injector->getInstance($factoryClass);
return static fn (array $row): mixed => $method->invokeArgs($factory, array_values($row));

This correctly separates static dispatch from injected-instance dispatch without relying on PHP's context-sensitive is_callable behavior.

Factory silent failure — fixed with InvalidFactoryException:

if (! class_exists($factoryClass) || ! method_exists($factoryClass, $factoryMethod)) {
    throw new InvalidFactoryException($factoryClass, $factoryMethod);
}
if (! $method->isPublic()) {
    throw new InvalidFactoryException($factoryClass, $factoryMethod);
}

testMissingFactoryClassThrows and testMissingFactoryMethodThrows cover both paths.


Remaining open items

These are lower priority but worth tracking:

  1. selectPostQuery is .jsonl-only and ignores PostQueryInterface::fromContext() — the newInstance($rows) constructor path is intentional, but developers who implement fromContext() and place a .json file will get a FakeJsonNotFoundException with no hint about why. The README's "Constructor-based" note covers it, but a clarifying sentence about fromContext() not being used here would help.

  2. fakeDir trailing-slash robustness in queryIdFromFile()substr($file->getPathname(), strlen($this->fakeDir) + 1) is fragile if $fakeDir is passed with a trailing /. A single rtrim($this->fakeDir, '/') in the constructor would harden this.

  3. README: factory field-mapping contract — the array_values($row) positional dispatch (JSON key order → factory argument order, no snake→camelCase conversion on the factory path) is a meaningful contract worth documenting explicitly.

  4. assert($file instanceof SplFileInfo) in validateFakeFiles() — always true given RecursiveIteratorIterator; a /** @var SplFileInfo $file */ docblock would be sufficient for static analysis without the runtime assertion.

Would you like me to open issues for any of the remaining items?

🐇✨

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant