Skip to content

Clone Pager attribute in DbPager::__invoke to prevent mutation#81

Merged
koriym merged 3 commits into1.xfrom
dbpager-no-mutation
Apr 13, 2026
Merged

Clone Pager attribute in DbPager::__invoke to prevent mutation#81
koriym merged 3 commits into1.xfrom
dbpager-no-mutation

Conversation

@koriym
Copy link
Copy Markdown
Member

@koriym koriym commented Apr 13, 2026

Summary

  • Stop DbPager::__invoke() from mutating the #[Pager] attribute instance it receives by cloning it at the top of the method.
  • Add a regression test that constructs DbPager directly with minimal fakes, reuses the same Pager instance across three calls, and asserts both that $pager->perPage stays the original key string and that each call forwards its own perPage to SqlQueryInterface::getPages().

Fixes #80

Commits

  1. Add failing test for DbPager Pager attribute mutation — red test that fails on current 1.x (first call mutates $pager->perPage from 'perPage' to int, assertion fails immediately).
  2. Clone Pager attribute in DbPager::__invoke to prevent mutation — one-line fix ($pager = clone $pager;) that turns the red test green.

Impact on typical AOP usage

On current Ray.Aop (2.19.0) ReflectionMethod::getAnnotation() returns a fresh Pager on every call, so AOP-driven production traffic has not observed this leak. The fix is defensive and restores the API contract for any caller that holds on to a Pager instance (custom interceptors, unit tests, third-party code, or any future Ray.Aop change that caches reflection results).

Test plan

  • composer cs-fix — no violations
  • composer sa — Psalm + PHPStan clean
  • composer tests — 88 tests, 130 assertions, all pass
  • New DbPagerTest::testPagerInstanceIsNotMutatedAcrossCalls fails on the red commit and passes on the green commit

Summary by Sourcery

Prevent DbPager from mutating the Pager attribute instance it receives and add a regression test to ensure pager state remains consistent across multiple invocations.

Bug Fixes:

  • Ensure DbPager clones the Pager attribute to avoid mutating the caller-provided instance when resolving dynamic pagination settings.

Tests:

  • Add a regression test verifying that a shared Pager instance is not mutated across multiple DbPager invocations and that each call forwards its own perPage value to SqlQueryInterface::getPages().

Summary by CodeRabbit

  • Bug Fixes

    • Fixed an issue where DbPager would mutate the original Pager instance when invoked multiple times, ensuring the caller's Pager object remains unchanged.
  • Tests

    • Added regression test to verify Pager instances are not mutated across consecutive calls.

@sourcery-ai
Copy link
Copy Markdown
Contributor

sourcery-ai bot commented Apr 13, 2026

Reviewer's Guide

DbPager::__invoke() now clones its Pager attribute parameter at the start of the method to avoid mutating the caller’s instance, and a focused regression test was added that uses minimal fakes to verify the Pager object remains unchanged across multiple invocations while each call forwards the correct per-page value to SqlQueryInterface::getPages().

Sequence diagram for DbPager __invoke cloning Pager attribute

sequenceDiagram
    participant Caller
    participant DbPager
    participant SqlQueryInterface

    Caller->>DbPager: __invoke(queryId, values, pager, entity)
    activate DbPager
    DbPager->>DbPager: clone pager
    DbPager->>DbPager: dynamicPager(clonedPager, values)
    DbPager->>SqlQueryInterface: getPages(queryId, valuesWithPerPage, entity)
    activate SqlQueryInterface
    SqlQueryInterface-->>DbPager: pages
    deactivate SqlQueryInterface
    DbPager-->>Caller: pages
    deactivate DbPager
Loading

Updated class diagram for DbPager and Pager interaction

classDiagram
    class DbPager {
        - SqlQueryInterface sqlQuery
        + __construct(SqlQueryInterface sqlQuery)
        + __invoke(string queryId, array values, Pager pager, string entity) PagesInterface
        - dynamicPager(Pager pager, array values) array
    }

    class Pager {
        + mixed perPage
    }

    class SqlQueryInterface {
        <<interface>>
        + getPages(string queryId, array values, string entity) PagesInterface
    }

    class PagesInterface {
        <<interface>>
    }

    DbPager --> SqlQueryInterface : uses
    DbPager --> Pager : clones_and_reads
    DbPager --> PagesInterface : returns
Loading

File-Level Changes

Change Details Files
Prevent DbPager from mutating the Pager attribute instance received in __invoke.
  • Clone the Pager argument at the start of DbPager::__invoke() to work on a local copy.
  • Preserve existing behavior of dynamicPager() and downstream per-page logic while ensuring the caller’s Pager object remains unchanged.
