Skip to content

Abilities API: Add a core/content-query ability - #12195

Open
jorgefilipecosta wants to merge 33 commits into
WordPress:trunkfrom
jorgefilipecosta:add/core-content-ability
Open

jorgefilipecosta wants to merge 33 commits into
WordPress:trunkfrom
jorgefilipecosta:add/core-content-ability

Conversation

@jorgefilipecosta

@jorgefilipecosta jorgefilipecosta commented Jun 16, 2026 •

Copy link
Copy Markdown
Member

Part of WordPress/ai#40. Ports the core/content-query ability from the AI plugin, added in WordPress/ai#739, renamed from core/read-content in WordPress/ai#1002, and updated with the read changes from WordPress/ai#1025.

  • Adds the read-only core/content-query ability, in a new content category, through the internal WP_Content_Abilities class.
  • Adds a show_in_abilities post type argument that controls which post types the ability can read. It defaults to false; post and page set it to true.
  • The ability returns one post by id, or by post_type and slug, or a page of posts of one type filtered by status, author_slug, parent, or include, with fields to choose what each post returns.
  • Permissions follow the REST posts controller: raw fields need edit access, rendered fields need read access, and statuses other than publish need edit access, or read_private_posts for private.

Testing Instructions

Verify unit tests are passing:

npm run test:php -- --group 64606

Trac ticket: https://core.trac.wordpress.org/ticket/64606

Use of AI Tools

AI assistance: Yes. Claude Code was used to sync this PR with the plugin and apply the review feedback; I reviewed the changes.


This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See GitHub Pull Requests for Code Review in the Core Handbook for more details.

@github-actions

Copy link
Copy Markdown

Hi there! 👋

Thank you for your contribution to WordPress! 💖

It looks like this is your first pull request to wordpress-develop. Here are a few things to be aware of that may help you out!

No one monitors this repository for new pull requests. Pull requests must be attached to a Trac ticket to be considered for inclusion in WordPress Core. To attach a pull request to a Trac ticket, please include the ticket's full URL in your pull request description.

Pull requests are never merged on GitHub. The WordPress codebase continues to be managed through the SVN repository that this GitHub repository mirrors. Please feel free to open pull requests to work on any contribution you are making.

More information about how GitHub pull requests can be used to contribute to WordPress can be found in the Core Handbook.

Please include automated tests. Including tests in your pull request is one way to help your patch be considered faster. To learn about WordPress' test suites, visit the Automated Testing page in the handbook.

If you have not had a chance, please review the Contribute with Code page in the WordPress Core Handbook.

The Developer Hub also documents the various coding standards that are followed:

Thank you,
The WordPress Project

@github-actions

Copy link
Copy Markdown

Test using WordPress Playground

The changes in this pull request can previewed and tested using a WordPress Playground instance.

WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser.

Some things to be aware of

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

@jorgefilipecosta
jorgefilipecosta force-pushed the add/core-content-ability branch from 87c0275 to 3cf4957 Compare June 18, 2026 10:12
@jorgefilipecosta
jorgefilipecosta force-pushed the add/core-content-ability branch from 103a8d6 to 8123116 Compare July 10, 2026 16:00
@jorgefilipecosta jorgefilipecosta changed the title [in progress] Abilities API: Add a core/content ability Abilities API: Add a core/read-content ability Jul 10, 2026
@jorgefilipecosta
jorgefilipecosta marked this pull request as ready for review July 10, 2026 16:06
@jorgefilipecosta
jorgefilipecosta requested a review from gziolo July 10, 2026 16:06
@github-actions

github-actions Bot commented Jul 10, 2026 •

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

Core Committers: Use this line as a base for the props when committing in SVN:

Props jorgefilipecosta, peterwilsoncc, gziolo.

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@gziolo

gziolo commented Jul 13, 2026

Copy link
Copy Markdown
Member

