Commit 067306f6351 for woocommerce
commit 067306f635195d4f526cd5079380b191c999745d
Author: Luis Felipe Zaguini <26530524+zaguiini@users.noreply.github.com>
Date: Fri Oct 16 20:34:26 2026 -0300
Filter the product and order list abilities by extension filters (#69684)
* Filter the product and order list abilities by extension filters
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Rename AbilityExtensions to ExtensibleAbilities after the rebase
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Apply Core's exclude next to the IDs of extension filters, and create the HPOS tables once per class
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Refuse a filter namespace or name that is only numbers, like register_field()
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Cast the exclude list to an array for PHPStan
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/add-ability-extension-query-filters b/plugins/woocommerce/changelog/add-ability-extension-query-filters
new file mode 100644
index 00000000000..5ad02f8e187
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-ability-extension-query-filters
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add ExtensibleAbilities::register_query_filter(). With the experimental extensible abilities feature on, the product and order list abilities accept extension filters under filters.extensions.<namespace>.<filter>.
diff --git a/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php b/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php
index 91ee7228376..9be9ff482ad 100644
--- a/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php
+++ b/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php
@@ -22,6 +22,8 @@ defined( 'ABSPATH' ) || exit;
* An ActionableAbility writes the same `extensions` input to the fields that
* have an `update_callback`, runs the validators of the resource, and saves
* the object one time.
+ * An extension also registers a query filter, which the list ability of the
+ * resource accepts under `filters.extensions.<namespace>.<filter>`.
*
* The API is experimental while the `extensible_abilities` feature exists.
*
@@ -70,6 +72,18 @@ class ExtensibleAbilities {
return FeaturesUtil::feature_is_enabled( self::FEATURE_ID );
}
+ /**
+ * Query filters keyed by resource, then namespace, then filter name.
+ *
+ * @var array<string, array<string, array<string, array<string, mixed>>>>
+ */
+ private static array $filters = array();
+
+ /**
+ * Query arguments that a filter callback can return.
+ */
+ private const QUERY_ARG_KEYS = array( 'meta_query', 'tax_query', 'include_ids', 'exclude_ids' );
+
/**
* Register a resource that abilities format, so extensions can register fields on it.
*
@@ -196,6 +210,248 @@ class ExtensibleAbilities {
self::$fields[ $args['resource'] ][ $args['namespace'] ][ $args['field'] ] = $args;
}
+ /**
+ * Register a filter that the list ability of a resource accepts under `filters.extensions.<namespace>.<filter>`.
+ *
+ * Call it inside the `woocommerce_extensible_abilities_init` action. A call after that action is refused.
+ * The resource must be registered with `register_resource()`.
+ *
+ * @since 11.3.0
+ *
+ * @param array $args {
+ * Filter arguments.
+ *
+ * @type string $resource Resource whose list ability applies the filters. Core's `products-query` and
+ * `orders-query` apply the filters of `product` and `order`. The list ability
+ * of another resource must call `get_query_args()` and `add_query_filters_schema()` itself.
+ * @type string $namespace Slug of the extension that owns the filter, such as `subscriptions`.
+ * @type string $filter Filter name inside the namespace, such as `subscribable`.
+ * @type array $schema JSON schema of the value. Its `description` tells agents how to use the filter.
+ * @type callable $callback Receives the value, sanitized with the schema, and returns query arguments:
+ * a `meta_query`, for products also a `tax_query`, `include_ids` or
+ * `exclude_ids`. Core adds each one to the query with AND. Return a
+ * WP_Error to refuse the value. A `tax_query` is accepted only for
+ * `product`. Any other key, or a `tax_query` for another resource, fails the call.
+ * `include_ids` and `exclude_ids` are lists of object IDs. Use them for
+ * data that is not meta, such as order items or a table of the extension:
+ * query that data and return the IDs that match. Core keeps the IDs that
+ * every `include_ids` filter returns, then drops the IDs that any
+ * `exclude_ids` filter returns. An empty `include_ids` matches nothing.
+ * }
+ */
+ public static function register_query_filter( array $args ): void {
+ if ( did_action( 'woocommerce_extensible_abilities_init' ) && ! doing_action( 'woocommerce_extensible_abilities_init' ) ) {
+ wc_doing_it_wrong( __METHOD__, __( 'Register a filter inside the woocommerce_extensible_abilities_init action, before the abilities build their schemas.', 'woocommerce' ), '11.3.0' );
+ return;
+ }
+ foreach ( array( 'resource', 'namespace', 'filter' ) as $key ) {
+ if ( ! is_string( $args[ $key ] ?? null ) || '' === $args[ $key ] || sanitize_key( $args[ $key ] ) !== $args[ $key ] || ctype_digit( $args[ $key ] ) ) {
+ wc_doing_it_wrong(
+ __METHOD__,
+ sprintf(
+ /* translators: %s: argument name. */
+ __( 'The "%s" argument must be a slug of lowercase letters, numbers, dashes and underscores, not only numbers.', 'woocommerce' ),
+ $key
+ ),
+ '11.3.0'
+ );
+ return;
+ }
+ }
+ if ( ! isset( self::$resources[ $args['resource'] ] ) ) {
+ wc_doing_it_wrong(
+ __METHOD__,
+ sprintf(
+ /* translators: %s: resource name. */
+ __( 'The "%s" resource is not registered. Call ExtensibleAbilities::register_resource() first.', 'woocommerce' ),
+ $args['resource']
+ ),
+ '11.3.0'
+ );
+ return;
+ }
+ if ( ! is_array( $args['schema'] ?? null ) || ! isset( $args['schema']['type'] ) ) {
+ wc_doing_it_wrong( __METHOD__, __( 'The "schema" argument must be an array with a "type".', 'woocommerce' ), '11.3.0' );
+ return;
+ }
+ if ( ! is_callable( $args['callback'] ?? null ) ) {
+ wc_doing_it_wrong( __METHOD__, __( 'The "callback" argument must be callable.', 'woocommerce' ), '11.3.0' );
+ return;
+ }
+ if ( isset( self::$filters[ $args['resource'] ][ $args['namespace'] ][ $args['filter'] ] ) ) {
+ wc_doing_it_wrong(
+ __METHOD__,
+ sprintf(
+ /* translators: 1: extension namespace, 2: filter name, 3: resource name. */
+ __( 'The filter "%1$s.%2$s" of "%3$s" is already registered.', 'woocommerce' ),
+ $args['namespace'],
+ $args['filter'],
+ $args['resource']
+ ),
+ '11.3.0'
+ );
+ return;
+ }
+
+ self::$filters[ $args['resource'] ][ $args['namespace'] ][ $args['filter'] ] = $args;
+ }
+
+ /**
+ * Add the `filters.extensions` property to the input schema of a list
+ * ability, when the feature is on and the resource has filters. It does
+ * not allow other filters, because a filter that the query ignores
+ * returns wrong results.
+ *
+ * @since 11.3.0
+ *
+ * @param array<string, mixed> $schema Input schema.
+ * @param string $resource_name Resource.
+ * @return array<string, mixed>
+ */
+ public static function add_query_filters_schema( array $schema, string $resource_name ): array {
+ if ( empty( self::$filters[ $resource_name ] ) || ! self::is_enabled() ) {
+ return $schema;
+ }
+
+ $extensions = array(
+ 'type' => 'object',
+ 'description' => __( 'Filters that extensions add, keyed by extension namespace, then filter.', 'woocommerce' ),
+ 'properties' => array(),
+ 'additionalProperties' => false,
+ );
+ foreach ( self::$filters[ $resource_name ] as $namespace => $filters ) {
+ $extensions['properties'][ $namespace ] = array(
+ 'type' => 'object',
+ 'properties' => array_map(
+ static function ( array $args ): array {
+ return $args['schema'];
+ },
+ $filters
+ ),
+ 'additionalProperties' => false,
+ );
+ }
+ $schema['properties']['filters'] = array(
+ 'type' => 'object',
+ 'properties' => array( 'extensions' => $extensions ),
+ 'additionalProperties' => false,
+ );
+ return $schema;
+ }
+
+ /**
+ * Get the query arguments of the filters in the input of a list ability.
+ * Each key holds one clause for each filter, so `array_merge_recursive()`
+ * adds them to WP_Query or wc_get_orders() arguments with AND. `post__in`
+ * holds the IDs that every `include_ids` filter returned, without the IDs
+ * that any `exclude_ids` filter returned, or `0` when none remain, because
+ * an empty `post__in` does not filter. Without `include_ids`, the excluded
+ * IDs go to `post__not_in`, which WP_Query ignores next to `post__in`.
+ *
+ * @since 11.3.0
+ *
+ * @param string $resource_name Resource.
+ * @param array<string, mixed> $input Ability input.
+ * @return array<string, array>|\WP_Error The error of a filter that refused its value or failed.
+ */
+ public static function get_query_args( string $resource_name, array $input ) {
+ $query_args = array();
+ $include = null;
+ $exclude = array();
+ if ( ! self::is_enabled() ) {
+ return $query_args;
+ }
+
+ foreach ( self::$filters[ $resource_name ] ?? array() as $namespace => $filters ) {
+ foreach ( $filters as $filter => $args ) {
+ if ( ! is_array( $input['filters']['extensions'][ $namespace ] ?? null ) || ! array_key_exists( $filter, $input['filters']['extensions'][ $namespace ] ) ) {
+ continue;
+ }
+
+ $value = rest_sanitize_value_from_schema( $input['filters']['extensions'][ $namespace ][ $filter ], $args['schema'], $filter );
+ try {
+ $clauses = call_user_func( $args['callback'], $value );
+ } catch ( \Throwable $e ) {
+ wc_get_logger()->error(
+ sprintf( 'Ability filter "%s.%s" of "%s" failed: %s', $namespace, $filter, $resource_name, $e->getMessage() ),
+ array( 'source' => 'extensible-abilities' )
+ );
+ $clauses = null;
+ }
+ if ( is_wp_error( $clauses ) ) {
+ return $clauses;
+ }
+ if (
+ ! is_array( $clauses )
+ || ! empty( array_diff_key( $clauses, array_flip( self::QUERY_ARG_KEYS ) ) )
+ || ( 'product' !== $resource_name && isset( $clauses['tax_query'] ) )
+ || in_array( false, array_map( 'is_array', $clauses ), true )
+ ) {
+ return new \WP_Error(
+ 'woocommerce_ability_extension_filter_failed',
+ /* translators: %s: filter name, such as subscriptions.subscribable. */
+ sprintf( __( 'The "%s" filter failed.', 'woocommerce' ), $namespace . '.' . $filter ),
+ array( 'status' => 500 )
+ );
+ }
+ if ( isset( $clauses['include_ids'] ) ) {
+ $filter_ids = self::get_filter_ids( $clauses['include_ids'], $namespace . '.' . $filter );
+ if ( is_wp_error( $filter_ids ) ) {
+ return $filter_ids;
+ }
+ $include = null === $include ? $filter_ids : array_values( array_intersect( $include, $filter_ids ) );
+ unset( $clauses['include_ids'] );
+ }
+ if ( isset( $clauses['exclude_ids'] ) ) {
+ $filter_ids = self::get_filter_ids( $clauses['exclude_ids'], $namespace . '.' . $filter );
+ if ( is_wp_error( $filter_ids ) ) {
+ return $filter_ids;
+ }
+ $exclude = array_merge( $exclude, $filter_ids );
+ unset( $clauses['exclude_ids'] );
+ }
+ foreach ( $clauses as $key => $clause ) {
+ if ( empty( $clause ) ) {
+ continue;
+ }
+ $query_args[ $key ][] = $clause;
+ }
+ }
+ }
+ if ( null !== $include ) {
+ $include = array_values( array_diff( $include, $exclude ) );
+ $query_args['post__in'] = empty( $include ) ? array( 0 ) : $include;
+ } elseif ( ! empty( $exclude ) ) {
+ $query_args['post__not_in'] = array_values( array_unique( $exclude ) );
+ }
+ return $query_args;
+ }
+
+ /**
+ * The IDs that a filter returned, as integers.
+ *
+ * @param array $ids IDs from the filter callback.
+ * @param string $filter Filter name, such as `subscriptions.related_to`.
+ * @return int[]|\WP_Error An error when an ID is not a positive integer.
+ */
+ private static function get_filter_ids( array $ids, string $filter ) {
+ $valid = array_filter(
+ array_map(
+ static fn( $id ) => filter_var( $id, FILTER_VALIDATE_INT, array( 'options' => array( 'min_range' => 1 ) ) ),
+ $ids
+ )
+ );
+ if ( count( $valid ) !== count( $ids ) ) {
+ return new \WP_Error(
+ 'woocommerce_ability_extension_filter_failed',
+ /* translators: %s: filter name, such as subscriptions.subscribable. */
+ sprintf( __( 'The "%s" filter failed.', 'woocommerce' ), $filter ),
+ array( 'status' => 500 )
+ );
+ }
+ return array_values( array_unique( $valid ) );
+ }
+
/**
* Register a validator that can refuse a change to an object of a resource.
*
diff --git a/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php b/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
index b6797381329..a1ab40593f6 100644
--- a/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
+++ b/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
@@ -63,6 +63,13 @@ class AbilitiesLoader {
*/
private static array $registered_core_abilities = array();
+ /**
+ * Query var that holds the extension filter clauses of a list ability.
+ *
+ * @internal
+ */
+ public const EXTENSION_QUERY_VAR = 'woocommerce_extensible_abilities_query_args';
+
/**
* Initialize ability registration hooks.
*
@@ -85,6 +92,8 @@ class AbilitiesLoader {
add_action( 'wp_abilities_api_init', array( __CLASS__, 'init_extensions' ), 0 );
add_action( 'abilities_api_init', array( __CLASS__, 'register_abilities' ) );
add_action( 'wp_abilities_api_init', array( __CLASS__, 'register_abilities' ) );
+ add_filter( 'woocommerce_product_data_store_cpt_get_products_query', array( __CLASS__, 'add_extension_query_args' ), 10, 2 );
+ add_filter( 'woocommerce_order_data_store_cpt_get_orders_query', array( __CLASS__, 'add_extension_query_args' ), 10, 2 );
self::$initialized = true;
}
@@ -102,6 +111,30 @@ class AbilitiesLoader {
}
}
+ /**
+ * Add the extension filter clauses of a list ability to its WP_Query arguments.
+ *
+ * The posts storage ignores a meta_query argument, so a list ability puts the
+ * clauses in the EXTENSION_QUERY_VAR query var. Other queries do not have it,
+ * so a query that a hook runs inside the ability's query stays the same.
+ *
+ * @internal
+ *
+ * @since 11.3.0
+ *
+ * @param array $wp_query_args WP_Query arguments.
+ * @param array $query_vars Query vars of wc_get_products() or wc_get_orders().
+ * @return array
+ */
+ public static function add_extension_query_args( $wp_query_args, $query_vars ) {
+ if ( empty( $query_vars[ self::EXTENSION_QUERY_VAR ] ) || ! is_array( $wp_query_args ) ) {
+ return $wp_query_args;
+ }
+
+ unset( $wp_query_args[ self::EXTENSION_QUERY_VAR ] );
+ return array_merge_recursive( $wp_query_args, $query_vars[ self::EXTENSION_QUERY_VAR ] );
+ }
+
/**
* Let extensions register their resources, then their ability fields, before the abilities build their schemas.
*
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/OrdersQuery.php b/plugins/woocommerce/src/Internal/Abilities/Domain/OrdersQuery.php
index 1a17c1ed1dd..6e2a60d372b 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/OrdersQuery.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/OrdersQuery.php
@@ -8,6 +8,8 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\Internal\Abilities\Domain;
use Automattic\WooCommerce\Abilities\AbilityDefinition;
+use Automattic\WooCommerce\Abilities\ExtensibleAbilities;
+use Automattic\WooCommerce\Internal\Abilities\AbilitiesLoader;
use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\OrderAbilityTrait;
use Automattic\WooCommerce\Utilities\OrderUtil;
@@ -76,6 +78,14 @@ class OrdersQuery extends AbstractDomainAbility implements AbilityDefinition {
public static function execute( array $input ) {
$include_line_items = (bool) ( $input['include_line_items'] ?? false );
+ if ( ! empty( $input['id'] ) && ! empty( $input['filters'] ) ) {
+ return new \WP_Error(
+ 'woocommerce_ability_filters_with_id',
+ __( 'Send either an id or filters, not both.', 'woocommerce' ),
+ array( 'status' => 400 )
+ );
+ }
+
if ( ! empty( $input['id'] ) ) {
$order = self::get_order_from_input( $input );
@@ -146,6 +156,26 @@ class OrdersQuery extends AbstractDomainAbility implements AbilityDefinition {
$args['date_query'][] = $modified_date_query;
}
+ $extension_args = ExtensibleAbilities::get_query_args( 'order', $input );
+ if ( is_wp_error( $extension_args ) ) {
+ return $extension_args;
+ }
+
+ if ( isset( $extension_args['post__not_in'] ) ) {
+ $args['exclude'] = array_merge( (array) ( $args['exclude'] ?? array() ), $extension_args['post__not_in'] );
+ unset( $extension_args['post__not_in'] );
+ }
+ if ( isset( $extension_args['post__in'] ) && ! empty( $args['exclude'] ) ) {
+ $include = array_values( array_diff( $extension_args['post__in'], (array) $args['exclude'] ) );
+ $extension_args['post__in'] = empty( $include ) ? array( 0 ) : $include;
+ }
+
+ if ( OrderUtil::custom_orders_table_usage_is_enabled() ) {
+ $args = array_merge_recursive( $args, $extension_args );
+ } else {
+ $args[ AbilitiesLoader::EXTENSION_QUERY_VAR ] = $extension_args;
+ }
+
$results = wc_get_orders( $args );
$orders = is_object( $results ) && isset( $results->orders ) ? $results->orders : array();
$orders = array_values(
@@ -209,7 +239,7 @@ class OrdersQuery extends AbstractDomainAbility implements AbilityDefinition {
* @return array
*/
private static function get_input_schema(): array {
- return array(
+ $schema = array(
'type' => 'object',
'properties' => array(
'id' => array(
@@ -311,6 +341,7 @@ class OrdersQuery extends AbstractDomainAbility implements AbilityDefinition {
'additionalProperties' => false,
'default' => array(),
);
+ return ExtensibleAbilities::add_query_filters_schema( $schema, 'order' );
}
/**
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductsQuery.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductsQuery.php
index 3de97939e45..a9f471534ef 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductsQuery.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductsQuery.php
@@ -8,7 +8,9 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\Internal\Abilities\Domain;
use Automattic\WooCommerce\Abilities\AbilityDefinition;
+use Automattic\WooCommerce\Abilities\ExtensibleAbilities;
use Automattic\WooCommerce\Enums\ProductType;
+use Automattic\WooCommerce\Internal\Abilities\AbilitiesLoader;
use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\ProductAbilityTrait;
defined( 'ABSPATH' ) || exit;
@@ -74,6 +76,14 @@ class ProductsQuery extends AbstractDomainAbility implements AbilityDefinition {
* @since 10.9.0
*/
public static function execute( array $input ) {
+ if ( ! empty( $input['id'] ) && ! empty( $input['filters'] ) ) {
+ return new \WP_Error(
+ 'woocommerce_ability_filters_with_id',
+ __( 'Send either an id or filters, not both.', 'woocommerce' ),
+ array( 'status' => 400 )
+ );
+ }
+
if ( ! empty( $input['id'] ) ) {
$product = self::get_product_from_input( $input );
@@ -127,6 +137,13 @@ class ProductsQuery extends AbstractDomainAbility implements AbilityDefinition {
$args['s'] = wc_clean( $input['search'] );
}
+ $extension_args = ExtensibleAbilities::get_query_args( 'product', $input );
+ if ( is_wp_error( $extension_args ) ) {
+ return $extension_args;
+ }
+
+ $args[ AbilitiesLoader::EXTENSION_QUERY_VAR ] = $extension_args;
+
$results = wc_get_products( $args );
$products = is_object( $results ) && isset( $results->products ) ? $results->products : array();
$pages = is_object( $results ) && isset( $results->max_num_pages ) ? (int) $results->max_num_pages : ( count( $products ) > 0 ? 1 : 0 );
@@ -164,7 +181,7 @@ class ProductsQuery extends AbstractDomainAbility implements AbilityDefinition {
* @return array
*/
private static function get_input_schema(): array {
- return array(
+ $schema = array(
'type' => 'object',
'properties' => array(
'id' => array(
@@ -207,5 +224,6 @@ class ProductsQuery extends AbstractDomainAbility implements AbilityDefinition {
'additionalProperties' => false,
'default' => array(),
);
+ return ExtensibleAbilities::add_query_filters_schema( $schema, 'product' );
}
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
index 0ffe14a7d20..bb3fe9e63fc 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
@@ -10,12 +10,18 @@ namespace Automattic\WooCommerce\Tests\Internal\AbilitiesApi;
use Automattic\WooCommerce\Abilities\ExtensibleAbilities;
use Automattic\WooCommerce\Abilities\ActionableAbility;
use Automattic\WooCommerce\Internal\Abilities\AbilitiesLoader;
+use Automattic\WooCommerce\Internal\DataStores\Orders\DataSynchronizer;
+use Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper;
+use Automattic\WooCommerce\RestApi\UnitTests\HPOSToggleTrait;
+use Automattic\WooCommerce\Utilities\OrderUtil;
/**
* Namespaced extension fields in the product and order abilities, read and written through the REST route.
*/
class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
+ use HPOSToggleTrait;
+
private const CODE_SCHEMA = array(
'type' => 'string',
'title' => 'Test code',
@@ -56,6 +62,17 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
*/
public static function wpSetUpBeforeClass( $factory ): void {
self::$administrator_id = $factory->user->create( array( 'role' => 'administrator' ) );
+
+ $previous_hpos_state = OrderUtil::custom_orders_table_usage_is_enabled();
+ add_filter( 'wc_allow_changing_orders_storage_while_sync_is_pending', '__return_true' );
+ try {
+ self::setup_cot_tables();
+ if ( OrderUtil::custom_orders_table_usage_is_enabled() !== $previous_hpos_state ) {
+ OrderHelper::toggle_cot_feature_and_usage( $previous_hpos_state );
+ }
+ } finally {
+ remove_filter( 'wc_allow_changing_orders_storage_while_sync_is_pending', '__return_true' );
+ }
}
/**
@@ -83,6 +100,7 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
wp_set_current_user( self::$administrator_id );
$this->set_feature( true );
add_action( 'woocommerce_extensible_abilities_init', array( $this, 'register_test_fields' ) );
+ add_action( 'woocommerce_extensible_abilities_init', array( $this, 'register_test_filters' ) );
}
/**
@@ -92,6 +110,7 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
global $wp_actions;
remove_action( 'woocommerce_extensible_abilities_init', array( $this, 'register_test_fields' ) );
+ remove_action( 'woocommerce_extensible_abilities_init', array( $this, 'register_test_filters' ) );
foreach ( $this->test_actions as list( $hook, $callback, $priority ) ) {
remove_action( $hook, $callback, $priority );
}
@@ -206,6 +225,577 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
}
}
+ /**
+ * Register a product filter and an order filter, the way an extension does inside woocommerce_extensible_abilities_init.
+ *
+ * @internal
+ */
+ public function register_test_filters(): void {
+ ExtensibleAbilities::register_query_filter(
+ array(
+ 'resource' => 'product',
+ 'namespace' => 'test-ext',
+ 'filter' => 'has_code',
+ 'schema' => array( 'type' => 'boolean' ),
+ 'callback' => static function ( bool $has_code ) {
+ return array(
+ 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
+ array(
+ 'key' => '_test_code',
+ 'compare' => $has_code ? 'EXISTS' : 'NOT EXISTS',
+ ),
+ ),
+ );
+ },
+ )
+ );
+ ExtensibleAbilities::register_query_filter(
+ array(
+ 'resource' => 'product',
+ 'namespace' => 'test-ext',
+ 'filter' => 'category',
+ 'schema' => array( 'type' => 'string' ),
+ 'callback' => static function ( string $slug ) {
+ return array(
+ 'tax_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
+ array(
+ 'taxonomy' => 'product_cat',
+ 'field' => 'slug',
+ 'terms' => $slug,
+ ),
+ ),
+ );
+ },
+ )
+ );
+ ExtensibleAbilities::register_query_filter(
+ array(
+ 'resource' => 'order',
+ 'namespace' => 'test-ext',
+ 'filter' => 'tag',
+ 'schema' => array( 'type' => 'string' ),
+ 'callback' => static function ( string $tag ) {
+ if ( 'bad' === $tag ) {
+ return new \WP_Error( 'test_bad_tag', 'Bad tag.', array( 'status' => 400 ) );
+ }
+ return array(
+ 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
+ array(
+ 'key' => '_test_tag',
+ 'value' => $tag,
+ ),
+ ),
+ );
+ },
+ )
+ );
+ }
+
+ /**
+ * @testdox Should return only the products that match an extension filter sent as a query string, with correct pagination.
+ */
+ public function test_product_filter_returns_matching_products(): void {
+ $with_code = array();
+ foreach ( array( 'A1', 'A2', 'A3' ) as $code ) {
+ $product = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen ' . $code ) );
+ $product->update_meta_data( '_test_code', $code );
+ $product->save();
+ $with_code[] = $product->get_id();
+ }
+ $without_code = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen plain' ) );
+ $filters = array( 'extensions' => array( 'test-ext' => array( 'has_code' => 'true' ) ) );
+
+ $page_one = $this->run_ability(
+ 'woocommerce/products-query',
+ array(
+ 'search' => 'Pen',
+ 'per_page' => 2,
+ 'filters' => $filters,
+ )
+ );
+ $page_two = $this->run_ability(
+ 'woocommerce/products-query',
+ array(
+ 'search' => 'Pen',
+ 'per_page' => 2,
+ 'page' => 2,
+ 'filters' => $filters,
+ )
+ );
+ $plain = $this->run_ability(
+ 'woocommerce/products-query',
+ array(
+ 'search' => 'Pen',
+ 'filters' => array( 'extensions' => array( 'test-ext' => array( 'has_code' => 'false' ) ) ),
+ )
+ );
+
+ $this->assertSame( 2, $page_one['total_pages'] );
+ $this->assertEqualsCanonicalizing( $with_code, array_merge( array_column( $page_one['products'], 'id' ), array_column( $page_two['products'], 'id' ) ) );
+ $this->assertSame( array( $without_code->get_id() ), array_column( $plain['products'], 'id' ) );
+ }
+
+ /**
+ * @testdox Should add a tax_query filter to Core's own product type clauses with AND.
+ */
+ public function test_product_tax_filter_adds_to_core_clauses(): void {
+ $category = wp_insert_term( 'Gifts', 'product_cat' )['term_id'];
+ $simple = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen simple' ) );
+ $external = \WC_Helper_Product::create_external_product();
+ foreach ( array( $simple, $external ) as $product ) {
+ $product->set_category_ids( array( $category ) );
+ $product->save();
+ }
+ \WC_Helper_Product::create_external_product();
+
+ $output = $this->run_ability(
+ 'woocommerce/products-query',
+ array(
+ 'product_type_alias' => 'affiliate',
+ 'filters' => array( 'extensions' => array( 'test-ext' => array( 'category' => 'gifts' ) ) ),
+ )
+ );
+
+ $this->assertSame( array( $external->get_id() ), array_column( $output['products'], 'id' ) );
+ }
+
+ /**
+ * @testdox Should not filter a product query that a hook runs inside the ability's query.
+ */
+ public function test_product_filter_does_not_reach_a_nested_query(): void {
+ $with_code = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen coded' ) );
+ $with_code->update_meta_data( '_test_code', 'A1' );
+ $with_code->save();
+ $without_code = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen plain' ) );
+ $nested = null;
+ $run_nested = static function ( $wp_query_args ) use ( &$nested ) {
+ if ( null === $nested ) {
+ $nested = array();
+ $nested = wc_get_products(
+ array(
+ 'limit' => -1,
+ 'return' => 'ids',
+ )
+ );
+ }
+ return $wp_query_args;
+ };
+ add_filter( 'woocommerce_product_data_store_cpt_get_products_query', $run_nested, 5 );
+
+ $output = $this->run_ability(
+ 'woocommerce/products-query',
+ array( 'filters' => array( 'extensions' => array( 'test-ext' => array( 'has_code' => 'true' ) ) ) )
+ );
+
+ remove_filter( 'woocommerce_product_data_store_cpt_get_products_query', $run_nested, 5 );
+ $this->assertSame( array( $with_code->get_id() ), array_column( $output['products'], 'id' ) );
+ $this->assertContains( $without_code->get_id(), $nested );
+ }
+
+ /**
+ * @testdox Should return only the orders that match an extension filter, with HPOS and with posts storage, with and without sync.
+ * @testWith [true, false]
+ * [true, true]
+ * [false, false]
+ * [false, true]
+ *
+ * @param bool $hpos Whether HPOS is on.
+ * @param bool $sync Whether HPOS and posts storage sync.
+ */
+ public function test_order_filter_returns_matching_orders( bool $hpos, bool $sync ): void {
+ $original = OrderUtil::custom_orders_table_usage_is_enabled();
+ add_filter( 'wc_allow_changing_orders_storage_while_sync_is_pending', '__return_true' );
+ $this->toggle_cot_feature_and_usage( $hpos );
+ if ( $sync ) {
+ $this->enable_cot_sync();
+ } else {
+ $this->disable_cot_sync();
+ }
+
+ try {
+ $tagged = array();
+ foreach ( array( 'gift', 'gift', 'plain' ) as $tag ) {
+ $order = \WC_Helper_Order::create_order();
+ $order->update_meta_data( '_test_tag', $tag );
+ $order->save();
+ if ( 'gift' === $tag ) {
+ $tagged[] = $order->get_id();
+ }
+ }
+
+ $output = $this->run_ability(
+ 'woocommerce/orders-query',
+ array(
+ 'per_page' => 1,
+ 'filters' => array( 'extensions' => array( 'test-ext' => array( 'tag' => 'gift' ) ) ),
+ )
+ );
+
+ $this->assertSame( $hpos, OrderUtil::custom_orders_table_usage_is_enabled() );
+ $this->assertSame( $sync, OrderUtil::is_custom_order_tables_in_sync() );
+ $this->assertSame( 2, $output['total_pages'] );
+ $this->assertContains( $output['orders'][0]['id'], $tagged );
+ } finally {
+ remove_all_filters( 'pre_option_' . DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION );
+ $this->toggle_cot_feature_and_usage( $original );
+ remove_filter( 'wc_allow_changing_orders_storage_while_sync_is_pending', '__return_true' );
+ }
+ }
+
+ /**
+ * @testdox Should keep the orders whose IDs every filter returns, from data that is not meta, and still apply Core's exclude, with HPOS and with posts storage.
+ * @testWith [true, false]
+ * [true, true]
+ * [false, false]
+ * [false, true]
+ *
+ * @param bool $hpos Whether HPOS is on.
+ * @param bool $sync Whether HPOS and posts storage sync.
+ */
+ public function test_order_ids_filter_intersects_with_other_filters( bool $hpos, bool $sync ): void {
+ $original = OrderUtil::custom_orders_table_usage_is_enabled();
+ add_filter( 'wc_allow_changing_orders_storage_while_sync_is_pending', '__return_true' );
+ $this->toggle_cot_feature_and_usage( $hpos );
+ if ( $sync ) {
+ $this->enable_cot_sync();
+ } else {
+ $this->disable_cot_sync();
+ }
+ add_action( 'woocommerce_extensible_abilities_init', array( $this, 'register_contains_product_filter' ) );
+
+ try {
+ $coffee = \WC_Helper_Product::create_simple_product();
+ $orders = array();
+ foreach ( array( 'gift', 'plain' ) as $tag ) {
+ $order = \WC_Helper_Order::create_order( 0, $coffee );
+ $order->update_meta_data( '_test_tag', $tag );
+ $order->save();
+ $orders[ $tag ] = $order->get_id();
+ }
+ $other = \WC_Helper_Order::create_order();
+ $other->save();
+ $filters = static fn( array $values ) => array( 'filters' => array( 'extensions' => array( 'test-ext' => $values ) ) );
+
+ $contains = $this->run_ability( 'woocommerce/orders-query', $filters( array( 'contains_product' => $coffee->get_id() ) ) );
+ $both = $this->run_ability(
+ 'woocommerce/orders-query',
+ $filters(
+ array(
+ 'contains_product' => $coffee->get_id(),
+ 'tag' => 'gift',
+ )
+ )
+ );
+ $none = $this->run_ability( 'woocommerce/orders-query', $filters( array( 'contains_product' => 999999 ) ) );
+ $without = $this->run_ability( 'woocommerce/orders-query', $filters( array( 'without' => array( $orders['plain'] ) ) ) );
+ $excluded = $this->run_ability( 'woocommerce/orders-query', array( 'exclude' => array( $orders['gift'] ) ) + $filters( array( 'contains_product' => $coffee->get_id() ) ) );
+ $both_out = $this->run_ability( 'woocommerce/orders-query', array( 'exclude' => array( $other->get_id() ) ) + $filters( array( 'without' => array( $orders['plain'] ) ) ) );
+
+ $this->assertEqualsCanonicalizing( array_values( $orders ), array_column( $contains['orders'], 'id' ) );
+ $this->assertSame( array( $orders['gift'] ), array_column( $both['orders'], 'id' ) );
+ $this->assertSame( array(), $none['orders'] );
+ $this->assertEqualsCanonicalizing( array( $orders['gift'], $other->get_id() ), array_column( $without['orders'], 'id' ) );
+ $this->assertSame( array( $orders['plain'] ), array_column( $excluded['orders'], 'id' ) );
+ $this->assertSame( array( $orders['gift'] ), array_column( $both_out['orders'], 'id' ) );
+ } finally {
+ remove_action( 'woocommerce_extensible_abilities_init', array( $this, 'register_contains_product_filter' ) );
+ remove_all_filters( 'pre_option_' . DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION );
+ $this->toggle_cot_feature_and_usage( $original );
+ remove_filter( 'wc_allow_changing_orders_storage_while_sync_is_pending', '__return_true' );
+ }
+ }
+
+ /**
+ * Register an order filter that reads order items, which are not meta, and returns order IDs,
+ * and one that excludes the order IDs it receives.
+ *
+ * @internal
+ */
+ public function register_contains_product_filter(): void {
+ ExtensibleAbilities::register_query_filter(
+ array(
+ 'resource' => 'order',
+ 'namespace' => 'test-ext',
+ 'filter' => 'without',
+ 'schema' => array(
+ 'type' => 'array',
+ 'items' => array( 'type' => 'integer' ),
+ ),
+ 'callback' => static fn( array $ids ) => array( 'exclude_ids' => $ids ),
+ )
+ );
+ ExtensibleAbilities::register_query_filter(
+ array(
+ 'resource' => 'order',
+ 'namespace' => 'test-ext',
+ 'filter' => 'contains_product',
+ 'schema' => array( 'type' => 'integer' ),
+ 'callback' => static function ( int $product_id ) {
+ global $wpdb;
+ return array(
+ 'include_ids' => $wpdb->get_col(
+ $wpdb->prepare(
+ "SELECT DISTINCT items.order_id FROM {$wpdb->prefix}woocommerce_order_items AS items INNER JOIN {$wpdb->prefix}woocommerce_order_itemmeta AS meta ON meta.order_item_id = items.order_item_id WHERE meta.meta_key = '_product_id' AND meta.meta_value = %d",
+ $product_id
+ )
+ ),
+ );
+ },
+ )
+ );
+ }
+
+ /**
+ * @testdox Should keep the products whose IDs a filter includes, drop the IDs a filter excludes, and refuse IDs that are not integers.
+ */
+ public function test_product_ids_filters_include_and_exclude(): void {
+ $coded = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen coded' ) );
+ $coded->update_meta_data( '_test_code', 'A1' );
+ $coded->save();
+ $plain = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen plain' ) );
+ add_action(
+ 'woocommerce_extensible_abilities_init',
+ static function () use ( $plain ) {
+ $callbacks = array(
+ 'picked' => static fn( array $ids ) => array( 'include_ids' => $ids ),
+ 'not_plain' => static fn() => array( 'exclude_ids' => array( $plain->get_id() ) ),
+ 'not_ids' => static fn() => array( 'include_ids' => array( 'x' ) ),
+ );
+ foreach ( $callbacks as $filter => $callback ) {
+ ExtensibleAbilities::register_query_filter(
+ array(
+ 'resource' => 'product',
+ 'namespace' => 'test-ext',
+ 'filter' => $filter,
+ 'schema' => 'picked' === $filter ? array(
+ 'type' => 'array',
+ 'items' => array( 'type' => 'integer' ),
+ ) : array( 'type' => 'boolean' ),
+ 'callback' => $callback,
+ )
+ );
+ }
+ }
+ );
+ $filters = static fn( array $values ) => array(
+ 'search' => 'Pen',
+ 'filters' => array( 'extensions' => array( 'test-ext' => $values ) ),
+ );
+
+ $picked = $this->run_ability( 'woocommerce/products-query', $filters( array( 'picked' => array( $coded->get_id(), $plain->get_id() ) ) ) );
+ $both = $this->run_ability(
+ 'woocommerce/products-query',
+ $filters(
+ array(
+ 'picked' => array( $coded->get_id(), $plain->get_id() ),
+ 'has_code' => true,
+ )
+ )
+ );
+ $empty = $this->run_ability( 'woocommerce/products-query', $filters( array( 'picked' => array() ) ) );
+ $excluded = $this->run_ability( 'woocommerce/products-query', $filters( array( 'not_plain' => true ) ) );
+ $narrowed = $this->run_ability(
+ 'woocommerce/products-query',
+ $filters(
+ array(
+ 'not_plain' => true,
+ 'picked' => array( $coded->get_id(), $plain->get_id() ),
+ )
+ )
+ );
+ $nothing = $this->run_ability(
+ 'woocommerce/products-query',
+ $filters(
+ array(
+ 'not_plain' => true,
+ 'picked' => array( $plain->get_id() ),
+ )
+ )
+ );
+ $not_ids = $this->run_ability_response( 'woocommerce/products-query', $filters( array( 'not_ids' => true ) ) );
+
+ $this->assertEqualsCanonicalizing( array( $coded->get_id(), $plain->get_id() ), array_column( $picked['products'], 'id' ) );
+ $this->assertSame( array( $coded->get_id() ), array_column( $both['products'], 'id' ) );
+ $this->assertSame( array(), $empty['products'] );
+ $this->assertSame( array( $coded->get_id() ), array_column( $excluded['products'], 'id' ) );
+ $this->assertSame( array( $coded->get_id() ), array_column( $narrowed['products'], 'id' ) );
+ $this->assertSame( array(), $nothing['products'] );
+ $this->assertSame( 'woocommerce_ability_extension_filter_failed', $not_ids->get_data()['code'] );
+ }
+
+ /**
+ * @testdox Should list the filters in the input schemas, and refuse a filter that is not registered.
+ */
+ public function test_input_schemas_list_filters_and_refuse_unknown_ones(): void {
+ $products = $this->get_ability( 'woocommerce/products-query' )['input_schema']['properties']['filters']['properties']['extensions'];
+ $orders = $this->get_ability( 'woocommerce/orders-query' )['input_schema']['properties']['filters']['properties']['extensions'];
+
+ $response = $this->run_ability_response(
+ 'woocommerce/orders-query',
+ array( 'filters' => array( 'extensions' => array( 'test-ext' => array( 'unknown' => 'x' ) ) ) )
+ );
+
+ $this->assertSame( array( 'type' => 'boolean' ), $products['properties']['test-ext']['properties']['has_code'] );
+ $this->assertSame( array( 'type' => 'string' ), $orders['properties']['test-ext']['properties']['tag'] );
+ $this->assertSame( 400, $response->get_status() );
+ }
+
+ /**
+ * @testdox Should return the error of a filter that refuses its value, or that returns arguments Core does not support.
+ */
+ public function test_a_filter_that_refuses_or_fails_returns_an_error(): void {
+ add_action(
+ 'woocommerce_extensible_abilities_init',
+ static function () {
+ $callbacks = array(
+ array( 'order', 'broken', static fn() => array( 'post__in' => array( 1 ) ) ),
+ array( 'order', 'taxed', static fn() => array( 'tax_query' => array( array( 'taxonomy' => 'product_cat' ) ) ) ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
+ array( 'product', 'scalar', static fn() => array( 'meta_query' => 'x' ) ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
+ );
+ foreach ( $callbacks as list( $resource, $filter, $callback ) ) {
+ ExtensibleAbilities::register_query_filter(
+ array(
+ 'resource' => $resource,
+ 'namespace' => 'test-ext',
+ 'filter' => $filter,
+ 'schema' => array( 'type' => 'boolean' ),
+ 'callback' => $callback,
+ )
+ );
+ }
+ }
+ );
+
+ $refused = $this->run_ability_response( 'woocommerce/orders-query', array( 'filters' => array( 'extensions' => array( 'test-ext' => array( 'tag' => 'bad' ) ) ) ) );
+ $failed = $this->run_ability_response( 'woocommerce/orders-query', array( 'filters' => array( 'extensions' => array( 'test-ext' => array( 'broken' => true ) ) ) ) );
+ $taxed = $this->run_ability_response( 'woocommerce/orders-query', array( 'filters' => array( 'extensions' => array( 'test-ext' => array( 'taxed' => true ) ) ) ) );
+ $scalar = $this->run_ability_response( 'woocommerce/products-query', array( 'filters' => array( 'extensions' => array( 'test-ext' => array( 'scalar' => true ) ) ) ) );
+
+ $this->assertSame( 'test_bad_tag', $refused->get_data()['code'] );
+ $this->assertSame( 'woocommerce_ability_extension_filter_failed', $failed->get_data()['code'] );
+ $this->assertSame( 'woocommerce_ability_extension_filter_failed', $taxed->get_data()['code'] );
+ $this->assertSame( 'woocommerce_ability_extension_filter_failed', $scalar->get_data()['code'] );
+ }
+
+ /**
+ * @testdox Should accept a filter that returns an empty clause, and not filter the query.
+ */
+ public function test_a_filter_that_returns_an_empty_clause_does_not_filter(): void {
+ add_action(
+ 'woocommerce_extensible_abilities_init',
+ static function () {
+ ExtensibleAbilities::register_query_filter(
+ array(
+ 'resource' => 'product',
+ 'namespace' => 'test-ext',
+ 'filter' => 'empty_clause',
+ 'schema' => array( 'type' => 'boolean' ),
+ 'callback' => static fn() => array( 'meta_query' => array() ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
+ )
+ );
+ }
+ );
+ $this->get_ability( 'woocommerce/products-query' )['input_schema'];
+
+ $this->assertSame( array(), ExtensibleAbilities::get_query_args( 'product', array( 'filters' => array( 'extensions' => array( 'test-ext' => array( 'empty_clause' => true ) ) ) ) ) );
+ }
+
+ /**
+ * @testdox Should refuse a filter on a resource that is not registered, and accept one on a registered resource.
+ */
+ public function test_a_filter_on_an_unsupported_resource_is_refused(): void {
+ $this->setExpectedIncorrectUsage( ExtensibleAbilities::class . '::register_query_filter' );
+ $input = array( 'filters' => array( 'extensions' => array( 'test-ext' => array( 'gift' => true ) ) ) );
+ $filter = array(
+ 'resource' => 'ticket',
+ 'namespace' => 'test-ext',
+ 'filter' => 'gift',
+ 'schema' => array( 'type' => 'boolean' ),
+ 'callback' => static fn() => array( 'meta_query' => array( array( 'key' => 'gift' ) ) ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
+ );
+
+ ExtensibleAbilities::register_query_filter( $filter );
+ $this->assertSame( array(), ExtensibleAbilities::get_query_args( 'ticket', $input ) );
+
+ $register_resource = static function () {
+ ExtensibleAbilities::register_resource( 'ticket' );
+ };
+ $register = static function () use ( $filter ) {
+ ExtensibleAbilities::register_query_filter( $filter );
+ };
+ add_action( 'woocommerce_extensible_abilities_resources_init', $register_resource );
+ add_action( 'woocommerce_extensible_abilities_init', $register );
+ $this->get_ability( 'woocommerce/products-query' )['input_schema'];
+ remove_action( 'woocommerce_extensible_abilities_resources_init', $register_resource );
+ remove_action( 'woocommerce_extensible_abilities_init', $register );
+
+ $this->assertSame( array( 'meta_query' => array( array( array( 'key' => 'gift' ) ) ) ), ExtensibleAbilities::get_query_args( 'ticket', $input ) ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
+ }
+
+ /**
+ * @testdox Should refuse a filter whose namespace or name is only numbers.
+ */
+ public function test_a_filter_with_a_numeric_slug_is_refused(): void {
+ $this->setExpectedIncorrectUsage( ExtensibleAbilities::class . '::register_query_filter' );
+ $register = static function () {
+ foreach ( array( array( '0', 'gift' ), array( 'test-ext', '42' ) ) as list( $extension_namespace, $filter ) ) {
+ ExtensibleAbilities::register_query_filter(
+ array(
+ 'resource' => 'product',
+ 'namespace' => $extension_namespace,
+ 'filter' => $filter,
+ 'schema' => array( 'type' => 'boolean' ),
+ 'callback' => static fn() => array( 'meta_query' => array( array( 'key' => 'gift' ) ) ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
+ )
+ );
+ }
+ };
+ add_action( 'woocommerce_extensible_abilities_init', $register );
+ $schema = $this->get_ability( 'woocommerce/products-query' )['input_schema'];
+ remove_action( 'woocommerce_extensible_abilities_init', $register );
+
+ $this->assertSame( array( 'test-ext' ), array_keys( $schema['properties']['filters']['properties']['extensions']['properties'] ) );
+ $this->assertSame( array( 'has_code', 'category' ), array_keys( $schema['properties']['filters']['properties']['extensions']['properties']['test-ext']['properties'] ) );
+ }
+
+ /**
+ * @testdox Should refuse a filter that is registered after the init action.
+ */
+ public function test_a_filter_registered_after_the_init_action_is_refused(): void {
+ $this->setExpectedIncorrectUsage( ExtensibleAbilities::class . '::register_query_filter' );
+ $this->get_ability( 'woocommerce/products-query' )['input_schema'];
+
+ ExtensibleAbilities::register_query_filter(
+ array(
+ 'resource' => 'product',
+ 'namespace' => 'late-ext',
+ 'filter' => 'gift',
+ 'schema' => array( 'type' => 'boolean' ),
+ 'callback' => static fn() => array( 'meta_query' => array( array( 'key' => 'gift' ) ) ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
+ )
+ );
+
+ $this->assertSame( array(), ExtensibleAbilities::get_query_args( 'product', array( 'filters' => array( 'extensions' => array( 'late-ext' => array( 'gift' => true ) ) ) ) ) );
+ }
+
+ /**
+ * @testdox Should refuse an id sent together with filters.
+ */
+ public function test_an_id_with_filters_is_refused(): void {
+ $input = array(
+ 'id' => 1,
+ 'filters' => array( 'extensions' => array( 'test-ext' => array( 'has_code' => true ) ) ),
+ );
+
+ $products = $this->run_ability_response( 'woocommerce/products-query', $input );
+ $orders = $this->run_ability_response( 'woocommerce/orders-query', array( 'id' => 1 ) + array( 'filters' => array( 'extensions' => array( 'test-ext' => array( 'tag' => 'gift' ) ) ) ) );
+
+ $this->assertSame( 400, $products->get_status() );
+ $this->assertSame( 'woocommerce_ability_filters_with_id', $products->get_data()['code'] );
+ $this->assertSame( 400, $orders->get_status() );
+ $this->assertSame( 'woocommerce_ability_filters_with_id', $orders->get_data()['code'] );
+ }
+
/**
* @testdox Should return the fields of each extension under its namespace, and leave out a product with no values.
*/
@@ -431,6 +1021,8 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
$this->assertSame( 0, did_action( 'woocommerce_extensible_abilities_resources_init' ) );
$this->assertSame( 0, did_action( 'woocommerce_extensible_abilities_init' ) );
+ $this->assertArrayNotHasKey( 'filters', $this->get_ability( 'woocommerce/products-query' )['input_schema']['properties'] );
+ $this->assertSame( 400, $this->run_ability_response( 'woocommerce/products-query', array( 'filters' => array( 'extensions' => array( 'test-ext' => array( 'has_code' => true ) ) ) ) )->get_status() );
$this->assertArrayNotHasKey( 'extensions', $this->get_output_schema( 'woocommerce/products-query' )['properties']['products']['items']['properties'] );
$this->assertArrayNotHasKey( 'extensions', $products[0] );
$this->assertArrayNotHasKey( 'extensions', $orders[0] );
@@ -1482,7 +2074,7 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
private function reset_registries(): void {
global $wp_actions;
- foreach ( array( 'fields', 'reported', 'validators' ) as $property ) {
+ foreach ( array( 'fields', 'reported', 'validators', 'filters' ) as $property ) {
$reflection = new \ReflectionProperty( ExtensibleAbilities::class, $property );
$reflection->setAccessible( true );
$reflection->setValue( null, array() );