src/DbPager.php
Add regression test ensuring Pager attribute is not mutated across multiple DbPager calls.
  • Introduce DbPagerTest::testPagerInstanceIsNotMutatedAcrossCalls that constructs DbPager with inlined test doubles for SqlQueryInterface and MediaQueryLoggerInterface.
  • Record per-page values passed into SqlQueryInterface::getPages() and assert they match each invocation’s input.
  • Assert that the shared Pager instance retains its original string perPage key after multiple DbPager invocations.
tests/DbPagerTest.php

Assessment against linked issues

Issue Objective Addressed Explanation
#80 Ensure DbPager::__invoke(Pager $pager) does not mutate the Pager instance it receives (e.g., by treating the argument as read-only, such as cloning it).
#80 Ensure that when the same Pager instance is reused across multiple DbPager::__invoke calls with different perPage values, each call forwards its own perPage value to SqlQueryInterface::getPages().

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 13, 2026

Warning

Rate limit exceeded

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

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 48 minutes and 7 seconds.

⌛ 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: 9edea603-1d6d-462a-8a22-65a581b6e7b6

📥 Commits

Reviewing files that changed from the base of the PR and between 3a543a4 and ee23b54.

📒 Files selected for processing (5)
  • src/DbPager.php
  • tests/DbPagerTest.php
  • tests/Fake/FakeEmptyPages.php
  • tests/Fake/FakeMediaQueryLogger.php
  • tests/Fake/FakeSqlQuery.php
📝 Walkthrough

Walkthrough

The change clones the incoming Pager attribute in DbPager::__invoke() before invoking dynamicPager(), preventing mutation of the caller's original Pager instance. A regression test validates that the Pager remains unchanged across multiple invocations with different perPage values.

Changes

Cohort / File(s) Summary
Bug fix
src/DbPager.php
Added cloning of Pager attribute at the start of __invoke() to prevent mutation of the caller's instance when dynamicPager() modifies $pager->perPage.
Regression test
tests/DbPagerTest.php
Added new test class DbPagerTest with test testPagerInstanceIsNotMutatedAcrossCalls() that verifies the Pager instance remains unmodified across multiple invocations and that each call receives the correct perPage value.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Poem

🐰 A pager once mutated in place,
But clones now save the caller's face!
Each invocation stays pristine and pure,
The fix is simple, the test is sure. ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 12.50% 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 title clearly and concisely summarizes the main change: cloning the Pager attribute to prevent mutation.
Linked Issues check ✅ Passed The PR fully implements the fix specified in issue #80: clones the Pager in __invoke() and adds a regression test verifying the Pager is not mutated across calls.
Out of Scope Changes check ✅ Passed All changes are directly related to fixing issue #80: the clone statement and the regression test validating the fix; no unrelated changes are present.

✏️ 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 dbpager-no-mutation

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.

Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey - I've reviewed your changes and they look great!


Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Copy link
Copy Markdown
Contributor

@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.

🧹 Nitpick comments (1)
tests/DbPagerTest.php (1)

23-100: Move anonymous test doubles into tests/Fake/ to match repo test structure rules.

Inline interface implementations make reuse harder and diverge from the project’s test-double placement convention.

As per coding guidelines, "Store test doubles, mock objects, and fake implementations in /tests/Fake/ subdirectory".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/DbPagerTest.php` around lines 23 - 100, Extract the two anonymous test
doubles into named fake classes under tests/Fake/: create a FakeSqlQuery
implementing SqlQueryInterface (move the perPageHistory property and the
getPages() implementation that returns a concrete PagesInterface fake) and a
FakePages implementing PagesInterface (wrap ArrayIterator and implement
offsetExists/offsetGet/offsetSet/offsetUnset/count), and create a
FakeMediaQueryLogger implementing MediaQueryLoggerInterface for the logger;
replace the anonymous class instantiations in DbPagerTest.php with new
FakeSqlQuery() and new FakeMediaQueryLogger() so tests follow the repo
test-double placement convention.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@tests/DbPagerTest.php`:
- Around line 23-100: Extract the two anonymous test doubles into named fake
classes under tests/Fake/: create a FakeSqlQuery implementing SqlQueryInterface
(move the perPageHistory property and the getPages() implementation that returns
a concrete PagesInterface fake) and a FakePages implementing PagesInterface
(wrap ArrayIterator and implement
offsetExists/offsetGet/offsetSet/offsetUnset/count), and create a
FakeMediaQueryLogger implementing MediaQueryLoggerInterface for the logger;
replace the anonymous class instantiations in DbPagerTest.php with new
FakeSqlQuery() and new FakeMediaQueryLogger() so tests follow the repo
test-double placement convention.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9b93b3e5-fd64-4004-9d71-2bbbbbe87732

📥 Commits

Reviewing files that changed from the base of the PR and between 7850653 and 3a543a4.

📒 Files selected for processing (2)
  • src/DbPager.php
  • tests/DbPagerTest.php

