PluginProbe ʕ •ᴥ•ʔ
WooCommerce / 10.9.4
WooCommerce v10.9.4
10.9.4 10.9.3 10.9.2 10.9.1 10.9.0 10.9.0-rc.1 10.9.0-beta.2 10.9.0-beta.1 10.8.1 10.8.0 10.8.0-rc.1 10.8.0-beta.2 10.8.0-beta.1 7.8.0-beta.1 7.8.0-beta.2 7.8.0-rc.1 7.8.0-rc.2 7.8.1 7.8.2 7.8.3 7.8.4 7.9.0 7.9.0-beta.1 7.9.0-beta.2 7.9.0-rc.2 7.9.0-rc.3 7.9.1 7.9.2 8.0.0 8.0.0-beta.1 8.0.0-beta.2 8.0.0-rc.1 8.0.0-rc.2 8.0.1 8.0.2 8.0.3 8.0.4 8.0.5 8.1.0 8.1.0-beta.1 8.1.0-rc.1 8.1.0-rc.2 8.1.1 8.1.2 8.1.3 8.1.4 8.2.0 8.2.0-beta.1 8.2.0-rc.1 8.2.0-rc.2 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.3.0 8.3.0-beta.1 8.3.0-rc.1 8.3.0-rc.2 8.3.1 8.3.2 8.3.3 8.3.4 8.4.0 8.4.0-beta.1 8.4.0-rc.1 8.4.1 8.4.2 8.4.3 8.5.0 8.5.0-beta.1 8.5.0-rc.1 8.5.1 8.5.2 8.5.3 8.5.4 8.5.5 8.6.0 8.6.0-beta.1 8.6.0-rc.1 8.6.1 8.6.2 8.6.3 8.6.4 8.7.0 8.7.0-beta.1 8.7.0-beta.2 8.7.0-rc.1 8.7.1 8.7.2 8.7.3 8.8.0 8.8.0-beta.1 8.8.0-rc.1 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.8.6 8.8.7 8.9.0 8.9.0-beta.1 8.9.0-rc.1 8.9.1 8.9.2 8.9.3 8.9.4 8.9.5 9.0.0 9.0.0-beta.1 9.0.0-beta.2 9.0.0-rc.1 9.0.1 9.0.2 9.0.3 9.0.4 9.1.0 9.1.0-beta.1 9.1.0-rc.1 9.1.1 9.1.2 9.1.3 9.1.4 9.1.5 9.1.6 9.2.0 9.2.0-beta.1 9.2.0-rc.1 9.2.1 9.2.2 9.2.3 9.2.4 9.2.5 9.3.0 9.3.0-beta.1 9.3.0-rc.1 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.3.6 9.4.0 9.4.0-beta.1 9.4.0-beta.2 9.4.0-rc.1 9.4.0-rc.2 9.4.0-rc.3 9.4.0-rc.4 9.4.1 9.4.2 9.4.3 9.4.4 9.4.5 9.5.0 9.5.0-beta.1 9.5.0-beta.2 9.5.0-rc.1 9.5.1 9.5.2 9.5.3 9.5.4 9.6.0 9.6.0-beta.1 9.6.0-beta.2 9.6.0-rc.1 9.6.1 9.6.2 9.6.3 9.6.4 9.7.0 9.7.0-beta.1 9.7.0-rc.1 9.7.1 9.7.2 9.7.3 9.8.0 9.8.0-beta.1 9.8.0-rc.1 9.8.1 9.8.2 9.8.3 9.8.4 9.8.5 9.8.6 9.8.7 9.9.0 9.9.0-beta.1 9.9.0-rc.1 9.9.1 9.9.2 9.9.3 9.9.4 9.9.5 9.9.6 9.9.7 3.7.3 7.1.2 3.8.0 7.2.0 3.8.0-beta.1 7.2.0-beta.1 3.8.0-rc.1 7.2.0-beta.2 3.8.0-rc.2 7.2.0-rc.1 3.8.1 7.2.0-rc.2 3.8.2 7.2.1 3.8.3 7.2.2 3.9.0 7.2.3 3.9.0-beta.1 7.2.4 3.9.0-beta.2 7.3.0 3.9.0-rc.1 7.3.0-beta.1 3.9.0-rc.2 7.3.0-beta.2 3.9.0-rc.3 7.3.0-rc.1 3.9.0-rc.4 7.3.0-rc.2 3.9.1 7.3.1 3.9.2 7.4.0 3.9.3 7.4.0-beta.1 3.9.4 7.4.0-beta.2 3.9.5 7.4.0-rc.1 4.0.0 7.4.0-rc.2 4.0.0-beta.1 7.4.1 4.0.0-rc.1 7.4.2 4.0.0-rc.2 7.5.0 4.0.1 7.5.0-beta.1 4.0.2 7.5.0-beta.2 4.0.3 7.5.0-rc.1 4.0.4 7.5.1 4.1.0 7.5.2 4.1.0-beta.1 7.6.0 4.1.0-beta.2 7.6.0-beta.1 4.1.0-rc.1 7.6.0-beta.2 4.1.0-rc.2 7.6.0-rc.1 4.1.1 7.6.0-rc.2 4.1.2 7.6.0-rc.3 4.1.3 7.6.1 4.1.4 7.6.2 4.2.0 7.7.0 4.2.0-RC.1 7.7.0-beta.1 4.2.0-RC.2 7.7.0-beta.2 4.2.0-beta.1 7.7.0-rc.1 4.2.1 7.7.1 4.2.2 7.7.2 4.2.3 7.7.3 4.2.4 7.8.0 4.2.5 4.3.0 4.3.0-beta.1 4.3.0-rc.1 4.3.0-rc.2 4.3.0-rc.3 4.3.1 4.3.2 4.3.3 4.3.4 4.3.5 4.3.6 4.4.0 4.4.0-beta.1 4.4.0-rc.1 4.4.1 4.4.2 4.4.3 4.4.4 4.5.0 4.5.0-beta.1 4.5.0-rc.1 4.5.0-rc.3 4.5.1 4.5.2 4.5.3 4.5.4 4.5.5 4.6.0 4.6.0-beta.1 4.6.0-rc.1 4.6.1 4.6.2 4.6.3 4.6.4 4.6.5 4.7.0 4.7.0-beta.1 4.7.0-beta.2 4.7.0-rc.1 4.7.1 4.7.1-beta.1 4.7.2 4.7.3 4.7.4 4.8.0 4.8.0-beta.1 4.8.0-rc.1 4.8.0-rc.2 4.8.1 4.8.2 4.8.3 4.9.0 4.9.0-beta.1 4.9.0-rc.1 4.9.0-rc.2 4.9.1 4.9.2 4.9.3 4.9.4 4.9.5 5.0.0 5.0.0-beta.1 5.0.0-beta.2 5.0.0-rc.1 5.0.0-rc.2 5.0.0-rc.3 5.0.1 5.0.2 5.0.3 5.1.0 5.1.0-beta.1 5.1.0-rc.1 trunk 5.1.1 10.0.0 5.1.2 10.0.0-rc.1 5.1.3 10.0.0-rc.2 5.2.0 10.0.1 5.2.0-beta.1 10.0.2 5.2.0-rc.1 10.0.3 5.2.0-rc.2 10.0.4 5.2.1 10.0.5 5.2.2 10.0.6 5.2.3 10.1.0 5.2.4 10.1.0-rc.1 5.2.5 10.1.0-rc.2 5.3.0 10.1.0-rc.3 5.3.0-beta.1 10.1.0-rc.4 5.3.0-rc.1 10.1.1 5.3.0-rc.2 10.1.2 5.3.1 10.1.3 5.3.2 10.1.4 5.3.3 10.2.0 5.4.0 10.2.0-beta.1 5.4.0-beta.1 10.2.0-beta.2 5.4.0-rc.1 10.2.0-rc.1 5.4.1 10.2.1 5.4.2 10.2.2 5.4.3 10.2.3 5.4.4 10.2.4 5.4.5 10.3.0 5.5.0 10.3.0-beta.1 5.5.0-beta.1 10.3.0-beta.2 5.5.0-rc.1 10.3.0-rc.1 5.5.0-rc.2 10.3.0-rc.2 5.5.1 10.3.1 5.5.2 10.3.2 5.5.3 10.3.3 5.5.4 10.3.4 5.5.5 10.3.5 5.6.0 10.3.6 5.6.0-beta.1 10.3.7 5.6.0-rc.1 10.3.8 5.6.0-rc.2 10.4.0 5.6.1 10.4.0-beta.1 5.6.2 10.4.0-beta.2 5.6.3 10.4.0-rc.1 5.7.0 10.4.1 5.7.0-beta.1 10.4.2 5.7.0-rc.1 10.4.3 5.7.1 10.4.4 5.7.2 10.5.0 5.7.3 10.5.0-beta.1 5.8.0 10.5.0-beta.2 5.8.0-beta.1 10.5.0-rc.1 5.8.0-beta.2 10.5.0-rc.2 5.8.0-rc.1 10.5.0-rc.3 5.8.1 10.5.1 5.8.2 10.5.2 5.9.0 10.5.3 5.9.0-beta.1 10.6.0 5.9.0-rc.1 10.6.0-beta.1 5.9.0-rc.2 10.6.0-beta.2 5.9.1 10.6.0-rc.1 5.9.2 10.6.1 6.0.0 10.6.2 6.0.0-beta.1 10.7.0 6.0.0-rc.1 10.7.0-beta.1 6.0.1 10.7.0-beta.2 6.0.2 10.7.0-rc.1 6.1.0 3.0.0 6.1.0-beta.1 3.0.1 6.1.0-rc.1 3.0.2 6.1.0-rc.2 3.0.3 6.1.1 3.0.4 6.1.2 3.0.5 6.1.3 3.0.6 6.2.0 3.0.7 6.2.0-beta.1 3.0.8 6.2.0-rc.1 3.0.9 6.2.0-rc.2 3.1.0 6.2.1 3.1.1 6.2.2 3.1.2 6.2.3 3.2.0 6.3.0 3.2.1 6.3.0-beta.1 3.2.2 6.3.0-rc.1 3.2.3 6.3.0-rc.2 3.2.4 6.3.1 3.2.5 6.3.2 3.2.6 6.4.0 3.3.0 6.4.0-beta.1 3.3.1 6.4.0-rc.1 3.3.2 6.4.1 3.3.2-rc.1 6.4.2 3.3.3 6.5.0 3.3.4 6.5.0-beta.1 3.3.5 6.5.0-rc.1 3.3.6 6.5.0-rc.2 3.4.0 6.5.1 3.4.0-beta.1 6.5.2 3.4.0-rc.2 6.6.0 3.4.1 6.6.0-beta.1 3.4.2 6.6.0-rc.1 3.4.3 6.6.0-rc.2 3.4.4 6.6.1 3.4.5 6.6.2 3.4.6 6.7.0 3.4.7 6.7.0-beta.1 3.4.8 6.7.0-beta.2 3.5.0 6.7.0-rc.1 3.5.0-beta.1 6.7.1 3.5.0-rc.1 6.8.0 3.5.0-rc.2 6.8.0-beta.1 3.5.1 6.8.0-beta.2 3.5.10 6.8.0-rc.1 3.5.2 6.8.1 3.5.3 6.8.2 3.5.4 6.8.3 3.5.5 6.9.0 3.5.6 6.9.0-beta.1 3.5.7 6.9.0-beta.2 3.5.8 6.9.0-rc.1 3.5.9 6.9.1 3.6.0 6.9.2 3.6.0-beta.1 6.9.3 3.6.0-rc.1 6.9.4 3.6.0-rc.2 6.9.5 3.6.0-rc.3 7.0.0 3.6.1 7.0.0-beta.1 3.6.2 7.0.0-beta.2 3.6.3 7.0.0-beta.3 3.6.4 7.0.0-rc.1 3.6.5 7.0.0-rc.2 3.6.6 7.0.1 3.6.7 7.0.2 3.7.0 7.1.0 3.7.0-beta.1 7.1.0-beta.1 3.7.0-rc.1 7.1.0-beta.2 3.7.0-rc.2 7.1.0-rc.1 3.7.1 7.1.0-rc.2 3.7.2 7.1.1
woocommerce / src / Api / Utils / Products / ProductMapper.php
woocommerce / src / Api / Utils / Products Last commit date
ProductMapper.php 2 months ago ProductRepository.php 2 months ago
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