diff --git a/docs/4.using-abilities.md b/docs/4.using-abilities.md index 0a51d63..bf6a774 100644 --- a/docs/4.using-abilities.md +++ b/docs/4.using-abilities.md @@ -35,27 +35,70 @@ 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. * - * @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 +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 + +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 examples: + +```php +// Filter by category +$abilities = wp_get_abilities()->where_category( 'data-retrieval' ); + +// Search for abilities +$abilities = wp_get_abilities()->search( 'email' ); + +// Filter by namespace and meta +$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 [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()`) Once you have a `WP_Ability` object (usually from `wp_get_ability`), you execute it using the `execute()` method. diff --git a/docs/8.advanced-filtering-and-sorting.md b/docs/8.advanced-filtering-and-sorting.md new file mode 100644 index 0000000..ba3fc45 --- /dev/null +++ b/docs/8.advanced-filtering-and-sorting.md @@ -0,0 +1,441 @@ +# 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. + +## 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, $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 | +| `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 (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 | +| `count()` | Count abilities | +| `pluck($value, $key)` | Extract property values (supports dot notation) | + +## 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: + +**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(); + +// 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(); +// Note: all() and to_array() are equivalent - all() is an alias for to_array() +$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() + +The `pluck()` method supports dot notation for extracting nested properties: + +```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 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 + +### 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/includes/abilities-api.php b/includes/abilities-api.php index 6672b0c..06960a7 100644 --- a/includes/abilities-api.php +++ b/includes/abilities-api.php @@ -92,16 +92,41 @@ function wp_get_ability( string $name ): ?WP_Ability { } /** - * Retrieves all registered abilities using Abilities API. + * Retrieves a collection of registered abilities. + * + * 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 Returns WP_Abilities_Collection instead of array. + * + * @see WP_Abilities_Collection + * + * @return \WP_Abilities_Collection Collection of WP_Ability instances. + * + * @example + * // Get all abilities as collection + * $abilities = wp_get_abilities(); + * + * @example + * // Filter by category + * $math_abilities = wp_get_abilities()->where_category('math'); * - * @see WP_Abilities_Registry::get_all_registered() + * @example + * // Chain multiple filters + * $abilities = wp_get_abilities() + * ->where_namespace(['WordPress', 'woocommerce']) + * ->where_meta(['show_in_rest' => true]) + * ->search('product') + * ->sort_by_desc('name'); * - * @return \WP_Ability[] The array of registered abilities. + * @example + * // Convert to array if needed + * $abilities_array = wp_get_abilities()->to_array(); */ -function wp_get_abilities(): array { - return WP_Abilities_Registry::get_instance()->get_all_registered(); +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-collection.php b/includes/abilities-api/class-wp-abilities-collection.php new file mode 100644 index 0000000..8511e23 --- /dev/null +++ b/includes/abilities-api/class-wp-abilities-collection.php @@ -0,0 +1,577 @@ + + */ +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. + * + * 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 (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 { + $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 $result; + } + + /** + * 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/includes/bootstrap.php b/includes/bootstrap.php index ae23de5..265664e 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_Collection' ) ) { + require_once __DIR__ . '/abilities-api/class-wp-abilities-collection.php'; +} // Ensure procedural functions are available, too. if ( ! function_exists( 'wp_register_ability' ) ) { 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/wpAbilitiesCollection.php b/tests/unit/abilities-api/wpAbilitiesCollection.php new file mode 100644 index 0000000..1b897da --- /dev/null +++ b/tests/unit/abilities-api/wpAbilitiesCollection.php @@ -0,0 +1,1278 @@ +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 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. + * + * @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 ); + } + } +} 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 ); }