@koriym
Copy link
Copy Markdown
Member Author

koriym commented Apr 13, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 13, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

koriym added 2 commits April 14, 2026 07:54
DbPager::__invoke() currently mutates the #[Pager] attribute instance it
receives: dynamicPager() writes $values[perPage] back into $pager->perPage,
turning the string key into an int and destroying the dynamic-perPage
behavior for any subsequent call that reuses the same Pager instance.

The test constructs DbPager directly with minimal fakes, passes the same
Pager instance three times with different perPage values, and asserts
that both the Pager's perPage remains the original key string and that
each call forwards its own perPage to SqlQuery::getPages().

This test fails on current 1.x: the first call mutates the Pager to int,
so the assertion 'perPage' === 2 fails immediately. The next commit
introduces the fix.
DbPager::__invoke() received the #[Pager] attribute instance as an
argument and let dynamicPager() overwrite its perPage property. Clone
the Pager at the top of __invoke() so the caller's instance stays
untouched; dynamicPager() continues to operate on the local copy
without any further changes.

On current Ray.Aop (2.19.0) ReflectionMethod::getAnnotation() returns a
fresh Pager on every call, so AOP-driven production traffic has not
observed this leak; the fix is defensive and restores the API contract
for any caller that holds on to a Pager instance.

Fixes #80
@koriym koriym force-pushed the dbpager-no-mutation branch from 3a543a4 to 1cfdb55 Compare April 13, 2026 22:54
Move the inline anonymous test doubles used by DbPagerTest into named
fake classes under tests/Fake/, matching the project convention:

- FakeSqlQuery implements SqlQueryInterface and records perPageHistory
- FakeEmptyPages implements PagesInterface wrapping an empty ArrayIterator
- FakeMediaQueryLogger implements MediaQueryLoggerInterface as a no-op

DbPagerTest now constructs DbPager with these named fakes, keeping the
test body focused on the mutation assertion.
@koriym
Copy link
Copy Markdown
Member Author

koriym commented Apr 13, 2026

@coderabbitai Fixed in commit ee23b54 — extracted the inline anonymous fakes into tests/Fake/FakeSqlQuery.php, tests/Fake/FakeEmptyPages.php, and tests/Fake/FakeMediaQueryLogger.php to match the repo's test-double placement convention.

@koriym
Copy link
Copy Markdown
Member Author

koriym commented Apr 13, 2026

@coderabbitai No outstanding blockers. Please resolve all open conversations and approve this PR.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 13, 2026

Tip

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# List all review comments by coderabbitai on PR `#81`
gh pr view 81 --repo ray-di/Ray.MediaQuery --json reviews,comments
gh api repos/ray-di/Ray.MediaQuery/pulls/81/comments --jq '.[] | select(.user.login == "coderabbitai[bot]") | {id: .id, body: .body[0:80]}'

Length of output: 28568


@koriym: The only open conversation was the nitpick about extracting the inline anonymous test doubles into the tests/Fake/ directory — which you've already addressed in commit ee23b54. Resolving all conversations and approving the PR now. ✅

[resolve]
[approve]

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 13, 2026

Tip

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check the fake files exist and review their content
fd . tests/Fake --type f
echo "---"
cat tests/Fake/FakeSqlQuery.php 2>/dev/null || echo "FakeSqlQuery.php not found"
echo "---"
cat tests/Fake/FakeEmptyPages.php 2>/dev/null || echo "FakeEmptyPages.php not found"
echo "---"
cat tests/Fake/FakeMediaQueryLogger.php 2>/dev/null || echo "FakeMediaQueryLogger.php not found"
echo "---"
cat tests/DbPagerTest.php 2>/dev/null || echo "DbPagerTest.php not found"

Length of output: 5533


@koriym: The extracted fakes look great — all three files (FakeSqlQuery.php, FakeEmptyPages.php, FakeMediaQueryLogger.php) are properly placed in tests/Fake/, use declare(strict_types=1), carry the correct Ray\MediaQuery namespace, and DbPagerTest references them cleanly by class name. This matches the repo's test-double placement convention perfectly. ✅


🧠 Learnings used
Learnt from: CR
Repo: ray-di/Ray.MediaQuery PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-28T18:22:57.759Z
Learning: Applies to tests/**/*.php : Organize test files in /tests/ directory using PHP 8.2+ syntax. Store test doubles, mock objects, and fake implementations in /tests/Fake/ subdirectory

If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

@koriym koriym merged commit c5c25ab into 1.x Apr 13, 2026
27 checks passed
@koriym koriym deleted the dbpager-no-mutation branch April 13, 2026 23:02
@koriym koriym mentioned this pull request Apr 13, 2026
1 task
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.

DbPager mutates the #[Pager] attribute instance it receives

1 participant