Automattic\WooCommerce\Api\Queries\Products

ListProducts::executepublicWC 1.0

[ConnectionOf( Product::class )]

Method of the class: ListProducts{}

No Hooks.

Returns

null. Nothing (null).

Usage

$ListProducts = new ListProducts();
$ListProducts->execute( $pagination, #[Unroll] ProductFilterInput $filters, #[Description( foo )] ?ProductType $product_type, ?array $_query_info,  ): Connection;
$pagination(PaginationParams) (required)
.

[Unroll] ProductFilterInput $filters (required)

: .

[Description( foo )] ?ProductType $product_type

: .
Default: null

?array $_query_info

.
Default: null

(required)

.

ListProducts::execute() code WC 10.9.1

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;
}