ProductMapper.php
<?php
declare(strict_types=1);
namespace Automattic\WooCommerce\Api\Utils\Products;
use Automattic\WooCommerce\Api\Enums\Products\ProductStatus;
use Automattic\WooCommerce\Api\Enums\Products\ProductType;
use Automattic\WooCommerce\Api\Enums\Products\StockStatus;
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\Types\Products\ExternalProduct;
use Automattic\WooCommerce\Api\Types\Products\ProductAttribute;
use Automattic\WooCommerce\Api\Types\Products\ProductDimensions;
use Automattic\WooCommerce\Api\Types\Products\ProductImage;
use Automattic\WooCommerce\Api\Types\Products\ProductReview;
use Automattic\WooCommerce\Api\Types\Products\ProductVariation;
use Automattic\WooCommerce\Api\Types\Products\SelectedAttribute;
use Automattic\WooCommerce\Api\Types\Products\SimpleProduct;
use Automattic\WooCommerce\Api\Types\Products\VariableProduct;
/**
* Maps a WC_Product to the appropriate product DTO.
*/
class ProductMapper {
/**
* Map a WC_Product to the appropriate product DTO based on its type.
*
* @param \WC_Product $wc_product The WooCommerce product object.
* @param ?array $query_info Unified query info tree from the GraphQL request.
* @return object
*/
public static function from_wc_product(
\WC_Product $wc_product,
?array $query_info = null,
): object {
$product = match ( $wc_product->get_type() ) {
'external' => self::build_external_product( $wc_product ),
'variable' => self::build_variable_product( $wc_product, $query_info ),
'variation' => self::build_product_variation( $wc_product ),
default => new SimpleProduct(),
};
self::populate_common_fields( $product, $wc_product, $query_info );
return $product;
}
/**
* Build an ExternalProduct with type-specific fields.
*
* @param \WC_Product $wc_product The external product.
* @return ExternalProduct
*/
private static function build_external_product( \WC_Product $wc_product ): ExternalProduct {
$product = new ExternalProduct();
$url = $wc_product->get_product_url();
$product->product_url = ! empty( $url ) ? $url : null;
$text = $wc_product->get_button_text();
$product->button_text = ! empty( $text ) ? $text : null;
return $product;
}
/**
* Build a VariableProduct with type-specific fields.
*
* @param \WC_Product $wc_product The variable product.
* @param ?array $query_info Unified query info tree from the GraphQL request.
* @return VariableProduct
*/
private static function build_variable_product( \WC_Product $wc_product, ?array $query_info = null ): VariableProduct {
$product = new VariableProduct();
$child_ids = $wc_product->get_children();
$total_count = count( $child_ids );
// Extract the per-variation selection and pagination args from
// $query_info up front. Narrowing $query_info keeps recursive
// from_wc_product() calls from fetching subtrees the client didn't
// request (e.g. reviews for every variation).
$variations_info = $query_info['...VariableProduct']['variations']
?? $query_info['variations']
?? null;
$variation_query_info = self::connection_node_info( $variations_info );
$pagination_args = $variations_info['__args'] ?? array();
// Slice the ID window *before* mapping: otherwise `variations(first: 1)`
// on a product with N variations would prime+map all N just to slice
// the result down afterwards. The resolver-level validation at
// Connection::slice() is now bypassed (we're building a pre-sliced
// connection), so call validate_args() explicitly to keep the 0..
// MAX_PAGE_SIZE bounds enforced.
PaginationParams::validate_args( $pagination_args );
$page = self::slice_variation_ids( $child_ids, $pagination_args );
// Prime post + meta caches for only the paged subset.
if ( ! empty( $page['ids'] ) ) {
_prime_post_caches( $page['ids'] );
}
$edges = array();
$nodes = array();
foreach ( $page['ids'] as $child_id ) {
$child_product = wc_get_product( $child_id );
if ( ! $child_product ) {
continue;
}
$variation = self::from_wc_product( $child_product, $variation_query_info );
$edge = new Edge();
$edge->cursor = base64_encode( (string) $child_id );
$edge->node = $variation;
$edges[] = $edge;
$nodes[] = $variation;
}
$page_info = new PageInfo();
$page_info->has_next_page = $page['has_next_page'];
$page_info->has_previous_page = $page['has_previous_page'];
$page_info->start_cursor = ! empty( $edges ) ? $edges[0]->cursor : null;
$page_info->end_cursor = ! empty( $edges ) ? $edges[ count( $edges ) - 1 ]->cursor : null;
// total_count reflects the full variation set, not the paged one —
// consistent with how the root list resolvers compute it.
$product->variations = Connection::pre_sliced( $edges, $page_info, $total_count );
return $product;
}
/**
* Compute a Relay cursor page against a list of variation IDs.
*
* Mirrors the logic in {@see Connection::slice()} but operates on raw
* IDs so the caller can page-down *before* calling `wc_get_product()`
* + `from_wc_product()` on each child. Returns the paged IDs and the
* corresponding `has_next_page` / `has_previous_page` flags in Relay
* semantics.
*
* @param int[] $child_ids Full variation ID list, in menu_order.
* @param array $args `{first?, last?, after?, before?}` raw GraphQL args.
* @return array{ids: int[], has_next_page: bool, has_previous_page: bool}
*/
private static function slice_variation_ids( array $child_ids, array $args ): array {
$first = $args['first'] ?? null;
$last = $args['last'] ?? null;
$after = $args['after'] ?? null;
$before = $args['before'] ?? null;
// No pagination requested — return the full list as-is.
if ( null === $first && null === $last && null === $after && null === $before ) {
return array(
'ids' => array_values( $child_ids ),
'has_next_page' => false,
'has_previous_page' => false,
);
}
// Narrow by `after`: drop IDs up to and including the cursor position.
if ( null !== $after ) {
$after_id = IdCursorFilter::decode_id_cursor( $after, 'after' );
$idx = array_search( $after_id, $child_ids, true );
$child_ids = false !== $idx ? array_slice( $child_ids, $idx + 1 ) : array();
}
// Narrow by `before`: drop IDs from the cursor position onward.
if ( null !== $before ) {
$before_id = IdCursorFilter::decode_id_cursor( $before, 'before' );
$idx = array_search( $before_id, $child_ids, true );
if ( false !== $idx ) {
$child_ids = array_slice( $child_ids, 0, $idx );
}
}
$total_after_cursors = count( $child_ids );
// Apply first/last limits.
if ( null !== $first && $first >= 0 ) {
$child_ids = array_slice( $child_ids, 0, $first );
}
if ( null !== $last && $last >= 0 ) {
$child_ids = array_slice( $child_ids, max( 0, count( $child_ids ) - $last ) );
}
// Relay semantics for the forward / backward branches match what
// ListProducts / ListCoupons use at the root level.
return array(
'ids' => array_values( $child_ids ),
'has_next_page' =>
null !== $first ? count( $child_ids ) < $total_after_cursors : ( null !== $before ),
'has_previous_page' =>
null !== $last ? count( $child_ids ) < $total_after_cursors : ( null !== $after ),
);
}
/**
* Build a ProductVariation with type-specific fields.
*
* @param \WC_Product $wc_product The variation product.
* @return ProductVariation
*/
private static function build_product_variation( \WC_Product $wc_product ): ProductVariation {
$product = new ProductVariation();
$product->parent_id = $wc_product->get_parent_id();
$selected_attributes = array();
foreach ( $wc_product->get_attributes() as $taxonomy => $value ) {
$attr = new SelectedAttribute();
$attr->name = $taxonomy;
// For taxonomy attributes, resolve the slug to a human-readable term name.
if ( taxonomy_exists( $taxonomy ) && ! empty( $value ) ) {
$term = get_term_by( 'slug', $value, $taxonomy );
if ( $term && ! is_wp_error( $term ) ) {
$attr->value = $term->name;
} else {
$attr->value = $value;
}
} else {
$attr->value = $value;
}
$selected_attributes[] = $attr;
}
$product->selected_attributes = $selected_attributes;
return $product;
}
/**
* Populate the common fields shared by all product types.
*
* @param object $product The product DTO to populate.
* @param \WC_Product $wc_product The WooCommerce product object.
* @param ?array $query_info Unified query info tree from the GraphQL request.
*/
private static function populate_common_fields(
object $product,
\WC_Product $wc_product,
?array $query_info,
): void {
$raw_status = (string) $wc_product->get_status();
$raw_product_type = (string) $wc_product->get_type();
$product->id = $wc_product->get_id();
$product->name = $wc_product->get_name();
$product->slug = $wc_product->get_slug();
$sku = $wc_product->get_sku();
$product->sku = '' !== $sku ? $sku : null;
$product->description = $wc_product->get_description();
$product->short_description = $wc_product->get_short_description();
$product->status = ProductStatus::tryFrom( $raw_status ) ?? ProductStatus::Other;
$product->raw_status = $raw_status;
$product->product_type = ProductType::tryFrom( $raw_product_type ) ?? ProductType::Other;
$product->raw_product_type = $raw_product_type;
// Price fields support a "formatted" argument for currency display.
// An empty stored value means "not set" and is surfaced as null —
// without this, wc_price( (float) '' ) would render as "$0.00" and
// be indistinguishable from a genuinely-zero price.
$format_regular = $query_info['regular_price']['__args']['formatted'] ?? true;
$raw_regular = $wc_product->get_regular_price();
if ( '' === $raw_regular ) {
$product->regular_price = null;
} else {
$product->regular_price = $format_regular
? wc_price( (float) $raw_regular )
: $raw_regular;
}
$format_sale = $query_info['sale_price']['__args']['formatted'] ?? true;
$raw_sale = $wc_product->get_sale_price();
if ( '' === $raw_sale ) {
$product->sale_price = null;
} else {
$product->sale_price = $format_sale
? wc_price( (float) $raw_sale )
: $raw_sale;
}
$raw_stock_status = (string) $wc_product->get_stock_status();
$product->stock_status = self::map_stock_status( $raw_stock_status );
$product->raw_stock_status = $raw_stock_status;
$product->stock_quantity = $wc_product->get_stock_quantity();
// Nested output type: dimensions.
$product->dimensions = self::build_dimensions( $wc_product );
// Array of objects: images.
$product->images = self::build_images( $wc_product );
// Array of objects: attributes.
$product->attributes = self::build_attributes( $wc_product );
// Sub-collection connection: reviews.
// Only populate if explicitly requested (optimization via $query_info).
if ( null === $query_info || array_key_exists( 'reviews', $query_info ) ) {
$product->reviews = self::build_reviews( $wc_product->get_id() );
} else {
$product->reviews = self::empty_connection();
}
$product->date_created = $wc_product->get_date_created()?->format( \DateTimeInterface::ATOM );
$product->date_modified = $wc_product->get_date_modified()?->format( \DateTimeInterface::ATOM );
// Ignored field — set to null; it won't appear in the schema.
$product->internal_notes = null;
}
/**
* Map WooCommerce stock status string to the int-backed StockStatus enum.
*
* @param string $wc_status The WC stock status string.
* @return StockStatus
*/
private static function map_stock_status( string $wc_status ): StockStatus {
return match ( $wc_status ) {
'instock' => StockStatus::InStock,
'outofstock' => StockStatus::OutOfStock,
'onbackorder' => StockStatus::OnBackorder,
default => StockStatus::Other,
};
}
/**
* Build product dimensions from a WC_Product.
*
* @param \WC_Product $wc_product The product.
* @return ?ProductDimensions
*/
private static function build_dimensions( \WC_Product $wc_product ): ?ProductDimensions {
$length = $wc_product->get_length();
$width = $wc_product->get_width();
$height = $wc_product->get_height();
$weight = $wc_product->get_weight();
if ( '' === $length && '' === $width && '' === $height && '' === $weight ) {
return null;
}
$dims = new ProductDimensions();
$dims->length = '' !== $length ? (float) $length : null;
$dims->width = '' !== $width ? (float) $width : null;
$dims->height = '' !== $height ? (float) $height : null;
$dims->weight = '' !== $weight ? (float) $weight : null;
return $dims;
}
/**
* Build product images from a WC_Product.
*
* @param \WC_Product $wc_product The product.
* @return ProductImage[]
*/
private static function build_images( \WC_Product $wc_product ): array {
$images = array();
$position = 0;
// Include the featured image first.
$featured_id = $wc_product->get_image_id();
if ( $featured_id ) {
$image = self::build_image( (int) $featured_id, $position );
if ( null !== $image ) {
$images[] = $image;
++$position;
}
}
// Then gallery images.
foreach ( $wc_product->get_gallery_image_ids() as $image_id ) {
$image = self::build_image( (int) $image_id, $position );
if ( null !== $image ) {
$images[] = $image;
++$position;
}
}
return $images;
}
/**
* Build product attributes from a WC_Product.
*
* For variations, attributes are simple key→value pairs (handled by selected_attributes),
* so this returns an empty array. For other product types, it returns full attribute definitions.
*
* @param \WC_Product $wc_product The product.
* @return ProductAttribute[]
*/
private static function build_attributes( \WC_Product $wc_product ): array {
// Variations store attributes as simple string values, not WC_Product_Attribute objects.
if ( 'variation' === $wc_product->get_type() ) {
return array();
}
$attributes = array();
foreach ( $wc_product->get_attributes() as $wc_attr ) {
if ( ! $wc_attr instanceof \WC_Product_Attribute ) {
continue;
}
$attr = new ProductAttribute();
$attr->slug = $wc_attr->get_name();
if ( $wc_attr->is_taxonomy() ) {
$attr->name = wc_attribute_label( $wc_attr->get_name() );
$attr->options = array_map(
function ( $term ) {
return $term->name;
},
$wc_attr->get_terms() ? $wc_attr->get_terms() : array()
);
} else {
$attr->name = $wc_attr->get_name();
$attr->options = $wc_attr->get_options();
}
$attr->position = $wc_attr->get_position();
$attr->visible = $wc_attr->get_visible();
$attr->variation = $wc_attr->get_variation();
$attr->is_taxonomy = $wc_attr->is_taxonomy();
$attributes[] = $attr;
}//end foreach
return $attributes;
}
/**
* Build a single ProductImage from an attachment ID.
*
* @param int $attachment_id The WordPress attachment ID.
* @param int $position The display position.
* @return ?ProductImage
*/
private static function build_image( int $attachment_id, int $position ): ?ProductImage {
$url = wp_get_attachment_url( $attachment_id );
if ( ! $url ) {
return null;
}
$image = new ProductImage();
$image->id = $attachment_id;
$image->url = $url;
$alt = get_post_meta( $attachment_id, '_wp_attachment_image_alt', true );
$image->alt = ! empty( $alt ) ? $alt : '';
$image->position = $position;
return $image;
}
/**
* Build a reviews connection for a product.
*
* @param int $product_id The product ID.
* @return Connection
*/
private static function build_reviews( int $product_id ): Connection {
$base_args = array(
'post_id' => $product_id,
'type' => 'review',
'status' => 'approve',
);
// Separate count query: otherwise `total_count` would be the page
// size (capped at 10) instead of the real review total.
$total_count = (int) get_comments( $base_args + array( 'count' => true ) );
$comments = get_comments(
$base_args + array(
'orderby' => 'comment_date',
'order' => 'DESC',
'number' => 10,
)
);
$edges = array();
$nodes = array();
foreach ( $comments as $comment ) {
$review = new ProductReview();
$review->id = (int) $comment->comment_ID;
$review->product_id = $product_id;
$review->reviewer = $comment->comment_author;
$review->review = $comment->comment_content;
$review->rating = (int) get_comment_meta( $comment->comment_ID, 'rating', true );
$review->date_created = $comment->comment_date_gmt
? ( new \DateTimeImmutable( $comment->comment_date_gmt, new \DateTimeZone( 'UTC' ) ) )->format( \DateTimeInterface::ATOM )
: null;
$edge = new Edge();
$edge->cursor = base64_encode( (string) $review->id );
$edge->node = $review;
$edges[] = $edge;
$nodes[] = $review;
}
$page_info = new PageInfo();
$page_info->has_next_page = $total_count > count( $comments );
$page_info->has_previous_page = false;
$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;
}
/**
* Extract the per-node selection from a connection's query_info entry.
*
* Connections can be queried via `nodes { ... }` (the plain form) or
* `edges { node { ... } }` (Relay form); clients may use either or both.
* The per-node selection is what gets forwarded to the recursive
* mapper call so each node is built with the right sub-fields.
*
* @param ?array $connection_info The query_info entry for the connection (e.g. `$query_info['variations']`).
* @return ?array The merged per-node selection, or null when the caller didn't request any node fields.
*/
public static function connection_node_info( ?array $connection_info ): ?array {
if ( null === $connection_info ) {
return null;
}
$nodes = is_array( $connection_info['nodes'] ?? null ) ? $connection_info['nodes'] : array();
$edge = is_array( $connection_info['edges']['node'] ?? null ) ? $connection_info['edges']['node'] : array();
if ( empty( $nodes ) && empty( $edge ) ) {
return null;
}
return array_merge( $edge, $nodes );
}
/**
* Return an empty connection (for skipped sub-collections).
*
* @return Connection
*/
private static function empty_connection(): Connection {
$page_info = new PageInfo();
$page_info->has_next_page = false;
$page_info->has_previous_page = false;
$page_info->start_cursor = null;
$page_info->end_cursor = null;
$connection = new Connection();
$connection->edges = array();
$connection->nodes = array();
$connection->page_info = $page_info;
$connection->total_count = 0;
return $connection;
}
}