Skip to content

fix(post): guard the post_author read in format_hits_as_posts - #4354

Open
freibergergarcia wants to merge 1 commit into
10up:developfrom
freibergergarcia:fix/post-author-unguarded-read
Open

fix(post): guard the post_author read in format_hits_as_posts#4354
freibergergarcia wants to merge 1 commit into
10up:developfrom
freibergergarcia:fix/post-author-unguarded-read

Conversation

@freibergergarcia

Copy link
Copy Markdown

Description of the Change

QueryIntegration::format_hits_as_posts() copies a fixed list of properties from each Elasticsearch hit onto the post object it returns. Every property is read behind an isset() check except post_author, which is special-cased and read unconditionally through a nested ['id'] subscript:

foreach ( $post_return_args as $key ) {
if ( 'post_author' === $key ) {
$post->$key = $post_array[ $key ]['id'];
} elseif ( isset( $post_array[ $key ] ) ) {
if ( in_array( $key, [ 'terms', 'meta', 'post_meta' ], true ) && is_array( $post_array[ $key ] ) ) {
$post->$key = wp_json_encode( $post_array[ $key ] );
} else {
$post->$key = $post_array[ $key ];
}
}
}

Because the subscript is unguarded, what happens depends entirely on the shape of the value in the document:

post_author in the document Before this PR
[... 'id' => 5 ] 5
[ 'id' => 0 ] 0
[ 'id' => '' ] (indexed for a deleted user, Post.php:444-449) ''
[ 'id' => null ] null, no warning
key present, id missing null + Undefined array key "id"
absent entirely null + 2 warnings
a scalar integer null + Trying to access array offset on value of type int
a string uncaught TypeError: Cannot access offset of type string on string

The common route to the middle rows is a query that narrows _source through ep_formatted_args while leaving fields at its default, which routes hits to this formatter. That produces two warnings for every hit, on every such query.

ElasticPress never trips this itself, which is why it has survived so long. The plugin's own _source narrowing (Post::maybe_set_fields(), for fields => 'ids' and 'id=>parent') always pairs with format_hits_as_ids() / format_hits_as_id_parents(), formatters that expect a narrow document. Only third-party code reaches the combination of a narrowed _source and the default formatter.

The change. Give post_author the same guard its 20 siblings already have:

if ( 'post_author' === $key ) {
	if ( isset( $post_array[ $key ]['id'] ) ) {
		$post->$key = $post_array[ $key ]['id'];
	}
} elseif ( isset( $post_array[ $key ] ) ) {

A valid id is still copied, including 0 and the empty string indexed for a deleted user, since isset() is true for both. Anything unusable leaves the property unset, exactly as the sibling properties already do, so WP_Post supplies its own default.

Benefits. Removes the log noise, and removes a fatal error path. Sites narrowing _source for performance no longer have to choose between the optimisation and a clean log.

History suggests this was an oversight rather than a decision. d96e67588 (2015-06-03) introduced the copy loop and the post_author special case together, with nothing guarded. A month later 9bd776390 (2015-07-02) changed } else { to } elseif ( isset( $post_array[ $key ] ) ) { — commit subject "isset and unit test using ep_search_post_return_args filter to test. Fixes #306", a deliberate fix for a key in the return-args list being missing from the document. That is the same failure mode post_author still has; the fix simply did not extend into the branch above it. Everything since is cosmetic: ba55e0fe87 moved the loop, aaf3a97d9e was PHPCS whitespace, 45a6d34df2 applied Yoda conditions.

Alternative considered, and a question for maintainers. The other candidate fix is $post->$key = $post_array[ $key ]['id'] ?? null;. Both silence the warnings and the TypeError; they differ only in what a caller sees when there is no usable id:

?? null isset() guard (this PR)
Value null '0' (WP_Post default)
isset( $post->post_author ) false true
Matches the other 20 properties no yes
Matches dropping post_author via ep_search_post_return_args no (null vs '0') yes
Preserves the exact current return value yes no

I went with the guard because it makes all the "no author data" paths agree and yields a value of the type WP_Post declares, but ?? null is the smaller behavioural delta and is a defensible preference. It is a one-line swap plus test expectations — happy to switch if you would rather preserve null.

Not addressed here: a post_author arriving as an object still fatals (Cannot use object of type stdClass as array), before and after this change. It seemed out of scope for a guard on the documented shape, but say the word if you would like it covered.

Closes #

How to test the Change

Automated:

composer run setup-local-tests   # if not already set up (needs MySQL + Elasticsearch)
EP_HOST=http://127.0.0.1:8890/ composer run test-single-site -- --filter testFormatHitsAsPosts

Nine cases pass. Reverting just the includes/classes/Indexable/Post/QueryIntegration.php hunk and re-running fails five of them — four as errors raised by the suite's convertWarningsToExceptions, one on the value.

Manually, on any indexed site with WP_DEBUG on:

  1. Add a filter that narrows _source on a marked query:
    add_filter(
        'ep_formatted_args',
        function ( $formatted_args, $args ) {
            if ( ! empty( $args['my_id_only_query'] ) ) {
                $formatted_args['_source'] = array( 'post_id' );
            }
            return $formatted_args;
        },
        10,
        2
    );
  2. Run new WP_Query( array( 'ep_integrate' => true, 'my_id_only_query' => true, 'posts_per_page' => 100 ) );
  3. Before this change the debug log fills with Undefined array key "post_author" and Trying to access array offset on ..., two per hit. After, the log is clean and the posts come back unchanged.

To see the fatal, return a string from ep_retrieve_the_post for post_author and run any ep_integrate query.

Changelog Entry

Fixed - PHP warnings, and a fatal error on some document shapes, when post_author is missing or malformed in an Elasticsearch hit — for example when a query narrows _source via ep_formatted_args.

Credits

Props @freibergergarcia

Checklist:

format_hits_as_posts() copies a fixed list of properties from each
Elasticsearch hit onto the post object it returns. Every property is
read behind an isset() check except post_author, which is special-cased
and read unconditionally through a nested ['id'] subscript.

Any query that narrows _source via ep_formatted_args while leaving
fields at its default is routed to this formatter, so a document
without post_author produces two PHP warnings for every hit. The
plugin's own narrowing (maybe_set_fields, for fields => ids and
id=>parent) always pairs with a different formatter, which is why this
is only reachable from third-party code.

A document whose post_author is a string rather than the indexed object
is worse than noisy: the subscript raises an uncaught TypeError
("Cannot access offset of type string on string").

Guarding the read makes post_author behave like its 20 siblings, which
leave a missing property unset so WP_Post supplies its own default. A
valid id is still copied, including 0 and the empty string the plugin
indexes for a deleted user.

Adds a data-provider test covering every post_author shape a document
can carry, plus a test that a narrowed _source builds posts cleanly.
Five of the nine cases fail without the fix, four of them as errors
raised by the suite's warning-to-exception conversion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CaiQXpxDeoy1XXh4y9PPzx
@freibergergarcia
freibergergarcia force-pushed the fix/post-author-unguarded-read branch from 600ee58 to 5f01787 Compare August 13, 2026 08:47
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.

Lots of notices in regular search query since 1.5

1 participant