From ffb6ac8c3945a8bd962e0444f1cae4c3f495a9ab Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 2 Feb 2026 17:07:31 +0000 Subject: [PATCH 1/8] initial --- .../class-wp-abilities-registry.php | 4 +- src/wp-includes/abilities.php | 12 + .../class-wp-post-type-abilities.php | 1242 +++++++++++++++++ src/wp-includes/class-wp-post-type.php | 14 + src/wp-includes/post.php | 2 + 5 files changed, 1272 insertions(+), 2 deletions(-) create mode 100644 src/wp-includes/abilities/class-wp-post-type-abilities.php diff --git a/src/wp-includes/abilities-api/class-wp-abilities-registry.php b/src/wp-includes/abilities-api/class-wp-abilities-registry.php index ecd6dc2785e70..f8c68c301251b 100644 --- a/src/wp-includes/abilities-api/class-wp-abilities-registry.php +++ b/src/wp-includes/abilities-api/class-wp-abilities-registry.php @@ -78,11 +78,11 @@ final class WP_Abilities_Registry { * @return WP_Ability|null The registered ability instance on success, null on failure. */ public function register( string $name, array $args ): ?WP_Ability { - if ( ! preg_match( '/^[a-z0-9-]+\/[a-z0-9-]+$/', $name ) ) { + if ( ! preg_match( '/^[a-z0-9-]+(?:\/[a-z0-9-]+)+$/', $name ) ) { _doing_it_wrong( __METHOD__, __( - 'Ability name must be a string containing a namespace prefix, i.e. "my-plugin/my-ability". It can only contain lowercase alphanumeric characters, dashes and the forward slash.' + 'Ability name must be a string containing a namespace prefix, e.g. "my-plugin/my-ability" or "my-plugin/my-namespace/my-ability". It can only contain lowercase alphanumeric characters, dashes and forward slashes as separators.' ), '6.9.0' ); diff --git a/src/wp-includes/abilities.php b/src/wp-includes/abilities.php index 0320df3b9f38a..b8a069ed252d3 100644 --- a/src/wp-includes/abilities.php +++ b/src/wp-includes/abilities.php @@ -9,6 +9,8 @@ declare( strict_types = 1 ); +require_once __DIR__ . '/abilities/class-wp-post-type-abilities.php'; + /** * Registers the core ability categories. * @@ -32,6 +34,14 @@ function wp_register_core_ability_categories(): void { 'description' => __( 'Abilities that retrieve or modify user information and settings.' ), ) ); + + wp_register_ability_category( + 'post', + array( + 'label' => __( 'Post' ), + 'description' => __( 'Abilities related to the creation and management of posts of all types.' ), + ) + ); } /** @@ -259,4 +269,6 @@ function wp_register_core_abilities(): void { ), ) ); + + WP_Post_Type_Abilities::register(); } diff --git a/src/wp-includes/abilities/class-wp-post-type-abilities.php b/src/wp-includes/abilities/class-wp-post-type-abilities.php new file mode 100644 index 0000000000000..f7e9ac89cd3ee --- /dev/null +++ b/src/wp-includes/abilities/class-wp-post-type-abilities.php @@ -0,0 +1,1242 @@ +show_in_abilities ?? false; + + if ( false === $show ) { + continue; + } + + $register_get = true === $show || ( is_array( $show ) && ! empty( $show['get'] ) ); + + if ( $register_get ) { + self::register_get_ability( $post_type_object ); + } + } + } + + /** + * Registers the get ability for a specific post type. + * + * @since 7.0.0 + * + * @param WP_Post_Type $post_type_object The post type object. + * @return void + */ + private static function register_get_ability( WP_Post_Type $post_type_object ): void { + $slug = $post_type_object->name; + $label = $post_type_object->labels->singular_name ?? $post_type_object->label; + $name = "core/post-type/{$slug}/get"; + + wp_register_ability( + $name, + array( + 'label' => sprintf( + /* translators: %s: Post type singular name. */ + __( 'Get %s' ), + $label + ), + 'description' => sprintf( + /* translators: %1$s: Post type singular name (lowercase), %2$s: Post type plural name (lowercase). */ + __( 'Retrieves a single %1$s by ID or queries multiple %2$s with optional filters.' ), + strtolower( $label ), + strtolower( $post_type_object->labels->name ?? $post_type_object->label ) + ), + 'category' => 'post', + 'input_schema' => self::build_get_input_schema( $post_type_object ), + 'output_schema' => self::build_get_output_schema( $post_type_object ), + 'execute_callback' => self::make_execute_get_callback( $post_type_object ), + 'permission_callback' => self::make_permission_get_callback( $post_type_object ), + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + ), + ) + ); + } + + /* + * ------------------------------------------------------------------------- + * Schema Building + * ------------------------------------------------------------------------- + */ + + /** + * Builds the input schema for the get ability. + * + * @since 7.0.0 + * + * @param WP_Post_Type $post_type_object The post type object. + * @return array The JSON schema for input. + */ + private static function build_get_input_schema( WP_Post_Type $post_type_object ): array { + $statuses = array_values( get_post_stati( array( 'internal' => false ) ) ); + + $include_properties = array( + 'taxonomies' => array( + 'type' => 'boolean', + 'description' => __( 'Whether to include taxonomy terms in the response.' ), + 'default' => false, + ), + ); + + if ( post_type_supports( $post_type_object->name, 'custom-fields' ) ) { + $include_properties['meta'] = array( + 'type' => 'boolean', + 'description' => __( 'Whether to include post meta in the response.' ), + 'default' => false, + ); + } + + $include_schema = array( + 'type' => 'object', + 'description' => __( 'Additional data to include in the response.' ), + 'properties' => $include_properties, + 'additionalProperties' => false, + ); + + $query_properties = array( + 'tax' => self::build_query_group_schema( + __( 'Taxonomy query to filter posts by taxonomy terms.' ), + self::build_tax_clause_schema() + ), + 'meta' => self::build_query_group_schema( + __( 'Meta query to filter posts by post meta values.' ), + self::build_meta_clause_schema() + ), + 'date' => self::build_date_query_schema(), + ); + + // Single post retrieval by ID. + $by_id_schema = array( + 'type' => 'object', + 'description' => __( 'Retrieve a single post by its ID.' ), + 'required' => array( 'id' ), + 'properties' => array( + 'id' => array( + 'type' => 'integer', + 'description' => __( 'Unique identifier for the post.' ), + 'minimum' => 1, + ), + 'include' => $include_schema, + ), + 'additionalProperties' => false, + ); + + // Multi-post query with filters. + $query_schema = array( + 'type' => 'object', + 'description' => __( 'Query multiple posts with optional filters.' ), + 'properties' => array( + 'status' => array( + 'type' => 'string', + 'description' => __( 'Filter by post status.' ), + 'enum' => $statuses, + ), + 'search' => array( + 'type' => 'string', + 'description' => __( 'Search term to filter posts by.' ), + ), + 'author' => array( + 'type' => 'integer', + 'description' => __( 'Filter posts by author user ID.' ), + 'minimum' => 1, + ), + 'per_page' => array( + 'type' => 'integer', + 'description' => __( 'Maximum number of posts to return.' ), + 'minimum' => 1, + 'maximum' => 100, + 'default' => 10, + ), + 'page' => array( + 'type' => 'integer', + 'description' => __( 'Page number for paginated results.' ), + 'minimum' => 1, + 'default' => 1, + ), + 'order' => array( + 'type' => 'object', + 'description' => __( 'Ordering parameters.' ), + 'properties' => array( + 'orderby' => array( + 'type' => 'string', + 'description' => __( 'Field to order results by.' ), + 'enum' => array( 'date', 'title', 'modified', 'id', 'author', 'relevance' ), + 'default' => 'date', + ), + 'direction' => array( + 'type' => 'string', + 'description' => __( 'Order direction.' ), + 'enum' => array( 'asc', 'desc' ), + 'default' => 'desc', + ), + ), + 'additionalProperties' => false, + ), + 'query' => array( + 'type' => 'object', + 'description' => __( 'Advanced query filters for taxonomy terms, meta values, and dates.' ), + 'properties' => $query_properties, + 'additionalProperties' => false, + ), + 'include' => $include_schema, + ), + 'additionalProperties' => false, + ); + + return array( + 'oneOf' => array( + $by_id_schema, + $query_schema, + ), + ); + } + + /** + * Builds a query group schema with the recursive { relation, queries[] } structure. + * + * @since 7.0.0 + * + * @param string $description Description for the query group. + * @param array $leaf_schema JSON Schema for a leaf clause. + * @return array The JSON schema for the query group. + */ + private static function build_query_group_schema( string $description, array $leaf_schema ): array { + $nested_group_schema = array( + 'type' => 'object', + 'description' => __( 'Nested query group with its own relation.' ), + 'required' => array( 'queries' ), + 'properties' => array( + 'relation' => array( + 'type' => 'string', + 'description' => __( 'Logical relation between nested clauses.' ), + 'enum' => array( 'AND', 'OR' ), + ), + 'queries' => array( + 'type' => 'array', + 'description' => __( 'Nested query clauses.' ), + ), + ), + 'additionalProperties' => false, + ); + + return array( + 'type' => 'object', + 'description' => $description, + 'properties' => array( + 'relation' => array( + 'type' => 'string', + 'description' => __( 'Logical relation between query clauses.' ), + 'enum' => array( 'AND', 'OR' ), + ), + 'queries' => array( + 'type' => 'array', + 'description' => __( 'List of query clauses or nested groups.' ), + 'items' => array( + 'oneOf' => array( + $leaf_schema, + $nested_group_schema, + ), + ), + ), + ), + 'additionalProperties' => false, + ); + } + + /** + * Builds the taxonomy clause leaf schema. + * + * @since 7.0.0 + * + * @return array The JSON schema for a taxonomy query clause. + */ + private static function build_tax_clause_schema(): array { + return array( + 'type' => 'object', + 'required' => array( 'taxonomy', 'terms' ), + 'properties' => array( + 'taxonomy' => array( + 'type' => 'string', + 'description' => __( 'Taxonomy slug to query.' ), + ), + 'terms' => array( + 'type' => 'array', + 'description' => __( 'Taxonomy terms to match.' ), + 'items' => array( + 'type' => array( 'integer', 'string' ), + ), + ), + 'field' => array( + 'type' => 'string', + 'description' => __( 'Term field to match against.' ), + 'enum' => array( 'term_id', 'slug', 'name', 'term_taxonomy_id' ), + ), + 'operator' => array( + 'type' => 'string', + 'description' => __( 'SQL operator to use for the query.' ), + 'enum' => array( 'IN', 'NOT IN', 'AND', 'EXISTS', 'NOT EXISTS' ), + ), + 'include_children' => array( + 'type' => 'boolean', + 'description' => __( 'Whether to include child terms. Only applicable for hierarchical taxonomies.' ), + ), + ), + 'additionalProperties' => false, + ); + } + + /** + * Builds the meta clause leaf schema. + * + * @since 7.0.0 + * + * @return array The JSON schema for a meta query clause. + */ + private static function build_meta_clause_schema(): array { + return array( + 'type' => 'object', + 'required' => array( 'key' ), + 'properties' => array( + 'key' => array( + 'type' => 'string', + 'description' => __( 'Meta key to query.' ), + ), + 'value' => array( + 'type' => array( 'string', 'integer', 'array' ), + 'description' => __( 'Meta value to match. Use an array for BETWEEN, NOT BETWEEN, IN, and NOT IN comparisons.' ), + ), + 'compare' => array( + 'type' => 'string', + 'description' => __( 'Comparison operator.' ), + 'enum' => array( + '=', + '!=', + '>', + '>=', + '<', + '<=', + 'LIKE', + 'NOT LIKE', + 'IN', + 'NOT IN', + 'BETWEEN', + 'NOT BETWEEN', + 'EXISTS', + 'NOT EXISTS', + 'REGEXP', + 'NOT REGEXP', + 'RLIKE', + ), + ), + 'type' => array( + 'type' => 'string', + 'description' => __( 'Cast the meta value to this type for comparison.' ), + 'enum' => array( + 'NUMERIC', + 'CHAR', + 'DATE', + 'DATETIME', + 'TIME', + 'BINARY', + 'SIGNED', + 'UNSIGNED', + 'DECIMAL', + ), + ), + ), + 'additionalProperties' => false, + ); + } + + /** + * Builds the date query schema with the top-level column field. + * + * @since 7.0.0 + * + * @return array The JSON schema for a date query. + */ + private static function build_date_query_schema(): array { + $date_columns = array( 'post_date', 'post_date_gmt', 'post_modified', 'post_modified_gmt' ); + + $date_object_schema = array( + 'type' => 'object', + 'properties' => array( + 'year' => array( + 'type' => 'integer', + 'description' => __( 'Year.' ), + ), + 'month' => array( + 'type' => 'integer', + 'description' => __( 'Month.' ), + ), + 'day' => array( + 'type' => 'integer', + 'description' => __( 'Day.' ), + ), + ), + 'additionalProperties' => false, + ); + + $date_clause_schema = array( + 'type' => 'object', + 'properties' => array( + 'year' => array( + 'type' => 'integer', + 'description' => __( 'Four-digit year.' ), + ), + 'month' => array( + 'type' => 'integer', + 'description' => __( 'Month number (1-12).' ), + ), + 'week' => array( + 'type' => 'integer', + 'description' => __( 'Week of the year (0-53).' ), + ), + 'day' => array( + 'type' => 'integer', + 'description' => __( 'Day of the month (1-31).' ), + ), + 'hour' => array( + 'type' => 'integer', + 'description' => __( 'Hour (0-23).' ), + ), + 'minute' => array( + 'type' => 'integer', + 'description' => __( 'Minute (0-59).' ), + ), + 'second' => array( + 'type' => 'integer', + 'description' => __( 'Second (0-59).' ), + ), + 'dayofweek' => array( + 'type' => 'integer', + 'description' => __( 'Day of the week (1-7, Sunday is 1).' ), + ), + 'dayofweek_iso' => array( + 'type' => 'integer', + 'description' => __( 'ISO day of the week (1-7, Monday is 1).' ), + ), + 'dayofyear' => array( + 'type' => 'integer', + 'description' => __( 'Day of the year (1-366).' ), + ), + 'after' => array( + 'oneOf' => array( + array( + 'type' => 'string', + 'description' => __( 'Date string parseable by strtotime().' ), + ), + $date_object_schema, + ), + 'description' => __( 'Retrieve posts after this date.' ), + ), + 'before' => array( + 'oneOf' => array( + array( + 'type' => 'string', + 'description' => __( 'Date string parseable by strtotime().' ), + ), + $date_object_schema, + ), + 'description' => __( 'Retrieve posts before this date.' ), + ), + 'inclusive' => array( + 'type' => 'boolean', + 'description' => __( 'Whether the after/before dates are inclusive.' ), + ), + 'compare' => array( + 'type' => 'string', + 'description' => __( 'Comparison operator.' ), + 'enum' => array( '=', '!=', '>', '>=', '<', '<=', 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN' ), + ), + 'column' => array( + 'type' => 'string', + 'description' => __( 'Database column to query against for this clause.' ), + 'enum' => $date_columns, + ), + ), + 'additionalProperties' => false, + ); + + $nested_group_schema = array( + 'type' => 'object', + 'description' => __( 'Nested date query group with its own relation.' ), + 'required' => array( 'queries' ), + 'properties' => array( + 'relation' => array( + 'type' => 'string', + 'description' => __( 'Logical relation between nested clauses.' ), + 'enum' => array( 'AND', 'OR' ), + ), + 'queries' => array( + 'type' => 'array', + 'description' => __( 'Nested date query clauses.' ), + ), + ), + 'additionalProperties' => false, + ); + + return array( + 'type' => 'object', + 'description' => __( 'Date query to filter posts by date fields.' ), + 'properties' => array( + 'relation' => array( + 'type' => 'string', + 'description' => __( 'Logical relation between date query clauses.' ), + 'enum' => array( 'AND', 'OR' ), + ), + 'column' => array( + 'type' => 'string', + 'description' => __( 'Default database column to query against.' ), + 'enum' => $date_columns, + ), + 'queries' => array( + 'type' => 'array', + 'description' => __( 'List of date query clauses or nested groups.' ), + 'items' => array( + 'oneOf' => array( + $date_clause_schema, + $nested_group_schema, + ), + ), + ), + ), + 'additionalProperties' => false, + ); + } + + /** + * Builds the output schema for the get ability, based on post type supports. + * + * @since 7.0.0 + * + * @param WP_Post_Type $post_type_object The post type object. + * @return array The JSON schema for output. + */ + private static function build_get_output_schema( WP_Post_Type $post_type_object ): array { + $post_schema = self::build_post_schema( $post_type_object ); + + return array( + 'oneOf' => array( + $post_schema, + array( + 'type' => 'object', + 'properties' => array( + 'posts' => array( + 'type' => 'array', + 'description' => __( 'List of posts matching the query.' ), + 'items' => $post_schema, + ), + 'total' => array( + 'type' => 'integer', + 'description' => __( 'Total number of posts matching the query.' ), + ), + 'total_pages' => array( + 'type' => 'integer', + 'description' => __( 'Total number of pages.' ), + ), + ), + 'required' => array( 'posts', 'total', 'total_pages' ), + 'additionalProperties' => false, + ), + ), + ); + } + + /** + * Builds a single post object schema based on what the post type supports. + * + * @since 7.0.0 + * + * @param WP_Post_Type $post_type_object The post type object. + * @return array The JSON schema for a single post object. + */ + private static function build_post_schema( WP_Post_Type $post_type_object ): array { + $slug = $post_type_object->name; + + // Base fields that are always present regardless of supports. + $properties = array( + 'id' => array( + 'type' => 'integer', + 'description' => __( 'The post ID.' ), + ), + 'type' => array( + 'type' => 'string', + 'description' => __( 'The post type.' ), + ), + 'status' => array( + 'type' => 'string', + 'description' => __( 'The post status.' ), + ), + 'date' => array( + 'type' => 'string', + 'description' => __( 'The post publication date in ISO 8601 format.' ), + ), + 'modified' => array( + 'type' => 'string', + 'description' => __( 'The post last modified date in ISO 8601 format.' ), + ), + 'slug' => array( + 'type' => 'string', + 'description' => __( 'The post slug.' ), + ), + 'link' => array( + 'type' => 'string', + 'description' => __( 'The permalink URL.' ), + ), + ); + + $required = array( 'id', 'type', 'status', 'date', 'modified', 'slug', 'link' ); + + // Conditional fields based on post type supports. + if ( post_type_supports( $slug, 'title' ) ) { + $properties['title'] = array( + 'type' => 'string', + 'description' => __( 'The post title.' ), + ); + $required[] = 'title'; + } + + if ( post_type_supports( $slug, 'editor' ) ) { + $properties['content'] = array( + 'type' => 'string', + 'description' => __( 'The post content.' ), + ); + $required[] = 'content'; + } + + if ( post_type_supports( $slug, 'excerpt' ) ) { + $properties['excerpt'] = array( + 'type' => 'string', + 'description' => __( 'The post excerpt.' ), + ); + $required[] = 'excerpt'; + } + + if ( post_type_supports( $slug, 'author' ) ) { + $properties['author'] = array( + 'type' => 'object', + 'description' => __( 'The post author.' ), + 'properties' => array( + 'id' => array( + 'type' => 'integer', + 'description' => __( 'The author user ID.' ), + ), + 'display_name' => array( + 'type' => 'string', + 'description' => __( 'The author display name.' ), + ), + ), + 'required' => array( 'id', 'display_name' ), + 'additionalProperties' => false, + ); + $required[] = 'author'; + } + + if ( post_type_supports( $slug, 'thumbnail' ) ) { + $properties['featured_media'] = array( + 'type' => 'integer', + 'description' => __( 'The featured image attachment ID. 0 if no featured image is set.' ), + ); + $required[] = 'featured_media'; + } + + if ( post_type_supports( $slug, 'page-attributes' ) ) { + $properties['parent'] = array( + 'type' => 'integer', + 'description' => __( 'The parent post ID. 0 if no parent.' ), + ); + $properties['menu_order'] = array( + 'type' => 'integer', + 'description' => __( 'The order value for the post, used for sorting.' ), + ); + $required[] = 'parent'; + $required[] = 'menu_order'; + } + + if ( post_type_supports( $slug, 'post-formats' ) ) { + $properties['format'] = array( + 'type' => 'string', + 'description' => __( 'The post format.' ), + ); + $required[] = 'format'; + } + + if ( post_type_supports( $slug, 'comments' ) ) { + $properties['comment_status'] = array( + 'type' => 'string', + 'description' => __( 'Whether comments are allowed.' ), + 'enum' => array( 'open', 'closed' ), + ); + $required[] = 'comment_status'; + } + + if ( post_type_supports( $slug, 'trackbacks' ) ) { + $properties['ping_status'] = array( + 'type' => 'string', + 'description' => __( 'Whether trackbacks and pingbacks are allowed.' ), + 'enum' => array( 'open', 'closed' ), + ); + $required[] = 'ping_status'; + } + + return array( + 'type' => 'object', + 'properties' => $properties, + 'required' => $required, + 'additionalProperties' => false, + ); + } + + /* + * ------------------------------------------------------------------------- + * Execution + * ------------------------------------------------------------------------- + */ + + /** + * Creates the execute callback for a post type's get ability. + * + * @since 7.0.0 + * + * @param WP_Post_Type $post_type_object The post type object. + * @return Closure The execute callback. + */ + private static function make_execute_get_callback( WP_Post_Type $post_type_object ): Closure { + return static function ( $input = array() ) use ( $post_type_object ) { + $input = is_array( $input ) ? $input : array(); + + // Single post retrieval by ID. + if ( ! empty( $input['id'] ) ) { + return self::execute_get_single( (int) $input['id'], $post_type_object, $input ); + } + + // Multi-post query. + return self::execute_get_query( $post_type_object, $input ); + }; + } + + /** + * Creates the permission callback for a post type's get ability. + * + * @since 7.0.0 + * + * @param WP_Post_Type $post_type_object The post type object. + * @return Closure The permission callback. + */ + private static function make_permission_get_callback( WP_Post_Type $post_type_object ): Closure { + return static function ( $input = array() ) use ( $post_type_object ): bool { + $input = is_array( $input ) ? $input : array(); + + // For single post retrieval, check specific post permission. + if ( ! empty( $input['id'] ) ) { + return current_user_can( 'read_post', (int) $input['id'] ); + } + + // For queries, check general read capability. + return current_user_can( $post_type_object->cap->read ?? 'read' ); + }; + } + + /** + * Retrieves a single post by ID. + * + * @since 7.0.0 + * + * @param int $post_id The post ID. + * @param WP_Post_Type $post_type_object The post type object. + * @param array $input The input parameters. + * @return array|WP_Error Post data or error. + */ + private static function execute_get_single( int $post_id, WP_Post_Type $post_type_object, array $input ) { + $post = get_post( $post_id ); + + if ( ! $post || $post->post_type !== $post_type_object->name ) { + return new WP_Error( + 'post_not_found', + __( 'Post not found.' ), + array( 'status' => 404 ) + ); + } + + return self::format_post( $post, $post_type_object, $input ); + } + + /** + * Queries multiple posts. + * + * @since 7.0.0 + * + * @param WP_Post_Type $post_type_object The post type object. + * @param array $input The input parameters. + * @return array Query results with posts, total, and total_pages. + */ + private static function execute_get_query( WP_Post_Type $post_type_object, array $input ): array { + $per_page = $input['per_page'] ?? 10; + $page = $input['page'] ?? 1; + + $query_args = array( + 'post_type' => $post_type_object->name, + 'post_status' => $input['status'] ?? 'publish', + 'posts_per_page' => $per_page, + 'paged' => $page, + 'perm' => 'readable', + ); + + if ( ! empty( $input['search'] ) ) { + $query_args['s'] = sanitize_text_field( $input['search'] ); + } + + if ( ! empty( $input['author'] ) ) { + $query_args['author'] = (int) $input['author']; + } + + if ( ! empty( $input['order'] ) ) { + $order_input = $input['order']; + $orderby_map = array( + 'date' => 'date', + 'title' => 'title', + 'modified' => 'modified', + 'id' => 'ID', + 'author' => 'author', + 'relevance' => 'relevance', + ); + + if ( ! empty( $order_input['orderby'] ) && isset( $orderby_map[ $order_input['orderby'] ] ) ) { + $query_args['orderby'] = $orderby_map[ $order_input['orderby'] ]; + } + if ( ! empty( $order_input['direction'] ) ) { + $query_args['order'] = strtoupper( $order_input['direction'] ); + } + } + + // Process advanced query clauses. + if ( ! empty( $input['query'] ) ) { + $query_input = $input['query']; + + if ( ! empty( $query_input['tax'] ) ) { + $tax_query = self::process_query_recursive( + $query_input['tax'], + array( __CLASS__, 'process_tax_clause' ) + ); + if ( ! empty( $tax_query ) ) { + $query_args['tax_query'] = $tax_query; + } + } + + if ( ! empty( $query_input['meta'] ) ) { + $meta_query = self::process_query_recursive( + $query_input['meta'], + array( __CLASS__, 'process_meta_clause' ) + ); + if ( ! empty( $meta_query ) ) { + $query_args['meta_query'] = $meta_query; + } + } + + if ( ! empty( $query_input['date'] ) ) { + $date_query = self::process_query_recursive( + $query_input['date'], + array( __CLASS__, 'process_date_clause' ), + array( __CLASS__, 'process_date_top_level' ) + ); + if ( ! empty( $date_query ) ) { + $query_args['date_query'] = $date_query; + } + } + } + + $query = new WP_Query( $query_args ); + $posts = array(); + + foreach ( $query->posts as $post ) { + $posts[] = self::format_post( $post, $post_type_object, $input ); + } + + return array( + 'posts' => $posts, + 'total' => (int) $query->found_posts, + 'total_pages' => (int) $query->max_num_pages, + ); + } + + /** + * Formats a post object into the ability output format. + * + * Fields included depend on what the post type supports. + * + * @since 7.0.0 + * + * @param WP_Post $post The post object. + * @param WP_Post_Type $post_type_object The post type object. + * @param array $input The input parameters (for include flags). + * @return array Formatted post data. + */ + private static function format_post( WP_Post $post, WP_Post_Type $post_type_object, array $input ): array { + $slug = $post_type_object->name; + + // Base fields always present. + $data = array( + 'id' => $post->ID, + 'type' => $post->post_type, + 'status' => $post->post_status, + 'date' => mysql2date( 'c', $post->post_date_gmt ), + 'modified' => mysql2date( 'c', $post->post_modified_gmt ), + 'slug' => $post->post_name, + 'link' => get_permalink( $post ), + ); + + // Conditional fields based on post type supports. + if ( post_type_supports( $slug, 'title' ) ) { + $data['title'] = get_the_title( $post ); + } + + if ( post_type_supports( $slug, 'editor' ) ) { + /** This filter is documented in wp-includes/post-template.php */ + $data['content'] = apply_filters( 'the_content', $post->post_content ); + } + + if ( post_type_supports( $slug, 'excerpt' ) ) { + $data['excerpt'] = get_the_excerpt( $post ); + } + + if ( post_type_supports( $slug, 'author' ) ) { + $author = get_userdata( (int) $post->post_author ); + $data['author'] = array( + 'id' => (int) $post->post_author, + 'display_name' => $author ? $author->display_name : '', + ); + } + + if ( post_type_supports( $slug, 'thumbnail' ) ) { + $data['featured_media'] = (int) get_post_thumbnail_id( $post ); + } + + if ( post_type_supports( $slug, 'page-attributes' ) ) { + $data['parent'] = (int) $post->post_parent; + $data['menu_order'] = (int) $post->menu_order; + } + + if ( post_type_supports( $slug, 'post-formats' ) ) { + $format = get_post_format( $post ); + $data['format'] = $format ? $format : 'standard'; + } + + if ( post_type_supports( $slug, 'comments' ) ) { + $data['comment_status'] = $post->comment_status; + } + + if ( post_type_supports( $slug, 'trackbacks' ) ) { + $data['ping_status'] = $post->ping_status; + } + + // Include optional data based on include flags. + $include = $input['include'] ?? array(); + + if ( ! empty( $include['taxonomies'] ) ) { + $taxonomies = get_object_taxonomies( $post->post_type, 'objects' ); + $terms_data = array(); + + foreach ( $taxonomies as $taxonomy ) { + if ( ! $taxonomy->public ) { + continue; + } + $terms = get_the_terms( $post, $taxonomy->name ); + if ( $terms && ! is_wp_error( $terms ) ) { + $terms_data[ $taxonomy->name ] = array_map( + static function ( $term ): array { + return array( + 'id' => $term->term_id, + 'name' => $term->name, + 'slug' => $term->slug, + ); + }, + $terms + ); + } + } + + $data['taxonomies'] = $terms_data; + } + + if ( ! empty( $include['meta'] ) && post_type_supports( $slug, 'custom-fields' ) ) { + $meta = get_post_meta( $post->ID ); + $public_meta = array(); + + foreach ( $meta as $key => $values ) { + if ( is_protected_meta( $key, 'post' ) ) { + continue; + } + $public_meta[ $key ] = count( $values ) === 1 ? $values[0] : $values; + } + + $data['meta'] = $public_meta; + } + + return $data; + } + + /* + * ------------------------------------------------------------------------- + * Query Processing + * ------------------------------------------------------------------------- + */ + + /** + * Recursively converts a semantic { relation, queries[] } structure to a native WP query array. + * + * The semantic JSON format uses explicit `relation` and `queries` properties, + * while WP uses numeric-keyed arrays with a `relation` string key. + * + * @since 7.0.0 + * + * @param array $input The semantic query input. + * @param callable $process_leaf Callback to process a leaf clause. Receives an array, returns an array or null. + * @param callable|null $process_top_level Optional. Callback to handle top-level fields (e.g., date_query 'column'). + * Receives ($input, &$result). + * @return array The native WP query array. + */ + private static function process_query_recursive( array $input, callable $process_leaf, ?callable $process_top_level = null ): array { + $result = array(); + + if ( ! empty( $input['relation'] ) && in_array( $input['relation'], array( 'AND', 'OR' ), true ) ) { + $result['relation'] = $input['relation']; + } + + if ( $process_top_level ) { + $process_top_level( $input, $result ); + } + + if ( ! empty( $input['queries'] ) && is_array( $input['queries'] ) ) { + foreach ( $input['queries'] as $query ) { + if ( ! is_array( $query ) ) { + continue; + } + + if ( isset( $query['queries'] ) ) { + // Nested group: recurse. + $nested = self::process_query_recursive( $query, $process_leaf, $process_top_level ); + if ( ! empty( $nested ) ) { + $result[] = $nested; + } + } else { + // Leaf clause: process with type-specific callback. + $clause = $process_leaf( $query ); + if ( null !== $clause ) { + $result[] = $clause; + } + } + } + } + + return $result; + } + + /** + * Processes a taxonomy query leaf clause. + * + * @since 7.0.0 + * + * @param array $clause The raw clause data. + * @return array|null The processed clause or null if invalid. + */ + private static function process_tax_clause( array $clause ): ?array { + if ( empty( $clause['taxonomy'] ) || empty( $clause['terms'] ) ) { + return null; + } + + $taxonomy = sanitize_key( $clause['taxonomy'] ); + if ( ! taxonomy_exists( $taxonomy ) ) { + return null; + } + + $result = array( + 'taxonomy' => $taxonomy, + 'terms' => (array) $clause['terms'], + ); + + $allowed_fields = array( 'term_id', 'slug', 'name', 'term_taxonomy_id' ); + if ( ! empty( $clause['field'] ) && in_array( $clause['field'], $allowed_fields, true ) ) { + $result['field'] = $clause['field']; + } + + $allowed_operators = array( 'IN', 'NOT IN', 'AND', 'EXISTS', 'NOT EXISTS' ); + if ( ! empty( $clause['operator'] ) && in_array( $clause['operator'], $allowed_operators, true ) ) { + $result['operator'] = $clause['operator']; + } + + if ( isset( $clause['include_children'] ) ) { + $result['include_children'] = (bool) $clause['include_children']; + } + + return $result; + } + + /** + * Processes a meta query leaf clause. + * + * @since 7.0.0 + * + * @param array $clause The raw clause data. + * @return array|null The processed clause or null if invalid. + */ + private static function process_meta_clause( array $clause ): ?array { + if ( empty( $clause['key'] ) ) { + return null; + } + + $result = array( + 'key' => sanitize_key( $clause['key'] ), + ); + + if ( isset( $clause['value'] ) ) { + $result['value'] = $clause['value']; + } + + $allowed_compare = array( + '=', '!=', '>', '>=', '<', '<=', + 'LIKE', 'NOT LIKE', 'IN', 'NOT IN', + 'BETWEEN', 'NOT BETWEEN', + 'EXISTS', 'NOT EXISTS', + 'REGEXP', 'NOT REGEXP', 'RLIKE', + ); + if ( ! empty( $clause['compare'] ) && in_array( $clause['compare'], $allowed_compare, true ) ) { + $result['compare'] = $clause['compare']; + } + + $allowed_types = array( + 'NUMERIC', 'CHAR', 'DATE', 'DATETIME', + 'TIME', 'BINARY', 'SIGNED', 'UNSIGNED', 'DECIMAL', + ); + if ( ! empty( $clause['type'] ) && in_array( $clause['type'], $allowed_types, true ) ) { + $result['type'] = $clause['type']; + } + + return $result; + } + + /** + * Processes a date query leaf clause. + * + * @since 7.0.0 + * + * @param array $clause The raw clause data. + * @return array|null The processed clause or null if invalid. + */ + private static function process_date_clause( array $clause ): ?array { + $result = array(); + + $int_fields = array( + 'year', 'month', 'week', 'day', + 'hour', 'minute', 'second', + 'dayofweek', 'dayofweek_iso', 'dayofyear', + ); + + foreach ( $int_fields as $field ) { + if ( isset( $clause[ $field ] ) ) { + $result[ $field ] = (int) $clause[ $field ]; + } + } + + // Handle after/before as string or { year, month, day } object. + foreach ( array( 'after', 'before' ) as $boundary ) { + if ( isset( $clause[ $boundary ] ) ) { + if ( is_string( $clause[ $boundary ] ) ) { + $result[ $boundary ] = sanitize_text_field( $clause[ $boundary ] ); + } elseif ( is_array( $clause[ $boundary ] ) ) { + $date_parts = array(); + foreach ( array( 'year', 'month', 'day' ) as $part ) { + if ( isset( $clause[ $boundary ][ $part ] ) ) { + $date_parts[ $part ] = (int) $clause[ $boundary ][ $part ]; + } + } + if ( ! empty( $date_parts ) ) { + $result[ $boundary ] = $date_parts; + } + } + } + } + + if ( isset( $clause['inclusive'] ) ) { + $result['inclusive'] = (bool) $clause['inclusive']; + } + + $allowed_compare = array( '=', '!=', '>', '>=', '<', '<=', 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN' ); + if ( ! empty( $clause['compare'] ) && in_array( $clause['compare'], $allowed_compare, true ) ) { + $result['compare'] = $clause['compare']; + } + + $allowed_columns = array( 'post_date', 'post_date_gmt', 'post_modified', 'post_modified_gmt' ); + if ( ! empty( $clause['column'] ) && in_array( $clause['column'], $allowed_columns, true ) ) { + $result['column'] = $clause['column']; + } + + return ! empty( $result ) ? $result : null; + } + + /** + * Processes top-level date query fields. + * + * Handles the `column` field that applies as the default for all date clauses. + * + * @since 7.0.0 + * + * @param array $input The date query input. + * @param array $result The result array (passed by reference). + * @return void + */ + private static function process_date_top_level( array $input, array &$result ): void { + $allowed_columns = array( 'post_date', 'post_date_gmt', 'post_modified', 'post_modified_gmt' ); + if ( ! empty( $input['column'] ) && in_array( $input['column'], $allowed_columns, true ) ) { + $result['column'] = $input['column']; + } + } +} diff --git a/src/wp-includes/class-wp-post-type.php b/src/wp-includes/class-wp-post-type.php index b53a244d7de84..5911bf660cf23 100644 --- a/src/wp-includes/class-wp-post-type.php +++ b/src/wp-includes/class-wp-post-type.php @@ -371,6 +371,19 @@ final class WP_Post_Type { */ public $show_in_rest; + /** + * Whether to register abilities for this post type via the Abilities API. + * + * Can be a boolean or an array of ability names mapped to booleans. + * - If true, all supported abilities are registered (currently just 'get'). + * - If false (default), no abilities are registered. + * - If an array, selectively enable abilities: e.g. array( 'get' => true ). + * + * @since 7.0.0 + * @var bool|array $show_in_abilities + */ + public $show_in_abilities; + /** * The base path for this post type's REST API endpoints. * @@ -551,6 +564,7 @@ public function set_props( $args ) { 'can_export' => true, 'delete_with_user' => null, 'show_in_rest' => false, + 'show_in_abilities' => false, 'rest_base' => false, 'rest_namespace' => false, 'rest_controller_class' => false, diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index 70abcfb1134a8..006742fde9f0d 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -50,6 +50,7 @@ function create_initial_post_types() { 'post-formats', ), 'show_in_rest' => true, + 'show_in_abilities' => true, 'rest_base' => 'posts', 'rest_controller_class' => 'WP_REST_Posts_Controller', ) @@ -84,6 +85,7 @@ function create_initial_post_types() { 'revisions', ), 'show_in_rest' => true, + 'show_in_abilities' => true, 'rest_base' => 'pages', 'rest_controller_class' => 'WP_REST_Posts_Controller', ) From db4eb011f450caff47c288413cdb0761835d6920 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 2 Feb 2026 18:07:52 +0000 Subject: [PATCH 2/8] remove defaults to be valid schema --- .../abilities/class-wp-post-type-abilities.php | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-post-type-abilities.php b/src/wp-includes/abilities/class-wp-post-type-abilities.php index f7e9ac89cd3ee..7c9c3118d0441 100644 --- a/src/wp-includes/abilities/class-wp-post-type-abilities.php +++ b/src/wp-includes/abilities/class-wp-post-type-abilities.php @@ -122,7 +122,6 @@ private static function build_get_input_schema( WP_Post_Type $post_type_object ) 'taxonomies' => array( 'type' => 'boolean', 'description' => __( 'Whether to include taxonomy terms in the response.' ), - 'default' => false, ), ); @@ -130,7 +129,6 @@ private static function build_get_input_schema( WP_Post_Type $post_type_object ) $include_properties['meta'] = array( 'type' => 'boolean', 'description' => __( 'Whether to include post meta in the response.' ), - 'default' => false, ); } @@ -190,16 +188,14 @@ private static function build_get_input_schema( WP_Post_Type $post_type_object ) ), 'per_page' => array( 'type' => 'integer', - 'description' => __( 'Maximum number of posts to return.' ), + 'description' => __( 'Maximum number of posts to return. Defaults to 10.' ), 'minimum' => 1, 'maximum' => 100, - 'default' => 10, ), 'page' => array( 'type' => 'integer', - 'description' => __( 'Page number for paginated results.' ), + 'description' => __( 'Page number for paginated results. Defaults to 1.' ), 'minimum' => 1, - 'default' => 1, ), 'order' => array( 'type' => 'object', @@ -207,15 +203,13 @@ private static function build_get_input_schema( WP_Post_Type $post_type_object ) 'properties' => array( 'orderby' => array( 'type' => 'string', - 'description' => __( 'Field to order results by.' ), + 'description' => __( 'Field to order results by. Defaults to date.' ), 'enum' => array( 'date', 'title', 'modified', 'id', 'author', 'relevance' ), - 'default' => 'date', ), 'direction' => array( 'type' => 'string', - 'description' => __( 'Order direction.' ), + 'description' => __( 'Order direction. Defaults to desc.' ), 'enum' => array( 'asc', 'desc' ), - 'default' => 'desc', ), ), 'additionalProperties' => false, From 18bd07b82f6d94b44867306182d1fd42fe463c66 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 2 Feb 2026 18:18:19 +0000 Subject: [PATCH 3/8] fix output schemas --- .../class-wp-post-type-abilities.php | 44 ++++++++++++++++++- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-post-type-abilities.php b/src/wp-includes/abilities/class-wp-post-type-abilities.php index 7c9c3118d0441..1449127afdcbf 100644 --- a/src/wp-includes/abilities/class-wp-post-type-abilities.php +++ b/src/wp-includes/abilities/class-wp-post-type-abilities.php @@ -722,6 +722,46 @@ private static function build_post_schema( WP_Post_Type $post_type_object ): arr $required[] = 'ping_status'; } + // Optional fields included when requested via `include` input flags. + $term_schema = array( + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'type' => 'integer', + 'description' => __( 'The term ID.' ), + ), + 'name' => array( + 'type' => 'string', + 'description' => __( 'The term name.' ), + ), + 'slug' => array( + 'type' => 'string', + 'description' => __( 'The term slug.' ), + ), + ), + 'required' => array( 'id', 'name', 'slug' ), + 'additionalProperties' => false, + ); + + $properties['taxonomies'] = array( + 'type' => 'object', + 'description' => __( 'Taxonomy terms grouped by taxonomy name. Only present when include.taxonomies is true.' ), + 'additionalProperties' => array( + 'type' => 'array', + 'items' => $term_schema, + ), + ); + + if ( post_type_supports( $slug, 'custom-fields' ) ) { + $properties['meta'] = array( + 'type' => 'object', + 'description' => __( 'Public post meta key-value pairs. Only present when include.meta is true.' ), + 'additionalProperties' => array( + 'type' => array( 'string', 'array' ), + ), + ); + } + return array( 'type' => 'object', 'properties' => $properties, @@ -998,7 +1038,7 @@ static function ( $term ): array { } } - $data['taxonomies'] = $terms_data; + $data['taxonomies'] = ! empty( $terms_data ) ? $terms_data : new stdClass(); } if ( ! empty( $include['meta'] ) && post_type_supports( $slug, 'custom-fields' ) ) { @@ -1012,7 +1052,7 @@ static function ( $term ): array { $public_meta[ $key ] = count( $values ) === 1 ? $values[0] : $values; } - $data['meta'] = $public_meta; + $data['meta'] = ! empty( $public_meta ) ? $public_meta : new stdClass(); } return $data; From f87c7a056df75a6e1f7b50d61cc19e11225cd01f Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 3 Feb 2026 20:48:37 +0000 Subject: [PATCH 4/8] multiple enhacemtns: empty input, additional filters --- .../class-wp-post-type-abilities.php | 192 +++++++++++------- 1 file changed, 113 insertions(+), 79 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-post-type-abilities.php b/src/wp-includes/abilities/class-wp-post-type-abilities.php index 1449127afdcbf..921052c3556ce 100644 --- a/src/wp-includes/abilities/class-wp-post-type-abilities.php +++ b/src/wp-includes/abilities/class-wp-post-type-abilities.php @@ -116,6 +116,7 @@ private static function register_get_ability( WP_Post_Type $post_type_object ): * @return array The JSON schema for input. */ private static function build_get_input_schema( WP_Post_Type $post_type_object ): array { + $slug = $post_type_object->name; $statuses = array_values( get_post_stati( array( 'internal' => false ) ) ); $include_properties = array( @@ -125,7 +126,7 @@ private static function build_get_input_schema( WP_Post_Type $post_type_object ) ), ); - if ( post_type_supports( $post_type_object->name, 'custom-fields' ) ) { + if ( post_type_supports( $slug, 'custom-fields' ) ) { $include_properties['meta'] = array( 'type' => 'boolean', 'description' => __( 'Whether to include post meta in the response.' ), @@ -151,85 +152,104 @@ private static function build_get_input_schema( WP_Post_Type $post_type_object ) 'date' => self::build_date_query_schema(), ); - // Single post retrieval by ID. - $by_id_schema = array( - 'type' => 'object', - 'description' => __( 'Retrieve a single post by its ID.' ), - 'required' => array( 'id' ), - 'properties' => array( - 'id' => array( - 'type' => 'integer', - 'description' => __( 'Unique identifier for the post.' ), - 'minimum' => 1, - ), - 'include' => $include_schema, - ), - 'additionalProperties' => false, - ); + // Build orderby enum dynamically based on post type supports. + $orderby_values = array( 'date', 'title', 'modified', 'id', 'author', 'relevance' ); + if ( post_type_supports( $slug, 'page-attributes' ) ) { + $orderby_values[] = 'menu_order'; + } + if ( post_type_supports( $slug, 'comments' ) ) { + $orderby_values[] = 'comment_count'; + } - // Multi-post query with filters. - $query_schema = array( - 'type' => 'object', - 'description' => __( 'Query multiple posts with optional filters.' ), - 'properties' => array( - 'status' => array( - 'type' => 'string', - 'description' => __( 'Filter by post status.' ), - 'enum' => $statuses, - ), - 'search' => array( - 'type' => 'string', - 'description' => __( 'Search term to filter posts by.' ), - ), - 'author' => array( - 'type' => 'integer', - 'description' => __( 'Filter posts by author user ID.' ), - 'minimum' => 1, - ), - 'per_page' => array( - 'type' => 'integer', - 'description' => __( 'Maximum number of posts to return. Defaults to 10.' ), - 'minimum' => 1, - 'maximum' => 100, - ), - 'page' => array( - 'type' => 'integer', - 'description' => __( 'Page number for paginated results. Defaults to 1.' ), - 'minimum' => 1, - ), - 'order' => array( - 'type' => 'object', - 'description' => __( 'Ordering parameters.' ), - 'properties' => array( - 'orderby' => array( - 'type' => 'string', - 'description' => __( 'Field to order results by. Defaults to date.' ), - 'enum' => array( 'date', 'title', 'modified', 'id', 'author', 'relevance' ), - ), - 'direction' => array( - 'type' => 'string', - 'description' => __( 'Order direction. Defaults to desc.' ), - 'enum' => array( 'asc', 'desc' ), - ), + // All properties are optional. When `id` is present, single-post mode. + // When absent, query mode. Empty input returns latest published posts. + $properties = array( + 'id' => array( + 'type' => 'integer', + 'description' => __( 'Unique identifier for the post. When provided, retrieves a single post by ID.' ), + 'minimum' => 1, + ), + 'status' => array( + 'type' => 'string', + 'description' => __( 'Filter by post status.' ), + 'enum' => $statuses, + ), + 'search' => array( + 'type' => 'string', + 'description' => __( 'Search term to filter posts by.' ), + ), + 'author' => array( + 'type' => 'integer', + 'description' => __( 'Filter posts by author user ID.' ), + 'minimum' => 1, + ), + 'per_page' => array( + 'type' => 'integer', + 'description' => __( 'Maximum number of posts to return. Defaults to 10.' ), + 'minimum' => 1, + 'maximum' => 100, + ), + 'page' => array( + 'type' => 'integer', + 'description' => __( 'Page number for paginated results. Defaults to 1.' ), + 'minimum' => 1, + ), + 'order' => array( + 'type' => 'object', + 'description' => __( 'Ordering parameters.' ), + 'properties' => array( + 'orderby' => array( + 'type' => 'string', + 'description' => __( 'Field to order results by. Defaults to date.' ), + 'enum' => $orderby_values, + ), + 'direction' => array( + 'type' => 'string', + 'description' => __( 'Order direction. Defaults to desc.' ), + 'enum' => array( 'asc', 'desc' ), ), - 'additionalProperties' => false, - ), - 'query' => array( - 'type' => 'object', - 'description' => __( 'Advanced query filters for taxonomy terms, meta values, and dates.' ), - 'properties' => $query_properties, - 'additionalProperties' => false, ), - 'include' => $include_schema, + 'additionalProperties' => false, ), - 'additionalProperties' => false, + 'query' => array( + 'type' => 'object', + 'description' => __( 'Advanced query filters for taxonomy terms, meta values, and dates.' ), + 'properties' => $query_properties, + 'additionalProperties' => false, + ), + 'include' => $include_schema, ); + // Supports-dependent filter properties. + if ( post_type_supports( $slug, 'comments' ) ) { + $properties['comment_status'] = array( + 'type' => 'string', + 'description' => __( 'Filter by comment status.' ), + 'enum' => array( 'open', 'closed' ), + ); + } + + if ( post_type_supports( $slug, 'trackbacks' ) ) { + $properties['ping_status'] = array( + 'type' => 'string', + 'description' => __( 'Filter by ping status.' ), + 'enum' => array( 'open', 'closed' ), + ); + } + + if ( $post_type_object->hierarchical ) { + $properties['parent'] = array( + 'type' => 'integer', + 'description' => __( 'Filter by parent post ID. Use 0 for top-level posts.' ), + 'minimum' => 0, + ); + } + return array( - 'oneOf' => array( - $by_id_schema, - $query_schema, - ), + 'type' => 'object', + 'properties' => $properties, + 'additionalProperties' => false, + 'default' => array(), ); } @@ -873,15 +893,29 @@ private static function execute_get_query( WP_Post_Type $post_type_object, array $query_args['author'] = (int) $input['author']; } + if ( ! empty( $input['comment_status'] ) ) { + $query_args['comment_status'] = sanitize_key( $input['comment_status'] ); + } + + if ( ! empty( $input['ping_status'] ) ) { + $query_args['ping_status'] = sanitize_key( $input['ping_status'] ); + } + + if ( isset( $input['parent'] ) ) { + $query_args['post_parent'] = (int) $input['parent']; + } + if ( ! empty( $input['order'] ) ) { $order_input = $input['order']; $orderby_map = array( - 'date' => 'date', - 'title' => 'title', - 'modified' => 'modified', - 'id' => 'ID', - 'author' => 'author', - 'relevance' => 'relevance', + 'date' => 'date', + 'title' => 'title', + 'modified' => 'modified', + 'id' => 'ID', + 'author' => 'author', + 'relevance' => 'relevance', + 'menu_order' => 'menu_order', + 'comment_count' => 'comment_count', ); if ( ! empty( $order_input['orderby'] ) && isset( $orderby_map[ $order_input['orderby'] ] ) ) { From afe92827333ec512c96a0fbd50513ca70aa37171 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 3 Feb 2026 21:06:20 +0000 Subject: [PATCH 5/8] fix 404 return when id does not exist --- .../abilities/class-wp-post-type-abilities.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/abilities/class-wp-post-type-abilities.php b/src/wp-includes/abilities/class-wp-post-type-abilities.php index 921052c3556ce..db813ec3e2be3 100644 --- a/src/wp-includes/abilities/class-wp-post-type-abilities.php +++ b/src/wp-includes/abilities/class-wp-post-type-abilities.php @@ -831,8 +831,16 @@ private static function make_permission_get_callback( WP_Post_Type $post_type_ob $input = is_array( $input ) ? $input : array(); // For single post retrieval, check specific post permission. + // If the post doesn't exist, verify the user has general read + // capability before letting the execute callback return a 404. if ( ! empty( $input['id'] ) ) { - return current_user_can( 'read_post', (int) $input['id'] ); + $post = get_post( (int) $input['id'] ); + + if ( ! $post || $post->post_type !== $post_type_object->name ) { + return current_user_can( $post_type_object->cap->read_others_posts ?? 'read' ); + } + + return current_user_can( 'read_post', $post->ID ); } // For queries, check general read capability. From 09bdcdf5798e09f7a9a79999f167b4aed3bd47c8 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 3 Feb 2026 21:06:30 +0000 Subject: [PATCH 6/8] add unit tests --- .../abilities-api/wpPostTypeAbilitiesRest.php | 509 ++++++++++++++++++ 1 file changed, 509 insertions(+) create mode 100644 tests/phpunit/tests/abilities-api/wpPostTypeAbilitiesRest.php diff --git a/tests/phpunit/tests/abilities-api/wpPostTypeAbilitiesRest.php b/tests/phpunit/tests/abilities-api/wpPostTypeAbilitiesRest.php new file mode 100644 index 0000000000000..6ce20a1e2e2d8 --- /dev/null +++ b/tests/phpunit/tests/abilities-api/wpPostTypeAbilitiesRest.php @@ -0,0 +1,509 @@ +user->create( array( 'role' => 'editor' ) ); + + // Create tags. + foreach ( array( 't-23', 'c-1', 'b-1', 'other-tag' ) as $slug ) { + self::$tag_ids[ $slug ] = $factory->term->create( + array( + 'taxonomy' => 'post_tag', + 'slug' => $slug, + 'name' => $slug, + ) + ); + } + + // Post 1: has footnotes meta, a=23, b=1, tags t-23 & c-1, date 2025-11-26. + self::$post_ids[1] = $factory->post->create( + array( + 'post_title' => 'Post with footnotes', + 'post_status' => 'publish', + 'post_date' => '2025-11-26 10:00:00', + ) + ); + update_post_meta( self::$post_ids[1], 'footnotes', '[{}]' ); + update_post_meta( self::$post_ids[1], 'a', '23' ); + update_post_meta( self::$post_ids[1], 'b', '1' ); + wp_set_object_terms( self::$post_ids[1], array( 't-23', 'c-1' ), 'post_tag' ); + + // Post 2: a=23, c=1, tags t-23 & b-1, date 2025-11-15. + self::$post_ids[2] = $factory->post->create( + array( + 'post_title' => 'Post with meta a and c', + 'post_status' => 'publish', + 'post_date' => '2025-11-15 10:00:00', + ) + ); + update_post_meta( self::$post_ids[2], 'a', '23' ); + update_post_meta( self::$post_ids[2], 'c', '1' ); + wp_set_object_terms( self::$post_ids[2], array( 't-23', 'b-1' ), 'post_tag' ); + + // Post 3: b=1, tag b-1, date 2025-06-26. + self::$post_ids[3] = $factory->post->create( + array( + 'post_title' => 'Post with meta b only', + 'post_status' => 'publish', + 'post_date' => '2025-06-26 10:00:00', + ) + ); + update_post_meta( self::$post_ids[3], 'b', '1' ); + wp_set_object_terms( self::$post_ids[3], array( 'b-1' ), 'post_tag' ); + + // Post 4: x=99, tag other-tag, date 2024-03-10. + self::$post_ids[4] = $factory->post->create( + array( + 'post_title' => 'Post with unrelated meta', + 'post_status' => 'publish', + 'post_date' => '2024-03-10 10:00:00', + ) + ); + update_post_meta( self::$post_ids[4], 'x', '99' ); + wp_set_object_terms( self::$post_ids[4], array( 'other-tag' ), 'post_tag' ); + + // Post 5: no meta, no tags, date 2025-11-01. + self::$post_ids[5] = $factory->post->create( + array( + 'post_title' => 'Post for date nov', + 'post_status' => 'publish', + 'post_date' => '2025-11-01 10:00:00', + ) + ); + + // Post 6: no meta, no tags, date 2025-06-26. + self::$post_ids[6] = $factory->post->create( + array( + 'post_title' => 'Post for date day26', + 'post_status' => 'publish', + 'post_date' => '2025-06-26 10:00:00', + ) + ); + } + + /** + * Clean up after all tests. + */ + public static function wpTearDownAfterClass(): void { + add_action( 'wp_abilities_api_categories_init', '_unhook_core_ability_categories_registration', 1 ); + add_action( 'wp_abilities_api_init', '_unhook_core_abilities_registration', 1 ); + + foreach ( wp_get_abilities() as $ability ) { + wp_unregister_ability( $ability->get_name() ); + } + foreach ( wp_get_ability_categories() as $category ) { + wp_unregister_ability_category( $category->get_slug() ); + } + } + + /** + * Set up each test with an authenticated editor user. + */ + public function set_up(): void { + parent::set_up(); + wp_set_current_user( self::$editor_id ); + } + + /** + * Dispatches a GET request to the post type get ability endpoint. + * + * @param array $input Input parameters for the ability. + * @return WP_REST_Response The response. + */ + private function dispatch_get_ability( array $input = array() ): WP_REST_Response { + $request = new WP_REST_Request( 'GET', self::ROUTE ); + if ( ! empty( $input ) ) { + $request->set_query_params( array( 'input' => $input ) ); + } + return rest_get_server()->dispatch( $request ); + } + + /** + * Extracts post IDs from a query response's posts array. + * + * @param array $data Response data containing 'posts' key. + * @return int[] Array of post IDs. + */ + private function get_response_post_ids( array $data ): array { + return array_map( + static function ( $post ) { + return $post['id']; + }, + $data['posts'] + ); + } + + /** + * Tests that the ability run route is registered. + */ + public function test_route_is_registered(): void { + $routes = rest_get_server()->get_routes(); + // The route pattern covers all ability names including this one. + $this->assertArrayHasKey( + '/wp-abilities/v1/abilities/(?P[a-zA-Z0-9\\-\\/]+?)/run', + $routes + ); + } + + /** + * Tests that POST method is rejected for this readonly ability. + */ + public function test_post_method_rejected(): void { + $request = new WP_REST_Request( 'POST', self::ROUTE ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( array( 'input' => array() ) ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 405, $response->get_status() ); + } + + /** + * Tests retrieving a single post by ID. + */ + public function test_get_single_post_by_id(): void { + $response = $this->dispatch_get_ability( array( 'id' => self::$post_ids[1] ) ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertSame( self::$post_ids[1], $data['id'] ); + $this->assertSame( 'post', $data['type'] ); + $this->assertSame( 'publish', $data['status'] ); + $this->assertSame( 'Post with footnotes', $data['title'] ); + $this->assertArrayHasKey( 'slug', $data ); + $this->assertArrayHasKey( 'link', $data ); + $this->assertArrayHasKey( 'date', $data ); + $this->assertArrayHasKey( 'modified', $data ); + } + + /** + * Tests retrieving a single post with meta and taxonomies included. + */ + public function test_get_single_post_with_meta_and_taxonomies(): void { + $response = $this->dispatch_get_ability( + array( + 'id' => self::$post_ids[1], + 'include' => array( + 'meta' => true, + 'taxonomies' => true, + ), + ) + ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + + // Meta should contain the public meta keys. + $this->assertArrayHasKey( 'meta', $data ); + $this->assertArrayHasKey( 'footnotes', $data['meta'] ); + $this->assertArrayHasKey( 'a', $data['meta'] ); + $this->assertArrayHasKey( 'b', $data['meta'] ); + $this->assertSame( '23', $data['meta']['a'] ); + + // Taxonomies should contain post_tag terms. + $this->assertArrayHasKey( 'taxonomies', $data ); + $this->assertArrayHasKey( 'post_tag', $data['taxonomies'] ); + $tag_slugs = array_column( $data['taxonomies']['post_tag'], 'slug' ); + $this->assertContains( 't-23', $tag_slugs ); + $this->assertContains( 'c-1', $tag_slugs ); + } + + /** + * Tests that requesting a non-existent post returns 404. + */ + public function test_get_single_post_not_found(): void { + $response = $this->dispatch_get_ability( array( 'id' => 999999 ) ); + + $this->assertSame( 404, $response->get_status() ); + } + + /** + * Tests that query mode returns paginated results. + */ + public function test_query_returns_paginated_results(): void { + $response = $this->dispatch_get_ability( + array( + 'per_page' => 2, + 'page' => 1, + ) + ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'posts', $data ); + $this->assertCount( 2, $data['posts'] ); + $this->assertSame( 6, $data['total'] ); + $this->assertSame( 3, $data['total_pages'] ); + } + + /** + * Tests meta query with EXISTS operator finds only the post with footnotes. + */ + public function test_meta_query_exists(): void { + $response = $this->dispatch_get_ability( + array( + 'include' => array( 'meta' => true ), + 'query' => array( + 'meta' => array( + 'queries' => array( + array( + 'key' => 'footnotes', + 'compare' => 'EXISTS', + ), + ), + ), + ), + ) + ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $post_ids = $this->get_response_post_ids( $data ); + + $this->assertSame( 1, $data['total'] ); + $this->assertContains( self::$post_ids[1], $post_ids ); + } + + /** + * Tests nested meta query: a=23 AND (b=1 OR c=1). + * + * Should match posts 1 and 2. + */ + public function test_meta_query_nested_and_or(): void { + $response = $this->dispatch_get_ability( + array( + 'query' => array( + 'meta' => array( + 'relation' => 'AND', + 'queries' => array( + array( + 'key' => 'a', + 'compare' => '=', + 'value' => '23', + ), + array( + 'relation' => 'OR', + 'queries' => array( + array( + 'key' => 'b', + 'compare' => '=', + 'value' => '1', + ), + array( + 'key' => 'c', + 'compare' => '=', + 'value' => '1', + ), + ), + ), + ), + ), + ), + ) + ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $post_ids = $this->get_response_post_ids( $data ); + + $this->assertSame( 2, $data['total'] ); + $this->assertContains( self::$post_ids[1], $post_ids ); + $this->assertContains( self::$post_ids[2], $post_ids ); + } + + /** + * Tests nested tax query: tag t-23 AND (tag c-1 OR tag b-1). + * + * Should match posts 1 and 2. + */ + public function test_tax_query_nested_and_or(): void { + $response = $this->dispatch_get_ability( + array( + 'query' => array( + 'tax' => array( + 'relation' => 'AND', + 'queries' => array( + array( + 'taxonomy' => 'post_tag', + 'field' => 'slug', + 'terms' => array( 't-23' ), + ), + array( + 'relation' => 'OR', + 'queries' => array( + array( + 'taxonomy' => 'post_tag', + 'field' => 'slug', + 'terms' => array( 'c-1' ), + ), + array( + 'taxonomy' => 'post_tag', + 'field' => 'slug', + 'terms' => array( 'b-1' ), + ), + ), + ), + ), + ), + ), + ) + ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $post_ids = $this->get_response_post_ids( $data ); + + $this->assertSame( 2, $data['total'] ); + $this->assertContains( self::$post_ids[1], $post_ids ); + $this->assertContains( self::$post_ids[2], $post_ids ); + } + + /** + * Tests nested date query: year=2025 AND (day=26 OR month=11). + * + * Should match posts 1, 2, 3, 5, 6 (all 2025 posts that have day=26 or month=11). + * Post 4 excluded because it is from 2024. + */ + public function test_date_query_nested_and_or(): void { + $response = $this->dispatch_get_ability( + array( + 'per_page' => 100, + 'query' => array( + 'date' => array( + 'relation' => 'AND', + 'queries' => array( + array( 'year' => 2025 ), + array( + 'relation' => 'OR', + 'queries' => array( + array( 'day' => 26 ), + array( 'month' => 11 ), + ), + ), + ), + ), + ), + ) + ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $post_ids = $this->get_response_post_ids( $data ); + + $this->assertSame( 5, $data['total'] ); + $this->assertContains( self::$post_ids[1], $post_ids ); + $this->assertContains( self::$post_ids[2], $post_ids ); + $this->assertContains( self::$post_ids[3], $post_ids ); + $this->assertContains( self::$post_ids[5], $post_ids ); + $this->assertContains( self::$post_ids[6], $post_ids ); + $this->assertNotContains( self::$post_ids[4], $post_ids ); + } + + /** + * Tests that unauthenticated requests are rejected. + */ + public function test_unauthenticated_query_rejected(): void { + wp_set_current_user( 0 ); + + $response = $this->dispatch_get_ability( array() ); + + $this->assertContains( $response->get_status(), array( 401, 403 ) ); + } + + /** + * Tests that authenticated editor can query posts. + */ + public function test_authenticated_query_succeeds(): void { + $response = $this->dispatch_get_ability( array() ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'posts', $data ); + $this->assertGreaterThan( 0, $data['total'] ); + } + + /** + * Tests ordering by title ascending. + */ + public function test_query_with_ordering(): void { + $response = $this->dispatch_get_ability( + array( + 'order' => array( + 'orderby' => 'title', + 'direction' => 'asc', + ), + 'per_page' => 100, + ) + ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $titles = array_map( + static function ( $post ) { + return $post['title']; + }, + $data['posts'] + ); + + $sorted = $titles; + sort( $sorted, SORT_STRING ); + $this->assertSame( $sorted, $titles ); + } +} From ac54718618fd73b8d7fe88a2a00bf4ad46de912f Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 5 Feb 2026 16:13:22 +0000 Subject: [PATCH 7/8] lint fixes --- .../class-wp-post-type-abilities.php | 54 ++++++++++++++----- 1 file changed, 40 insertions(+), 14 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-post-type-abilities.php b/src/wp-includes/abilities/class-wp-post-type-abilities.php index db813ec3e2be3..a38eb00aa9ea7 100644 --- a/src/wp-includes/abilities/class-wp-post-type-abilities.php +++ b/src/wp-includes/abilities/class-wp-post-type-abilities.php @@ -1025,8 +1025,8 @@ private static function format_post( WP_Post $post, WP_Post_Type $post_type_obje } if ( post_type_supports( $slug, 'author' ) ) { - $author = get_userdata( (int) $post->post_author ); - $data['author'] = array( + $author = get_userdata( (int) $post->post_author ); + $data['author'] = array( 'id' => (int) $post->post_author, 'display_name' => $author ? $author->display_name : '', ); @@ -1042,8 +1042,8 @@ private static function format_post( WP_Post $post, WP_Post_Type $post_type_obje } if ( post_type_supports( $slug, 'post-formats' ) ) { - $format = get_post_format( $post ); - $data['format'] = $format ? $format : 'standard'; + $format = get_post_format( $post ); + $data['format'] = $format ? $format : 'standard'; } if ( post_type_supports( $slug, 'comments' ) ) { @@ -1218,19 +1218,38 @@ private static function process_meta_clause( array $clause ): ?array { } $allowed_compare = array( - '=', '!=', '>', '>=', '<', '<=', - 'LIKE', 'NOT LIKE', 'IN', 'NOT IN', - 'BETWEEN', 'NOT BETWEEN', - 'EXISTS', 'NOT EXISTS', - 'REGEXP', 'NOT REGEXP', 'RLIKE', + '=', + '!=', + '>', + '>=', + '<', + '<=', + 'LIKE', + 'NOT LIKE', + 'IN', + 'NOT IN', + 'BETWEEN', + 'NOT BETWEEN', + 'EXISTS', + 'NOT EXISTS', + 'REGEXP', + 'NOT REGEXP', + 'RLIKE', ); if ( ! empty( $clause['compare'] ) && in_array( $clause['compare'], $allowed_compare, true ) ) { $result['compare'] = $clause['compare']; } $allowed_types = array( - 'NUMERIC', 'CHAR', 'DATE', 'DATETIME', - 'TIME', 'BINARY', 'SIGNED', 'UNSIGNED', 'DECIMAL', + 'NUMERIC', + 'CHAR', + 'DATE', + 'DATETIME', + 'TIME', + 'BINARY', + 'SIGNED', + 'UNSIGNED', + 'DECIMAL', ); if ( ! empty( $clause['type'] ) && in_array( $clause['type'], $allowed_types, true ) ) { $result['type'] = $clause['type']; @@ -1251,9 +1270,16 @@ private static function process_date_clause( array $clause ): ?array { $result = array(); $int_fields = array( - 'year', 'month', 'week', 'day', - 'hour', 'minute', 'second', - 'dayofweek', 'dayofweek_iso', 'dayofyear', + 'year', + 'month', + 'week', + 'day', + 'hour', + 'minute', + 'second', + 'dayofweek', + 'dayofweek_iso', + 'dayofyear', ); foreach ( $int_fields as $field ) { From dceb2018bacd7a84a2892f451fa779054c40ac02 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 5 Feb 2026 16:21:09 +0000 Subject: [PATCH 8/8] add ticket annotation to tests --- .../abilities-api/wpPostTypeAbilitiesRest.php | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/phpunit/tests/abilities-api/wpPostTypeAbilitiesRest.php b/tests/phpunit/tests/abilities-api/wpPostTypeAbilitiesRest.php index 6ce20a1e2e2d8..d7dea0ed62046 100644 --- a/tests/phpunit/tests/abilities-api/wpPostTypeAbilitiesRest.php +++ b/tests/phpunit/tests/abilities-api/wpPostTypeAbilitiesRest.php @@ -5,6 +5,8 @@ /** * Tests for the post type get ability via the REST API. * + * @ticket 64606 + * * @covers WP_Post_Type_Abilities * * @group abilities-api @@ -184,6 +186,8 @@ static function ( $post ) { /** * Tests that the ability run route is registered. + * + * @ticket 64606 */ public function test_route_is_registered(): void { $routes = rest_get_server()->get_routes(); @@ -196,6 +200,8 @@ public function test_route_is_registered(): void { /** * Tests that POST method is rejected for this readonly ability. + * + * @ticket 64606 */ public function test_post_method_rejected(): void { $request = new WP_REST_Request( 'POST', self::ROUTE ); @@ -208,6 +214,8 @@ public function test_post_method_rejected(): void { /** * Tests retrieving a single post by ID. + * + * @ticket 64606 */ public function test_get_single_post_by_id(): void { $response = $this->dispatch_get_ability( array( 'id' => self::$post_ids[1] ) ); @@ -227,6 +235,8 @@ public function test_get_single_post_by_id(): void { /** * Tests retrieving a single post with meta and taxonomies included. + * + * @ticket 64606 */ public function test_get_single_post_with_meta_and_taxonomies(): void { $response = $this->dispatch_get_ability( @@ -260,6 +270,8 @@ public function test_get_single_post_with_meta_and_taxonomies(): void { /** * Tests that requesting a non-existent post returns 404. + * + * @ticket 64606 */ public function test_get_single_post_not_found(): void { $response = $this->dispatch_get_ability( array( 'id' => 999999 ) ); @@ -269,6 +281,8 @@ public function test_get_single_post_not_found(): void { /** * Tests that query mode returns paginated results. + * + * @ticket 64606 */ public function test_query_returns_paginated_results(): void { $response = $this->dispatch_get_ability( @@ -289,6 +303,8 @@ public function test_query_returns_paginated_results(): void { /** * Tests meta query with EXISTS operator finds only the post with footnotes. + * + * @ticket 64606 */ public function test_meta_query_exists(): void { $response = $this->dispatch_get_ability( @@ -320,6 +336,8 @@ public function test_meta_query_exists(): void { * Tests nested meta query: a=23 AND (b=1 OR c=1). * * Should match posts 1 and 2. + * + * @ticket 64606 */ public function test_meta_query_nested_and_or(): void { $response = $this->dispatch_get_ability( @@ -368,6 +386,8 @@ public function test_meta_query_nested_and_or(): void { * Tests nested tax query: tag t-23 AND (tag c-1 OR tag b-1). * * Should match posts 1 and 2. + * + * @ticket 64606 */ public function test_tax_query_nested_and_or(): void { $response = $this->dispatch_get_ability( @@ -417,6 +437,8 @@ public function test_tax_query_nested_and_or(): void { * * Should match posts 1, 2, 3, 5, 6 (all 2025 posts that have day=26 or month=11). * Post 4 excluded because it is from 2024. + * + * @ticket 64606 */ public function test_date_query_nested_and_or(): void { $response = $this->dispatch_get_ability( @@ -456,6 +478,8 @@ public function test_date_query_nested_and_or(): void { /** * Tests that unauthenticated requests are rejected. + * + * @ticket 64606 */ public function test_unauthenticated_query_rejected(): void { wp_set_current_user( 0 ); @@ -467,6 +491,8 @@ public function test_unauthenticated_query_rejected(): void { /** * Tests that authenticated editor can query posts. + * + * @ticket 64606 */ public function test_authenticated_query_succeeds(): void { $response = $this->dispatch_get_ability( array() ); @@ -480,6 +506,8 @@ public function test_authenticated_query_succeeds(): void { /** * Tests ordering by title ascending. + * + * @ticket 64606 */ public function test_query_with_ordering(): void { $response = $this->dispatch_get_ability(