ListProducts.php
<?php
declare(strict_types=1);
namespace Automattic\WooCommerce\Api\Queries\Products;
use Automattic\WooCommerce\Api\ApiException;
use Automattic\WooCommerce\Api\Attributes\ConnectionOf;
use Automattic\WooCommerce\Api\Attributes\Description;
use Automattic\WooCommerce\Api\Attributes\Name;
use Automattic\WooCommerce\Api\Attributes\RequiredCapability;
use Automattic\WooCommerce\Api\Attributes\Unroll;
use Automattic\WooCommerce\Api\Enums\Products\ProductType;
use Automattic\WooCommerce\Api\Enums\Products\StockStatus;
use Automattic\WooCommerce\Api\InputTypes\Products\ProductFilterInput;
use Automattic\WooCommerce\Api\Pagination\Connection;
use Automattic\WooCommerce\Api\Pagination\Edge;
use Automattic\WooCommerce\Api\Pagination\IdCursorFilter;
use Automattic\WooCommerce\Api\Pagination\PageInfo;
use Automattic\WooCommerce\Api\Pagination\PaginationParams;
use Automattic\WooCommerce\Api\Interfaces\Product;
use Automattic\WooCommerce\Api\Utils\Products\ProductMapper;
/**
* Query to list products with cursor-based pagination.
*
* Demonstrates: #[Unroll] on parameter, enum as direct param, multiple capabilities.
*/
#[Name( 'products' )]
#[Description( 'List products with cursor-based pagination and optional filtering.' )]
#[RequiredCapability( 'manage_woocommerce' )]
#[RequiredCapability( 'edit_products' )]
class ListProducts {
/**
* List products with optional filtering and pagination.
*
* @param PaginationParams $pagination The pagination parameters.
* @param ProductFilterInput $filters Filter criteria (unrolled to flat args).
* @param ?ProductType $product_type Optional product type filter.
* @param ?array $_query_info Unified query info tree from the GraphQL request.
* @return Connection
* @throws ApiException When an unsupported `stock_status` filter value is passed.
*/
#[ConnectionOf( Product::class )]
public function execute(
PaginationParams $pagination,
#[Unroll]
ProductFilterInput $filters,
#[Description( 'Filter by product type.' )]
?ProductType $product_type = null,
?array $_query_info = null,
): Connection {
$first = $pagination->first;
$last = $pagination->last;
$after = $pagination->after;
$before = $pagination->before;
$limit = $first ?? $last ?? PaginationParams::get_default_page_size();
$query_args = array(
'post_type' => 'product',
'posts_per_page' => $limit + 1,
'orderby' => 'ID',
'order' => null !== $last ? 'DESC' : 'ASC',
'post_status' => $filters->status?->value ?? 'any',
);
// Product type filter via taxonomy. `ProductType::Other` is the
// output-only signal for "stored product_type doesn't match any
// known standard" (typically plugin-added types), mirroring how
// `StockStatus::Other` is handled for the meta-query path above.
// Map it to NOT IN the standard slugs rather than the literal
// 'other' term, which wouldn't match anything.
if ( null !== $product_type ) {
if ( ProductType::Other === $product_type ) {
$query_args['tax_query'] = array(
array(
'taxonomy' => 'product_type',
'field' => 'slug',
'terms' => array_values(
array_filter(
array_map(
static fn( ProductType $t ): string => $t->value,
ProductType::cases()
),
static fn( string $slug ): bool => ProductType::Other->value !== $slug
)
),
'operator' => 'NOT IN',
),
);
} else {
$query_args['tax_query'] = array(
array(
'taxonomy' => 'product_type',
'field' => 'slug',
'terms' => $product_type->value,
),
);
}
}
// Stock status filter via meta. `StockStatus::Other` means "stored
// _stock_status isn't one of the three standard WooCommerce values"
// (typically a plugin-added custom status), so it maps to NOT IN
// those three. `default` throws INVALID_ARGUMENT so any future
// enum case added without updating this match fails loudly with a
// clean 400 instead of a PHP-level UnhandledMatchError → HTTP 500.
if ( null !== $filters->stock_status ) {
// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Not HTML; serialized as JSON.
$meta_clause = match ( $filters->stock_status ) {
StockStatus::InStock => array(
'key' => '_stock_status',
'value' => 'instock',
),
StockStatus::OutOfStock => array(
'key' => '_stock_status',
'value' => 'outofstock',
),
StockStatus::OnBackorder => array(
'key' => '_stock_status',
'value' => 'onbackorder',
),
StockStatus::Other => array(
'key' => '_stock_status',
'value' => array( 'instock', 'outofstock', 'onbackorder' ),
'compare' => 'NOT IN',
),
default => throw new ApiException(
sprintf( 'Unsupported stock_status filter value: %s.', $filters->stock_status->name ),
'INVALID_ARGUMENT',
status_code: 400,
),
};
// phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
$query_args['meta_query'] = array( $meta_clause );
}
// Search filter.
if ( null !== $filters->search ) {
$query_args['s'] = $filters->search;
}
// Total count query. Derive from $query_args — which already has
// the tax_query / meta_query / search clauses applied — *before*
// we set cursor query vars on it. Building $count_args from scratch
// with only post_status would drop every user filter and report the
// count of "all products in that status" instead of "all products
// matching the filters", making Relay consumers' "X of Y" wrong.
// Only `found_posts` is read, so posts_per_page => 1 keeps the
// underlying SELECT cheap.
$count_args = $query_args;
$count_args['posts_per_page'] = 1;
$count_args['fields'] = 'ids';
$count_query = new \WP_Query( $count_args );
$total_count = $count_query->found_posts;
// Cursor-based filtering via IdCursorFilter (see class docblock).
if ( null !== $after ) {
$query_args[ IdCursorFilter::AFTER_ID ] = IdCursorFilter::decode_id_cursor( $after, 'after' );
}
if ( null !== $before ) {
$query_args[ IdCursorFilter::BEFORE_ID ] = IdCursorFilter::decode_id_cursor( $before, 'before' );
}
IdCursorFilter::ensure_registered();
$query = new \WP_Query( $query_args );
$posts = $query->posts;
// Determine pagination.
$has_extra = count( $posts ) > $limit;
if ( $has_extra ) {
$posts = array_slice( $posts, 0, $limit );
}
if ( null !== $last ) {
$posts = array_reverse( $posts );
}
// Narrow $_query_info to the per-node selection so each mapped
// product only fetches the subtrees the client actually asked for
// under `nodes { ... }` / `edges { node { ... } }`. Without this,
// ProductMapper::populate_common_fields() hits its null-$query_info
// fallback and runs build_reviews() (plus its count query) for
// every product on the page — N+1 on reviews even when no client
// selected them.
$node_query_info = ProductMapper::connection_node_info( $_query_info );
// Build edges and nodes.
$edges = array();
$nodes = array();
foreach ( $posts as $post ) {
$wc_product = wc_get_product( $post->ID );
if ( ! $wc_product instanceof \WC_Product ) {
continue;
}
$product = ProductMapper::from_wc_product( $wc_product, $node_query_info );
$edge = new Edge();
$edge->cursor = base64_encode( (string) $product->id );
$edge->node = $product;
$edges[] = $edge;
$nodes[] = $product;
}
$page_info = new PageInfo();
// Relay semantics for backward pagination (`last`, `before`): the
// returned window ends just before `$before`, so items after the
// window exist whenever `$before` was supplied — not whenever
// `$after` was. `has_previous_page` in the backward case is driven
// by the "did we fetch limit+1?" sentinel (`$has_extra`).
$page_info->has_next_page = null !== $last ? ( null !== $before ) : $has_extra;
$page_info->has_previous_page = null !== $last ? $has_extra : ( null !== $after );
$page_info->start_cursor = ! empty( $edges ) ? $edges[0]->cursor : null;
$page_info->end_cursor = ! empty( $edges ) ? $edges[ count( $edges ) - 1 ]->cursor : null;
$connection = new Connection();
$connection->edges = $edges;
$connection->nodes = $nodes;
$connection->page_info = $page_info;
$connection->total_count = $total_count;
return $connection;
}
}