ProductMapper.php
565 lines
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Automattic\WooCommerce\Api\Utils\Products; |
| 6 | |
| 7 | use Automattic\WooCommerce\Api\Enums\Products\ProductStatus; |
| 8 | use Automattic\WooCommerce\Api\Enums\Products\ProductType; |
| 9 | use Automattic\WooCommerce\Api\Enums\Products\StockStatus; |
| 10 | use Automattic\WooCommerce\Api\Pagination\Connection; |
| 11 | use Automattic\WooCommerce\Api\Pagination\Edge; |
| 12 | use Automattic\WooCommerce\Api\Pagination\IdCursorFilter; |
| 13 | use Automattic\WooCommerce\Api\Pagination\PageInfo; |
| 14 | use Automattic\WooCommerce\Api\Pagination\PaginationParams; |
| 15 | use Automattic\WooCommerce\Api\Types\Products\ExternalProduct; |
| 16 | use Automattic\WooCommerce\Api\Types\Products\ProductAttribute; |
| 17 | use Automattic\WooCommerce\Api\Types\Products\ProductDimensions; |
| 18 | use Automattic\WooCommerce\Api\Types\Products\ProductImage; |
| 19 | use Automattic\WooCommerce\Api\Types\Products\ProductReview; |
| 20 | use Automattic\WooCommerce\Api\Types\Products\ProductVariation; |
| 21 | use Automattic\WooCommerce\Api\Types\Products\SelectedAttribute; |
| 22 | use Automattic\WooCommerce\Api\Types\Products\SimpleProduct; |
| 23 | use Automattic\WooCommerce\Api\Types\Products\VariableProduct; |
| 24 | |
| 25 | /** |
| 26 | * Maps a WC_Product to the appropriate product DTO. |
| 27 | */ |
| 28 | class ProductMapper { |
| 29 | /** |
| 30 | * Map a WC_Product to the appropriate product DTO based on its type. |
| 31 | * |
| 32 | * @param \WC_Product $wc_product The WooCommerce product object. |
| 33 | * @param ?array $query_info Unified query info tree from the GraphQL request. |
| 34 | * @return object |
| 35 | */ |
| 36 | public static function from_wc_product( |
| 37 | \WC_Product $wc_product, |
| 38 | ?array $query_info = null, |
| 39 | ): object { |
| 40 | $product = match ( $wc_product->get_type() ) { |
| 41 | 'external' => self::build_external_product( $wc_product ), |
| 42 | 'variable' => self::build_variable_product( $wc_product, $query_info ), |
| 43 | 'variation' => self::build_product_variation( $wc_product ), |
| 44 | default => new SimpleProduct(), |
| 45 | }; |
| 46 | |
| 47 | self::populate_common_fields( $product, $wc_product, $query_info ); |
| 48 | |
| 49 | return $product; |
| 50 | } |
| 51 | |
| 52 | /** |
| 53 | * Build an ExternalProduct with type-specific fields. |
| 54 | * |
| 55 | * @param \WC_Product $wc_product The external product. |
| 56 | * @return ExternalProduct |
| 57 | */ |
| 58 | private static function build_external_product( \WC_Product $wc_product ): ExternalProduct { |
| 59 | $product = new ExternalProduct(); |
| 60 | |
| 61 | $url = $wc_product->get_product_url(); |
| 62 | $product->product_url = ! empty( $url ) ? $url : null; |
| 63 | $text = $wc_product->get_button_text(); |
| 64 | $product->button_text = ! empty( $text ) ? $text : null; |
| 65 | |
| 66 | return $product; |
| 67 | } |
| 68 | |
| 69 | /** |
| 70 | * Build a VariableProduct with type-specific fields. |
| 71 | * |
| 72 | * @param \WC_Product $wc_product The variable product. |
| 73 | * @param ?array $query_info Unified query info tree from the GraphQL request. |
| 74 | * @return VariableProduct |
| 75 | */ |
| 76 | private static function build_variable_product( \WC_Product $wc_product, ?array $query_info = null ): VariableProduct { |
| 77 | $product = new VariableProduct(); |
| 78 | |
| 79 | $child_ids = $wc_product->get_children(); |
| 80 | $total_count = count( $child_ids ); |
| 81 | |
| 82 | // Extract the per-variation selection and pagination args from |
| 83 | // $query_info up front. Narrowing $query_info keeps recursive |
| 84 | // from_wc_product() calls from fetching subtrees the client didn't |
| 85 | // request (e.g. reviews for every variation). |
| 86 | $variations_info = $query_info['...VariableProduct']['variations'] |
| 87 | ?? $query_info['variations'] |
| 88 | ?? null; |
| 89 | $variation_query_info = self::connection_node_info( $variations_info ); |
| 90 | $pagination_args = $variations_info['__args'] ?? array(); |
| 91 | |
| 92 | // Slice the ID window *before* mapping: otherwise `variations(first: 1)` |
| 93 | // on a product with N variations would prime+map all N just to slice |
| 94 | // the result down afterwards. The resolver-level validation at |
| 95 | // Connection::slice() is now bypassed (we're building a pre-sliced |
| 96 | // connection), so call validate_args() explicitly to keep the 0.. |
| 97 | // MAX_PAGE_SIZE bounds enforced. |
| 98 | PaginationParams::validate_args( $pagination_args ); |
| 99 | $page = self::slice_variation_ids( $child_ids, $pagination_args ); |
| 100 | |
| 101 | // Prime post + meta caches for only the paged subset. |
| 102 | if ( ! empty( $page['ids'] ) ) { |
| 103 | _prime_post_caches( $page['ids'] ); |
| 104 | } |
| 105 | |
| 106 | $edges = array(); |
| 107 | $nodes = array(); |
| 108 | foreach ( $page['ids'] as $child_id ) { |
| 109 | $child_product = wc_get_product( $child_id ); |
| 110 | if ( ! $child_product ) { |
| 111 | continue; |
| 112 | } |
| 113 | |
| 114 | $variation = self::from_wc_product( $child_product, $variation_query_info ); |
| 115 | |
| 116 | $edge = new Edge(); |
| 117 | $edge->cursor = base64_encode( (string) $child_id ); |
| 118 | $edge->node = $variation; |
| 119 | |
| 120 | $edges[] = $edge; |
| 121 | $nodes[] = $variation; |
| 122 | } |
| 123 | |
| 124 | $page_info = new PageInfo(); |
| 125 | $page_info->has_next_page = $page['has_next_page']; |
| 126 | $page_info->has_previous_page = $page['has_previous_page']; |
| 127 | $page_info->start_cursor = ! empty( $edges ) ? $edges[0]->cursor : null; |
| 128 | $page_info->end_cursor = ! empty( $edges ) ? $edges[ count( $edges ) - 1 ]->cursor : null; |
| 129 | |
| 130 | // total_count reflects the full variation set, not the paged one — |
| 131 | // consistent with how the root list resolvers compute it. |
| 132 | $product->variations = Connection::pre_sliced( $edges, $page_info, $total_count ); |
| 133 | |
| 134 | return $product; |
| 135 | } |
| 136 | |
| 137 | /** |
| 138 | * Compute a Relay cursor page against a list of variation IDs. |
| 139 | * |
| 140 | * Mirrors the logic in {@see Connection::slice()} but operates on raw |
| 141 | * IDs so the caller can page-down *before* calling `wc_get_product()` |
| 142 | * + `from_wc_product()` on each child. Returns the paged IDs and the |
| 143 | * corresponding `has_next_page` / `has_previous_page` flags in Relay |
| 144 | * semantics. |
| 145 | * |
| 146 | * @param int[] $child_ids Full variation ID list, in menu_order. |
| 147 | * @param array $args `{first?, last?, after?, before?}` raw GraphQL args. |
| 148 | * @return array{ids: int[], has_next_page: bool, has_previous_page: bool} |
| 149 | */ |
| 150 | private static function slice_variation_ids( array $child_ids, array $args ): array { |
| 151 | $first = $args['first'] ?? null; |
| 152 | $last = $args['last'] ?? null; |
| 153 | $after = $args['after'] ?? null; |
| 154 | $before = $args['before'] ?? null; |
| 155 | |
| 156 | // No pagination requested — return the full list as-is. |
| 157 | if ( null === $first && null === $last && null === $after && null === $before ) { |
| 158 | return array( |
| 159 | 'ids' => array_values( $child_ids ), |
| 160 | 'has_next_page' => false, |
| 161 | 'has_previous_page' => false, |
| 162 | ); |
| 163 | } |
| 164 | |
| 165 | // Narrow by `after`: drop IDs up to and including the cursor position. |
| 166 | if ( null !== $after ) { |
| 167 | $after_id = IdCursorFilter::decode_id_cursor( $after, 'after' ); |
| 168 | $idx = array_search( $after_id, $child_ids, true ); |
| 169 | $child_ids = false !== $idx ? array_slice( $child_ids, $idx + 1 ) : array(); |
| 170 | } |
| 171 | |
| 172 | // Narrow by `before`: drop IDs from the cursor position onward. |
| 173 | if ( null !== $before ) { |
| 174 | $before_id = IdCursorFilter::decode_id_cursor( $before, 'before' ); |
| 175 | $idx = array_search( $before_id, $child_ids, true ); |
| 176 | if ( false !== $idx ) { |
| 177 | $child_ids = array_slice( $child_ids, 0, $idx ); |
| 178 | } |
| 179 | } |
| 180 | |
| 181 | $total_after_cursors = count( $child_ids ); |
| 182 | |
| 183 | // Apply first/last limits. |
| 184 | if ( null !== $first && $first >= 0 ) { |
| 185 | $child_ids = array_slice( $child_ids, 0, $first ); |
| 186 | } |
| 187 | if ( null !== $last && $last >= 0 ) { |
| 188 | $child_ids = array_slice( $child_ids, max( 0, count( $child_ids ) - $last ) ); |
| 189 | } |
| 190 | |
| 191 | // Relay semantics for the forward / backward branches match what |
| 192 | // ListProducts / ListCoupons use at the root level. |
| 193 | return array( |
| 194 | 'ids' => array_values( $child_ids ), |
| 195 | 'has_next_page' => |
| 196 | null !== $first ? count( $child_ids ) < $total_after_cursors : ( null !== $before ), |
| 197 | 'has_previous_page' => |
| 198 | null !== $last ? count( $child_ids ) < $total_after_cursors : ( null !== $after ), |
| 199 | ); |
| 200 | } |
| 201 | |
| 202 | /** |
| 203 | * Build a ProductVariation with type-specific fields. |
| 204 | * |
| 205 | * @param \WC_Product $wc_product The variation product. |
| 206 | * @return ProductVariation |
| 207 | */ |
| 208 | private static function build_product_variation( \WC_Product $wc_product ): ProductVariation { |
| 209 | $product = new ProductVariation(); |
| 210 | $product->parent_id = $wc_product->get_parent_id(); |
| 211 | |
| 212 | $selected_attributes = array(); |
| 213 | foreach ( $wc_product->get_attributes() as $taxonomy => $value ) { |
| 214 | $attr = new SelectedAttribute(); |
| 215 | $attr->name = $taxonomy; |
| 216 | |
| 217 | // For taxonomy attributes, resolve the slug to a human-readable term name. |
| 218 | if ( taxonomy_exists( $taxonomy ) && ! empty( $value ) ) { |
| 219 | $term = get_term_by( 'slug', $value, $taxonomy ); |
| 220 | if ( $term && ! is_wp_error( $term ) ) { |
| 221 | $attr->value = $term->name; |
| 222 | } else { |
| 223 | $attr->value = $value; |
| 224 | } |
| 225 | } else { |
| 226 | $attr->value = $value; |
| 227 | } |
| 228 | |
| 229 | $selected_attributes[] = $attr; |
| 230 | } |
| 231 | $product->selected_attributes = $selected_attributes; |
| 232 | |
| 233 | return $product; |
| 234 | } |
| 235 | |
| 236 | /** |
| 237 | * Populate the common fields shared by all product types. |
| 238 | * |
| 239 | * @param object $product The product DTO to populate. |
| 240 | * @param \WC_Product $wc_product The WooCommerce product object. |
| 241 | * @param ?array $query_info Unified query info tree from the GraphQL request. |
| 242 | */ |
| 243 | private static function populate_common_fields( |
| 244 | object $product, |
| 245 | \WC_Product $wc_product, |
| 246 | ?array $query_info, |
| 247 | ): void { |
| 248 | $raw_status = (string) $wc_product->get_status(); |
| 249 | $raw_product_type = (string) $wc_product->get_type(); |
| 250 | |
| 251 | $product->id = $wc_product->get_id(); |
| 252 | $product->name = $wc_product->get_name(); |
| 253 | $product->slug = $wc_product->get_slug(); |
| 254 | $sku = $wc_product->get_sku(); |
| 255 | $product->sku = '' !== $sku ? $sku : null; |
| 256 | $product->description = $wc_product->get_description(); |
| 257 | $product->short_description = $wc_product->get_short_description(); |
| 258 | $product->status = ProductStatus::tryFrom( $raw_status ) ?? ProductStatus::Other; |
| 259 | $product->raw_status = $raw_status; |
| 260 | $product->product_type = ProductType::tryFrom( $raw_product_type ) ?? ProductType::Other; |
| 261 | $product->raw_product_type = $raw_product_type; |
| 262 | |
| 263 | // Price fields support a "formatted" argument for currency display. |
| 264 | // An empty stored value means "not set" and is surfaced as null — |
| 265 | // without this, wc_price( (float) '' ) would render as "$0.00" and |
| 266 | // be indistinguishable from a genuinely-zero price. |
| 267 | $format_regular = $query_info['regular_price']['__args']['formatted'] ?? true; |
| 268 | $raw_regular = $wc_product->get_regular_price(); |
| 269 | if ( '' === $raw_regular ) { |
| 270 | $product->regular_price = null; |
| 271 | } else { |
| 272 | $product->regular_price = $format_regular |
| 273 | ? wc_price( (float) $raw_regular ) |
| 274 | : $raw_regular; |
| 275 | } |
| 276 | |
| 277 | $format_sale = $query_info['sale_price']['__args']['formatted'] ?? true; |
| 278 | $raw_sale = $wc_product->get_sale_price(); |
| 279 | if ( '' === $raw_sale ) { |
| 280 | $product->sale_price = null; |
| 281 | } else { |
| 282 | $product->sale_price = $format_sale |
| 283 | ? wc_price( (float) $raw_sale ) |
| 284 | : $raw_sale; |
| 285 | } |
| 286 | |
| 287 | $raw_stock_status = (string) $wc_product->get_stock_status(); |
| 288 | $product->stock_status = self::map_stock_status( $raw_stock_status ); |
| 289 | $product->raw_stock_status = $raw_stock_status; |
| 290 | $product->stock_quantity = $wc_product->get_stock_quantity(); |
| 291 | |
| 292 | // Nested output type: dimensions. |
| 293 | $product->dimensions = self::build_dimensions( $wc_product ); |
| 294 | |
| 295 | // Array of objects: images. |
| 296 | $product->images = self::build_images( $wc_product ); |
| 297 | |
| 298 | // Array of objects: attributes. |
| 299 | $product->attributes = self::build_attributes( $wc_product ); |
| 300 | |
| 301 | // Sub-collection connection: reviews. |
| 302 | // Only populate if explicitly requested (optimization via $query_info). |
| 303 | if ( null === $query_info || array_key_exists( 'reviews', $query_info ) ) { |
| 304 | $product->reviews = self::build_reviews( $wc_product->get_id() ); |
| 305 | } else { |
| 306 | $product->reviews = self::empty_connection(); |
| 307 | } |
| 308 | |
| 309 | $product->date_created = $wc_product->get_date_created()?->format( \DateTimeInterface::ATOM ); |
| 310 | $product->date_modified = $wc_product->get_date_modified()?->format( \DateTimeInterface::ATOM ); |
| 311 | |
| 312 | // Ignored field — set to null; it won't appear in the schema. |
| 313 | $product->internal_notes = null; |
| 314 | } |
| 315 | |
| 316 | /** |
| 317 | * Map WooCommerce stock status string to the int-backed StockStatus enum. |
| 318 | * |
| 319 | * @param string $wc_status The WC stock status string. |
| 320 | * @return StockStatus |
| 321 | */ |
| 322 | private static function map_stock_status( string $wc_status ): StockStatus { |
| 323 | return match ( $wc_status ) { |
| 324 | 'instock' => StockStatus::InStock, |
| 325 | 'outofstock' => StockStatus::OutOfStock, |
| 326 | 'onbackorder' => StockStatus::OnBackorder, |
| 327 | default => StockStatus::Other, |
| 328 | }; |
| 329 | } |
| 330 | |
| 331 | /** |
| 332 | * Build product dimensions from a WC_Product. |
| 333 | * |
| 334 | * @param \WC_Product $wc_product The product. |
| 335 | * @return ?ProductDimensions |
| 336 | */ |
| 337 | private static function build_dimensions( \WC_Product $wc_product ): ?ProductDimensions { |
| 338 | $length = $wc_product->get_length(); |
| 339 | $width = $wc_product->get_width(); |
| 340 | $height = $wc_product->get_height(); |
| 341 | $weight = $wc_product->get_weight(); |
| 342 | |
| 343 | if ( '' === $length && '' === $width && '' === $height && '' === $weight ) { |
| 344 | return null; |
| 345 | } |
| 346 | |
| 347 | $dims = new ProductDimensions(); |
| 348 | $dims->length = '' !== $length ? (float) $length : null; |
| 349 | $dims->width = '' !== $width ? (float) $width : null; |
| 350 | $dims->height = '' !== $height ? (float) $height : null; |
| 351 | $dims->weight = '' !== $weight ? (float) $weight : null; |
| 352 | |
| 353 | return $dims; |
| 354 | } |
| 355 | |
| 356 | /** |
| 357 | * Build product images from a WC_Product. |
| 358 | * |
| 359 | * @param \WC_Product $wc_product The product. |
| 360 | * @return ProductImage[] |
| 361 | */ |
| 362 | private static function build_images( \WC_Product $wc_product ): array { |
| 363 | $images = array(); |
| 364 | $position = 0; |
| 365 | |
| 366 | // Include the featured image first. |
| 367 | $featured_id = $wc_product->get_image_id(); |
| 368 | if ( $featured_id ) { |
| 369 | $image = self::build_image( (int) $featured_id, $position ); |
| 370 | if ( null !== $image ) { |
| 371 | $images[] = $image; |
| 372 | ++$position; |
| 373 | } |
| 374 | } |
| 375 | |
| 376 | // Then gallery images. |
| 377 | foreach ( $wc_product->get_gallery_image_ids() as $image_id ) { |
| 378 | $image = self::build_image( (int) $image_id, $position ); |
| 379 | if ( null !== $image ) { |
| 380 | $images[] = $image; |
| 381 | ++$position; |
| 382 | } |
| 383 | } |
| 384 | |
| 385 | return $images; |
| 386 | } |
| 387 | |
| 388 | /** |
| 389 | * Build product attributes from a WC_Product. |
| 390 | * |
| 391 | * For variations, attributes are simple key→value pairs (handled by selected_attributes), |
| 392 | * so this returns an empty array. For other product types, it returns full attribute definitions. |
| 393 | * |
| 394 | * @param \WC_Product $wc_product The product. |
| 395 | * @return ProductAttribute[] |
| 396 | */ |
| 397 | private static function build_attributes( \WC_Product $wc_product ): array { |
| 398 | // Variations store attributes as simple string values, not WC_Product_Attribute objects. |
| 399 | if ( 'variation' === $wc_product->get_type() ) { |
| 400 | return array(); |
| 401 | } |
| 402 | |
| 403 | $attributes = array(); |
| 404 | foreach ( $wc_product->get_attributes() as $wc_attr ) { |
| 405 | if ( ! $wc_attr instanceof \WC_Product_Attribute ) { |
| 406 | continue; |
| 407 | } |
| 408 | |
| 409 | $attr = new ProductAttribute(); |
| 410 | $attr->slug = $wc_attr->get_name(); |
| 411 | |
| 412 | if ( $wc_attr->is_taxonomy() ) { |
| 413 | $attr->name = wc_attribute_label( $wc_attr->get_name() ); |
| 414 | $attr->options = array_map( |
| 415 | function ( $term ) { |
| 416 | return $term->name; |
| 417 | }, |
| 418 | $wc_attr->get_terms() ? $wc_attr->get_terms() : array() |
| 419 | ); |
| 420 | } else { |
| 421 | $attr->name = $wc_attr->get_name(); |
| 422 | $attr->options = $wc_attr->get_options(); |
| 423 | } |
| 424 | |
| 425 | $attr->position = $wc_attr->get_position(); |
| 426 | $attr->visible = $wc_attr->get_visible(); |
| 427 | $attr->variation = $wc_attr->get_variation(); |
| 428 | $attr->is_taxonomy = $wc_attr->is_taxonomy(); |
| 429 | |
| 430 | $attributes[] = $attr; |
| 431 | }//end foreach |
| 432 | |
| 433 | return $attributes; |
| 434 | } |
| 435 | |
| 436 | /** |
| 437 | * Build a single ProductImage from an attachment ID. |
| 438 | * |
| 439 | * @param int $attachment_id The WordPress attachment ID. |
| 440 | * @param int $position The display position. |
| 441 | * @return ?ProductImage |
| 442 | */ |
| 443 | private static function build_image( int $attachment_id, int $position ): ?ProductImage { |
| 444 | $url = wp_get_attachment_url( $attachment_id ); |
| 445 | if ( ! $url ) { |
| 446 | return null; |
| 447 | } |
| 448 | |
| 449 | $image = new ProductImage(); |
| 450 | $image->id = $attachment_id; |
| 451 | $image->url = $url; |
| 452 | $alt = get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ); |
| 453 | $image->alt = ! empty( $alt ) ? $alt : ''; |
| 454 | $image->position = $position; |
| 455 | |
| 456 | return $image; |
| 457 | } |
| 458 | |
| 459 | /** |
| 460 | * Build a reviews connection for a product. |
| 461 | * |
| 462 | * @param int $product_id The product ID. |
| 463 | * @return Connection |
| 464 | */ |
| 465 | private static function build_reviews( int $product_id ): Connection { |
| 466 | $base_args = array( |
| 467 | 'post_id' => $product_id, |
| 468 | 'type' => 'review', |
| 469 | 'status' => 'approve', |
| 470 | ); |
| 471 | |
| 472 | // Separate count query: otherwise `total_count` would be the page |
| 473 | // size (capped at 10) instead of the real review total. |
| 474 | $total_count = (int) get_comments( $base_args + array( 'count' => true ) ); |
| 475 | |
| 476 | $comments = get_comments( |
| 477 | $base_args + array( |
| 478 | 'orderby' => 'comment_date', |
| 479 | 'order' => 'DESC', |
| 480 | 'number' => 10, |
| 481 | ) |
| 482 | ); |
| 483 | |
| 484 | $edges = array(); |
| 485 | $nodes = array(); |
| 486 | |
| 487 | foreach ( $comments as $comment ) { |
| 488 | $review = new ProductReview(); |
| 489 | $review->id = (int) $comment->comment_ID; |
| 490 | $review->product_id = $product_id; |
| 491 | $review->reviewer = $comment->comment_author; |
| 492 | $review->review = $comment->comment_content; |
| 493 | $review->rating = (int) get_comment_meta( $comment->comment_ID, 'rating', true ); |
| 494 | $review->date_created = $comment->comment_date_gmt |
| 495 | ? ( new \DateTimeImmutable( $comment->comment_date_gmt, new \DateTimeZone( 'UTC' ) ) )->format( \DateTimeInterface::ATOM ) |
| 496 | : null; |
| 497 | |
| 498 | $edge = new Edge(); |
| 499 | $edge->cursor = base64_encode( (string) $review->id ); |
| 500 | $edge->node = $review; |
| 501 | |
| 502 | $edges[] = $edge; |
| 503 | $nodes[] = $review; |
| 504 | } |
| 505 | |
| 506 | $page_info = new PageInfo(); |
| 507 | $page_info->has_next_page = $total_count > count( $comments ); |
| 508 | $page_info->has_previous_page = false; |
| 509 | $page_info->start_cursor = ! empty( $edges ) ? $edges[0]->cursor : null; |
| 510 | $page_info->end_cursor = ! empty( $edges ) ? $edges[ count( $edges ) - 1 ]->cursor : null; |
| 511 | |
| 512 | $connection = new Connection(); |
| 513 | $connection->edges = $edges; |
| 514 | $connection->nodes = $nodes; |
| 515 | $connection->page_info = $page_info; |
| 516 | $connection->total_count = $total_count; |
| 517 | |
| 518 | return $connection; |
| 519 | } |
| 520 | |
| 521 | /** |
| 522 | * Extract the per-node selection from a connection's query_info entry. |
| 523 | * |
| 524 | * Connections can be queried via `nodes { ... }` (the plain form) or |
| 525 | * `edges { node { ... } }` (Relay form); clients may use either or both. |
| 526 | * The per-node selection is what gets forwarded to the recursive |
| 527 | * mapper call so each node is built with the right sub-fields. |
| 528 | * |
| 529 | * @param ?array $connection_info The query_info entry for the connection (e.g. `$query_info['variations']`). |
| 530 | * @return ?array The merged per-node selection, or null when the caller didn't request any node fields. |
| 531 | */ |
| 532 | public static function connection_node_info( ?array $connection_info ): ?array { |
| 533 | if ( null === $connection_info ) { |
| 534 | return null; |
| 535 | } |
| 536 | $nodes = is_array( $connection_info['nodes'] ?? null ) ? $connection_info['nodes'] : array(); |
| 537 | $edge = is_array( $connection_info['edges']['node'] ?? null ) ? $connection_info['edges']['node'] : array(); |
| 538 | if ( empty( $nodes ) && empty( $edge ) ) { |
| 539 | return null; |
| 540 | } |
| 541 | return array_merge( $edge, $nodes ); |
| 542 | } |
| 543 | |
| 544 | /** |
| 545 | * Return an empty connection (for skipped sub-collections). |
| 546 | * |
| 547 | * @return Connection |
| 548 | */ |
| 549 | private static function empty_connection(): Connection { |
| 550 | $page_info = new PageInfo(); |
| 551 | $page_info->has_next_page = false; |
| 552 | $page_info->has_previous_page = false; |
| 553 | $page_info->start_cursor = null; |
| 554 | $page_info->end_cursor = null; |
| 555 | |
| 556 | $connection = new Connection(); |
| 557 | $connection->edges = array(); |
| 558 | $connection->nodes = array(); |
| 559 | $connection->page_info = $page_info; |
| 560 | $connection->total_count = 0; |
| 561 | |
| 562 | return $connection; |
| 563 | } |
| 564 | } |
| 565 |