From 01cff2fe754a06605884a6a80e8a5b72f065f222 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Tue, 14 Oct 2025 11:21:42 +0300 Subject: [PATCH 01/17] feat(abilities-api): Introduce WP_Abilities_Query class --- .../class-wp-abilities-query.php | 519 ++++++++++++++++++ includes/bootstrap.php | 3 + 2 files changed, 522 insertions(+) create mode 100644 includes/abilities-api/class-wp-abilities-query.php diff --git a/includes/abilities-api/class-wp-abilities-query.php b/includes/abilities-api/class-wp-abilities-query.php new file mode 100644 index 0000000..3b8e013 --- /dev/null +++ b/includes/abilities-api/class-wp-abilities-query.php @@ -0,0 +1,519 @@ + + */ + public const VALID_ORDERBY_FIELDS = array( 'name', 'label', 'category' ); + + /** + * Valid order directions. + * + * @since n.e.x.t + * @var array + */ + public const VALID_ORDER_DIRECTIONS = array( 'ASC', 'DESC' ); + + /** + * Constant representing no limit on results. + * + * @since n.e.x.t + * @var int + */ + public const NO_LIMIT = -1; + + /** + * Query arguments after parsing. + * + * @since n.e.x.t + * @var array + */ + protected array $query_vars = array(); + + /** + * The filtered abilities result. + * + * @since n.e.x.t + * @var \WP_Ability[]|null + */ + protected ?array $abilities = null; + + /** + * Constructor. + * + * @since n.e.x.t + * + * @param array $args Optional. Query arguments. Default empty array. + * + * @phpstan-param array{ + * category?: string|array, + * namespace?: string|array, + * search?: string, + * meta?: array, + * orderby?: string, + * order?: string, + * limit?: int, + * offset?: int, + * ... + * } $args + */ + public function __construct( array $args = array() ) { + $this->parse_query( $args ); + } + + /** + * Parses and validates query arguments. + * + * @since n.e.x.t + * + * @param array $args Query arguments. + * + */ + protected function parse_query( array $args ): void { + $defaults = array( + 'category' => '', + 'namespace' => '', + 'search' => '', + 'meta' => array(), + 'orderby' => '', + 'order' => 'ASC', + 'limit' => self::NO_LIMIT, + 'offset' => 0, + ); + + $this->query_vars = wp_parse_args( $args, $defaults ); + + $this->validate_meta_arg(); + $this->validate_orderby(); + $this->validate_order(); + $this->validate_pagination_args(); + } + + /** + * Validates the meta query argument. + * + * @since n.e.x.t + * + */ + protected function validate_meta_arg(): void { + if ( ! is_array( $this->query_vars['meta'] ) ) { + $this->query_vars['meta'] = array(); + } + } + + /** + * Validates the orderby query argument. + * + * @since n.e.x.t + * + */ + protected function validate_orderby(): void { + if ( empty( $this->query_vars['orderby'] ) ) { + return; + } + + if ( ! in_array( $this->query_vars['orderby'], self::VALID_ORDERBY_FIELDS, true ) ) { + $this->query_vars['orderby'] = ''; + } + } + + /** + * Validates the order query argument. + * + * @since n.e.x.t + * + */ + protected function validate_order(): void { + $this->query_vars['order'] = strtoupper( $this->query_vars['order'] ); + if ( ! in_array( $this->query_vars['order'], self::VALID_ORDER_DIRECTIONS, true ) ) { + $this->query_vars['order'] = 'ASC'; + } + } + + /** + * Validates the pagination query arguments (limit and offset). + * + * @since n.e.x.t + * + */ + protected function validate_pagination_args(): void { + $this->query_vars['limit'] = (int) $this->query_vars['limit']; + $this->query_vars['offset'] = (int) $this->query_vars['offset']; + } + + /** + * Retrieves the filtered abilities based on query arguments. + * + * @return \WP_Ability[] Array of filtered abilities. + * @since n.e.x.t + * + */ + public function get_abilities(): array { + if ( null !== $this->abilities ) { + return $this->abilities; + } + + $abilities = WP_Abilities_Registry::get_instance()->get_all_registered(); + + $abilities = $this->apply_filters( $abilities ); + + if ( empty( $abilities ) ) { + $this->abilities = array(); + + return $this->abilities; + } + + $abilities = $this->apply_ordering( $abilities ); + + $abilities = $this->apply_pagination( $abilities ); + + $this->abilities = $abilities; + + return $this->abilities; + } + + /** + * Applies all filters in a single pass for optimal performance. + * + * @param \WP_Ability[] $abilities Abilities to filter. + * + * @return \WP_Ability[] Filtered abilities. + * @since n.e.x.t + * + */ + protected function apply_filters( array $abilities ): array { + $has_category = ! empty( $this->query_vars['category'] ); + $has_namespace = ! empty( $this->query_vars['namespace'] ); + $has_search = ! empty( $this->query_vars['search'] ); + $has_meta = ! empty( $this->query_vars['meta'] ) && is_array( $this->query_vars['meta'] ); + + if ( ! $has_category && ! $has_namespace && ! $has_search && ! $has_meta ) { + return $abilities; + } + + $filtered = array(); + + foreach ( $abilities as $name => $ability ) { + if ( $has_category && ! $this->filter_by_category( $ability ) ) { + continue; + } + + if ( $has_namespace && ! $this->filter_by_namespace( $ability ) ) { + continue; + } + + if ( $has_meta && ! $this->filter_by_meta( $ability ) ) { + continue; + } + + if ( $has_search && ! $this->filter_by_search( $ability ) ) { + continue; + } + + $filtered[ $name ] = $ability; + } + + return $filtered; + } + + /** + * Checks if an ability matches the category filter. + * + * @param \WP_Ability $ability The ability to check. + * + * @return bool True if ability matches category filter, false otherwise. + * @since n.e.x.t + * + */ + protected function filter_by_category( WP_Ability $ability ): bool { + return $this->matches_filter( $ability->get_category(), $this->query_vars['category'] ); + } + + /** + * Checks if an ability matches the namespace filter. + * + * @param \WP_Ability $ability The ability to check. + * + * @return bool True if ability matches namespace filter, false otherwise. + * @since n.e.x.t + * + */ + protected function filter_by_namespace( WP_Ability $ability ): bool { + $ability_namespace = self::get_ability_namespace( $ability->get_name() ); + + if ( null === $ability_namespace ) { + return false; + } + + return $this->matches_filter( $ability_namespace, $this->query_vars['namespace'] ); + } + + /** + * Checks if an ability matches the meta filters. + * + * @param \WP_Ability $ability The ability to check. + * + * @return bool True if ability matches meta filters, false otherwise. + * @since n.e.x.t + * + */ + protected function filter_by_meta( WP_Ability $ability ): bool { + $filters = $this->query_vars['meta']; + + if ( empty( $filters ) ) { + return true; + } + + $ability_meta = $ability->get_meta(); + + [ $flat_filters, $nested_filters ] = $this->separate_meta_filters( $filters ); + + if ( ! $this->check_flat_meta_filters( $ability_meta, $flat_filters ) ) { + return false; + } + + if ( ! $this->check_nested_meta_filters( $ability_meta, $nested_filters ) ) { + return false; + } + + return true; + } + + /** + * Checks if an ability matches the search term. + * + * @param \WP_Ability $ability The ability to check. + * + * @return bool True if ability matches search term, false otherwise. + * @since n.e.x.t + * + */ + protected function filter_by_search( WP_Ability $ability ): bool { + $search = $this->query_vars['search']; + + return stripos( $ability->get_name(), $search ) !== false + || stripos( $ability->get_label(), $search ) !== false + || stripos( $ability->get_description(), $search ) !== false; + } + + /** + * Checks if a value matches the filter (either equals or in array). + * + * @param string $value The value to check. + * @param string|array $filter The filter to match against. + * + * @return bool True if value matches the filter, false otherwise. + * @since n.e.x.t + * + */ + protected function matches_filter( string $value, $filter ): bool { + if ( is_array( $filter ) ) { + return in_array( $value, $filter, true ); + } + + return $value === $filter; + } + + /** + * Extracts the namespace from an ability name. + * + * @param string $ability_name The ability name (e.g., 'namespace/ability-name'). + * + * @return string|null The namespace part, or null if no slash found. + * @since n.e.x.t + * + */ + protected static function get_ability_namespace( string $ability_name ): ?string { + $slash_pos = strpos( $ability_name, '/' ); + + if ( false === $slash_pos ) { + return null; + } + + return substr( $ability_name, 0, $slash_pos ); + } + + /** + * Separates meta filters into flat and nested arrays. + * + * @param array $filters The meta filters to separate. + * + * @return array{0: array, 1: array} Array containing flat filters and nested filters. + * @since n.e.x.t + * + */ + protected function separate_meta_filters( array $filters ): array { + $flat_filters = array(); + $nested_filters = array(); + + foreach ( $filters as $key => $value ) { + if ( is_array( $value ) ) { + $nested_filters[ $key ] = $value; + } else { + $flat_filters[ $key ] = $value; + } + } + + return array( $flat_filters, $nested_filters ); + } + + /** + * Checks if ability meta matches flat filters. + * + * @param array $ability_meta The ability's meta data. + * @param array $flat_filters The flat filters to match. + * + * @return bool True if meta matches all flat filters, false otherwise. + * @since n.e.x.t + * + */ + protected function check_flat_meta_filters( array $ability_meta, array $flat_filters ): bool { + if ( empty( $flat_filters ) ) { + return true; + } + + $flat_filtered = wp_list_filter( array( $ability_meta ), $flat_filters ); + + return ! empty( $flat_filtered ); + } + + /** + * Checks if ability meta matches nested filters. + * + * @param array $ability_meta The ability's meta data. + * @param array $nested_filters The nested filters to match. + * + * @return bool True if meta matches all nested filters, false otherwise. + * @since n.e.x.t + * + */ + protected function check_nested_meta_filters( array $ability_meta, array $nested_filters ): bool { + if ( empty( $nested_filters ) ) { + return true; + } + + foreach ( $nested_filters as $key => $nested_filter ) { + if ( ! isset( $ability_meta[ $key ] ) || ! is_array( $ability_meta[ $key ] ) ) { + return false; + } + + $nested_filtered = wp_list_filter( array( $ability_meta[ $key ] ), $nested_filter ); + if ( empty( $nested_filtered ) ) { + return false; + } + } + + return true; + } + + /** + * Applies ordering to abilities. + * + * @param \WP_Ability[] $abilities Abilities to order. + * + * @return \WP_Ability[] Ordered abilities. + * @since n.e.x.t + * + */ + protected function apply_ordering( array $abilities ): array { + $orderby = $this->query_vars['orderby']; + + if ( empty( $orderby ) ) { + return $abilities; + } + + $order = $this->query_vars['order']; + + // Map orderby field to getter method name. + $getter_map = array( + 'name' => 'get_name', + 'label' => 'get_label', + 'category' => 'get_category', + ); + + if ( ! isset( $getter_map[ $orderby ] ) ) { + return $abilities; + } + + $getter_method = $getter_map[ $orderby ]; + $order_multiplier = 'DESC' === $order ? - 1 : 1; + + $abilities = array_values( $abilities ); + + usort( + $abilities, + static function ( $a, $b ) use ( $getter_method, $order_multiplier ) { + return strcasecmp( $a->$getter_method(), $b->$getter_method() ) * $order_multiplier; + } + ); + + return $abilities; + } + + /** + * Applies pagination to abilities. + * + * @param \WP_Ability[] $abilities Abilities to paginate. + * + * @return \WP_Ability[] Paginated abilities. + * @since n.e.x.t + * + */ + protected function apply_pagination( array $abilities ): array { + $limit = $this->query_vars['limit']; + $offset = $this->query_vars['offset']; + + // No pagination if limit is -1. + if ( self::NO_LIMIT === $limit ) { + // Apply offset only if specified. + if ( $offset > 0 ) { + return array_slice( $abilities, $offset ); + } + + return $abilities; + } + + // Apply offset and limit. + return array_slice( $abilities, $offset, $limit ); + } + + /** + * Gets the query variables. + * + * @param string $key Optional. Specific query var to retrieve. Default empty string. + * + * @return mixed Query var value if key provided, all query vars if no key. + * @since n.e.x.t + * + */ + public function get( string $key = '' ) { + if ( ! empty( $key ) ) { + return $this->query_vars[ $key ] ?? null; + } + + return $this->query_vars; + } +} diff --git a/includes/bootstrap.php b/includes/bootstrap.php index 4838495..7eb4175 100644 --- a/includes/bootstrap.php +++ b/includes/bootstrap.php @@ -35,6 +35,9 @@ if ( ! class_exists( 'WP_Abilities_Category_Registry' ) ) { require_once __DIR__ . '/abilities-api/class-wp-abilities-category-registry.php'; } +if ( ! class_exists( 'WP_Abilities_Query' ) ) { + require_once __DIR__ . '/abilities-api/class-wp-abilities-query.php'; +} // Ensure procedural functions are available, too. if ( ! function_exists( 'wp_register_ability' ) ) { From 46d4711da5e2754b3e3f82e4670ff91a0b2d80a9 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Tue, 14 Oct 2025 11:21:42 +0300 Subject: [PATCH 02/17] feat(abilities-api): Add filtering capabilities to wp_get_abilities --- includes/abilities-api.php | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/includes/abilities-api.php b/includes/abilities-api.php index 6e8c283..665022e 100644 --- a/includes/abilities-api.php +++ b/includes/abilities-api.php @@ -95,13 +95,38 @@ function wp_get_ability( string $name ): ?WP_Ability { * Retrieves all registered abilities using Abilities API. * * @since 0.1.0 + * @since n.e.x.t Added optional $args parameter for filtering abilities. * * @see WP_Abilities_Registry::get_all_registered() + * @see WP_Abilities_Query * + * @param array $args Optional. Arguments to filter abilities. Default empty array. + * Accepts 'category', 'namespace', 'search', 'meta', 'orderby', + * 'order', 'limit', and 'offset'. + * 'category' and 'namespace' accept string or array for multi-value filtering. + * All filters use AND logic between different filter types and meta properties. * @return \WP_Ability[] The array of registered abilities. + * + * @phpstan-param array{ + * category?: string|string[], + * namespace?: string|string[], + * search?: string, + * meta?: array, + * orderby?: string, + * order?: string, + * limit?: int, + * offset?: int, + * ... + * } $args */ -function wp_get_abilities(): array { - return WP_Abilities_Registry::get_instance()->get_all_registered(); +function wp_get_abilities( array $args = array() ): array { + if ( empty( $args ) ) { + // Backward compatibility: return all abilities if no args. + return WP_Abilities_Registry::get_instance()->get_all_registered(); + } + + $query = new WP_Abilities_Query( $args ); + return $query->get_abilities(); } /** From 3ebeb374ba8a3496db9da7f58f36127303904ffa Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Tue, 14 Oct 2025 11:21:42 +0300 Subject: [PATCH 03/17] test(abilities-api): Add unit tests for WP_Abilities_Query --- tests/unit/abilities-api/wpAbilitiesQuery.php | 834 ++++++++++++++++++ 1 file changed, 834 insertions(+) create mode 100644 tests/unit/abilities-api/wpAbilitiesQuery.php diff --git a/tests/unit/abilities-api/wpAbilitiesQuery.php b/tests/unit/abilities-api/wpAbilitiesQuery.php new file mode 100644 index 0000000..c473545 --- /dev/null +++ b/tests/unit/abilities-api/wpAbilitiesQuery.php @@ -0,0 +1,834 @@ +registry = WP_Abilities_Registry::get_instance(); + + // Register test categories. + add_action( + 'abilities_api_categories_init', + static function () { + $categories = array( 'math', 'data-retrieval', 'communication', 'ecommerce' ); + foreach ( $categories as $category_slug ) { + $registry = WP_Abilities_Category_Registry::get_instance(); + if ( $registry->is_registered( $category_slug ) ) { + continue; + } + + wp_register_ability_category( + $category_slug, + array( + 'label' => ucfirst( $category_slug ), + 'description' => ucfirst( $category_slug ) . ' category.', + ) + ); + } + } + ); + + // Fire the hook to allow category registration. + do_action( 'abilities_api_categories_init' ); + + // Register test abilities with diverse properties. + $this->register_test_abilities(); + } + + /** + * Tear down each test method. + */ + public function tear_down(): void { + // Clean up registered abilities. + $abilities = $this->registry->get_all_registered(); + foreach ( $abilities as $ability ) { + $this->registry->unregister( $ability->get_name() ); + } + + // Clean up registered categories. + $category_registry = WP_Abilities_Category_Registry::get_instance(); + $categories = array( 'math', 'data-retrieval', 'communication', 'ecommerce' ); + foreach ( $categories as $category_slug ) { + if ( ! $category_registry->is_registered( $category_slug ) ) { + continue; + } + + wp_unregister_ability_category( $category_slug ); + } + + $this->registry = null; + + parent::tear_down(); + } + + /** + * Registers test abilities with various properties for filtering tests. + */ + private function register_test_abilities(): void { + // Math abilities - test namespace. + $this->registry->register( + 'test/add-numbers', + array( + 'label' => 'Add Numbers', + 'description' => 'Adds two numbers together.', + 'category' => 'math', + 'execute_callback' => static function ( array $input ): int { + return $input['a'] + $input['b']; + }, + 'permission_callback' => '__return_true', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'a' => array( 'type' => 'number' ), + 'b' => array( 'type' => 'number' ), + ), + ), + 'output_schema' => array( 'type' => 'number' ), + 'meta' => array( + 'show_in_rest' => true, + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + ), + ) + ); + + $this->registry->register( + 'test/multiply-numbers', + array( + 'label' => 'Multiply Numbers', + 'description' => 'Multiplies two numbers together.', + 'category' => 'math', + 'execute_callback' => static function ( array $input ): int { + return $input['a'] * $input['b']; + }, + 'permission_callback' => '__return_true', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'a' => array( 'type' => 'number' ), + 'b' => array( 'type' => 'number' ), + ), + ), + 'output_schema' => array( 'type' => 'number' ), + 'meta' => array( + 'show_in_rest' => false, + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + ), + ) + ); + + // Data retrieval abilities - example namespace. + $this->registry->register( + 'example/get-user-data', + array( + 'label' => 'Get User Data', + 'description' => 'Retrieves user data from the database.', + 'category' => 'data-retrieval', + 'execute_callback' => static function () { + return array( 'user' => 'John Doe' ); + }, + 'permission_callback' => '__return_true', + 'input_schema' => array( 'type' => 'object' ), + 'output_schema' => array( 'type' => 'object' ), + 'meta' => array( + 'show_in_rest' => true, + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + 'custom_key' => 'custom_value', + ), + ) + ); + + // Communication abilities - demo namespace. + $this->registry->register( + 'demo/send-email', + array( + 'label' => 'Send Email', + 'description' => 'Sends an email to a recipient.', + 'category' => 'communication', + 'execute_callback' => static function (): bool { + return true; + }, + 'permission_callback' => '__return_true', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'to' => array( 'type' => 'string' ), + 'subject' => array( 'type' => 'string' ), + 'message' => array( 'type' => 'string' ), + ), + ), + 'output_schema' => array( 'type' => 'boolean' ), + 'meta' => array( + 'show_in_rest' => true, + 'annotations' => array( + 'readonly' => false, + 'destructive' => false, + 'idempotent' => false, + ), + ), + ) + ); + + // E-commerce abilities. + $this->registry->register( + 'example/process-payment', + array( + 'label' => 'Process Payment', + 'description' => 'Processes a payment transaction.', + 'category' => 'ecommerce', + 'execute_callback' => static function () { + return array( 'success' => true ); + }, + 'permission_callback' => '__return_true', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'amount' => array( 'type' => 'number' ), + ), + ), + 'output_schema' => array( 'type' => 'object' ), + 'meta' => array( + 'show_in_rest' => false, + 'annotations' => array( + 'readonly' => false, + 'destructive' => true, + 'idempotent' => false, + ), + ), + ) + ); + } + + /** + * Test basic query instantiation. + * + * @covers WP_Abilities_Query::__construct + */ + public function test_query_instantiation() { + $query = new WP_Abilities_Query(); + $this->assertInstanceOf( WP_Abilities_Query::class, $query ); + } + + /** + * Test query with no arguments returns all abilities. + * + * @covers WP_Abilities_Query::get_abilities + */ + public function test_query_no_args_returns_all() { + $query = new WP_Abilities_Query(); + $abilities = $query->get_abilities(); + + $this->assertCount( 5, $abilities ); + } + + /** + * Test wp_get_abilities() without arguments (backward compatibility). + * + * @covers wp_get_abilities + */ + public function test_wp_get_abilities_without_args() { + $abilities = wp_get_abilities(); + + $this->assertCount( 5, $abilities ); + $this->assertArrayHasKey( 'test/add-numbers', $abilities ); + } + + /** + * Test wp_get_abilities() with arguments uses query. + * + * @covers wp_get_abilities + */ + public function test_wp_get_abilities_with_args() { + $abilities = wp_get_abilities( array( 'category' => 'math' ) ); + + $this->assertCount( 2, $abilities ); + } + + /** + * Test filter by category. + * + * @covers WP_Abilities_Query::apply_filters + */ + public function test_filter_by_category() { + $query = new WP_Abilities_Query( array( 'category' => 'math' ) ); + $abilities = $query->get_abilities(); + + $this->assertCount( 2, $abilities ); + foreach ( $abilities as $ability ) { + $this->assertSame( 'math', $ability->get_category() ); + } + } + + /** + * Test filter by multiple categories. + * + * @covers WP_Abilities_Query::apply_filters + */ + public function test_filter_by_multiple_categories() { + $query = new WP_Abilities_Query( array( 'category' => array( 'math', 'communication' ) ) ); + $abilities = $query->get_abilities(); + + $this->assertCount( 3, $abilities ); + foreach ( $abilities as $ability ) { + $this->assertContains( $ability->get_category(), array( 'math', 'communication' ) ); + } + } + + /** + * Test filter by namespace. + * + * @covers WP_Abilities_Query::apply_filters + */ + public function test_filter_by_namespace() { + $query = new WP_Abilities_Query( array( 'namespace' => 'test' ) ); + $abilities = $query->get_abilities(); + + $this->assertCount( 2, $abilities ); + foreach ( $abilities as $ability ) { + $this->assertStringStartsWith( 'test/', $ability->get_name() ); + } + } + + /** + * Test filter by multiple namespaces. + * + * @covers WP_Abilities_Query::apply_filters + */ + public function test_filter_by_multiple_namespaces() { + $query = new WP_Abilities_Query( array( 'namespace' => array( 'test', 'example' ) ) ); + $abilities = $query->get_abilities(); + + $this->assertCount( 4, $abilities ); + foreach ( $abilities as $ability ) { + $name = $ability->get_name(); + $namespace = substr( $name, 0, strpos( $name, '/' ) ); + $this->assertContains( $namespace, array( 'test', 'example' ) ); + } + } + + /** + * Test filter by search term in name. + * + * @covers WP_Abilities_Query::apply_filters + */ + public function test_filter_by_search_in_name() { + $query = new WP_Abilities_Query( array( 'search' => 'email' ) ); + $abilities = $query->get_abilities(); + + $this->assertCount( 1, $abilities ); + $ability = reset( $abilities ); + $this->assertSame( 'demo/send-email', $ability->get_name() ); + } + + /** + * Test filter by search term in label. + * + * @covers WP_Abilities_Query::apply_filters + */ + public function test_filter_by_search_in_label() { + $query = new WP_Abilities_Query( array( 'search' => 'multiply' ) ); + $abilities = $query->get_abilities(); + + $this->assertCount( 1, $abilities ); + $ability = reset( $abilities ); + $this->assertSame( 'test/multiply-numbers', $ability->get_name() ); + } + + /** + * Test filter by search term in description. + * + * @covers WP_Abilities_Query::apply_filters + */ + public function test_filter_by_search_in_description() { + $query = new WP_Abilities_Query( array( 'search' => 'payment' ) ); + $abilities = $query->get_abilities(); + + $this->assertCount( 1, $abilities ); + $ability = reset( $abilities ); + $this->assertSame( 'example/process-payment', $ability->get_name() ); + } + + /** + * Test filter by show_in_rest using structured array. + * + * @covers WP_Abilities_Query::apply_filters + * @covers WP_Abilities_Query::matches_meta_filters + */ + public function test_filter_by_show_in_rest_structured() { + $query = new WP_Abilities_Query( + array( + 'meta' => array( + 'show_in_rest' => true, + ), + ) + ); + + $abilities = $query->get_abilities(); + + $this->assertCount( 3, $abilities ); + foreach ( $abilities as $ability ) { + $this->assertTrue( $ability->get_meta_item( 'show_in_rest' ) ); + } + } + + + /** + * Test filter by readonly annotation using structured array. + * + * @covers WP_Abilities_Query::apply_filters + * @covers WP_Abilities_Query::matches_meta_filters + */ + public function test_filter_by_readonly_structured() { + $query = new WP_Abilities_Query( + array( + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + ), + ), + ) + ); + + $abilities = $query->get_abilities(); + + $this->assertCount( 3, $abilities ); + foreach ( $abilities as $ability ) { + $meta = $ability->get_meta(); + $this->assertTrue( $meta['annotations']['readonly'] ); + } + } + + + /** + * Test filter by destructive annotation. + * + * @covers WP_Abilities_Query::apply_filters + * @covers WP_Abilities_Query::matches_meta_filters + */ + public function test_filter_by_destructive() { + $query = new WP_Abilities_Query( + array( + 'meta' => array( + 'annotations' => array( + 'destructive' => true, + ), + ), + ) + ); + + $abilities = $query->get_abilities(); + + $this->assertCount( 1, $abilities ); + $ability = reset( $abilities ); + $this->assertSame( 'example/process-payment', $ability->get_name() ); + } + + /** + * Test filter by idempotent annotation. + * + * @covers WP_Abilities_Query::apply_filters + * @covers WP_Abilities_Query::matches_meta_filters + */ + public function test_filter_by_idempotent() { + $query = new WP_Abilities_Query( + array( + 'meta' => array( + 'annotations' => array( + 'idempotent' => true, + ), + ), + ) + ); + + $abilities = $query->get_abilities(); + + $this->assertCount( 3, $abilities ); + } + + /** + * Test filter by custom meta key. + * + * @covers WP_Abilities_Query::apply_filters + * @covers WP_Abilities_Query::matches_meta_filters + */ + public function test_filter_by_custom_meta() { + $query = new WP_Abilities_Query( + array( + 'meta' => array( + 'custom_key' => 'custom_value', + ), + ) + ); + + $abilities = $query->get_abilities(); + + $this->assertCount( 1, $abilities ); + $ability = reset( $abilities ); + $this->assertSame( 'example/get-user-data', $ability->get_name() ); + } + + /** + * Test meta filters with AND logic (all conditions must match). + * + * @covers WP_Abilities_Query::apply_filters + * @covers WP_Abilities_Query::matches_meta + */ + public function test_meta_filters_and_logic() { + $query = new WP_Abilities_Query( + array( + 'meta' => array( + 'show_in_rest' => true, + 'annotations' => array( + 'readonly' => true, + ), + ), + ) + ); + + $abilities = $query->get_abilities(); + + $this->assertCount( 2, $abilities ); + foreach ( $abilities as $ability ) { + $this->assertTrue( $ability->get_meta_item( 'show_in_rest' ) ); + $meta = $ability->get_meta(); + $this->assertTrue( $meta['annotations']['readonly'] ); + } + } + + /** + * Test multiple filters combined. + * + * @covers WP_Abilities_Query::get_abilities + */ + public function test_multiple_filters_combined() { + $query = new WP_Abilities_Query( + array( + 'category' => 'math', + 'meta' => array( + 'show_in_rest' => true, + 'annotations' => array( + 'readonly' => true, + ), + ), + ) + ); + + $abilities = $query->get_abilities(); + + $this->assertCount( 1, $abilities ); + $ability = reset( $abilities ); + $this->assertSame( 'test/add-numbers', $ability->get_name() ); + } + + /** + * Test order by name ascending (default). + * + * @covers WP_Abilities_Query::apply_ordering + */ + public function test_order_by_name_asc() { + $query = new WP_Abilities_Query( array( 'orderby' => 'name' ) ); + $abilities = $query->get_abilities(); + + $names = array_map( + static function ( $ability ) { + return $ability->get_name(); + }, + $abilities + ); + + $expected = array( + 'demo/send-email', + 'example/get-user-data', + 'example/process-payment', + 'test/add-numbers', + 'test/multiply-numbers', + ); + + $this->assertSame( $expected, $names ); + } + + /** + * Test order by name descending. + * + * @covers WP_Abilities_Query::apply_ordering + */ + public function test_order_by_name_desc() { + $query = new WP_Abilities_Query( + array( + 'orderby' => 'name', + 'order' => 'DESC', + ) + ); + + $abilities = $query->get_abilities(); + + $names = array_map( + static function ( $ability ) { + return $ability->get_name(); + }, + $abilities + ); + + $expected = array( + 'test/multiply-numbers', + 'test/add-numbers', + 'example/process-payment', + 'example/get-user-data', + 'demo/send-email', + ); + + $this->assertSame( $expected, $names ); + } + + /** + * Test order by label. + * + * @covers WP_Abilities_Query::apply_ordering + */ + public function test_order_by_label() { + $query = new WP_Abilities_Query( + array( + 'orderby' => 'label', + 'order' => 'ASC', + ) + ); + + $abilities = $query->get_abilities(); + + $labels = array_map( + static function ( $ability ) { + return $ability->get_label(); + }, + $abilities + ); + + $expected = array( + 'Add Numbers', + 'Get User Data', + 'Multiply Numbers', + 'Process Payment', + 'Send Email', + ); + + $this->assertSame( $expected, $labels ); + } + + /** + * Test order by category. + * + * @covers WP_Abilities_Query::apply_ordering + */ + public function test_order_by_category() { + $query = new WP_Abilities_Query( + array( + 'orderby' => 'category', + 'order' => 'ASC', + ) + ); + + $abilities = $query->get_abilities(); + + $categories = array_map( + static function ( $ability ) { + return $ability->get_category(); + }, + $abilities + ); + + $expected = array( + 'communication', + 'data-retrieval', + 'ecommerce', + 'math', + 'math', + ); + + $this->assertSame( $expected, $categories ); + } + + /** + * Test pagination with limit. + * + * @covers WP_Abilities_Query::apply_pagination + */ + public function test_pagination_limit() { + $query = new WP_Abilities_Query( array( 'limit' => 2 ) ); + $abilities = $query->get_abilities(); + + $this->assertCount( 2, $abilities ); + } + + /** + * Test pagination with offset. + * + * @covers WP_Abilities_Query::apply_pagination + */ + public function test_pagination_offset() { + $query = new WP_Abilities_Query( + array( + 'orderby' => 'name', + 'offset' => 2, + ) + ); + + $abilities = $query->get_abilities(); + + $this->assertCount( 3, $abilities ); + $first = reset( $abilities ); + $this->assertSame( 'example/process-payment', $first->get_name() ); + } + + /** + * Test pagination with both limit and offset. + * + * @covers WP_Abilities_Query::apply_pagination + */ + public function test_pagination_limit_and_offset() { + $query = new WP_Abilities_Query( + array( + 'orderby' => 'name', + 'limit' => 2, + 'offset' => 1, + ) + ); + + $abilities = $query->get_abilities(); + + $this->assertCount( 2, $abilities ); + + $names = array_map( + static function ( $ability ) { + return $ability->get_name(); + }, + $abilities + ); + + $expected = array( + 'example/get-user-data', + 'example/process-payment', + ); + + $this->assertSame( $expected, $names ); + } + + /** + * Test query returns empty array when no matches. + * + * @covers WP_Abilities_Query::get_abilities + */ + public function test_no_matches_returns_empty_array() { + $query = new WP_Abilities_Query( array( 'category' => 'nonexistent' ) ); + $abilities = $query->get_abilities(); + + $this->assertIsArray( $abilities ); + $this->assertEmpty( $abilities ); + } + + /** + * Test query::get() method returns all query vars. + * + * @covers WP_Abilities_Query::get + */ + public function test_get_query_vars() { + $query = new WP_Abilities_Query( array( 'category' => 'math' ) ); + $query_vars = $query->get(); + + $this->assertIsArray( $query_vars ); + $this->assertSame( 'math', $query_vars['category'] ); + } + + /** + * Test query::get() method with specific key. + * + * @covers WP_Abilities_Query::get + */ + public function test_get_specific_query_var() { + $query = new WP_Abilities_Query( array( 'category' => 'math' ) ); + $category = $query->get( 'category' ); + + $this->assertSame( 'math', $category ); + } + + /** + * Test invalid orderby defaults to empty string (no ordering). + * + * @covers WP_Abilities_Query::parse_query + */ + public function test_invalid_orderby_defaults_to_empty() { + $query = new WP_Abilities_Query( array( 'orderby' => 'invalid' ) ); + $orderby = $query->get( 'orderby' ); + + $this->assertSame( '', $orderby ); + } + + /** + * Test invalid order defaults to 'ASC'. + * + * @covers WP_Abilities_Query::parse_query + */ + public function test_invalid_order_defaults_to_asc() { + $query = new WP_Abilities_Query( array( 'order' => 'invalid' ) ); + $order = $query->get( 'order' ); + + $this->assertSame( 'ASC', $order ); + } + + + /** + * Test query results are cached. + * + * @covers WP_Abilities_Query::get_abilities + */ + public function test_query_results_are_cached() { + $query = new WP_Abilities_Query( array( 'category' => 'math' ) ); + + $first_call = $query->get_abilities(); + $second_call = $query->get_abilities(); + + // Should return same instance (cached). + $this->assertSame( $first_call, $second_call ); + } + + /** + * Test case-insensitive search. + * + * @covers WP_Abilities_Query::apply_filters + */ + public function test_case_insensitive_search() { + $query = new WP_Abilities_Query( array( 'search' => 'EMAIL' ) ); + $abilities = $query->get_abilities(); + + $this->assertCount( 1, $abilities ); + $ability = reset( $abilities ); + $this->assertSame( 'demo/send-email', $ability->get_name() ); + } +} From 45d391788c6fe7b1045fc1bd04967d7fc7770d60 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Tue, 14 Oct 2025 11:21:42 +0300 Subject: [PATCH 04/17] docs(abilities-api): Add documentation for querying and filtering abilities --- docs/4.using-abilities.md | 27 +++- docs/8.querying-filtering-abilities.md | 213 +++++++++++++++++++++++++ 2 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 docs/8.querying-filtering-abilities.md diff --git a/docs/4.using-abilities.md b/docs/4.using-abilities.md index 0a51d63..d88bf64 100644 --- a/docs/4.using-abilities.md +++ b/docs/4.using-abilities.md @@ -41,9 +41,10 @@ To get an array of all registered abilities: /** * Retrieves all registered abilities using Abilities API. * + * @param array $args Optional. Arguments to filter abilities. * @return WP_Ability[] The array of registered abilities. */ -function wp_get_abilities(): array +function wp_get_abilities( array $args = array() ): array // Example: Get all registered abilities $all_abilities = wp_get_abilities(); @@ -56,6 +57,30 @@ foreach ( $all_abilities as $name => $ability ) { } ``` +### Filtering Abilities + +`wp_get_abilities()` accepts optional filter arguments. For detailed filtering documentation, see [Querying and Filtering Abilities](8.querying-filtering-abilities.md). + +Quick example: + +```php +// Get abilities in a specific category +$abilities = wp_get_abilities( array( 'category' => 'data-retrieval' ) ); + +// Search for abilities +$abilities = wp_get_abilities( array( 'search' => 'email' ) ); + +// Filter by namespace and meta +$abilities = wp_get_abilities( array( + 'namespace' => 'my-plugin', + 'meta' => array( + 'show_in_rest' => true, + ), +) ); +``` + +See [Querying and Filtering Abilities](8.querying-filtering-abilities.md) for complete filtering documentation, including meta filters, ordering, and pagination. + ## Executing an Ability (`$ability->execute()`) Once you have a `WP_Ability` object (usually from `wp_get_ability`), you execute it using the `execute()` method. diff --git a/docs/8.querying-filtering-abilities.md b/docs/8.querying-filtering-abilities.md new file mode 100644 index 0000000..272ba53 --- /dev/null +++ b/docs/8.querying-filtering-abilities.md @@ -0,0 +1,213 @@ +# 8. Querying and Filtering Abilities + +The Abilities API provides powerful filtering capabilities through the `WP_Abilities_Query` class and the `wp_get_abilities()` function. This allows you to efficiently find and retrieve specific abilities from potentially thousands of registered abilities. + +`wp_get_abilities()` returns an array of `WP_Ability` objects, keyed by ability name. + +When no abilities match the query, an empty array is returned. + +Invalid query parameters are normalized to safe defaults. + +## Overview + +When working with large numbers of abilities registered by core and various plugins, the query system provides optimized server-side filtering. You can pass query arguments to `wp_get_abilities()` to filter abilities by category, namespace, meta properties, and more. + +```php +// Get all abilities +$abilities = wp_get_abilities(); + +// Filter by category +$abilities = wp_get_abilities( array( 'category' => 'data-retrieval' ) ); + +// Combine multiple filters +$abilities = wp_get_abilities( array( + 'namespace' => 'my-plugin', + 'category' => 'communication', + 'orderby' => 'label', +) ); +``` + +## Quick Reference: Query Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `category` | string\|array | `''` | Filter by category slug. Accepts single value or array for multiple categories (OR logic) | +| `namespace` | string\|array | `''` | Filter by namespace. Accepts single value or array for multiple namespaces (OR logic) | +| `search` | string | `''` | Search in name, label, and description (case-insensitive) | +| `meta` | array | `array()` | Filter by meta properties using nested arrays (AND logic between multiple meta filters) | +| `orderby` | string | `''` | Sort by: `'name'`, `'label'`, or `'category'`. Empty = no sorting | +| `order` | string | `'ASC'` | Sort direction: `'ASC'` or `'DESC'` | +| `limit` | int | `-1` | Maximum results to return. `-1` = unlimited | +| `offset` | int | `0` | Number of results to skip | + +## Basic Filtering + +### Filter by Category + +```php +// Get all data retrieval abilities +$abilities = wp_get_abilities( array( 'category' => 'data-retrieval' ) ); + +// Get all communication abilities +$abilities = wp_get_abilities( array( 'category' => 'communication' ) ); + +// Get abilities from multiple categories (OR logic) +$abilities = wp_get_abilities( array( + 'category' => array( 'math', 'communication', 'data-retrieval' ), +) ); +``` + +### Filter by Namespace + +Namespace is the part before the `/` in the ability name (e.g., `my-plugin` in `my-plugin/send-email`). + +```php +// Get all abilities from your plugin +$abilities = wp_get_abilities( array( 'namespace' => 'my-plugin' ) ); + +// Get all WooCommerce abilities +$abilities = wp_get_abilities( array( 'namespace' => 'woocommerce' ) ); + +// Get abilities from multiple namespaces (OR logic) +$abilities = wp_get_abilities( array( + 'namespace' => array( 'my-plugin', 'woocommerce', 'jetpack' ), +) ); +``` + +### Search Abilities + +Search is case-insensitive and searches in name, label, and description: + +```php +// Find all abilities related to email +$abilities = wp_get_abilities( array( 'search' => 'email' ) ); + +// Find payment-related abilities +$abilities = wp_get_abilities( array( 'search' => 'payment' ) ); +``` + +### Combine Multiple Filters + +Filters are cumulative (AND logic): + +```php +// Get communication abilities from my-plugin only +$abilities = wp_get_abilities( array( + 'category' => 'communication', + 'namespace' => 'my-plugin', +) ); + +// Search for email abilities in the communication category +$abilities = wp_get_abilities( array( + 'category' => 'communication', + 'search' => 'email', +) ); +``` + +## Meta Filtering + +Filter abilities by their meta properties, including `show_in_rest`, `annotations`, and custom meta keys. All meta filters use AND logic — abilities must match all specified criteria. + +### Filter by REST API Visibility + +```php +// Get abilities exposed in REST API +$abilities = wp_get_abilities( array( + 'meta' => array( + 'show_in_rest' => true, + ), +) ); + +// Get abilities hidden from REST API +$abilities = wp_get_abilities( array( + 'meta' => array( + 'show_in_rest' => false, + ), +) ); +``` + +### Filter by Annotations + +```php +// Get readonly abilities +$abilities = wp_get_abilities( array( + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + ), + ), +) ); + +// Get non-destructive, idempotent abilities +$abilities = wp_get_abilities( array( + 'meta' => array( + 'annotations' => array( + 'destructive' => false, + 'idempotent' => true, + ), + ), +) ); +``` + +### Combine Multiple Meta Filters + +When you specify multiple meta conditions, abilities must match all of them (AND logic): + +```php +// Get readonly abilities that are exposed in REST API +$abilities = wp_get_abilities( array( + 'meta' => array( + 'show_in_rest' => true, + 'annotations' => array( + 'readonly' => true, + ), + ), +) ); + +// Get REST-enabled, readonly, idempotent abilities +$abilities = wp_get_abilities( array( + 'meta' => array( + 'show_in_rest' => true, + 'annotations' => array( + 'readonly' => true, + 'idempotent' => true, + ), + ), +) ); +``` + +### Filter by Custom Meta + +You can filter by any custom meta keys you've added to abilities: + +```php +$abilities = wp_get_abilities( array( + 'meta' => array( + 'custom_key' => 'custom_value', + 'another_meta' => 'another_value', + ), +) ); +``` + +## Ordering and Pagination + +```php +// Order by name, label, or category +$abilities = wp_get_abilities( array( + 'orderby' => 'label', + 'order' => 'ASC', // or 'DESC' +) ); + +// Paginate results +$abilities = wp_get_abilities( array( + 'limit' => 10, + 'offset' => 0, +) ); +``` + +## See Also + +- [Registering Abilities](3.registering-abilities.md) - How to register abilities with proper metadata +- [Using Abilities](4.using-abilities.md) - Basic ability usage, execution, and permissions +- [Registering Categories](7.registering-categories.md) - How to register ability categories +- [REST API](5.rest-api.md) - REST API endpoints for abilities From f114dae33fd3d61f30ac1185980f67ab75cb6cad Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Tue, 14 Oct 2025 11:53:06 +0300 Subject: [PATCH 05/17] Refactor `WP_Abilities_Query` for compatibility Public constants are converted to private static properties Additionally, validation methods are updated to use guard clauses, and some boolean logic is simplified. --- .../class-wp-abilities-query.php | 54 +++++++++---------- 1 file changed, 25 insertions(+), 29 deletions(-) diff --git a/includes/abilities-api/class-wp-abilities-query.php b/includes/abilities-api/class-wp-abilities-query.php index 3b8e013..09a0f47 100644 --- a/includes/abilities-api/class-wp-abilities-query.php +++ b/includes/abilities-api/class-wp-abilities-query.php @@ -22,28 +22,28 @@ class WP_Abilities_Query { /** - * Valid orderby fields. + * Constant representing no limit on results. * * @since n.e.x.t - * @var array + * @var int */ - public const VALID_ORDERBY_FIELDS = array( 'name', 'label', 'category' ); + private static $no_limit = -1; /** - * Valid order directions. + * Valid orderby fields. * * @since n.e.x.t * @var array */ - public const VALID_ORDER_DIRECTIONS = array( 'ASC', 'DESC' ); + private static $valid_orderby_fields = array( 'name', 'label', 'category' ); /** - * Constant representing no limit on results. + * Valid order directions. * * @since n.e.x.t - * @var int + * @var array */ - public const NO_LIMIT = -1; + private static $valid_order_directions = array( 'ASC', 'DESC' ); /** * Query arguments after parsing. @@ -51,7 +51,7 @@ class WP_Abilities_Query { * @since n.e.x.t * @var array */ - protected array $query_vars = array(); + protected $query_vars = array(); /** * The filtered abilities result. @@ -59,7 +59,7 @@ class WP_Abilities_Query { * @since n.e.x.t * @var \WP_Ability[]|null */ - protected ?array $abilities = null; + protected $abilities = null; /** * Constructor. @@ -100,7 +100,7 @@ protected function parse_query( array $args ): void { 'meta' => array(), 'orderby' => '', 'order' => 'ASC', - 'limit' => self::NO_LIMIT, + 'limit' => self::$no_limit, 'offset' => 0, ); @@ -119,9 +119,10 @@ protected function parse_query( array $args ): void { * */ protected function validate_meta_arg(): void { - if ( ! is_array( $this->query_vars['meta'] ) ) { - $this->query_vars['meta'] = array(); + if ( is_array( $this->query_vars['meta'] ) ) { + return; } + $this->query_vars['meta'] = array(); } /** @@ -135,9 +136,10 @@ protected function validate_orderby(): void { return; } - if ( ! in_array( $this->query_vars['orderby'], self::VALID_ORDERBY_FIELDS, true ) ) { - $this->query_vars['orderby'] = ''; + if ( in_array( $this->query_vars['orderby'], self::$valid_orderby_fields, true ) ) { + return; } + $this->query_vars['orderby'] = ''; } /** @@ -148,9 +150,10 @@ protected function validate_orderby(): void { */ protected function validate_order(): void { $this->query_vars['order'] = strtoupper( $this->query_vars['order'] ); - if ( ! in_array( $this->query_vars['order'], self::VALID_ORDER_DIRECTIONS, true ) ) { - $this->query_vars['order'] = 'ASC'; + if ( in_array( $this->query_vars['order'], self::$valid_order_directions, true ) ) { + return; } + $this->query_vars['order'] = 'ASC'; } /** @@ -291,15 +294,8 @@ protected function filter_by_meta( WP_Ability $ability ): bool { [ $flat_filters, $nested_filters ] = $this->separate_meta_filters( $filters ); - if ( ! $this->check_flat_meta_filters( $ability_meta, $flat_filters ) ) { - return false; - } - - if ( ! $this->check_nested_meta_filters( $ability_meta, $nested_filters ) ) { - return false; - } - - return true; + return $this->check_flat_meta_filters( $ability_meta, $flat_filters ) + && $this->check_nested_meta_filters( $ability_meta, $nested_filters ); } /** @@ -315,8 +311,8 @@ protected function filter_by_search( WP_Ability $ability ): bool { $search = $this->query_vars['search']; return stripos( $ability->get_name(), $search ) !== false - || stripos( $ability->get_label(), $search ) !== false - || stripos( $ability->get_description(), $search ) !== false; + || stripos( $ability->get_label(), $search ) !== false + || stripos( $ability->get_description(), $search ) !== false; } /** @@ -487,7 +483,7 @@ protected function apply_pagination( array $abilities ): array { $offset = $this->query_vars['offset']; // No pagination if limit is -1. - if ( self::NO_LIMIT === $limit ) { + if ( self::$no_limit === $limit ) { // Apply offset only if specified. if ( $offset > 0 ) { return array_slice( $abilities, $offset ); From c89f085f8692c391193328579257f012f105e174 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Tue, 14 Oct 2025 21:49:02 +0300 Subject: [PATCH 06/17] Remove redundant empty args check in wp_get_abilities() WP_Abilities_Query already returns all abilities when args is empty, making the backward compatibility check unnecessary. --- includes/abilities-api.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/includes/abilities-api.php b/includes/abilities-api.php index 665022e..eca0650 100644 --- a/includes/abilities-api.php +++ b/includes/abilities-api.php @@ -120,11 +120,6 @@ function wp_get_ability( string $name ): ?WP_Ability { * } $args */ function wp_get_abilities( array $args = array() ): array { - if ( empty( $args ) ) { - // Backward compatibility: return all abilities if no args. - return WP_Abilities_Registry::get_instance()->get_all_registered(); - } - $query = new WP_Abilities_Query( $args ); return $query->get_abilities(); } From 2ca7dae36a5beb213980f853f0dc0cfca63a1b7e Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Tue, 14 Oct 2025 22:04:04 +0300 Subject: [PATCH 07/17] Clarifies array type annotations in documentation Updates type hints to specify arrays of strings for 'category' and 'namespace' parameters. --- includes/abilities-api.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/includes/abilities-api.php b/includes/abilities-api.php index eca0650..7c70ae0 100644 --- a/includes/abilities-api.php +++ b/includes/abilities-api.php @@ -108,8 +108,8 @@ function wp_get_ability( string $name ): ?WP_Ability { * @return \WP_Ability[] The array of registered abilities. * * @phpstan-param array{ - * category?: string|string[], - * namespace?: string|string[], + * category?: string|array, + * namespace?: string|array, * search?: string, * meta?: array, * orderby?: string, From 68fea1b8e806570f09ae65173cbca93202499818 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Tue, 14 Oct 2025 22:18:03 +0300 Subject: [PATCH 08/17] Rename validation methods to sanitization methods Updates method names and related docblocks to clarify that query argument processing focuses on sanitization rather than validation. --- .../class-wp-abilities-query.php | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/includes/abilities-api/class-wp-abilities-query.php b/includes/abilities-api/class-wp-abilities-query.php index 09a0f47..2127392 100644 --- a/includes/abilities-api/class-wp-abilities-query.php +++ b/includes/abilities-api/class-wp-abilities-query.php @@ -85,7 +85,7 @@ public function __construct( array $args = array() ) { } /** - * Parses and validates query arguments. + * Parses and sanitizes query arguments. * * @since n.e.x.t * @@ -106,19 +106,19 @@ protected function parse_query( array $args ): void { $this->query_vars = wp_parse_args( $args, $defaults ); - $this->validate_meta_arg(); - $this->validate_orderby(); - $this->validate_order(); - $this->validate_pagination_args(); + $this->sanitize_meta_arg(); + $this->sanitize_orderby(); + $this->sanitize_order(); + $this->sanitize_pagination_args(); } /** - * Validates the meta query argument. + * Sanitizes the meta query argument. * * @since n.e.x.t * */ - protected function validate_meta_arg(): void { + protected function sanitize_meta_arg(): void { if ( is_array( $this->query_vars['meta'] ) ) { return; } @@ -126,12 +126,12 @@ protected function validate_meta_arg(): void { } /** - * Validates the orderby query argument. + * Sanitizes the orderby query argument. * * @since n.e.x.t * */ - protected function validate_orderby(): void { + protected function sanitize_orderby(): void { if ( empty( $this->query_vars['orderby'] ) ) { return; } @@ -143,12 +143,12 @@ protected function validate_orderby(): void { } /** - * Validates the order query argument. + * Sanitizes the order query argument. * * @since n.e.x.t * */ - protected function validate_order(): void { + protected function sanitize_order(): void { $this->query_vars['order'] = strtoupper( $this->query_vars['order'] ); if ( in_array( $this->query_vars['order'], self::$valid_order_directions, true ) ) { return; @@ -157,12 +157,12 @@ protected function validate_order(): void { } /** - * Validates the pagination query arguments (limit and offset). + * Sanitizes the pagination query arguments (limit and offset). * * @since n.e.x.t * */ - protected function validate_pagination_args(): void { + protected function sanitize_pagination_args(): void { $this->query_vars['limit'] = (int) $this->query_vars['limit']; $this->query_vars['offset'] = (int) $this->query_vars['offset']; } From de10fa7457cc796078adfbc1a9c02501e871b93f Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Tue, 14 Oct 2025 22:29:42 +0300 Subject: [PATCH 09/17] Clarifies abilities retrieval and filtering doc Improves documentation to specify use of the query class for retrieving and filtering abilities. --- includes/abilities-api.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/includes/abilities-api.php b/includes/abilities-api.php index 7c70ae0..79b8e02 100644 --- a/includes/abilities-api.php +++ b/includes/abilities-api.php @@ -94,10 +94,11 @@ function wp_get_ability( string $name ): ?WP_Ability { /** * Retrieves all registered abilities using Abilities API. * + * Uses WP_Abilities_Query to retrieve abilities from the registry with optional filtering. + * * @since 0.1.0 * @since n.e.x.t Added optional $args parameter for filtering abilities. * - * @see WP_Abilities_Registry::get_all_registered() * @see WP_Abilities_Query * * @param array $args Optional. Arguments to filter abilities. Default empty array. From 74c0990a118367550fe142004ba82cd9dcb3c532 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Tue, 14 Oct 2025 22:30:03 +0300 Subject: [PATCH 10/17] Enforces non-negative offset and valid limit in pagination --- includes/abilities-api/class-wp-abilities-query.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/includes/abilities-api/class-wp-abilities-query.php b/includes/abilities-api/class-wp-abilities-query.php index 2127392..6a939b4 100644 --- a/includes/abilities-api/class-wp-abilities-query.php +++ b/includes/abilities-api/class-wp-abilities-query.php @@ -165,6 +165,16 @@ protected function sanitize_order(): void { protected function sanitize_pagination_args(): void { $this->query_vars['limit'] = (int) $this->query_vars['limit']; $this->query_vars['offset'] = (int) $this->query_vars['offset']; + + // Ensure offset is non-negative. + if ( $this->query_vars['offset'] < 0 ) { + $this->query_vars['offset'] = 0; + } + + // Ensure limit is either -1 (no limit) or positive. + if ( $this->query_vars['limit'] < self::$no_limit ) { + $this->query_vars['limit'] = self::$no_limit; + } } /** From 844dddb690f838bbc61ef888be106e8e61149f1f Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Tue, 14 Oct 2025 22:37:54 +0300 Subject: [PATCH 11/17] Refactor sanitize_pagination_args to use early exit pattern --- includes/abilities-api/class-wp-abilities-query.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/includes/abilities-api/class-wp-abilities-query.php b/includes/abilities-api/class-wp-abilities-query.php index 6a939b4..7c7418e 100644 --- a/includes/abilities-api/class-wp-abilities-query.php +++ b/includes/abilities-api/class-wp-abilities-query.php @@ -172,9 +172,11 @@ protected function sanitize_pagination_args(): void { } // Ensure limit is either -1 (no limit) or positive. - if ( $this->query_vars['limit'] < self::$no_limit ) { - $this->query_vars['limit'] = self::$no_limit; + if ( $this->query_vars['limit'] >= self::$no_limit ) { + return; } + + $this->query_vars['limit'] = self::$no_limit; } /** From 7439cb2e144fb880c5ada2b2809c9eabb4b66a26 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Wed, 15 Oct 2025 12:01:04 +0300 Subject: [PATCH 12/17] feat(abilities): Introduce WP_Abilities_Collection class --- .../class-wp-abilities-collection.php | 572 ++++++++ .../abilities-api/wpAbilitiesCollection.php | 1201 +++++++++++++++++ 2 files changed, 1773 insertions(+) create mode 100644 includes/abilities-api/class-wp-abilities-collection.php create mode 100644 tests/unit/abilities-api/wpAbilitiesCollection.php diff --git a/includes/abilities-api/class-wp-abilities-collection.php b/includes/abilities-api/class-wp-abilities-collection.php new file mode 100644 index 0000000..bd48c55 --- /dev/null +++ b/includes/abilities-api/class-wp-abilities-collection.php @@ -0,0 +1,572 @@ + + */ +class WP_Abilities_Collection implements IteratorAggregate, Countable { + /** + * The abilities in this collection. + * + * @var array<\WP_Ability> + */ + private $abilities = array(); + + /** + * @since n.e.x.t + * + * @param array<\WP_Ability> $abilities Array of WP_Ability objects. + */ + public function __construct( array $abilities = array() ) { + $this->abilities = $abilities; + } + + /** + * Get iterator for foreach loops (IteratorAggregate). + * + * @since n.e.x.t + * + * @return \ArrayIterator Iterator over abilities. + */ + public function getIterator(): ArrayIterator { + return new ArrayIterator( $this->abilities ); + } + + /** + * Count abilities (Countable). + * + * @since n.e.x.t + * + * @return int Number of abilities. + */ + public function count(): int { + return count( $this->abilities ); + } + + /** + * Get underlying array of abilities. + * + * @since n.e.x.t + * + * @return array<\WP_Ability> Array of abilities. + */ + public function to_array(): array { + return $this->abilities; + } + + /** + * @since n.e.x.t + * + * @return array<\WP_Ability> Array of abilities. + */ + public function all(): array { + return $this->to_array(); + } + + /** + * Re-index abilities with sequential keys. + * + * @since n.e.x.t + * + * @return self New collection with re-indexed abilities. + */ + public function values(): self { + return new self( array_values( $this->abilities ) ); + } + + /** + * Get all ability names. + * + * @since n.e.x.t + * + * @return array Array of ability names. + */ + public function keys(): array { + return array_map( + static function ( $ability ) { + return $ability->get_name(); + }, + $this->abilities + ); + } + + /** + * Filter abilities using a callback. + * + * @since n.e.x.t + * + * @param callable $callback Filter callback (receives WP_Ability, returns bool). + * @return self New filtered collection. + */ + public function filter( callable $callback ): self { + return new self( array_filter( $this->abilities, $callback ) ); + } + + /** + * Extract a single property from all abilities. + * + * @since n.e.x.t + * + * @param string $value Property to extract. + * @param string|null $key Optional property to use as array keys. + * @return array Array of extracted values. + */ + public function pluck( string $value, ?string $key = null ): array { + // Convert WP_Ability objects to arrays for wp_list_pluck. + $abilities_array = array_map( + static function ( $ability ) { + return array( + 'name' => $ability->get_name(), + 'label' => $ability->get_label(), + 'description' => $ability->get_description(), + 'category' => $ability->get_category(), + 'meta' => $ability->get_meta(), + ); + }, + $this->abilities + ); + + return wp_list_pluck( $abilities_array, $value, $key ); + } + + /** + * Get nested property value using dot notation. + * + * Handles both object methods (get_name, get_meta) and nested array access. + * + * @since n.e.x.t + * + * @param \WP_Ability $ability The ability object. + * @param string $key Dot-notated key (e.g., 'meta.annotations.readonly'). + * @return mixed The property value or null if not found. + */ + private function data_get( WP_Ability $ability, string $key ) { + // Split key into segments. + $segments = explode( '.', $key ); + $first_segment = array_shift( $segments ); + + // Try to get value from ability getter method. + $method = 'get_' . $first_segment; + + if ( ! method_exists( $ability, $method ) ) { + return null; + } + + $value = $ability->$method(); + + // If no more segments, return value. + if ( empty( $segments ) ) { + return $value; + } + + // Traverse nested array segments. + return $this->array_get( $value, implode( '.', $segments ) ); + } + + /** + * Get array value using dot notation. + * + * @since n.e.x.t + * + * @param mixed $target Array to search. + * @param string $key Dot-notated key. + * @return mixed Value or null if not found. + */ + private function array_get( $target, string $key ) { + if ( ! is_array( $target ) ) { + return null; + } + + // Check if key exists directly (no dot). + if ( isset( $target[ $key ] ) ) { + return $target[ $key ]; + } + + // Traverse nested keys. + foreach ( explode( '.', $key ) as $segment ) { + if ( ! is_array( $target ) || ! array_key_exists( $segment, $target ) ) { + return null; + } + $target = $target[ $segment ]; + } + + return $target; + } + + /** + * Compare two values using an operator. + * + * @since n.e.x.t + * + * @param mixed $actual The actual value. + * @param string $operator Comparison operator (=, ===, !=, !==, >, <, >=, <=). + * @param mixed $expected The expected value. + * @return bool True if comparison passes. + */ + private function compare_values( $actual, string $operator, $expected ): bool { + switch ( $operator ) { + case '=': + case '==': + return $actual == $expected; // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual + + case '===': + return $actual === $expected; + + case '!=': + case '<>': + return $actual != $expected; // phpcs:ignore Universal.Operators.StrictComparisons.LooseNotEqual + + case '!==': + return $actual !== $expected; + + case '>': + return $actual > $expected; + + case '>=': + return $actual >= $expected; + + case '<': + return $actual < $expected; + + case '<=': + return $actual <= $expected; + + default: + return $actual === $expected; + } + } + + /** + * Filter abilities by property value using dot notation. + * + * Supports: + * - Direct properties: where('category', 'math') + * - Nested properties: where('meta.show_in_rest', true) + * - Deep nesting: where('meta.annotations.readonly', true) + * - Comparison operators: where('meta.priority', '>', 5) + * + * @since n.e.x.t + * + * @param string $key Property key (supports dot notation). + * @param mixed $operator Comparison operator or value if 2 args. + * @param mixed $value Value to compare (optional). + * @return self New collection with filtered abilities. + */ + public function where( string $key, $operator = null, $value = null ): self { + // Handle 2-argument version: where('key', 'value'). + if ( 2 === func_num_args() ) { + $value = $operator; + $operator = '='; + } + + return $this->filter( + function ( $ability ) use ( $key, $operator, $value ) { + $actual = $this->data_get( $ability, $key ); + return $this->compare_values( $actual, $operator, $value ); + } + ); + } + + /** + * Filter items where key is in given values (supports dot notation). + * + * @since n.e.x.t + * + * @param string $key Property key (supports dot notation). + * @param array $values Values to match. + * @return self New collection with filtered abilities. + */ + public function where_in( string $key, array $values ): self { + return $this->filter( + function ( $ability ) use ( $key, $values ) { + $actual = $this->data_get( $ability, $key ); + return in_array( $actual, $values, true ); + } + ); + } + + /** + * Filter items where key is NOT in given values (supports dot notation). + * + * @since n.e.x.t + * + * @param string $key Property key (supports dot notation). + * @param array $values Values to exclude. + * @return self New collection with filtered abilities. + */ + public function where_not_in( string $key, array $values ): self { + return $this->filter( + function ( $ability ) use ( $key, $values ) { + $actual = $this->data_get( $ability, $key ); + return ! in_array( $actual, $values, true ); + } + ); + } + + /** + * Filter abilities by category. + * + * @since n.e.x.t + * + * @param string|array $categories Single category or array of categories. + * @return self New collection with filtered abilities. + */ + public function where_category( $categories ): self { + if ( is_array( $categories ) ) { + return $this->where_in( 'category', $categories ); + } + + return $this->where( 'category', $categories ); + } + + /** + * Filter abilities by namespace. + * + * @since n.e.x.t + * + * @param string|array $namespaces Single namespace or array of namespaces. + * @return self New collection with filtered abilities. + */ + public function where_namespace( $namespaces ): self { + $namespaces = (array) $namespaces; + + return $this->filter( + static function ( $ability ) use ( $namespaces ) { + $name_parts = explode( '/', $ability->get_name() ); + $namespace = $name_parts[0] ?? ''; + + return in_array( $namespace, $namespaces, true ); + } + ); + } + + /** + * Filter abilities by meta properties (supports dot notation). + * + * @since n.e.x.t + * + * @param array $filters Associative array of meta filters. + * Supports dot notation for nested keys. + * @return self New collection with filtered abilities. + */ + public function where_meta( array $filters ): self { + return $this->filter( + function ( $ability ) use ( $filters ) { + $meta = $ability->get_meta(); + + foreach ( $filters as $key => $expected_value ) { + // Use array_get helper for dot notation support. + $actual_value = $this->array_get( $meta, $key ); + + if ( $actual_value !== $expected_value ) { + return false; + } + } + + return true; + } + ); + } + + /** + * Search abilities by term across name, label, and description. + * + * @since n.e.x.t + * + * @param string $term Search term. + * @return self New collection with matching abilities. + */ + public function search( string $term ): self { + $term = strtolower( $term ); + + return $this->filter( + static function ( $ability ) use ( $term ) { + $searchable = array( + $ability->get_name(), + $ability->get_label(), + $ability->get_description(), + ); + + foreach ( $searchable as $text ) { + if ( false !== stripos( $text, $term ) ) { + return true; + } + } + + return false; + } + ); + } + + /** + * Sort abilities by property or callback. + * + * @since n.e.x.t + * + * @param string|callable $callback Property name or callback function. + * @param bool $descending Sort in descending order (default: false). + * @return self New sorted collection. + */ + public function sort_by( $callback, bool $descending = false ): self { + // If callback is a string (property name), use wp_list_sort. + if ( is_string( $callback ) ) { + // Map property names to getter methods. + $property_map = array( + 'name' => 'name', + 'label' => 'label', + 'description' => 'description', + 'category' => 'category', + ); + + $field = $property_map[ $callback ] ?? $callback; + $order = $descending ? 'DESC' : 'ASC'; + + // Convert abilities to associative arrays for wp_list_sort. + $abilities_array = array_map( + static function ( $ability ) { + return array( + 'object' => $ability, // Keep original object. + 'name' => $ability->get_name(), + 'label' => $ability->get_label(), + 'description' => $ability->get_description(), + 'category' => $ability->get_category(), + ); + }, + $this->abilities + ); + + // Sort using wp_list_sort. + $sorted = wp_list_sort( $abilities_array, $field, $order ); + + // Extract back the WP_Ability objects. + $sorted_abilities = wp_list_pluck( $sorted, 'object' ); + + return new self( $sorted_abilities ); + } + + // For callbacks, use usort. + $sorted = $this->abilities; + usort( $sorted, $callback ); + + if ( $descending ) { + $sorted = array_reverse( $sorted ); + } + + return new self( $sorted ); + } + + /** + * Sort abilities by property or callback in descending order. + * + * @since n.e.x.t + * + * @param string|callable $callback Property name or callback function. + * @return self New sorted collection. + */ + public function sort_by_desc( $callback ): self { + return $this->sort_by( $callback, true ); + } + + /** + * Reverse the order of abilities. + * + * @since n.e.x.t + * + * @return self New collection with reversed order. + */ + public function reverse(): self { + return new self( array_reverse( $this->abilities ) ); + } + + /** + * Get first ability. + * + * @since n.e.x.t + * + * @param callable|null $callback Optional filter callback. + * @param mixed $default_value Default value if not found. + * @return mixed First ability or default. + */ + public function first( ?callable $callback = null, $default_value = null ) { + if ( null === $callback ) { + return ! empty( $this->abilities ) ? reset( $this->abilities ) : $default_value; + } + + $filtered = $this->filter( $callback ); + return ! $filtered->is_empty() ? $filtered->first() : $default_value; + } + + /** + * Get last ability. + * + * @since n.e.x.t + * + * @param callable|null $callback Optional filter callback. + * @param mixed $default_value Default value if not found. + * @return mixed Last ability or default. + */ + public function last( ?callable $callback = null, $default_value = null ) { + if ( null === $callback ) { + return ! empty( $this->abilities ) ? end( $this->abilities ) : $default_value; + } + + $filtered = $this->filter( $callback ); + return ! $filtered->is_empty() ? $filtered->last() : $default_value; + } + + /** + * Get ability by name. + * + * @since n.e.x.t + * + * @param string $name Ability name. + * @param mixed $default_value Default value if not found. + * @return mixed Ability or default. + */ + public function get( string $name, $default_value = null ) { + return $this->first( + static function ( $ability ) use ( $name ) { + return $ability->get_name() === $name; + }, + $default_value + ); + } + + /** + * Check if collection is empty. + * + * @since n.e.x.t + * + * @return bool True if empty. + */ + public function is_empty(): bool { + return empty( $this->abilities ); + } + + /** + * Check if collection is not empty. + * + * @since n.e.x.t + * + * @return bool True if not empty. + */ + public function is_not_empty(): bool { + return ! $this->is_empty(); + } +} diff --git a/tests/unit/abilities-api/wpAbilitiesCollection.php b/tests/unit/abilities-api/wpAbilitiesCollection.php new file mode 100644 index 0000000..c5877ba --- /dev/null +++ b/tests/unit/abilities-api/wpAbilitiesCollection.php @@ -0,0 +1,1201 @@ +registry = WP_Abilities_Registry::get_instance(); + + // Register test categories during the hook. + add_action( + 'abilities_api_categories_init', + array( $this, 'register_test_categories' ) + ); + do_action( 'abilities_api_categories_init' ); + + // Fire the abilities init hook to allow registration. + do_action( 'abilities_api_init' ); + + // Register test abilities (after hook has been fired). + $this->register_test_abilities(); + } + + /** + * Tear down test environment. + */ + public function tear_down(): void { + $this->cleanup_abilities(); + $this->cleanup_categories(); + parent::tear_down(); + } + + /** + * Test collection creation and count. + * + * @covers WP_Abilities_Collection::__construct + * @covers WP_Abilities_Collection::count + */ + public function test_collection_creation_and_count(): void { + $abilities = $this->registry->get_all_registered(); + $collection = new WP_Abilities_Collection( $abilities ); + + $this->assertInstanceOf( WP_Abilities_Collection::class, $collection ); + $this->assertSame( count( $abilities ), $collection->count() ); + } + + /** + * Test constructor with abilities array. + * + * @covers WP_Abilities_Collection::__construct + */ + public function test_constructor_with_abilities(): void { + $abilities = $this->registry->get_all_registered(); + $collection = new WP_Abilities_Collection( $abilities ); + + $this->assertInstanceOf( WP_Abilities_Collection::class, $collection ); + $this->assertSame( count( $abilities ), $collection->count() ); + } + + /** + * Test filter by single category. + * + * @covers WP_Abilities_Collection::where_category + */ + public function test_filter_by_single_category(): void { + $collection = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + $math_abilities = $collection->where_category( 'math' ); + + $this->assertGreaterThan( 0, $math_abilities->count() ); + + foreach ( $math_abilities as $ability ) { + $this->assertSame( 'math', $ability->get_category() ); + } + } + + /** + * Test filter by multiple categories. + * + * @covers WP_Abilities_Collection::where_category + */ + public function test_filter_by_multiple_categories(): void { + $collection = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + $filtered = $collection->where_category( array( 'math', 'data-retrieval' ) ); + + foreach ( $filtered as $ability ) { + $this->assertContains( $ability->get_category(), array( 'math', 'data-retrieval' ) ); + } + } + + /** + * Test filter by namespace. + * + * @covers WP_Abilities_Collection::where_namespace + */ + public function test_filter_by_namespace(): void { + $collection = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + $test_abilities = $collection->where_namespace( 'test' ); + + foreach ( $test_abilities as $ability ) { + $this->assertStringStartsWith( 'test/', $ability->get_name() ); + } + } + + /** + * Test filter by meta. + * + * @covers WP_Abilities_Collection::where_meta + */ + public function test_filter_by_meta(): void { + $collection = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + $rest_abilities = $collection->where_meta( array( 'show_in_rest' => true ) ); + + foreach ( $rest_abilities as $ability ) { + $meta = $ability->get_meta(); + $this->assertTrue( $meta['show_in_rest'] ); + } + } + + /** + * Test filter by nested meta using dot notation. + * + * @covers WP_Abilities_Collection::where_meta + */ + public function test_filter_by_nested_meta(): void { + $collection = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + $readonly = $collection->where_meta( array( 'annotations.readonly' => true ) ); + + foreach ( $readonly as $ability ) { + $meta = $ability->get_meta(); + $this->assertTrue( $meta['annotations']['readonly'] ); + } + } + + /** + * Test search abilities. + * + * @covers WP_Abilities_Collection::search + */ + public function test_search_abilities(): void { + $collection = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + $results = $collection->search( 'number' ); + + $this->assertGreaterThan( 0, $results->count() ); + + foreach ( $results as $ability ) { + $found = false !== stripos( $ability->get_name(), 'number' ) + || false !== stripos( $ability->get_label(), 'number' ) + || false !== stripos( $ability->get_description(), 'number' ); + + $this->assertTrue( $found, 'Search term not found in ability' ); + } + } + + /** + * Test sort by property ascending. + * + * @covers WP_Abilities_Collection::sort_by + */ + public function test_sort_by_property(): void { + $collection = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + $sorted = $collection->sort_by( 'name' ); + + $names = array_map( + static function ( $a ) { + return $a->get_name(); + }, + $sorted->to_array() + ); + $expected = $names; + sort( $expected ); + + $this->assertSame( $expected, $names ); + } + + /** + * Test sort by property descending. + * + * @covers WP_Abilities_Collection::sort_by_desc + */ + public function test_sort_by_property_descending(): void { + $collection = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + $sorted = $collection->sort_by_desc( 'name' ); + + $names = array_map( + static function ( $a ) { + return $a->get_name(); + }, + $sorted->to_array() + ); + $expected = $names; + rsort( $expected ); + + $this->assertSame( $expected, $names ); + } + + /** + * Test method chaining. + * + * @covers WP_Abilities_Collection::filter + * @covers WP_Abilities_Collection::where_category + * @covers WP_Abilities_Collection::where_meta + * @covers WP_Abilities_Collection::sort_by + */ + public function test_method_chaining(): void { + $collection = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + + $result = $collection + ->where_category( 'math' ) + ->where_meta( array( 'show_in_rest' => true ) ) + ->sort_by( 'name' ); + + $this->assertInstanceOf( WP_Abilities_Collection::class, $result ); + $this->assertGreaterThan( 0, $result->count() ); + + foreach ( $result as $ability ) { + $this->assertSame( 'math', $ability->get_category() ); + $meta = $ability->get_meta(); + $this->assertTrue( $meta['show_in_rest'] ); + } + } + + /** + * Test first method. + * + * @covers WP_Abilities_Collection::first + */ + public function test_first(): void { + $collection = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + $first = $collection->first(); + + $this->assertInstanceOf( WP_Ability::class, $first ); + } + + /** + * Test last method. + * + * @covers WP_Abilities_Collection::last + */ + public function test_last(): void { + $collection = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + $last = $collection->last(); + + $this->assertInstanceOf( WP_Ability::class, $last ); + } + + /** + * Test get by name. + * + * @covers WP_Abilities_Collection::get + */ + public function test_get_by_name(): void { + $collection = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + $ability = $collection->get( 'test/add-numbers' ); + + $this->assertInstanceOf( WP_Ability::class, $ability ); + $this->assertSame( 'test/add-numbers', $ability->get_name() ); + } + + /** + * Test is_empty method. + * + * @covers WP_Abilities_Collection::is_empty + */ + public function test_is_empty(): void { + $empty = new WP_Abilities_Collection( array() ); + $this->assertTrue( $empty->is_empty() ); + + $not_empty = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + $this->assertFalse( $not_empty->is_empty() ); + } + + /** + * Test is_not_empty method. + * + * @covers WP_Abilities_Collection::is_not_empty + */ + public function test_is_not_empty(): void { + $empty = new WP_Abilities_Collection( array() ); + $this->assertFalse( $empty->is_not_empty() ); + + $not_empty = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + $this->assertTrue( $not_empty->is_not_empty() ); + } + + /** + * Test immutability - original collection unchanged after filtering. + * + * @covers WP_Abilities_Collection::where_category + */ + public function test_immutability(): void { + $collection = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + $original_count = $collection->count(); + + $filtered = $collection->where_category( 'math' ); + + // Original unchanged. + $this->assertSame( $original_count, $collection->count() ); + + // Filtered is different. + $this->assertNotEquals( $original_count, $filtered->count() ); + } + + /** + * Test iterator interface. + * + * @covers WP_Abilities_Collection::getIterator + */ + public function test_iterator_interface(): void { + $abilities = $this->registry->get_all_registered(); + $collection = new WP_Abilities_Collection( $abilities ); + + $count = 0; + foreach ( $collection as $ability ) { + $this->assertInstanceOf( WP_Ability::class, $ability ); + ++$count; + } + + $this->assertSame( count( $abilities ), $count ); + } + + /** + * Test pluck method. + * + * @covers WP_Abilities_Collection::pluck + */ + public function test_pluck(): void { + $collection = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + $names = $collection->pluck( 'name' ); + + $this->assertIsArray( $names ); + $this->assertGreaterThan( 0, count( $names ) ); + $this->assertContains( 'test/add-numbers', $names ); + } + + /** + * Test pluck with key. + * + * @covers WP_Abilities_Collection::pluck + */ + public function test_pluck_with_key(): void { + $collection = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + $labels = $collection->pluck( 'label', 'name' ); + + $this->assertIsArray( $labels ); + $this->assertArrayHasKey( 'test/add-numbers', $labels ); + $this->assertSame( 'Add Numbers', $labels['test/add-numbers'] ); + } + + /** + * Test where with dot notation. + * + * @covers WP_Abilities_Collection::where + */ + public function test_where_with_dot_notation(): void { + $collection = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + $result = $collection->where( 'meta.show_in_rest', true ); + + $this->assertGreaterThan( 0, $result->count() ); + + foreach ( $result as $ability ) { + $meta = $ability->get_meta(); + $this->assertTrue( $meta['show_in_rest'] ); + } + } + + /** + * Test where with operator. + * + * @covers WP_Abilities_Collection::where + */ + public function test_where_with_operator(): void { + $collection = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + $result = $collection->where( 'category', '!==', 'math' ); + + foreach ( $result as $ability ) { + $this->assertNotSame( 'math', $ability->get_category() ); + } + } + + /** + * Test where_in method. + * + * @covers WP_Abilities_Collection::where_in + */ + public function test_where_in(): void { + $collection = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + $result = $collection->where_in( 'category', array( 'math', 'data-retrieval' ) ); + + foreach ( $result as $ability ) { + $this->assertContains( $ability->get_category(), array( 'math', 'data-retrieval' ) ); + } + } + + /** + * Test where_not_in method. + * + * @covers WP_Abilities_Collection::where_not_in + */ + public function test_where_not_in(): void { + $collection = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + $result = $collection->where_not_in( 'category', array( 'math', 'data-retrieval' ) ); + + foreach ( $result as $ability ) { + $this->assertNotContains( $ability->get_category(), array( 'math', 'data-retrieval' ) ); + } + } + + /** + * Test reverse method. + * + * @covers WP_Abilities_Collection::reverse + */ + public function test_reverse(): void { + $collection = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + $original = $collection->to_array(); + $reversed = $collection->reverse()->to_array(); + + $this->assertSame( array_reverse( $original ), $reversed ); + } + + /** + * Test values method. + * + * @covers WP_Abilities_Collection::values + */ + public function test_values(): void { + $collection = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + $values = $collection->values(); + + $this->assertInstanceOf( WP_Abilities_Collection::class, $values ); + $this->assertSame( $collection->count(), $values->count() ); + } + + /** + * Test keys method. + * + * @covers WP_Abilities_Collection::keys + */ + public function test_keys(): void { + $collection = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + $keys = $collection->keys(); + + $this->assertIsArray( $keys ); + $this->assertContains( 'test/add-numbers', $keys ); + } + + /** + * Test all method. + * + * @covers WP_Abilities_Collection::all + */ + public function test_all(): void { + $abilities = $this->registry->get_all_registered(); + $collection = new WP_Abilities_Collection( $abilities ); + + $this->assertSame( $abilities, $collection->all() ); + } + + /** + * Test first with callback. + * + * @covers WP_Abilities_Collection::first + */ + public function test_first_with_callback(): void { + $collection = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + + $first_math = $collection->first( + static function ( $ability ) { + return 'math' === $ability->get_category(); + } + ); + + $this->assertInstanceOf( WP_Ability::class, $first_math ); + $this->assertSame( 'math', $first_math->get_category() ); + } + + /** + * Test first with callback returning default. + * + * @covers WP_Abilities_Collection::first + */ + public function test_first_with_callback_default(): void { + $collection = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + + $result = $collection->first( + static function ( $ability ) { + return 'nonexistent-category' === $ability->get_category(); + }, + 'default-value' + ); + + $this->assertSame( 'default-value', $result ); + } + + /** + * Test first on empty collection returns default. + * + * @covers WP_Abilities_Collection::first + */ + public function test_first_empty_collection_default(): void { + $collection = new WP_Abilities_Collection( array() ); + + $result = $collection->first( null, 'empty-default' ); + + $this->assertSame( 'empty-default', $result ); + } + + /** + * Test first on empty collection returns null by default. + * + * @covers WP_Abilities_Collection::first + */ + public function test_first_empty_collection_null(): void { + $collection = new WP_Abilities_Collection( array() ); + + $result = $collection->first(); + + $this->assertNull( $result ); + } + + /** + * Test last with callback. + * + * @covers WP_Abilities_Collection::last + */ + public function test_last_with_callback(): void { + $collection = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + + $last_math = $collection->last( + static function ( $ability ) { + return 'math' === $ability->get_category(); + } + ); + + $this->assertInstanceOf( WP_Ability::class, $last_math ); + $this->assertSame( 'math', $last_math->get_category() ); + } + + /** + * Test last with callback returning default. + * + * @covers WP_Abilities_Collection::last + */ + public function test_last_with_callback_default(): void { + $collection = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + + $result = $collection->last( + static function ( $ability ) { + return 'nonexistent-category' === $ability->get_category(); + }, + 'default-value' + ); + + $this->assertSame( 'default-value', $result ); + } + + /** + * Test last on empty collection returns default. + * + * @covers WP_Abilities_Collection::last + */ + public function test_last_empty_collection_default(): void { + $collection = new WP_Abilities_Collection( array() ); + + $result = $collection->last( null, 'empty-default' ); + + $this->assertSame( 'empty-default', $result ); + } + + /** + * Test last on empty collection returns null by default. + * + * @covers WP_Abilities_Collection::last + */ + public function test_last_empty_collection_null(): void { + $collection = new WP_Abilities_Collection( array() ); + + $result = $collection->last(); + + $this->assertNull( $result ); + } + + /** + * Test get with default value when ability not found. + * + * @covers WP_Abilities_Collection::get + */ + public function test_get_with_default(): void { + $collection = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + + $result = $collection->get( 'nonexistent/ability', 'not-found' ); + + $this->assertSame( 'not-found', $result ); + } + + /** + * Test get returns null when ability not found and no default. + * + * @covers WP_Abilities_Collection::get + */ + public function test_get_returns_null_when_not_found(): void { + $collection = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + + $result = $collection->get( 'nonexistent/ability' ); + + $this->assertNull( $result ); + } + + /** + * Test sort_by with custom callback. + * + * @covers WP_Abilities_Collection::sort_by + */ + public function test_sort_by_custom_callback(): void { + $collection = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + + // Sort by name length. + $sorted = $collection->sort_by( + static function ( $a, $b ) { + return strlen( $a->get_name() ) <=> strlen( $b->get_name() ); + } + ); + + $names = array_map( + static function ( $a ) { + return $a->get_name(); + }, + $sorted->to_array() + ); + + // Verify ascending order by length. + $names_count = count( $names ); + for ( $i = 1; $i < $names_count; $i++ ) { + $this->assertLessThanOrEqual( + strlen( $names[ $i ] ), + strlen( $names[ $i - 1 ] ), + 'Names should be sorted by length' + ); + } + } + + /** + * Test sort_by with custom callback descending. + * + * @covers WP_Abilities_Collection::sort_by + */ + public function test_sort_by_custom_callback_descending(): void { + $collection = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + + // Sort by name length descending. + $sorted = $collection->sort_by( + static function ( $a, $b ) { + return strlen( $a->get_name() ) <=> strlen( $b->get_name() ); + }, + true + ); + + $names = array_map( + static function ( $a ) { + return $a->get_name(); + }, + $sorted->to_array() + ); + + // Verify descending order by length. + $names_count = count( $names ); + for ( $i = 1; $i < $names_count; $i++ ) { + $this->assertGreaterThanOrEqual( + strlen( $names[ $i ] ), + strlen( $names[ $i - 1 ] ), + 'Names should be sorted by length descending' + ); + } + } + + /** + * Test where with greater than operator. + * + * @covers WP_Abilities_Collection::where + */ + public function test_where_with_greater_than(): void { + $collection = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + $result = $collection->where( 'meta.priority', '>', 5 ); + + // Verify that filtering works correctly. + $this->assertInstanceOf( WP_Abilities_Collection::class, $result ); + + foreach ( $result as $ability ) { + $meta = $ability->get_meta(); + if ( ! isset( $meta['priority'] ) ) { + continue; + } + + $this->assertGreaterThan( 5, $meta['priority'] ); + } + + // Also test that we found the expected high priority ability. + $high_priority = $result->get( 'test/priority-high' ); + $this->assertInstanceOf( WP_Ability::class, $high_priority ); + $meta = $high_priority->get_meta(); + $this->assertSame( 10, $meta['priority'] ); + } + + /** + * Test where with less than operator. + * + * @covers WP_Abilities_Collection::where + */ + public function test_where_with_less_than(): void { + $collection = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + $result = $collection->where( 'meta.priority', '<', 5 ); + + // Verify that filtering works correctly. + $this->assertInstanceOf( WP_Abilities_Collection::class, $result ); + + foreach ( $result as $ability ) { + $meta = $ability->get_meta(); + if ( ! isset( $meta['priority'] ) ) { + continue; + } + + $this->assertLessThan( 5, $meta['priority'] ); + } + + // Also test that we found the expected low priority ability. + $low_priority = $result->get( 'test/priority-low' ); + $this->assertInstanceOf( WP_Ability::class, $low_priority ); + $meta = $low_priority->get_meta(); + $this->assertSame( 3, $meta['priority'] ); + } + + /** + * Test where with greater than or equal operator. + * + * @covers WP_Abilities_Collection::where + */ + public function test_where_with_greater_than_or_equal(): void { + $collection = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + $result = $collection->where( 'meta.priority', '>=', 10 ); + + // Verify that filtering works correctly. + $this->assertInstanceOf( WP_Abilities_Collection::class, $result ); + + foreach ( $result as $ability ) { + $meta = $ability->get_meta(); + if ( ! isset( $meta['priority'] ) ) { + continue; + } + + $this->assertGreaterThanOrEqual( 10, $meta['priority'] ); + } + + // Also test that we found the expected high priority ability. + $high_priority = $result->get( 'test/priority-high' ); + $this->assertInstanceOf( WP_Ability::class, $high_priority ); + $meta = $high_priority->get_meta(); + $this->assertSame( 10, $meta['priority'] ); + } + + /** + * Test where with less than or equal operator. + * + * @covers WP_Abilities_Collection::where + */ + public function test_where_with_less_than_or_equal(): void { + $collection = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + $result = $collection->where( 'meta.priority', '<=', 3 ); + + // Verify that filtering works correctly. + $this->assertInstanceOf( WP_Abilities_Collection::class, $result ); + + foreach ( $result as $ability ) { + $meta = $ability->get_meta(); + if ( ! isset( $meta['priority'] ) ) { + continue; + } + + $this->assertLessThanOrEqual( 3, $meta['priority'] ); + } + + // Also test that we found the expected low priority ability. + $low_priority = $result->get( 'test/priority-low' ); + $this->assertInstanceOf( WP_Ability::class, $low_priority ); + $meta = $low_priority->get_meta(); + $this->assertSame( 3, $meta['priority'] ); + } + + /** + * Test where with loose equality operator. + * + * @covers WP_Abilities_Collection::where + */ + public function test_where_with_loose_equality(): void { + $collection = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + $result = $collection->where( 'meta.count', '==', 10 ); + + $this->assertInstanceOf( WP_Abilities_Collection::class, $result ); + $this->assertGreaterThan( 0, $result->count() ); + + // Verify the string-number ability was found with loose equality. + $string_number = $result->get( 'test/string-number' ); + $this->assertInstanceOf( WP_Ability::class, $string_number ); + $meta = $string_number->get_meta(); + $this->assertSame( '10', $meta['count'] ); + } + + /** + * Test where with loose inequality operator. + * + * @covers WP_Abilities_Collection::where + */ + public function test_where_with_loose_inequality(): void { + $collection = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + $result = $collection->where( 'category', '!=', 'math' ); + + foreach ( $result as $ability ) { + $this->assertNotEquals( 'math', $ability->get_category() ); + } + } + + /** + * Test where with alternative inequality operator. + * + * @covers WP_Abilities_Collection::where + */ + public function test_where_with_alternative_inequality(): void { + $collection = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + $result = $collection->where( 'category', '<>', 'math' ); + + foreach ( $result as $ability ) { + $this->assertNotEquals( 'math', $ability->get_category() ); + } + } + + /** + * Test where_namespace with array of namespaces. + * + * @covers WP_Abilities_Collection::where_namespace + */ + public function test_where_namespace_with_array(): void { + $collection = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + $result = $collection->where_namespace( array( 'test', 'wordpress' ) ); + + foreach ( $result as $ability ) { + $name_parts = explode( '/', $ability->get_name() ); + $namespace = $name_parts[0] ?? ''; + + $this->assertContains( $namespace, array( 'test', 'wordpress' ) ); + } + } + + /** + * Test pluck on empty collection. + * + * @covers WP_Abilities_Collection::pluck + */ + public function test_pluck_empty_collection(): void { + $collection = new WP_Abilities_Collection( array() ); + $names = $collection->pluck( 'name' ); + + $this->assertIsArray( $names ); + $this->assertEmpty( $names ); + } + + /** + * Test filter method directly. + * + * @covers WP_Abilities_Collection::filter + */ + public function test_filter_method_directly(): void { + $collection = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + + $filtered = $collection->filter( + static function ( $ability ) { + return strlen( $ability->get_name() ) > 15; + } + ); + + $this->assertInstanceOf( WP_Abilities_Collection::class, $filtered ); + + foreach ( $filtered as $ability ) { + $this->assertGreaterThan( 15, strlen( $ability->get_name() ) ); + } + } + + /** + * Test where with nonexistent nested meta key. + * + * @covers WP_Abilities_Collection::where + */ + public function test_where_with_nonexistent_nested_key(): void { + $collection = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + $result = $collection->where( 'meta.nonexistent.deeply.nested', true ); + + $this->assertInstanceOf( WP_Abilities_Collection::class, $result ); + $this->assertSame( 0, $result->count() ); + } + + /** + * Test where_meta with nonexistent nested key. + * + * @covers WP_Abilities_Collection::where_meta + */ + public function test_where_meta_with_nonexistent_key(): void { + $collection = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + $result = $collection->where_meta( array( 'nonexistent.key' => 'value' ) ); + + $this->assertInstanceOf( WP_Abilities_Collection::class, $result ); + $this->assertSame( 0, $result->count() ); + } + + /** + * Test to_array method. + * + * @covers WP_Abilities_Collection::to_array + */ + public function test_to_array(): void { + $abilities = $this->registry->get_all_registered(); + $collection = new WP_Abilities_Collection( $abilities ); + + $array = $collection->to_array(); + + $this->assertIsArray( $array ); + $this->assertSame( $abilities, $array ); + } + + /** + * Test search with case insensitivity. + * + * @covers WP_Abilities_Collection::search + */ + public function test_search_case_insensitive(): void { + $collection = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + $lower = $collection->search( 'email' ); + $upper = $collection->search( 'EMAIL' ); + $mixed = $collection->search( 'EmAiL' ); + + $this->assertSame( $lower->count(), $upper->count() ); + $this->assertSame( $lower->count(), $mixed->count() ); + } + + /** + * Test search returns empty collection when no matches. + * + * @covers WP_Abilities_Collection::search + */ + public function test_search_no_matches(): void { + $collection = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + $result = $collection->search( 'xyznonexistentxyz' ); + + $this->assertInstanceOf( WP_Abilities_Collection::class, $result ); + $this->assertSame( 0, $result->count() ); + } + + /** + * Test where_in with dot notation. + * + * @covers WP_Abilities_Collection::where_in + */ + public function test_where_in_with_dot_notation(): void { + $collection = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + $result = $collection->where_in( 'meta.show_in_rest', array( true, false ) ); + + $this->assertGreaterThan( 0, $result->count() ); + } + + /** + * Test where_not_in with dot notation. + * + * @covers WP_Abilities_Collection::where_not_in + */ + public function test_where_not_in_with_dot_notation(): void { + $collection = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + $result = $collection->where_not_in( 'meta.show_in_rest', array( false ) ); + + foreach ( $result as $ability ) { + $meta = $ability->get_meta(); + if ( ! isset( $meta['show_in_rest'] ) ) { + continue; + } + + $this->assertTrue( $meta['show_in_rest'] ); + } + } + + /** + * Test complex chaining with all methods. + * + * @covers WP_Abilities_Collection::where_category + * @covers WP_Abilities_Collection::where_meta + * @covers WP_Abilities_Collection::search + * @covers WP_Abilities_Collection::sort_by + * @covers WP_Abilities_Collection::first + */ + public function test_complex_chaining(): void { + $collection = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + + $result = $collection + ->where_meta( array( 'show_in_rest' => true ) ) + ->where_meta( array( 'annotations.readonly' => true ) ) + ->sort_by( 'name' ) + ->first(); + + $this->assertInstanceOf( WP_Ability::class, $result ); + + $meta = $result->get_meta(); + $this->assertTrue( $meta['show_in_rest'] ); + $this->assertTrue( $meta['annotations']['readonly'] ); + } + + /** + * Test empty collection with all methods. + * + * @covers WP_Abilities_Collection::where_category + * @covers WP_Abilities_Collection::sort_by + * @covers WP_Abilities_Collection::reverse + * @covers WP_Abilities_Collection::values + */ + public function test_empty_collection_methods(): void { + $collection = new WP_Abilities_Collection( array() ); + + // All methods should return empty collections or appropriate defaults. + $this->assertSame( 0, $collection->where_category( 'math' )->count() ); + $this->assertSame( 0, $collection->sort_by( 'name' )->count() ); + $this->assertSame( 0, $collection->reverse()->count() ); + $this->assertSame( 0, $collection->values()->count() ); + $this->assertEmpty( $collection->keys() ); + } + + /** + * Register test categories. + */ + public function register_test_categories(): void { + $categories = array( 'math', 'data-retrieval', 'communication', 'ecommerce' ); + + foreach ( $categories as $slug ) { + wp_register_ability_category( + $slug, + array( + 'label' => ucfirst( $slug ), + 'description' => ucfirst( $slug ) . ' category.', + ) + ); + } + } + + /** + * Register test abilities. + */ + private function register_test_abilities(): void { + // Math abilities. + wp_register_ability( + 'test/add-numbers', + array( + 'label' => 'Add Numbers', + 'description' => 'Adds two numbers together.', + 'category' => 'math', + 'execute_callback' => static function ( $input ) { + return $input['a'] + $input['b']; + }, + 'permission_callback' => '__return_true', + 'meta' => array( + 'show_in_rest' => true, + 'annotations' => array( 'readonly' => true ), + ), + ) + ); + + wp_register_ability( + 'test/multiply-numbers', + array( + 'label' => 'Multiply Numbers', + 'description' => 'Multiplies two numbers.', + 'category' => 'math', + 'execute_callback' => static function ( $input ) { + return $input['a'] * $input['b']; + }, + 'permission_callback' => '__return_true', + 'meta' => array( + 'show_in_rest' => false, + 'annotations' => array( 'readonly' => true ), + ), + ) + ); + + // Data retrieval. + wp_register_ability( + 'wordpress/get-posts', + array( + 'label' => 'Get Posts', + 'description' => 'Retrieves WordPress posts.', + 'category' => 'data-retrieval', + 'execute_callback' => '__return_empty_array', + 'permission_callback' => '__return_true', + 'meta' => array( + 'show_in_rest' => true, + 'annotations' => array( 'readonly' => true ), + ), + ) + ); + + // Communication. + wp_register_ability( + 'wordpress/send-email', + array( + 'label' => 'Send Email', + 'description' => 'Sends an email.', + 'category' => 'communication', + 'execute_callback' => '__return_true', + 'permission_callback' => '__return_true', + 'meta' => array( + 'show_in_rest' => true, + 'annotations' => array( + 'readonly' => false, + 'destructive' => false, + ), + ), + ) + ); + + // Priority abilities for comparison operator tests. + wp_register_ability( + 'test/priority-high', + array( + 'label' => 'High Priority', + 'description' => 'High priority task.', + 'category' => 'math', + 'execute_callback' => '__return_true', + 'permission_callback' => '__return_true', + 'meta' => array( 'priority' => 10 ), + ) + ); + + wp_register_ability( + 'test/priority-low', + array( + 'label' => 'Low Priority', + 'description' => 'Low priority task.', + 'category' => 'math', + 'execute_callback' => '__return_true', + 'permission_callback' => '__return_true', + 'meta' => array( 'priority' => 3 ), + ) + ); + + // String number ability for loose equality test. + wp_register_ability( + 'test/string-number', + array( + 'label' => 'String Number', + 'description' => 'Has string number.', + 'category' => 'math', + 'execute_callback' => '__return_true', + 'permission_callback' => '__return_true', + 'meta' => array( 'count' => '10' ), + ) + ); + } + + /** + * Clean up registered abilities. + */ + private function cleanup_abilities(): void { + foreach ( $this->registry->get_all_registered() as $ability ) { + $this->registry->unregister( $ability->get_name() ); + } + } + + /** + * Clean up registered categories. + */ + private function cleanup_categories(): void { + $categories = array( 'math', 'data-retrieval', 'communication', 'ecommerce' ); + foreach ( $categories as $slug ) { + wp_unregister_ability_category( $slug ); + } + } +} From c81b0ba0543d7ccf3f3b467ed607bdf0d515ad5b Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Wed, 15 Oct 2025 12:01:04 +0300 Subject: [PATCH 13/17] refactor(abilities): Update wp_get_abilities to return a collection --- includes/abilities-api.php | 52 +- .../class-wp-abilities-query.php | 527 ----------- includes/bootstrap.php | 4 +- tests/unit/abilities-api/wpAbilitiesQuery.php | 834 ------------------ 4 files changed, 30 insertions(+), 1387 deletions(-) delete mode 100644 includes/abilities-api/class-wp-abilities-query.php delete mode 100644 tests/unit/abilities-api/wpAbilitiesQuery.php diff --git a/includes/abilities-api.php b/includes/abilities-api.php index 7c1c8b7..06960a7 100644 --- a/includes/abilities-api.php +++ b/includes/abilities-api.php @@ -92,37 +92,41 @@ function wp_get_ability( string $name ): ?WP_Ability { } /** - * Retrieves all registered abilities using Abilities API. + * Retrieves a collection of registered abilities. * - * Uses WP_Abilities_Query to retrieve abilities from the registry with optional filtering. + * Returns a WP_Abilities_Collection instance that provides a fluent, chainable + * API for filtering, sorting, and manipulating abilities. * * @since 0.1.0 - * @since n.e.x.t Added optional $args parameter for filtering abilities. + * @since n.e.x.t Returns WP_Abilities_Collection instead of array. * - * @see WP_Abilities_Query + * @see WP_Abilities_Collection * - * @param array $args Optional. Arguments to filter abilities. Default empty array. - * Accepts 'category', 'namespace', 'search', 'meta', 'orderby', - * 'order', 'limit', and 'offset'. - * 'category' and 'namespace' accept string or array for multi-value filtering. - * All filters use AND logic between different filter types and meta properties. - * @return \WP_Ability[] The array of registered abilities. + * @return \WP_Abilities_Collection Collection of WP_Ability instances. * - * @phpstan-param array{ - * category?: string|array, - * namespace?: string|array, - * search?: string, - * meta?: array, - * orderby?: string, - * order?: string, - * limit?: int, - * offset?: int, - * ... - * } $args + * @example + * // Get all abilities as collection + * $abilities = wp_get_abilities(); + * + * @example + * // Filter by category + * $math_abilities = wp_get_abilities()->where_category('math'); + * + * @example + * // Chain multiple filters + * $abilities = wp_get_abilities() + * ->where_namespace(['WordPress', 'woocommerce']) + * ->where_meta(['show_in_rest' => true]) + * ->search('product') + * ->sort_by_desc('name'); + * + * @example + * // Convert to array if needed + * $abilities_array = wp_get_abilities()->to_array(); */ -function wp_get_abilities( array $args = array() ): array { - $query = new WP_Abilities_Query( $args ); - return $query->get_abilities(); +function wp_get_abilities(): WP_Abilities_Collection { + $registry = WP_Abilities_Registry::get_instance(); + return new WP_Abilities_Collection( $registry->get_all_registered() ); } /** diff --git a/includes/abilities-api/class-wp-abilities-query.php b/includes/abilities-api/class-wp-abilities-query.php deleted file mode 100644 index 7c7418e..0000000 --- a/includes/abilities-api/class-wp-abilities-query.php +++ /dev/null @@ -1,527 +0,0 @@ - - */ - private static $valid_orderby_fields = array( 'name', 'label', 'category' ); - - /** - * Valid order directions. - * - * @since n.e.x.t - * @var array - */ - private static $valid_order_directions = array( 'ASC', 'DESC' ); - - /** - * Query arguments after parsing. - * - * @since n.e.x.t - * @var array - */ - protected $query_vars = array(); - - /** - * The filtered abilities result. - * - * @since n.e.x.t - * @var \WP_Ability[]|null - */ - protected $abilities = null; - - /** - * Constructor. - * - * @since n.e.x.t - * - * @param array $args Optional. Query arguments. Default empty array. - * - * @phpstan-param array{ - * category?: string|array, - * namespace?: string|array, - * search?: string, - * meta?: array, - * orderby?: string, - * order?: string, - * limit?: int, - * offset?: int, - * ... - * } $args - */ - public function __construct( array $args = array() ) { - $this->parse_query( $args ); - } - - /** - * Parses and sanitizes query arguments. - * - * @since n.e.x.t - * - * @param array $args Query arguments. - * - */ - protected function parse_query( array $args ): void { - $defaults = array( - 'category' => '', - 'namespace' => '', - 'search' => '', - 'meta' => array(), - 'orderby' => '', - 'order' => 'ASC', - 'limit' => self::$no_limit, - 'offset' => 0, - ); - - $this->query_vars = wp_parse_args( $args, $defaults ); - - $this->sanitize_meta_arg(); - $this->sanitize_orderby(); - $this->sanitize_order(); - $this->sanitize_pagination_args(); - } - - /** - * Sanitizes the meta query argument. - * - * @since n.e.x.t - * - */ - protected function sanitize_meta_arg(): void { - if ( is_array( $this->query_vars['meta'] ) ) { - return; - } - $this->query_vars['meta'] = array(); - } - - /** - * Sanitizes the orderby query argument. - * - * @since n.e.x.t - * - */ - protected function sanitize_orderby(): void { - if ( empty( $this->query_vars['orderby'] ) ) { - return; - } - - if ( in_array( $this->query_vars['orderby'], self::$valid_orderby_fields, true ) ) { - return; - } - $this->query_vars['orderby'] = ''; - } - - /** - * Sanitizes the order query argument. - * - * @since n.e.x.t - * - */ - protected function sanitize_order(): void { - $this->query_vars['order'] = strtoupper( $this->query_vars['order'] ); - if ( in_array( $this->query_vars['order'], self::$valid_order_directions, true ) ) { - return; - } - $this->query_vars['order'] = 'ASC'; - } - - /** - * Sanitizes the pagination query arguments (limit and offset). - * - * @since n.e.x.t - * - */ - protected function sanitize_pagination_args(): void { - $this->query_vars['limit'] = (int) $this->query_vars['limit']; - $this->query_vars['offset'] = (int) $this->query_vars['offset']; - - // Ensure offset is non-negative. - if ( $this->query_vars['offset'] < 0 ) { - $this->query_vars['offset'] = 0; - } - - // Ensure limit is either -1 (no limit) or positive. - if ( $this->query_vars['limit'] >= self::$no_limit ) { - return; - } - - $this->query_vars['limit'] = self::$no_limit; - } - - /** - * Retrieves the filtered abilities based on query arguments. - * - * @return \WP_Ability[] Array of filtered abilities. - * @since n.e.x.t - * - */ - public function get_abilities(): array { - if ( null !== $this->abilities ) { - return $this->abilities; - } - - $abilities = WP_Abilities_Registry::get_instance()->get_all_registered(); - - $abilities = $this->apply_filters( $abilities ); - - if ( empty( $abilities ) ) { - $this->abilities = array(); - - return $this->abilities; - } - - $abilities = $this->apply_ordering( $abilities ); - - $abilities = $this->apply_pagination( $abilities ); - - $this->abilities = $abilities; - - return $this->abilities; - } - - /** - * Applies all filters in a single pass for optimal performance. - * - * @param \WP_Ability[] $abilities Abilities to filter. - * - * @return \WP_Ability[] Filtered abilities. - * @since n.e.x.t - * - */ - protected function apply_filters( array $abilities ): array { - $has_category = ! empty( $this->query_vars['category'] ); - $has_namespace = ! empty( $this->query_vars['namespace'] ); - $has_search = ! empty( $this->query_vars['search'] ); - $has_meta = ! empty( $this->query_vars['meta'] ) && is_array( $this->query_vars['meta'] ); - - if ( ! $has_category && ! $has_namespace && ! $has_search && ! $has_meta ) { - return $abilities; - } - - $filtered = array(); - - foreach ( $abilities as $name => $ability ) { - if ( $has_category && ! $this->filter_by_category( $ability ) ) { - continue; - } - - if ( $has_namespace && ! $this->filter_by_namespace( $ability ) ) { - continue; - } - - if ( $has_meta && ! $this->filter_by_meta( $ability ) ) { - continue; - } - - if ( $has_search && ! $this->filter_by_search( $ability ) ) { - continue; - } - - $filtered[ $name ] = $ability; - } - - return $filtered; - } - - /** - * Checks if an ability matches the category filter. - * - * @param \WP_Ability $ability The ability to check. - * - * @return bool True if ability matches category filter, false otherwise. - * @since n.e.x.t - * - */ - protected function filter_by_category( WP_Ability $ability ): bool { - return $this->matches_filter( $ability->get_category(), $this->query_vars['category'] ); - } - - /** - * Checks if an ability matches the namespace filter. - * - * @param \WP_Ability $ability The ability to check. - * - * @return bool True if ability matches namespace filter, false otherwise. - * @since n.e.x.t - * - */ - protected function filter_by_namespace( WP_Ability $ability ): bool { - $ability_namespace = self::get_ability_namespace( $ability->get_name() ); - - if ( null === $ability_namespace ) { - return false; - } - - return $this->matches_filter( $ability_namespace, $this->query_vars['namespace'] ); - } - - /** - * Checks if an ability matches the meta filters. - * - * @param \WP_Ability $ability The ability to check. - * - * @return bool True if ability matches meta filters, false otherwise. - * @since n.e.x.t - * - */ - protected function filter_by_meta( WP_Ability $ability ): bool { - $filters = $this->query_vars['meta']; - - if ( empty( $filters ) ) { - return true; - } - - $ability_meta = $ability->get_meta(); - - [ $flat_filters, $nested_filters ] = $this->separate_meta_filters( $filters ); - - return $this->check_flat_meta_filters( $ability_meta, $flat_filters ) - && $this->check_nested_meta_filters( $ability_meta, $nested_filters ); - } - - /** - * Checks if an ability matches the search term. - * - * @param \WP_Ability $ability The ability to check. - * - * @return bool True if ability matches search term, false otherwise. - * @since n.e.x.t - * - */ - protected function filter_by_search( WP_Ability $ability ): bool { - $search = $this->query_vars['search']; - - return stripos( $ability->get_name(), $search ) !== false - || stripos( $ability->get_label(), $search ) !== false - || stripos( $ability->get_description(), $search ) !== false; - } - - /** - * Checks if a value matches the filter (either equals or in array). - * - * @param string $value The value to check. - * @param string|array $filter The filter to match against. - * - * @return bool True if value matches the filter, false otherwise. - * @since n.e.x.t - * - */ - protected function matches_filter( string $value, $filter ): bool { - if ( is_array( $filter ) ) { - return in_array( $value, $filter, true ); - } - - return $value === $filter; - } - - /** - * Extracts the namespace from an ability name. - * - * @param string $ability_name The ability name (e.g., 'namespace/ability-name'). - * - * @return string|null The namespace part, or null if no slash found. - * @since n.e.x.t - * - */ - protected static function get_ability_namespace( string $ability_name ): ?string { - $slash_pos = strpos( $ability_name, '/' ); - - if ( false === $slash_pos ) { - return null; - } - - return substr( $ability_name, 0, $slash_pos ); - } - - /** - * Separates meta filters into flat and nested arrays. - * - * @param array $filters The meta filters to separate. - * - * @return array{0: array, 1: array} Array containing flat filters and nested filters. - * @since n.e.x.t - * - */ - protected function separate_meta_filters( array $filters ): array { - $flat_filters = array(); - $nested_filters = array(); - - foreach ( $filters as $key => $value ) { - if ( is_array( $value ) ) { - $nested_filters[ $key ] = $value; - } else { - $flat_filters[ $key ] = $value; - } - } - - return array( $flat_filters, $nested_filters ); - } - - /** - * Checks if ability meta matches flat filters. - * - * @param array $ability_meta The ability's meta data. - * @param array $flat_filters The flat filters to match. - * - * @return bool True if meta matches all flat filters, false otherwise. - * @since n.e.x.t - * - */ - protected function check_flat_meta_filters( array $ability_meta, array $flat_filters ): bool { - if ( empty( $flat_filters ) ) { - return true; - } - - $flat_filtered = wp_list_filter( array( $ability_meta ), $flat_filters ); - - return ! empty( $flat_filtered ); - } - - /** - * Checks if ability meta matches nested filters. - * - * @param array $ability_meta The ability's meta data. - * @param array $nested_filters The nested filters to match. - * - * @return bool True if meta matches all nested filters, false otherwise. - * @since n.e.x.t - * - */ - protected function check_nested_meta_filters( array $ability_meta, array $nested_filters ): bool { - if ( empty( $nested_filters ) ) { - return true; - } - - foreach ( $nested_filters as $key => $nested_filter ) { - if ( ! isset( $ability_meta[ $key ] ) || ! is_array( $ability_meta[ $key ] ) ) { - return false; - } - - $nested_filtered = wp_list_filter( array( $ability_meta[ $key ] ), $nested_filter ); - if ( empty( $nested_filtered ) ) { - return false; - } - } - - return true; - } - - /** - * Applies ordering to abilities. - * - * @param \WP_Ability[] $abilities Abilities to order. - * - * @return \WP_Ability[] Ordered abilities. - * @since n.e.x.t - * - */ - protected function apply_ordering( array $abilities ): array { - $orderby = $this->query_vars['orderby']; - - if ( empty( $orderby ) ) { - return $abilities; - } - - $order = $this->query_vars['order']; - - // Map orderby field to getter method name. - $getter_map = array( - 'name' => 'get_name', - 'label' => 'get_label', - 'category' => 'get_category', - ); - - if ( ! isset( $getter_map[ $orderby ] ) ) { - return $abilities; - } - - $getter_method = $getter_map[ $orderby ]; - $order_multiplier = 'DESC' === $order ? - 1 : 1; - - $abilities = array_values( $abilities ); - - usort( - $abilities, - static function ( $a, $b ) use ( $getter_method, $order_multiplier ) { - return strcasecmp( $a->$getter_method(), $b->$getter_method() ) * $order_multiplier; - } - ); - - return $abilities; - } - - /** - * Applies pagination to abilities. - * - * @param \WP_Ability[] $abilities Abilities to paginate. - * - * @return \WP_Ability[] Paginated abilities. - * @since n.e.x.t - * - */ - protected function apply_pagination( array $abilities ): array { - $limit = $this->query_vars['limit']; - $offset = $this->query_vars['offset']; - - // No pagination if limit is -1. - if ( self::$no_limit === $limit ) { - // Apply offset only if specified. - if ( $offset > 0 ) { - return array_slice( $abilities, $offset ); - } - - return $abilities; - } - - // Apply offset and limit. - return array_slice( $abilities, $offset, $limit ); - } - - /** - * Gets the query variables. - * - * @param string $key Optional. Specific query var to retrieve. Default empty string. - * - * @return mixed Query var value if key provided, all query vars if no key. - * @since n.e.x.t - * - */ - public function get( string $key = '' ) { - if ( ! empty( $key ) ) { - return $this->query_vars[ $key ] ?? null; - } - - return $this->query_vars; - } -} diff --git a/includes/bootstrap.php b/includes/bootstrap.php index 0ead8d8..265664e 100644 --- a/includes/bootstrap.php +++ b/includes/bootstrap.php @@ -35,8 +35,8 @@ if ( ! class_exists( 'WP_Abilities_Category_Registry' ) ) { require_once __DIR__ . '/abilities-api/class-wp-abilities-category-registry.php'; } -if ( ! class_exists( 'WP_Abilities_Query' ) ) { - require_once __DIR__ . '/abilities-api/class-wp-abilities-query.php'; +if ( ! class_exists( 'WP_Abilities_Collection' ) ) { + require_once __DIR__ . '/abilities-api/class-wp-abilities-collection.php'; } // Ensure procedural functions are available, too. diff --git a/tests/unit/abilities-api/wpAbilitiesQuery.php b/tests/unit/abilities-api/wpAbilitiesQuery.php deleted file mode 100644 index c473545..0000000 --- a/tests/unit/abilities-api/wpAbilitiesQuery.php +++ /dev/null @@ -1,834 +0,0 @@ -registry = WP_Abilities_Registry::get_instance(); - - // Register test categories. - add_action( - 'abilities_api_categories_init', - static function () { - $categories = array( 'math', 'data-retrieval', 'communication', 'ecommerce' ); - foreach ( $categories as $category_slug ) { - $registry = WP_Abilities_Category_Registry::get_instance(); - if ( $registry->is_registered( $category_slug ) ) { - continue; - } - - wp_register_ability_category( - $category_slug, - array( - 'label' => ucfirst( $category_slug ), - 'description' => ucfirst( $category_slug ) . ' category.', - ) - ); - } - } - ); - - // Fire the hook to allow category registration. - do_action( 'abilities_api_categories_init' ); - - // Register test abilities with diverse properties. - $this->register_test_abilities(); - } - - /** - * Tear down each test method. - */ - public function tear_down(): void { - // Clean up registered abilities. - $abilities = $this->registry->get_all_registered(); - foreach ( $abilities as $ability ) { - $this->registry->unregister( $ability->get_name() ); - } - - // Clean up registered categories. - $category_registry = WP_Abilities_Category_Registry::get_instance(); - $categories = array( 'math', 'data-retrieval', 'communication', 'ecommerce' ); - foreach ( $categories as $category_slug ) { - if ( ! $category_registry->is_registered( $category_slug ) ) { - continue; - } - - wp_unregister_ability_category( $category_slug ); - } - - $this->registry = null; - - parent::tear_down(); - } - - /** - * Registers test abilities with various properties for filtering tests. - */ - private function register_test_abilities(): void { - // Math abilities - test namespace. - $this->registry->register( - 'test/add-numbers', - array( - 'label' => 'Add Numbers', - 'description' => 'Adds two numbers together.', - 'category' => 'math', - 'execute_callback' => static function ( array $input ): int { - return $input['a'] + $input['b']; - }, - 'permission_callback' => '__return_true', - 'input_schema' => array( - 'type' => 'object', - 'properties' => array( - 'a' => array( 'type' => 'number' ), - 'b' => array( 'type' => 'number' ), - ), - ), - 'output_schema' => array( 'type' => 'number' ), - 'meta' => array( - 'show_in_rest' => true, - 'annotations' => array( - 'readonly' => true, - 'destructive' => false, - 'idempotent' => true, - ), - ), - ) - ); - - $this->registry->register( - 'test/multiply-numbers', - array( - 'label' => 'Multiply Numbers', - 'description' => 'Multiplies two numbers together.', - 'category' => 'math', - 'execute_callback' => static function ( array $input ): int { - return $input['a'] * $input['b']; - }, - 'permission_callback' => '__return_true', - 'input_schema' => array( - 'type' => 'object', - 'properties' => array( - 'a' => array( 'type' => 'number' ), - 'b' => array( 'type' => 'number' ), - ), - ), - 'output_schema' => array( 'type' => 'number' ), - 'meta' => array( - 'show_in_rest' => false, - 'annotations' => array( - 'readonly' => true, - 'destructive' => false, - 'idempotent' => true, - ), - ), - ) - ); - - // Data retrieval abilities - example namespace. - $this->registry->register( - 'example/get-user-data', - array( - 'label' => 'Get User Data', - 'description' => 'Retrieves user data from the database.', - 'category' => 'data-retrieval', - 'execute_callback' => static function () { - return array( 'user' => 'John Doe' ); - }, - 'permission_callback' => '__return_true', - 'input_schema' => array( 'type' => 'object' ), - 'output_schema' => array( 'type' => 'object' ), - 'meta' => array( - 'show_in_rest' => true, - 'annotations' => array( - 'readonly' => true, - 'destructive' => false, - 'idempotent' => true, - ), - 'custom_key' => 'custom_value', - ), - ) - ); - - // Communication abilities - demo namespace. - $this->registry->register( - 'demo/send-email', - array( - 'label' => 'Send Email', - 'description' => 'Sends an email to a recipient.', - 'category' => 'communication', - 'execute_callback' => static function (): bool { - return true; - }, - 'permission_callback' => '__return_true', - 'input_schema' => array( - 'type' => 'object', - 'properties' => array( - 'to' => array( 'type' => 'string' ), - 'subject' => array( 'type' => 'string' ), - 'message' => array( 'type' => 'string' ), - ), - ), - 'output_schema' => array( 'type' => 'boolean' ), - 'meta' => array( - 'show_in_rest' => true, - 'annotations' => array( - 'readonly' => false, - 'destructive' => false, - 'idempotent' => false, - ), - ), - ) - ); - - // E-commerce abilities. - $this->registry->register( - 'example/process-payment', - array( - 'label' => 'Process Payment', - 'description' => 'Processes a payment transaction.', - 'category' => 'ecommerce', - 'execute_callback' => static function () { - return array( 'success' => true ); - }, - 'permission_callback' => '__return_true', - 'input_schema' => array( - 'type' => 'object', - 'properties' => array( - 'amount' => array( 'type' => 'number' ), - ), - ), - 'output_schema' => array( 'type' => 'object' ), - 'meta' => array( - 'show_in_rest' => false, - 'annotations' => array( - 'readonly' => false, - 'destructive' => true, - 'idempotent' => false, - ), - ), - ) - ); - } - - /** - * Test basic query instantiation. - * - * @covers WP_Abilities_Query::__construct - */ - public function test_query_instantiation() { - $query = new WP_Abilities_Query(); - $this->assertInstanceOf( WP_Abilities_Query::class, $query ); - } - - /** - * Test query with no arguments returns all abilities. - * - * @covers WP_Abilities_Query::get_abilities - */ - public function test_query_no_args_returns_all() { - $query = new WP_Abilities_Query(); - $abilities = $query->get_abilities(); - - $this->assertCount( 5, $abilities ); - } - - /** - * Test wp_get_abilities() without arguments (backward compatibility). - * - * @covers wp_get_abilities - */ - public function test_wp_get_abilities_without_args() { - $abilities = wp_get_abilities(); - - $this->assertCount( 5, $abilities ); - $this->assertArrayHasKey( 'test/add-numbers', $abilities ); - } - - /** - * Test wp_get_abilities() with arguments uses query. - * - * @covers wp_get_abilities - */ - public function test_wp_get_abilities_with_args() { - $abilities = wp_get_abilities( array( 'category' => 'math' ) ); - - $this->assertCount( 2, $abilities ); - } - - /** - * Test filter by category. - * - * @covers WP_Abilities_Query::apply_filters - */ - public function test_filter_by_category() { - $query = new WP_Abilities_Query( array( 'category' => 'math' ) ); - $abilities = $query->get_abilities(); - - $this->assertCount( 2, $abilities ); - foreach ( $abilities as $ability ) { - $this->assertSame( 'math', $ability->get_category() ); - } - } - - /** - * Test filter by multiple categories. - * - * @covers WP_Abilities_Query::apply_filters - */ - public function test_filter_by_multiple_categories() { - $query = new WP_Abilities_Query( array( 'category' => array( 'math', 'communication' ) ) ); - $abilities = $query->get_abilities(); - - $this->assertCount( 3, $abilities ); - foreach ( $abilities as $ability ) { - $this->assertContains( $ability->get_category(), array( 'math', 'communication' ) ); - } - } - - /** - * Test filter by namespace. - * - * @covers WP_Abilities_Query::apply_filters - */ - public function test_filter_by_namespace() { - $query = new WP_Abilities_Query( array( 'namespace' => 'test' ) ); - $abilities = $query->get_abilities(); - - $this->assertCount( 2, $abilities ); - foreach ( $abilities as $ability ) { - $this->assertStringStartsWith( 'test/', $ability->get_name() ); - } - } - - /** - * Test filter by multiple namespaces. - * - * @covers WP_Abilities_Query::apply_filters - */ - public function test_filter_by_multiple_namespaces() { - $query = new WP_Abilities_Query( array( 'namespace' => array( 'test', 'example' ) ) ); - $abilities = $query->get_abilities(); - - $this->assertCount( 4, $abilities ); - foreach ( $abilities as $ability ) { - $name = $ability->get_name(); - $namespace = substr( $name, 0, strpos( $name, '/' ) ); - $this->assertContains( $namespace, array( 'test', 'example' ) ); - } - } - - /** - * Test filter by search term in name. - * - * @covers WP_Abilities_Query::apply_filters - */ - public function test_filter_by_search_in_name() { - $query = new WP_Abilities_Query( array( 'search' => 'email' ) ); - $abilities = $query->get_abilities(); - - $this->assertCount( 1, $abilities ); - $ability = reset( $abilities ); - $this->assertSame( 'demo/send-email', $ability->get_name() ); - } - - /** - * Test filter by search term in label. - * - * @covers WP_Abilities_Query::apply_filters - */ - public function test_filter_by_search_in_label() { - $query = new WP_Abilities_Query( array( 'search' => 'multiply' ) ); - $abilities = $query->get_abilities(); - - $this->assertCount( 1, $abilities ); - $ability = reset( $abilities ); - $this->assertSame( 'test/multiply-numbers', $ability->get_name() ); - } - - /** - * Test filter by search term in description. - * - * @covers WP_Abilities_Query::apply_filters - */ - public function test_filter_by_search_in_description() { - $query = new WP_Abilities_Query( array( 'search' => 'payment' ) ); - $abilities = $query->get_abilities(); - - $this->assertCount( 1, $abilities ); - $ability = reset( $abilities ); - $this->assertSame( 'example/process-payment', $ability->get_name() ); - } - - /** - * Test filter by show_in_rest using structured array. - * - * @covers WP_Abilities_Query::apply_filters - * @covers WP_Abilities_Query::matches_meta_filters - */ - public function test_filter_by_show_in_rest_structured() { - $query = new WP_Abilities_Query( - array( - 'meta' => array( - 'show_in_rest' => true, - ), - ) - ); - - $abilities = $query->get_abilities(); - - $this->assertCount( 3, $abilities ); - foreach ( $abilities as $ability ) { - $this->assertTrue( $ability->get_meta_item( 'show_in_rest' ) ); - } - } - - - /** - * Test filter by readonly annotation using structured array. - * - * @covers WP_Abilities_Query::apply_filters - * @covers WP_Abilities_Query::matches_meta_filters - */ - public function test_filter_by_readonly_structured() { - $query = new WP_Abilities_Query( - array( - 'meta' => array( - 'annotations' => array( - 'readonly' => true, - ), - ), - ) - ); - - $abilities = $query->get_abilities(); - - $this->assertCount( 3, $abilities ); - foreach ( $abilities as $ability ) { - $meta = $ability->get_meta(); - $this->assertTrue( $meta['annotations']['readonly'] ); - } - } - - - /** - * Test filter by destructive annotation. - * - * @covers WP_Abilities_Query::apply_filters - * @covers WP_Abilities_Query::matches_meta_filters - */ - public function test_filter_by_destructive() { - $query = new WP_Abilities_Query( - array( - 'meta' => array( - 'annotations' => array( - 'destructive' => true, - ), - ), - ) - ); - - $abilities = $query->get_abilities(); - - $this->assertCount( 1, $abilities ); - $ability = reset( $abilities ); - $this->assertSame( 'example/process-payment', $ability->get_name() ); - } - - /** - * Test filter by idempotent annotation. - * - * @covers WP_Abilities_Query::apply_filters - * @covers WP_Abilities_Query::matches_meta_filters - */ - public function test_filter_by_idempotent() { - $query = new WP_Abilities_Query( - array( - 'meta' => array( - 'annotations' => array( - 'idempotent' => true, - ), - ), - ) - ); - - $abilities = $query->get_abilities(); - - $this->assertCount( 3, $abilities ); - } - - /** - * Test filter by custom meta key. - * - * @covers WP_Abilities_Query::apply_filters - * @covers WP_Abilities_Query::matches_meta_filters - */ - public function test_filter_by_custom_meta() { - $query = new WP_Abilities_Query( - array( - 'meta' => array( - 'custom_key' => 'custom_value', - ), - ) - ); - - $abilities = $query->get_abilities(); - - $this->assertCount( 1, $abilities ); - $ability = reset( $abilities ); - $this->assertSame( 'example/get-user-data', $ability->get_name() ); - } - - /** - * Test meta filters with AND logic (all conditions must match). - * - * @covers WP_Abilities_Query::apply_filters - * @covers WP_Abilities_Query::matches_meta - */ - public function test_meta_filters_and_logic() { - $query = new WP_Abilities_Query( - array( - 'meta' => array( - 'show_in_rest' => true, - 'annotations' => array( - 'readonly' => true, - ), - ), - ) - ); - - $abilities = $query->get_abilities(); - - $this->assertCount( 2, $abilities ); - foreach ( $abilities as $ability ) { - $this->assertTrue( $ability->get_meta_item( 'show_in_rest' ) ); - $meta = $ability->get_meta(); - $this->assertTrue( $meta['annotations']['readonly'] ); - } - } - - /** - * Test multiple filters combined. - * - * @covers WP_Abilities_Query::get_abilities - */ - public function test_multiple_filters_combined() { - $query = new WP_Abilities_Query( - array( - 'category' => 'math', - 'meta' => array( - 'show_in_rest' => true, - 'annotations' => array( - 'readonly' => true, - ), - ), - ) - ); - - $abilities = $query->get_abilities(); - - $this->assertCount( 1, $abilities ); - $ability = reset( $abilities ); - $this->assertSame( 'test/add-numbers', $ability->get_name() ); - } - - /** - * Test order by name ascending (default). - * - * @covers WP_Abilities_Query::apply_ordering - */ - public function test_order_by_name_asc() { - $query = new WP_Abilities_Query( array( 'orderby' => 'name' ) ); - $abilities = $query->get_abilities(); - - $names = array_map( - static function ( $ability ) { - return $ability->get_name(); - }, - $abilities - ); - - $expected = array( - 'demo/send-email', - 'example/get-user-data', - 'example/process-payment', - 'test/add-numbers', - 'test/multiply-numbers', - ); - - $this->assertSame( $expected, $names ); - } - - /** - * Test order by name descending. - * - * @covers WP_Abilities_Query::apply_ordering - */ - public function test_order_by_name_desc() { - $query = new WP_Abilities_Query( - array( - 'orderby' => 'name', - 'order' => 'DESC', - ) - ); - - $abilities = $query->get_abilities(); - - $names = array_map( - static function ( $ability ) { - return $ability->get_name(); - }, - $abilities - ); - - $expected = array( - 'test/multiply-numbers', - 'test/add-numbers', - 'example/process-payment', - 'example/get-user-data', - 'demo/send-email', - ); - - $this->assertSame( $expected, $names ); - } - - /** - * Test order by label. - * - * @covers WP_Abilities_Query::apply_ordering - */ - public function test_order_by_label() { - $query = new WP_Abilities_Query( - array( - 'orderby' => 'label', - 'order' => 'ASC', - ) - ); - - $abilities = $query->get_abilities(); - - $labels = array_map( - static function ( $ability ) { - return $ability->get_label(); - }, - $abilities - ); - - $expected = array( - 'Add Numbers', - 'Get User Data', - 'Multiply Numbers', - 'Process Payment', - 'Send Email', - ); - - $this->assertSame( $expected, $labels ); - } - - /** - * Test order by category. - * - * @covers WP_Abilities_Query::apply_ordering - */ - public function test_order_by_category() { - $query = new WP_Abilities_Query( - array( - 'orderby' => 'category', - 'order' => 'ASC', - ) - ); - - $abilities = $query->get_abilities(); - - $categories = array_map( - static function ( $ability ) { - return $ability->get_category(); - }, - $abilities - ); - - $expected = array( - 'communication', - 'data-retrieval', - 'ecommerce', - 'math', - 'math', - ); - - $this->assertSame( $expected, $categories ); - } - - /** - * Test pagination with limit. - * - * @covers WP_Abilities_Query::apply_pagination - */ - public function test_pagination_limit() { - $query = new WP_Abilities_Query( array( 'limit' => 2 ) ); - $abilities = $query->get_abilities(); - - $this->assertCount( 2, $abilities ); - } - - /** - * Test pagination with offset. - * - * @covers WP_Abilities_Query::apply_pagination - */ - public function test_pagination_offset() { - $query = new WP_Abilities_Query( - array( - 'orderby' => 'name', - 'offset' => 2, - ) - ); - - $abilities = $query->get_abilities(); - - $this->assertCount( 3, $abilities ); - $first = reset( $abilities ); - $this->assertSame( 'example/process-payment', $first->get_name() ); - } - - /** - * Test pagination with both limit and offset. - * - * @covers WP_Abilities_Query::apply_pagination - */ - public function test_pagination_limit_and_offset() { - $query = new WP_Abilities_Query( - array( - 'orderby' => 'name', - 'limit' => 2, - 'offset' => 1, - ) - ); - - $abilities = $query->get_abilities(); - - $this->assertCount( 2, $abilities ); - - $names = array_map( - static function ( $ability ) { - return $ability->get_name(); - }, - $abilities - ); - - $expected = array( - 'example/get-user-data', - 'example/process-payment', - ); - - $this->assertSame( $expected, $names ); - } - - /** - * Test query returns empty array when no matches. - * - * @covers WP_Abilities_Query::get_abilities - */ - public function test_no_matches_returns_empty_array() { - $query = new WP_Abilities_Query( array( 'category' => 'nonexistent' ) ); - $abilities = $query->get_abilities(); - - $this->assertIsArray( $abilities ); - $this->assertEmpty( $abilities ); - } - - /** - * Test query::get() method returns all query vars. - * - * @covers WP_Abilities_Query::get - */ - public function test_get_query_vars() { - $query = new WP_Abilities_Query( array( 'category' => 'math' ) ); - $query_vars = $query->get(); - - $this->assertIsArray( $query_vars ); - $this->assertSame( 'math', $query_vars['category'] ); - } - - /** - * Test query::get() method with specific key. - * - * @covers WP_Abilities_Query::get - */ - public function test_get_specific_query_var() { - $query = new WP_Abilities_Query( array( 'category' => 'math' ) ); - $category = $query->get( 'category' ); - - $this->assertSame( 'math', $category ); - } - - /** - * Test invalid orderby defaults to empty string (no ordering). - * - * @covers WP_Abilities_Query::parse_query - */ - public function test_invalid_orderby_defaults_to_empty() { - $query = new WP_Abilities_Query( array( 'orderby' => 'invalid' ) ); - $orderby = $query->get( 'orderby' ); - - $this->assertSame( '', $orderby ); - } - - /** - * Test invalid order defaults to 'ASC'. - * - * @covers WP_Abilities_Query::parse_query - */ - public function test_invalid_order_defaults_to_asc() { - $query = new WP_Abilities_Query( array( 'order' => 'invalid' ) ); - $order = $query->get( 'order' ); - - $this->assertSame( 'ASC', $order ); - } - - - /** - * Test query results are cached. - * - * @covers WP_Abilities_Query::get_abilities - */ - public function test_query_results_are_cached() { - $query = new WP_Abilities_Query( array( 'category' => 'math' ) ); - - $first_call = $query->get_abilities(); - $second_call = $query->get_abilities(); - - // Should return same instance (cached). - $this->assertSame( $first_call, $second_call ); - } - - /** - * Test case-insensitive search. - * - * @covers WP_Abilities_Query::apply_filters - */ - public function test_case_insensitive_search() { - $query = new WP_Abilities_Query( array( 'search' => 'EMAIL' ) ); - $abilities = $query->get_abilities(); - - $this->assertCount( 1, $abilities ); - $ability = reset( $abilities ); - $this->assertSame( 'demo/send-email', $ability->get_name() ); - } -} From 792abaaebe198741ca706c04e43e0504690c744c Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Wed, 15 Oct 2025 12:01:04 +0300 Subject: [PATCH 14/17] refactor(internal): Update consumers to use WP_Abilities_Collection API --- ...lass-wp-rest-abilities-list-controller.php | 24 ++++++------------- .../unit/abilities-api/wpRegisterAbility.php | 2 +- 2 files changed, 8 insertions(+), 18 deletions(-) diff --git a/includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php b/includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php index 54e393f..ef419cc 100644 --- a/includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php +++ b/includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php @@ -94,24 +94,13 @@ public function register_routes(): void { * @return \WP_REST_Response Response object on success. */ public function get_items( $request ) { - $abilities = array_filter( - wp_get_abilities(), - static function ( $ability ) { - return $ability->get_meta_item( 'show_in_rest' ); - } - ); + // Get all abilities and filter to only those shown in REST. + $abilities = wp_get_abilities()->where( 'meta.show_in_rest', true ); // Filter by category if specified. $category = $request->get_param( 'category' ); if ( ! empty( $category ) ) { - $abilities = array_filter( - $abilities, - static function ( $ability ) use ( $category ) { - return $ability->get_category() === $category; - } - ); - // Reset array keys after filtering. - $abilities = array_values( $abilities ); + $abilities = $abilities->where_category( $category ); } // Handle pagination with explicit defaults. @@ -120,16 +109,17 @@ static function ( $ability ) use ( $category ) { $per_page = $params['per_page'] ?? self::DEFAULT_PER_PAGE; $offset = ( $page - 1 ) * $per_page; - $total_abilities = count( $abilities ); + $total_abilities = $abilities->count(); $max_pages = ceil( $total_abilities / $per_page ); if ( $request->get_method() === 'HEAD' ) { $response = new \WP_REST_Response( array() ); } else { - $abilities = array_slice( $abilities, $offset, $per_page ); + // Paginate using array_slice on the array. + $abilities_array = array_slice( $abilities->to_array(), $offset, $per_page ); $data = array(); - foreach ( $abilities as $ability ) { + foreach ( $abilities_array as $ability ) { $item = $this->prepare_item_for_response( $ability, $request ); $data[] = $this->prepare_response_for_collection( $item ); } diff --git a/tests/unit/abilities-api/wpRegisterAbility.php b/tests/unit/abilities-api/wpRegisterAbility.php index 04a39a2..704bca1 100644 --- a/tests/unit/abilities-api/wpRegisterAbility.php +++ b/tests/unit/abilities-api/wpRegisterAbility.php @@ -493,7 +493,7 @@ public function test_get_all_registered_abilities() { $ability_three_name => new WP_Ability( $ability_three_name, $ability_three_args ), ); - $result = wp_get_abilities(); + $result = wp_get_abilities()->to_array(); $this->assertEquals( $expected, $result ); } From b26e4ba60e5a0f35192173c4100836c85984f2e4 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Wed, 15 Oct 2025 12:01:04 +0300 Subject: [PATCH 15/17] docs(abilities): Document the new WP_Abilities_Collection API --- docs/4.using-abilities.md | 56 ++- docs/8.advanced-filtering-and-sorting.md | 420 +++++++++++++++++++++++ docs/8.querying-filtering-abilities.md | 213 ------------ 3 files changed, 457 insertions(+), 232 deletions(-) create mode 100644 docs/8.advanced-filtering-and-sorting.md delete mode 100644 docs/8.querying-filtering-abilities.md diff --git a/docs/4.using-abilities.md b/docs/4.using-abilities.md index d88bf64..bf6a774 100644 --- a/docs/4.using-abilities.md +++ b/docs/4.using-abilities.md @@ -35,51 +35,69 @@ if ( $site_info_ability ) { ## Getting All Registered Abilities (`wp_get_abilities`) -To get an array of all registered abilities: +`wp_get_abilities()` returns a `WP_Abilities_Collection` instance, which provides a fluent, chainable API for filtering and sorting: ```php /** - * Retrieves all registered abilities using Abilities API. + * Retrieves a collection of registered abilities. * - * @param array $args Optional. Arguments to filter abilities. - * @return WP_Ability[] The array of registered abilities. + * Returns a WP_Abilities_Collection instance that provides a fluent, chainable + * API for filtering, sorting, and manipulating abilities. + * + * @since n.e.x.t Returns WP_Abilities_Collection instead of array. + * @return WP_Abilities_Collection Collection of WP_Ability instances. */ -function wp_get_abilities( array $args = array() ): array +function wp_get_abilities(): WP_Abilities_Collection // Example: Get all registered abilities -$all_abilities = wp_get_abilities(); +$abilities = wp_get_abilities(); -foreach ( $all_abilities as $name => $ability ) { +// Iterate over the collection +foreach ( $abilities as $ability ) { echo 'Ability Name: ' . esc_html( $ability->get_name() ) . "\n"; echo 'Label: ' . esc_html( $ability->get_label() ) . "\n"; echo 'Description: ' . esc_html( $ability->get_description() ) . "\n"; echo "---\n"; } + +// Or convert to array if needed +$abilities_array = $abilities->to_array(); ``` ### Filtering Abilities -`wp_get_abilities()` accepts optional filter arguments. For detailed filtering documentation, see [Querying and Filtering Abilities](8.querying-filtering-abilities.md). +The collection returned by `wp_get_abilities()` provides chainable methods for filtering and sorting. For detailed filtering documentation, see [Advanced Filtering and Sorting](8.advanced-filtering-and-sorting.md). -Quick example: +Quick examples: ```php -// Get abilities in a specific category -$abilities = wp_get_abilities( array( 'category' => 'data-retrieval' ) ); +// Filter by category +$abilities = wp_get_abilities()->where_category( 'data-retrieval' ); // Search for abilities -$abilities = wp_get_abilities( array( 'search' => 'email' ) ); +$abilities = wp_get_abilities()->search( 'email' ); // Filter by namespace and meta -$abilities = wp_get_abilities( array( - 'namespace' => 'my-plugin', - 'meta' => array( - 'show_in_rest' => true, - ), -) ); +$abilities = wp_get_abilities() + ->where_namespace( 'my-plugin' ) + ->where( 'meta.show_in_rest', true ); + +// Chain multiple filters +$abilities = wp_get_abilities() + ->where_category( array( 'communication', 'data-retrieval' ) ) + ->where_namespace( 'my-plugin' ) + ->where( 'meta.show_in_rest', true ) + ->search( 'email' ) + ->sort_by( 'label' ) + ->all(); // Convert to array + +// Get count without converting to array +$count = wp_get_abilities() + ->where_category( 'math' ) + ->count(); ``` -See [Querying and Filtering Abilities](8.querying-filtering-abilities.md) for complete filtering documentation, including meta filters, ordering, and pagination. +See [Advanced Filtering and Sorting](8.advanced-filtering-and-sorting.md) for complete filtering documentation, including nested meta filters, comparison operators, and custom callbacks. ## Executing an Ability (`$ability->execute()`) diff --git a/docs/8.advanced-filtering-and-sorting.md b/docs/8.advanced-filtering-and-sorting.md new file mode 100644 index 0000000..60af6b4 --- /dev/null +++ b/docs/8.advanced-filtering-and-sorting.md @@ -0,0 +1,420 @@ +# 9. Advanced Filtering and Sorting + +The `wp_get_abilities()` function returns a `WP_Abilities_Collection` instance, which provides a powerful, chainable API for filtering and sorting operations. This document covers filtering techniques using collection methods. + +## Overview + +The collection provides advanced filtering methods including: + +- **Dot notation** for nested property access (`meta.annotations.readonly`) +- **Comparison operators** for flexible filtering (`>`, `<`, `!==`, etc.) +- **Method chaining** for building complex queries +- **Custom callbacks** for advanced filtering logic +- **Flexible sorting** by properties or custom comparators + +```php +// wp_get_abilities() returns a collection directly +$abilities = wp_get_abilities(); + +// Chain filtering methods +$readonly_abilities = $abilities + ->where_category( 'math' ) + ->where( 'meta.annotations.readonly', true ) + ->where( 'meta.annotations.destructive', false ) + ->sort_by( 'label' ) + ->all(); +``` + +## When to Use Collection Methods + +`wp_get_abilities()` always returns a collection, giving you immediate access to: +- Filter by category, namespace, or search terms +- Filter by nested meta properties with dot notation +- Use comparison operators (`>`, `<`, `!==`) +- Chain multiple filtering operations +- Apply custom callback-based filtering +- Sort with custom comparators + +## Quick Reference: Collection Methods + +### Filtering Methods + +| Method | Description | +|--------|-------------| +| `where($key, $operator, $value)` | Filter by property with operator (supports dot notation) | +| `where_in($key, $values)` | Filter where property is in array | +| `where_not_in($key, $values)` | Filter where property is NOT in array | +| `where_category($categories)` | Filter by category/categories | +| `where_namespace($namespaces)` | Filter by namespace/namespaces | +| `where_meta($filters)` | Filter by meta properties | +| `filter($callback)` | Filter using custom callback | +| `search($term)` | Search in name, label, and description | + +### Sorting Methods + +| Method | Description | +|--------|-------------| +| `sort_by($property, $desc)` | Sort by property or callback | +| `sort_by_desc($property)` | Sort by property descending | +| `reverse()` | Reverse the order | + +### Retrieving Results + +| Method | Description | +|--------|-------------| +| `all()` | Get all abilities as array | +| `first($callback, $default)` | Get first ability | +| `last($callback, $default)` | Get last ability | +| `get($name, $default)` | Get ability by name | +| `count()` | Count abilities | +| `pluck($value, $key)` | Extract property values | + +## Advanced Filtering Techniques + +### Using where() with Dot Notation + +The `where()` method supports dot notation to access nested properties: + +```php +// wp_get_abilities() returns a collection +$abilities = wp_get_abilities(); + +// Filter by nested meta property +$rest_enabled = $abilities->where( 'meta.show_in_rest', true ); + +// Filter by deeply nested annotation +$readonly = $abilities->where( 'meta.annotations.readonly', true ); +$non_destructive = $abilities->where( 'meta.annotations.destructive', false ); +``` + +### Using Comparison Operators + +The `where()` method supports comparison operators: + +```php +$abilities = wp_get_abilities(); + +// Operators: =, ==, ===, !=, !==, <>, >, >=, <, <= + +// Not equal +$non_math = $abilities->where( 'category', '!==', 'math' ); + +// String comparison +$priority_abilities = $abilities->where( 'meta.priority', '>', 5 ); +``` + +### Using where_in() and where_not_in() + +Filter by checking if a value is in (or not in) an array: + +```php +$abilities = wp_get_abilities(); + +// Include multiple categories +$filtered = $abilities->where_in( 'category', array( 'math', 'communication' ) ); + +// Exclude categories +$filtered = $abilities->where_not_in( 'category', array( 'admin', 'system' ) ); + +// Works with dot notation too +$filtered = $abilities->where_in( 'meta.annotations.readonly', array( true ) ); +``` + +### Filtering by Meta Properties + +The `where_meta()` method filters by meta properties with dot notation support: + +```php +$abilities = wp_get_abilities(); + +// Single meta filter +$rest_abilities = $abilities->where_meta( array( + 'show_in_rest' => true, +) ); + +// Nested meta using dot notation +$readonly = $abilities->where_meta( array( + 'annotations.readonly' => true, +) ); + +// Multiple meta filters (AND logic - all must match) +$safe_abilities = $abilities->where_meta( array( + 'show_in_rest' => true, + 'annotations.readonly' => true, + 'annotations.destructive' => false, +) ); +``` + +### Using Custom Callbacks + +For complex filtering logic, use the `filter()` method with a custom callback: + +```php +$abilities = wp_get_abilities(); + +// Custom callback receives each WP_Ability object +$filtered = $abilities->filter( function( $ability ) { + // Keep only abilities with long descriptions + return strlen( $ability->get_description() ) > 100; +} ); + +// Complex custom logic +$filtered = $abilities->filter( function( $ability ) { + $meta = $ability->get_meta(); + + // Custom business logic + return isset( $meta['show_in_rest'] ) + && $meta['show_in_rest'] + && str_starts_with( $ability->get_name(), 'my-plugin/' ) + && ! empty( $ability->get_label() ); +} ); +``` + +## Advanced Sorting + +### Sort by Property + +```php +$abilities = wp_get_abilities(); + +// Sort by name (ascending) +$sorted = $abilities->sort_by( 'name' ); + +// Sort by label (ascending) +$sorted = $abilities->sort_by( 'label' ); + +// Sort by category (descending) +$sorted = $abilities->sort_by( 'category', true ); +// or +$sorted = $abilities->sort_by_desc( 'category' ); +``` + +### Sort by Custom Callback + +```php +$abilities = wp_get_abilities(); + +// Sort by description length +$sorted = $abilities->sort_by( function( $a, $b ) { + return strlen( $a->get_description() ) <=> strlen( $b->get_description() ); +} ); + +// Sort by custom priority (with fallback) +$sorted = $abilities->sort_by( function( $a, $b ) { + $meta_a = $a->get_meta(); + $meta_b = $b->get_meta(); + + $priority_a = $meta_a['priority'] ?? 0; + $priority_b = $meta_b['priority'] ?? 0; + + return $priority_b <=> $priority_a; // Higher priority first +} ); +``` + +### Reverse Order + +```php +$abilities = wp_get_abilities(); + +// Reverse the current order +$reversed = $abilities->reverse(); + +// Sort then reverse +$sorted_reversed = $abilities + ->sort_by( 'label' ) + ->reverse(); +``` + +## Method Chaining + +One of the most powerful features is the ability to chain multiple operations: + +```php +$abilities = wp_get_abilities(); + +// Build complex queries with chaining +$results = $abilities + ->where_category( array( 'communication', 'data-retrieval' ) ) + ->where_namespace( 'my-plugin' ) + ->where( 'meta.show_in_rest', true ) + ->where( 'meta.annotations.readonly', true ) + ->search( 'email' ) + ->sort_by( 'label' ) + ->all(); + +// Each method returns a new collection (immutable) +$base = $abilities->where_category( 'math' ); +$readonly = $base->where( 'meta.annotations.readonly', true ); +$destructive = $base->where( 'meta.annotations.destructive', true ); +// $base is unchanged by further filtering +``` + +## Retrieving Results + +### Get All Results + +```php +$abilities = wp_get_abilities(); + +$filtered = $abilities->where_category( 'math' ); + +// Get array of WP_Ability objects +$results = $filtered->all(); +// or +$results = $filtered->to_array(); + +// Count results +$count = $filtered->count(); +// or +$count = count( $filtered ); +``` + +### Get First or Last + +```php +$abilities = wp_get_abilities(); + +// Get first ability +$first = $abilities->first(); + +// Get last ability +$last = $abilities->last(); + +// Get first matching a condition +$first_readonly = $abilities->first( function( $ability ) { + $meta = $ability->get_meta(); + return isset( $meta['annotations']['readonly'] ) && $meta['annotations']['readonly']; +} ); + +// With default value if not found +$first_or_null = $abilities->first( function( $ability ) { + return $ability->get_category() === 'nonexistent'; +}, null ); +``` + +### Get by Name + +```php +$abilities = wp_get_abilities(); + +// Get specific ability by name +$ability = $abilities->get( 'my-plugin/send-email' ); + +// With default value if not found +$ability = $abilities->get( 'missing/ability', null ); +``` + +### Extract Property Values with pluck() + +```php +$abilities = wp_get_abilities(); + +// Get array of ability names +$names = $abilities->pluck( 'name' ); +// Returns: ['plugin/ability-one', 'plugin/ability-two', ...] + +// Get array of labels +$labels = $abilities->pluck( 'label' ); +// Returns: ['Ability One', 'Ability Two', ...] + +// Pluck with custom keys (second parameter) +$labels_by_name = $abilities->pluck( 'label', 'name' ); +// Returns: [ +// 'plugin/ability-one' => 'Ability One', +// 'plugin/ability-two' => 'Ability Two', +// ] +``` + +## Practical Examples + +### Example 1: Find Safe, REST-Enabled Abilities + +```php +// Get all abilities as a collection +$abilities = wp_get_abilities(); + +// Filter for safe abilities only +$safe_abilities = $abilities + ->where_meta( array( + 'show_in_rest' => true, + 'annotations.readonly' => true, + 'annotations.destructive' => false, + ) ) + ->sort_by( 'label' ) + ->all(); + +foreach ( $safe_abilities as $ability ) { + echo sprintf( + "✓ %s - %s\n", + $ability->get_label(), + $ability->get_description() + ); +} +``` + +### Example 2: Filter by Multiple Namespaces with Restrictions + +```php +// Get all abilities and apply filters +$abilities = wp_get_abilities(); + +// Chain multiple filters +$filtered = $abilities + ->where_category( 'communication' ) + ->where_namespace( array( 'my-plugin', 'woocommerce' ) ) + ->where( 'meta.show_in_rest', true ) + ->where_not_in( 'meta.annotations.destructive', array( true ) ) + ->sort_by( 'name' ); + +echo "Found {$filtered->count()} abilities\n"; +``` + +## Chaining Filters for Complex Queries + +You can chain multiple collection methods to build complex queries: + +```php +// Get all abilities and chain filters +$abilities = wp_get_abilities(); + +// Apply multiple filters in sequence +$results = $abilities + ->where_category( 'communication' ) + ->where_namespace( 'my-plugin' ) + ->where( 'meta.annotations.readonly', true ) + ->where( 'meta.priority', '>=', 5 ) + ->search( 'email' ) + ->sort_by( 'label' ) + ->all(); +``` + +## Immutability + +Collections are **immutable** — each filtering or sorting operation returns a **new** collection: + +```php +$abilities = wp_get_abilities(); + +echo $abilities->count(); // 100 + +// Filter returns a new collection +$filtered = $abilities->where_category( 'math' ); + +echo $abilities->count(); // Still 100 (original unchanged) +echo $filtered->count(); // 15 (new filtered collection) + +// You can reuse the original for different filters +$other_filter = $abilities->where_namespace( 'my-plugin' ); +echo $other_filter->count(); // 25 +``` + +This immutability allows you to: +- Reuse base collections for different queries +- Build queries step-by-step without affecting previous steps +- Safely pass collections without worrying about mutations + +## See Also + +- [Registering Abilities](3.registering-abilities.md) - How to register abilities with proper metadata +- [Using Abilities](4.using-abilities.md) - Basic ability usage and execution +- [REST API](5.rest-api.md) - REST API endpoints for abilities diff --git a/docs/8.querying-filtering-abilities.md b/docs/8.querying-filtering-abilities.md deleted file mode 100644 index 272ba53..0000000 --- a/docs/8.querying-filtering-abilities.md +++ /dev/null @@ -1,213 +0,0 @@ -# 8. Querying and Filtering Abilities - -The Abilities API provides powerful filtering capabilities through the `WP_Abilities_Query` class and the `wp_get_abilities()` function. This allows you to efficiently find and retrieve specific abilities from potentially thousands of registered abilities. - -`wp_get_abilities()` returns an array of `WP_Ability` objects, keyed by ability name. - -When no abilities match the query, an empty array is returned. - -Invalid query parameters are normalized to safe defaults. - -## Overview - -When working with large numbers of abilities registered by core and various plugins, the query system provides optimized server-side filtering. You can pass query arguments to `wp_get_abilities()` to filter abilities by category, namespace, meta properties, and more. - -```php -// Get all abilities -$abilities = wp_get_abilities(); - -// Filter by category -$abilities = wp_get_abilities( array( 'category' => 'data-retrieval' ) ); - -// Combine multiple filters -$abilities = wp_get_abilities( array( - 'namespace' => 'my-plugin', - 'category' => 'communication', - 'orderby' => 'label', -) ); -``` - -## Quick Reference: Query Parameters - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `category` | string\|array | `''` | Filter by category slug. Accepts single value or array for multiple categories (OR logic) | -| `namespace` | string\|array | `''` | Filter by namespace. Accepts single value or array for multiple namespaces (OR logic) | -| `search` | string | `''` | Search in name, label, and description (case-insensitive) | -| `meta` | array | `array()` | Filter by meta properties using nested arrays (AND logic between multiple meta filters) | -| `orderby` | string | `''` | Sort by: `'name'`, `'label'`, or `'category'`. Empty = no sorting | -| `order` | string | `'ASC'` | Sort direction: `'ASC'` or `'DESC'` | -| `limit` | int | `-1` | Maximum results to return. `-1` = unlimited | -| `offset` | int | `0` | Number of results to skip | - -## Basic Filtering - -### Filter by Category - -```php -// Get all data retrieval abilities -$abilities = wp_get_abilities( array( 'category' => 'data-retrieval' ) ); - -// Get all communication abilities -$abilities = wp_get_abilities( array( 'category' => 'communication' ) ); - -// Get abilities from multiple categories (OR logic) -$abilities = wp_get_abilities( array( - 'category' => array( 'math', 'communication', 'data-retrieval' ), -) ); -``` - -### Filter by Namespace - -Namespace is the part before the `/` in the ability name (e.g., `my-plugin` in `my-plugin/send-email`). - -```php -// Get all abilities from your plugin -$abilities = wp_get_abilities( array( 'namespace' => 'my-plugin' ) ); - -// Get all WooCommerce abilities -$abilities = wp_get_abilities( array( 'namespace' => 'woocommerce' ) ); - -// Get abilities from multiple namespaces (OR logic) -$abilities = wp_get_abilities( array( - 'namespace' => array( 'my-plugin', 'woocommerce', 'jetpack' ), -) ); -``` - -### Search Abilities - -Search is case-insensitive and searches in name, label, and description: - -```php -// Find all abilities related to email -$abilities = wp_get_abilities( array( 'search' => 'email' ) ); - -// Find payment-related abilities -$abilities = wp_get_abilities( array( 'search' => 'payment' ) ); -``` - -### Combine Multiple Filters - -Filters are cumulative (AND logic): - -```php -// Get communication abilities from my-plugin only -$abilities = wp_get_abilities( array( - 'category' => 'communication', - 'namespace' => 'my-plugin', -) ); - -// Search for email abilities in the communication category -$abilities = wp_get_abilities( array( - 'category' => 'communication', - 'search' => 'email', -) ); -``` - -## Meta Filtering - -Filter abilities by their meta properties, including `show_in_rest`, `annotations`, and custom meta keys. All meta filters use AND logic — abilities must match all specified criteria. - -### Filter by REST API Visibility - -```php -// Get abilities exposed in REST API -$abilities = wp_get_abilities( array( - 'meta' => array( - 'show_in_rest' => true, - ), -) ); - -// Get abilities hidden from REST API -$abilities = wp_get_abilities( array( - 'meta' => array( - 'show_in_rest' => false, - ), -) ); -``` - -### Filter by Annotations - -```php -// Get readonly abilities -$abilities = wp_get_abilities( array( - 'meta' => array( - 'annotations' => array( - 'readonly' => true, - ), - ), -) ); - -// Get non-destructive, idempotent abilities -$abilities = wp_get_abilities( array( - 'meta' => array( - 'annotations' => array( - 'destructive' => false, - 'idempotent' => true, - ), - ), -) ); -``` - -### Combine Multiple Meta Filters - -When you specify multiple meta conditions, abilities must match all of them (AND logic): - -```php -// Get readonly abilities that are exposed in REST API -$abilities = wp_get_abilities( array( - 'meta' => array( - 'show_in_rest' => true, - 'annotations' => array( - 'readonly' => true, - ), - ), -) ); - -// Get REST-enabled, readonly, idempotent abilities -$abilities = wp_get_abilities( array( - 'meta' => array( - 'show_in_rest' => true, - 'annotations' => array( - 'readonly' => true, - 'idempotent' => true, - ), - ), -) ); -``` - -### Filter by Custom Meta - -You can filter by any custom meta keys you've added to abilities: - -```php -$abilities = wp_get_abilities( array( - 'meta' => array( - 'custom_key' => 'custom_value', - 'another_meta' => 'another_value', - ), -) ); -``` - -## Ordering and Pagination - -```php -// Order by name, label, or category -$abilities = wp_get_abilities( array( - 'orderby' => 'label', - 'order' => 'ASC', // or 'DESC' -) ); - -// Paginate results -$abilities = wp_get_abilities( array( - 'limit' => 10, - 'offset' => 0, -) ); -``` - -## See Also - -- [Registering Abilities](3.registering-abilities.md) - How to register abilities with proper metadata -- [Using Abilities](4.using-abilities.md) - Basic ability usage, execution, and permissions -- [Registering Categories](7.registering-categories.md) - How to register ability categories -- [REST API](5.rest-api.md) - REST API endpoints for abilities From 3a65eca00ec4e656d5aad7d3c3036ad4f61665b5 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Wed, 15 Oct 2025 12:16:12 +0300 Subject: [PATCH 16/17] Enhance collection pluck() with dot notation support Updates the `WP_Abilities_Collection::pluck()` method to support dot notation for accessing nested properties. This allows for more flexible data extraction, such as retrieving values from `meta` properties like `meta.show_in_rest`. Both the value and key parameters now support this syntax. The implementation is updated to handle nested data retrieval, and comprehensive unit tests and documentation are added to reflect this enhancement. Also corrects a heading number in the advanced filtering documentation. --- docs/8.advanced-filtering-and-sorting.md | 22 +++++- .../class-wp-abilities-collection.php | 37 +++++---- .../abilities-api/wpAbilitiesCollection.php | 77 +++++++++++++++++++ 3 files changed, 118 insertions(+), 18 deletions(-) diff --git a/docs/8.advanced-filtering-and-sorting.md b/docs/8.advanced-filtering-and-sorting.md index 60af6b4..73a5f7d 100644 --- a/docs/8.advanced-filtering-and-sorting.md +++ b/docs/8.advanced-filtering-and-sorting.md @@ -1,4 +1,4 @@ -# 9. Advanced Filtering and Sorting +# 8. Advanced Filtering and Sorting The `wp_get_abilities()` function returns a `WP_Abilities_Collection` instance, which provides a powerful, chainable API for filtering and sorting operations. This document covers filtering techniques using collection methods. @@ -67,7 +67,7 @@ $readonly_abilities = $abilities | `last($callback, $default)` | Get last ability | | `get($name, $default)` | Get ability by name | | `count()` | Count abilities | -| `pluck($value, $key)` | Extract property values | +| `pluck($value, $key)` | Extract property values (supports dot notation) | ## Advanced Filtering Techniques @@ -306,6 +306,8 @@ $ability = $abilities->get( 'missing/ability', null ); ### Extract Property Values with pluck() +The `pluck()` method supports dot notation for extracting nested properties: + ```php $abilities = wp_get_abilities(); @@ -317,12 +319,28 @@ $names = $abilities->pluck( 'name' ); $labels = $abilities->pluck( 'label' ); // Returns: ['Ability One', 'Ability Two', ...] +// Pluck nested properties using dot notation +$show_in_rest = $abilities->pluck( 'meta.show_in_rest' ); +// Returns: [true, false, true, ...] + +// Pluck deeply nested properties +$readonly = $abilities->pluck( 'meta.annotations.readonly' ); +// Returns: [true, false, true, ...] + // Pluck with custom keys (second parameter) $labels_by_name = $abilities->pluck( 'label', 'name' ); // Returns: [ // 'plugin/ability-one' => 'Ability One', // 'plugin/ability-two' => 'Ability Two', // ] + +// Both parameters support dot notation +$priorities_by_category = $abilities->pluck( 'meta.priority', 'category' ); +// Returns: [ +// 'math' => 10, +// 'communication' => 5, +// ... +// ] ``` ## Practical Examples diff --git a/includes/abilities-api/class-wp-abilities-collection.php b/includes/abilities-api/class-wp-abilities-collection.php index bd48c55..8511e23 100644 --- a/includes/abilities-api/class-wp-abilities-collection.php +++ b/includes/abilities-api/class-wp-abilities-collection.php @@ -121,28 +121,33 @@ public function filter( callable $callback ): self { /** * Extract a single property from all abilities. * + * Supports dot notation for nested properties: + * - pluck('name') - Get all ability names + * - pluck('meta.show_in_rest') - Get nested meta property + * - pluck('label', 'name') - Get labels keyed by names + * - pluck('meta.priority', 'name') - Get nested values keyed by names + * * @since n.e.x.t * - * @param string $value Property to extract. - * @param string|null $key Optional property to use as array keys. + * @param string $value Property to extract (supports dot notation). + * @param string|null $key Optional property to use as array keys (supports dot notation). * @return array Array of extracted values. */ public function pluck( string $value, ?string $key = null ): array { - // Convert WP_Ability objects to arrays for wp_list_pluck. - $abilities_array = array_map( - static function ( $ability ) { - return array( - 'name' => $ability->get_name(), - 'label' => $ability->get_label(), - 'description' => $ability->get_description(), - 'category' => $ability->get_category(), - 'meta' => $ability->get_meta(), - ); - }, - $this->abilities - ); + $result = array(); + + foreach ( $this->abilities as $ability ) { + $plucked_value = $this->data_get( $ability, $value ); + + if ( null === $key ) { + $result[] = $plucked_value; + } else { + $key_value = $this->data_get( $ability, $key ); + $result[ $key_value ] = $plucked_value; + } + } - return wp_list_pluck( $abilities_array, $value, $key ); + return $result; } /** diff --git a/tests/unit/abilities-api/wpAbilitiesCollection.php b/tests/unit/abilities-api/wpAbilitiesCollection.php index c5877ba..1b897da 100644 --- a/tests/unit/abilities-api/wpAbilitiesCollection.php +++ b/tests/unit/abilities-api/wpAbilitiesCollection.php @@ -885,6 +885,83 @@ public function test_pluck_empty_collection(): void { $this->assertEmpty( $names ); } + /** + * Test pluck with dot notation for nested properties. + * + * @covers WP_Abilities_Collection::pluck + */ + public function test_pluck_with_dot_notation(): void { + $collection = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + $show_in_rest_values = $collection->pluck( 'meta.show_in_rest' ); + + $this->assertIsArray( $show_in_rest_values ); + $this->assertGreaterThan( 0, count( $show_in_rest_values ) ); + $this->assertContains( true, $show_in_rest_values ); + $this->assertContains( false, $show_in_rest_values ); + } + + /** + * Test pluck with dot notation for deeply nested properties. + * + * @covers WP_Abilities_Collection::pluck + */ + public function test_pluck_with_deep_dot_notation(): void { + $collection = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + $readonly_values = $collection->pluck( 'meta.annotations.readonly' ); + + $this->assertIsArray( $readonly_values ); + $this->assertGreaterThan( 0, count( $readonly_values ) ); + $this->assertContains( true, $readonly_values ); + } + + /** + * Test pluck with dot notation and key parameter. + * + * @covers WP_Abilities_Collection::pluck + */ + public function test_pluck_with_dot_notation_and_key(): void { + $collection = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + $result = $collection->pluck( 'meta.show_in_rest', 'name' ); + + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'test/add-numbers', $result ); + $this->assertTrue( $result['test/add-numbers'] ); + $this->assertArrayHasKey( 'test/multiply-numbers', $result ); + $this->assertFalse( $result['test/multiply-numbers'] ); + } + + /** + * Test pluck with both parameters using dot notation. + * + * @covers WP_Abilities_Collection::pluck + */ + public function test_pluck_with_both_dot_notation(): void { + $collection = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + $result = $collection->pluck( 'meta.annotations.readonly', 'category' ); + + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'math', $result ); + // Since multiple abilities have the same category, the last one wins. + // We're mainly testing that dot notation works for both parameters. + $this->assertIsBool( $result['math'] ); + } + + /** + * Test pluck with dot notation for nonexistent property. + * + * @covers WP_Abilities_Collection::pluck + */ + public function test_pluck_with_nonexistent_nested_property(): void { + $collection = new WP_Abilities_Collection( $this->registry->get_all_registered() ); + $result = $collection->pluck( 'meta.nonexistent.property' ); + + $this->assertIsArray( $result ); + // All values should be null since the property doesn't exist. + foreach ( $result as $value ) { + $this->assertNull( $value ); + } + } + /** * Test filter method directly. * From b7e0f113ddebf7fe73c6d80708a32c7ff7418858 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Wed, 15 Oct 2025 12:21:39 +0300 Subject: [PATCH 17/17] Docs: Clarify filtering and retrieval method usage Update documentation for advanced filtering and sorting to improve clarity and prevent common mistakes. - Clarify that the operator in the `where()` method is optional and defaults to an equality check. - Document that `all()` is an alias for `to_array()`. - Add a prominent note for `where_meta()` to specify that the 'meta.' prefix should be omitted from keys. --- docs/8.advanced-filtering-and-sorting.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/8.advanced-filtering-and-sorting.md b/docs/8.advanced-filtering-and-sorting.md index 73a5f7d..ba3fc45 100644 --- a/docs/8.advanced-filtering-and-sorting.md +++ b/docs/8.advanced-filtering-and-sorting.md @@ -41,7 +41,7 @@ $readonly_abilities = $abilities | Method | Description | |--------|-------------| -| `where($key, $operator, $value)` | Filter by property with operator (supports dot notation) | +| `where($key, $value)` or `where($key, $operator, $value)` | Filter by property with optional operator (supports dot notation). Defaults to `=` comparison. | | `where_in($key, $values)` | Filter where property is in array | | `where_not_in($key, $values)` | Filter where property is NOT in array | | `where_category($categories)` | Filter by category/categories | @@ -62,7 +62,8 @@ $readonly_abilities = $abilities | Method | Description | |--------|-------------| -| `all()` | Get all abilities as array | +| `all()` | Get all abilities as array (alias for `to_array()`) | +| `to_array()` | Get all abilities as array | | `first($callback, $default)` | Get first ability | | `last($callback, $default)` | Get last ability | | `get($name, $default)` | Get ability by name | @@ -124,6 +125,8 @@ $filtered = $abilities->where_in( 'meta.annotations.readonly', array( true ) ); The `where_meta()` method filters by meta properties with dot notation support: +**Note:** When using `where_meta()`, omit the `meta.` prefix from your keys since you're already filtering within the meta scope. Use `'annotations.readonly'` instead of `'meta.annotations.readonly'`. + ```php $abilities = wp_get_abilities(); @@ -260,7 +263,7 @@ $filtered = $abilities->where_category( 'math' ); // Get array of WP_Ability objects $results = $filtered->all(); -// or +// Note: all() and to_array() are equivalent - all() is an alias for to_array() $results = $filtered->to_array(); // Count results