Skip to content

fix: return memoized model field values as data instead of re-invoking them - #4252

Merged
jasonbahl merged 2 commits into
mainfrom
fix/4240-model-memoized-callable-collision
Sep 1, 2026
Merged

jasonbahl merged 2 commits into
mainfrom
fix/4240-model-memoized-callable-collision

Conversation

@jasonbahl

Copy link
Copy Markdown
Collaborator

What bug does this fix? Explain your changes.

Model::__get() memoizes a field's resolved value back into the fields array. On a later read of the same field on the same model instance, is_callable() matched memoized string values against defined PHP function names case-insensitively, so a value like "Max" (a first name) invoked the builtin max() with zero arguments and caused a fatal:

max() expects at least 1 argument, 0 given

Unresolved fields are always closures, because wrap_fields() wraps every field definition in one, so the invoke path now requires an actual Closure and everything else is returned as data. This restores the type guard that was removed in the __get() simplification first shipped in v2.3.3.

Any string value colliding with a function name ("Max", "Time", "Date", "List", "Sort", ...) could trigger this whenever the same field on the same model instance resolved more than once, for example a query requesting a field twice through an alias.

Does this close any currently open issues?

Fixes #4240

Testing Strategy

TDD: added tests/wpunit/ModelFieldMemoizationTest.php with two tests, confirmed both fail against the unfixed code (the model-level test with the ArgumentCountError fatal, the query-level test with errors in the response), then implemented the fix and confirmed both pass.

  • Widest surface: a GraphQL query resolving viewer.firstName twice via an alias, for a user named "Max Time" (both name parts collide with builtins)
  • Model layer: reading $model->firstName twice directly

Test Results

  • Failing tests added and verified against unfixed code
  • Fix implemented, tests pass
  • Full wpunit suite passing (1192 tests, 7309 assertions, 0 failures)
  • PHPCS and PHPStan (level 8) clean

Additional Context

Credit to @davidvexel for an excellent report, including pinpointing the regression to the __get() refactor in #3381. The suggested fix in the issue would still invoke callable-shaped arrays; since wrap_fields() guarantees unresolved fields are closures, this PR uses the tighter instanceof \Closure guard so memoized arrays and strings are always treated as data.

…g them

Model::__get() memoizes resolved field values back into the fields array.
On a later read of the same field on the same model instance, is_callable()
matched memoized string values against defined PHP function names
case-insensitively, so a value like "Max" invoked the builtin max() with
no arguments and caused a fatal.

Unresolved fields are always closures (wrap_fields() wraps every field),
so the invoke path now requires an actual Closure and everything else is
returned as data. This restores the type guard removed in v2.3.3.

Regression tests cover the widest reproducing surface (a GraphQL query
that reads the same field twice on one model instance via an alias) and
the model layer directly.
@vercel

vercel Bot commented Aug 31, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
wpgraphql-com Skipped Skipped Sep 1, 2026 8:20pm UTC

Request Review

@codecov

codecov Bot commented Aug 31, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 84.2%. Comparing base (be74113) to head (c14f9ee).
⚠️ Report is 11 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff            @@
##              main   #4252     +/-   ##
=========================================
- Coverage     84.2%   84.2%   -0.0%     
- Complexity    5625    5626      +1     
=========================================
  Files          291     291             
  Lines        23604   23607      +3     
=========================================
+ Hits         19880   19882      +2     
- Misses        3724    3725      +1     
Flag Coverage Δ
wp-graphql-acf-wpunit-twentytwentyfive-single 77.6% <ø> (ø)
wp-graphql-wpunit-twentytwentyfive-single 85.3% <100.0%> (-<0.1%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
plugins/wp-graphql/src/Model/Model.php 93.2% <100.0%> (ø)

... and 2 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Both live in the same method as the memoization fix and were fixed once
without a regression test, which is how they could silently break again:

- Falsey memoized values (false/0/'') must survive a re-read. Guards the
  fix that changed the outer guard from ! empty() to isset()/array_key_exists()
  (verified: this test fails with "null is identical to false" if ! empty()
  is reintroduced).
- Non-closure callback field definitions (callable arrays, the 'callback'
  shape) still resolve. Locks the wrap_fields() <-> __get() coupling the
  Closure-only guard depends on.
@jasonbahl
jasonbahl merged commit 0d44f7c into main Sep 1, 2026
74 checks passed
@jasonbahl
jasonbahl deleted the fix/4240-model-memoized-callable-collision branch September 1, 2026 20:36

This branch was previously deployed

1 inactive deployment
Preview — c14f9ee3 Deployed Sep 1, 2026 by vercel[bot]
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.

Model::__get() fatals when a memoized field value collides with a PHP builtin function name (e.g. "Max")

1 participant