Thanks @jorgefilipecosta! I went through this against the companion plugin PR WordPress/ai#739. The Core class is a truthful representation of what is proposed there. After accounting for the expected adaptations (no namespace, no ai text domain, and dropping the plugin-only init(), category self-registration, and override blocks), there is no divergent logic. The new show_in_abilities post type arg is clean and mirrors the settings approach, the wiring and category registration look right, and CI is green.

On sequencing: the AI plugin PR should go first. Since #739 is still open, we are matching a proposal, not merged code, so this PR should wait on that merge to avoid the two drifting.

One small thing to add: the Trac ticket reference is missing. There should already be one for this ability, like the sibling PRs have (users links #64657 and settings links #64605). Could you add it to the description and the test @ticket tags?

@gziolo
gziolo removed their request for review September 3, 2026 09:41

@peterwilsoncc peterwilsoncc left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I've added a few notes inline. LMK if this is no longer the canonical URL as there are a few linked to the ticket.

Comment on lines +1441 to +1443
/** This filter is documented in wp-includes/post-template.php. */
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Applying the core content filter to mirror REST rendering.
$content = apply_filters( 'the_content', $post->post_content );

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think the phpcs ignore will need to be on the same line so the docs parser recognises the docblock reference.

Suggested change
/** This filter is documented in wp-includes/post-template.php. */
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Applying the core content filter to mirror REST rendering.
$content = apply_filters( 'the_content', $post->post_content );
/** This filter is documented in wp-includes/post-template.php. */
$content = apply_filters( 'the_content', $post->post_content ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Applying the core content filter to mirror REST rendering.

Comment thread src/wp-includes/class-wp-post-type.php
$response = $this->server->dispatch( $this->run_request( array( 'post_type' => 'post' ) ) );
$data = $response->get_data();

$this->assertSame( 200, $response->get_status() );

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This and other tests containing multiple assertions will need a unique $message param for each assertion to assist with debugging at a later date.

🔢 This applies to multiple tests but I won't repeat myself.

)
);
} finally {
array_pop( $wp_current_filter );

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

$wp_current_filter is restored as part of the default teardown so you can remove the finally array_pop pattern in Core.

🔢 This applies to other tests so I won't repeat myself.

See:

protected function _restore_hooks() {
if ( isset( self::$hooks_saved['wp_filter'] ) ) {
$GLOBALS['wp_filter'] = array();
foreach ( self::$hooks_saved['wp_filter'] as $hook_name => $hook_object ) {
$GLOBALS['wp_filter'][ $hook_name ] = clone $hook_object;
}
}
$globals = array( 'wp_actions', 'wp_filters', 'wp_current_filter' );
foreach ( $globals as $key ) {
if ( isset( self::$hooks_saved[ $key ] ) ) {
$GLOBALS[ $key ] = self::$hooks_saved[ $key ];
}
}
}


$this->assertContains( $post_id, $ids, 'The custom post type should be queryable through the content ability.' );
} finally {
unregister_post_type( 'wpai_content_cpt' );

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Post types set up by the test suite are automatically removed

🔢 Applies to other tests so I won't repeat myself

see

/**
* Unregisters existing post types and register defaults.
*
* Run before each test in order to clean up the global scope, in case
* a test forgets to unregister a post type on its own, or fails before
* it has a chance to do so.
*/
protected function reset_post_types() {
foreach ( get_post_types( array(), 'objects' ) as $pt ) {
if ( empty( $pt->tests_no_auto_unregister ) ) {
_unregister_post_type( $pt->name );
}
}
create_initial_post_types();
}


$post_type_object = $exposed[ $post_type ];
if ( $requires_edit ) {
return current_user_can( $this->post_type_cap( $post_type_object, 'edit_posts' ) ); // phpcs:ignore WordPress.WP.Capabilities.Undetermined -- Capability is resolved from the post type's capability object.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'm unclear of the benefit $this->post_type_cap provides. If the post type doesn't exist or isn't exposed the permission check will fail above.

Comment on lines +572 to +573
// `orderby` is left unset, which orders by `post_date` descending, matching the
// default of the REST posts controller.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Multi line comment format -- https://developer.wordpress.org/coding-standards/inline-documentation-standards/php/#5-2-multi-line-comments

🔢 Applies in a few places so I won't repeat.

return $total;
}

$count_args = $query_args;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If $query_args has no found rows set, the query generated here will too, it will need to be unset.

@gziolo

gziolo commented Sep 30, 2026

Copy link
Copy Markdown
Member

In addition to addressing the feedback from @peterwilsoncc, we need to sync the latest fixes applied in the AI plugin. Most importantly:

@gziolo gziolo changed the title Abilities API: Add a core/read-content ability Abilities API: Add a core/content-query ability Oct 1, 2026
Adds a read-only `core/content` ability that retrieves one or more posts of a
post type exposed to abilities via a new `show_in_abilities` post type argument
(enabled for `post` and `page` by default). Fetch a single post by ID or by
slug, or query multiple posts filtered by post type, status, author, or parent,
selecting a support-aware set of fields per post.

Permissions follow the REST posts model: a coarse status/capability gate plus an
authoritative per-post read_post check, with password-protected content withheld
from users who cannot edit the post and a uniform not-found response to avoid
leaking the existence of posts.
Mirrors the refinements from the core/settings review that also apply to core/content:
- Memoize the exposed post types so the input schema and the permission/execute
  callbacks derive from a single walk of the registered post types.
- Default the input schema to an empty object so the type:object default serializes as {}.
- Harden input/value handling (type guards, a capability resolver, and a non-negative
  integer helper) against loosely-typed request data.
Convert WP_Content_Abilities from a static class to a final, instance-based one,
matching WP_Settings_Abilities and the canonical abilities pattern: register() is
now invoked via ( new WP_Content_Abilities() )->register() from
wp_register_core_abilities(). The externally-invoked entry points (register,
check_permission, execute_get_content, return_raw_title_format) stay public;
register_get_content() and the shared helpers become private; CATEGORY and the
per-page bounds become private consts; FIELDS becomes a private instance property;
and the cached exposed post types become instance state. Behaviour is unchanged.

The per-page assertions in the test read the now-private constants by value.
… modes.

Replace the flat anyOf(id|post_type) input schema with a oneOf of two modes,
each with additionalProperties:false:
 - Get a single post by id (optionally guarded by post_type), plus fields.
 - Query a set of posts by post_type plus slug/status/author/parent/page/per_page,
   plus fields.

Invalid combinations (e.g. per_page alongside id) now fail validation instead of
being silently ignored. Update wpRegisterCoreContentAbility accordingly and add
coverage for the id-mode rejecting query-only params and accepting a post_type guard.
@jorgefilipecosta
jorgefilipecosta force-pushed the add/core-content-ability branch from 670cd55 to b8ffb86 Compare October 5, 2026 21:26
Renames the ability from `core/read-content` to `core/content-query` to
match the AI plugin (WordPress/ai#1002), and ports the plugin's lean
projection test, which checks the query arguments the ability builds
instead of counting post meta queries.

Also drops the trailing period from the hook documentation references,
which the PHPStan hook docblock rule reads as part of the file path.
Declares `meta.public` instead of `meta.show_in_rest`, matching the other
core abilities since [62737]. The flag still seeds `show_in_rest`, so REST
exposure is unchanged.
…ility

Core's coding standards do not run the `PrefixAllGlobals` sniff, so the
ignores only separated each "This filter is documented in" reference from
its `apply_filters()` call, which keeps the docs parser from linking them.
The test case restores `$wp_current_filter` after each test, so the
helpers no longer need a `try`/`finally` to pop the faked action.
The test case unregisters non-default post types and statuses before each
test, so the `try`/`finally` blocks that removed them by hand can go.

The `tearDown()` reset of `show_in_abilities` on `post` and `page` stays:
those built-in objects are only recreated in the next `set_up()`, after the
next class's `set_up_before_class()` may already have registered the
core abilities.
`setUp()` registers the `content` category when it is missing, but nothing
removed it, so it leaked into later classes. In a random test order that
broke `Tests_Abilities_API_WpRegisterAbilityCategory::test_get_all_categories`,
which expects only the categories it registers.
`get_post_type_capabilities()` always sets `edit_posts` and
`read_private_posts` for registered post types, and the REST posts
controller reads them directly too, so the guard isn't needed.

This also drops the `WordPress.WP.Capabilities.Undetermined` ignores,
since core's PHPCS ruleset doesn't run that sniff.
jorgefilipecosta added a commit to WordPress/ai that referenced this pull request Oct 6, 2026
get_post_type_capabilities() always sets the capabilities these checks
read for registered post types, and the REST posts controller reads
them directly too, so the guard isn't needed. This matches the change
made in review of WordPress/wordpress-develop#12195.
jorgefilipecosta added a commit to WordPress/ai that referenced this pull request Oct 6, 2026
get_post_type_capabilities() always sets the capabilities these checks
read for registered post types, and the REST posts controller reads
them directly too, so the guard isn't needed. This matches the change
made in review of WordPress/wordpress-develop#12195.
…ery ability

The content query now resolves the requested post with `get_content_by_id()` and
the post type with `get_exposed_post_type()`, and builds its `fields` input and
post output schemas with `get_fields_input_schema()` and
`get_content_output_schema()`, as the AI plugin does so its content write
abilities can reuse them (WordPress/ai#1025).

`get_content_by_id()` parses the ID the way the integer filters are parsed, so
only an integer or an unsigned integer string resolves a post.
…ery ability

The query took an author user ID as its filter and returned the author as
`author`, an object with the user ID and display name. It now takes and returns
`author_slug`, the user's nicename, which `core/users-query` returns as `slug`,
as the AI plugin does (WordPress/ai#1025).

The slug must match exactly one user, or the filter fails as
`content_invalid_filter`. A user the caller may not see in `core/users-query` is
reported like a missing one, so the filter reveals no hidden user.
…urns a non-string

`get_title()` returned whatever the `the_title` filters returned, so a filter
that returns a non-string failed its string return type and turned the query
into an `ability_callback_exception`. The rendered excerpt and content are
already guarded this way, as the AI plugin does
(WordPress/ai#1025).
…ister the core abilities

`set_up_before_class()` removed the test suite's unhook callbacks and added the
core registration callbacks, and only `tear_down_after_class()` put the unhook
callbacks back. The first test of a run snapshots the hooks that every test is
reset to, so when this class ran first, the changed hooks leaked into the rest
of the run: later tests registered the core abilities whenever the registry
initialized, and `test_get_existing_ability_using_callback` failed because the
`site` category was not registered.

The hooks are now restored as soon as the core abilities are registered.
jorgefilipecosta added a commit to WordPress/ai that referenced this pull request Oct 6, 2026
WordPress/wordpress-develop#12195 now has the same ID lookup, post type
check, shared schemas, author_slug filter and field, and title guard as
the query here, so the markers that said what core does instead were
wrong. Remove them, and put back core's wording in the comments they
were added to. The class docblock now says that only the write
abilities and the helpers they alone use are missing from core.
…uery ability

`check_read_permission()` calls `get_exposed_post_type()` for every post a
query returns, up to 100 per page, and each call rebuilt the map of every
exposed post type. It now reads the post type object and checks
`show_in_abilities`, the same truthy rule `get_post_types()` applies, as the AI
plugin does (WordPress/ai#1025).

`get_exposed_post_types()` was then only used for the post type names at
registration, which `get_post_types()` returns directly, so it is removed.
The `get_author_by_slug()` docblock said the lookup reveals no hidden user, as
in `core/users-query`. A user who can edit others' posts of the post type, such
as an editor, resolves any slug, since they may make any user the author, while
`core/users-query` hides users like subscribers from them. The docblock now
says so, as the AI plugin does (WordPress/ai#1025).
…docblock

The docblock said the parser keeps `author => 0` from dropping the author
filter, but the author filter is now `author_slug`, resolved by
`get_author_by_slug()`. `parse_filter_int()` only reads the `id` input and the
`parent` filter, so the docblock now describes those, as the AI plugin does
(WordPress/ai#1025).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants