post_type = ['product']; $this->products_query = new ProductQuery(); /** * Call the parent construct to setup class data */ parent::__construct( $source, $args, $context, $info ); } /** * Return the name of the loader to be used with the connection resolver * * @return string */ public function get_loader_name() { return 'wc_post'; } /** * Confirms the user has the privileges to query the products * * @return bool */ public function should_execute() { return true; } /** * {@inheritDoc} */ public function get_query_args() { /** * Prepare for later use */ $last = ! empty( $this->args['last'] ) ? $this->args['last'] : null; $first = ! empty( $this->args['first'] ) ? $this->args['first'] : null; $query_args = []; /** * Ignore sticky posts by default */ $query_args['ignore_sticky_posts'] = true; /** * Set post_type */ $query_args['post_type'] = $this->post_type; /** * Set the wc_query to product_query */ $query_args['wc_query'] = 'product_query'; /** * Set the post_status */ $query_args['post_status'] = [ 'draft', 'pending', 'private', 'publish' ]; $query_args['perm'] = 'readable'; /** * Set posts_per_page the highest value of $first and $last, with a (filterable) max of 100 */ $query_args['posts_per_page'] = $this->one_to_one ? 1 : min( max( absint( $first ), absint( $last ), 10 ), $this->query_amount ) + 1; /** * Set the graphql cursor args. */ $query_args['graphql_cursor_compare'] = ( ! empty( $last ) ) ? '>' : '<'; $query_args['graphql_after_cursor'] = $this->get_after_offset(); $query_args['graphql_before_cursor'] = $this->get_before_offset(); /** * If the cursor offsets not empty, * ignore sticky posts on the query * and don't count the total number of posts */ if ( ! empty( $this->get_after_offset() ) || ! empty( $this->get_before_offset() ) ) { $query_args['ignore_sticky_posts'] = true; $query_args['no_found_rows'] = true; } /** * Pass the graphql $args to the WP_Query */ $query_args['graphql_args'] = $this->args; /** * Collect the input_fields and sanitize them to prepare them for sending to the WP_Query */ $input_fields = []; if ( ! empty( $this->args['where'] ) ) { $input_fields = $this->sanitize_input_fields( $this->args['where'] ); } /** * Merge the input_fields with the default query_args */ if ( ! empty( $input_fields ) ) { $query_args = array_merge( $query_args, $input_fields ); } /** * If the query contains search default the results to */ if ( ! empty( $query_args['search'] ) && empty( $query_args['orderby'] ) ) { /** * Don't order search results by title (causes funky issues with cursors) */ $query_args['search_orderby_title'] = false; $query_args = array_merge( $query_args, \WC()->query->get_catalog_ordering_args( 'relevance', isset( $last ) ? 'ASC' : 'DESC' ) ); } if ( empty( $query_args['orderby'] ) ) { $query_args = array_merge( $query_args, \WC()->query->get_catalog_ordering_args( 'menu_order', isset( $last ) ? 'ASC' : 'DESC' ) ); } $has_offset = isset( $last ) ? $this->get_before_offset() : $this->get_after_offset(); $offset_product = null; if ( $has_offset ) { /** @var \WPGraphQL\WooCommerce\Model\Product|null $offset_model */ $offset_model = $this->get_loader()->load( $has_offset ); /** @var \WC_Product|\WC_Product_Variable|null $offset_product */ $offset_product = $offset_model ? $offset_model->as_WC_Data() : null; } if ( $offset_product && 'price' === $query_args['orderby'] ) { /** @var array|float|null $price */ $price = is_a( $offset_product, 'WC_Product_Variable' ) ? $offset_product->get_variation_price() : $offset_product->get_price(); if ( 'ASC' === $query_args['order'] ) { if ( is_array( $price ) ) { $price = reset( $price ); } $query_args['graphql_cursor_compare_by_price_key'] = 'wc_product_meta_lookup.min_price'; $query_args['graphql_cursor_compare_by_price_value'] = $price; } else { if ( is_array( $price ) ) { $price = end( $price ); } $query_args['graphql_cursor_compare_by_price_key'] = 'wc_product_meta_lookup.max_price'; $query_args['graphql_cursor_compare_by_price_value'] = $price; } } elseif ( $offset_product && 'popularity' === $query_args['orderby'] ) { $query_args['graphql_cursor_compare_by_popularity_value'] = $offset_product->get_total_sales(); $query_args['graphql_cursor_compare_by_popularity_key'] = 'wc_product_meta_lookup.total_sales'; } elseif ( $offset_product && 'rating' === $query_args['orderby'] ) { $query_args['graphql_cursor_compare_by_rating_value'] = $offset_product->get_average_rating(); $query_args['graphql_cursor_compare_by_rating_key'] = 'wc_product_meta_lookup.average_rating'; } elseif ( $offset_product && 'comment_count' === $query_args['orderby'] ) { $query_args['graphql_cursor_compare_by_comment_count_value'] = $offset_product->get_rating_count(); $query_args['graphql_cursor_compare_by_comment_count_key'] = 'wc_product_meta_lookup.rating_count'; $query_args['graphql_cursor_compare_by_rating_value'] = $offset_product->get_average_rating(); $query_args['graphql_cursor_compare_by_rating_key'] = 'wc_product_meta_lookup.average_rating'; } elseif ( $offset_product && 'menu_order title' === $query_args['orderby'] ) { $query_args['orderby'] = [ 'menu_order' => $query_args['order'], 'post_title' => isset( $this->args['last'] ) ? 'ASC' : 'DESC', ]; unset( $query_args['order'] ); } /** * NOTE: Only IDs should be queried here as the Deferred resolution will handle * fetching the full objects, either from cache of from a follow-up query to the DB */ $query_args['fields'] = 'ids'; /** * Filter the $query args to allow folks to customize queries programmatically * * @param array $query_args The args that will be passed to the WP_Query * @param mixed $source The source that's passed down the GraphQL queries * @param array|null $args The inputArgs on the field * @param \WPGraphQL\AppContext $context The AppContext passed down the GraphQL tree * @param \GraphQL\Type\Definition\ResolveInfo $info The ResolveInfo passed down the GraphQL tree */ return apply_filters( 'graphql_product_connection_query_args', $query_args, $this->source, $this->args, $this->context, $this->info ); } /** * {@inheritDoc} */ public function get_query() { add_filter( 'posts_clauses', [ $this->products_query, 'add_query_clauses' ], 10, 2 ); // Temporary fix for the search query. if ( ! empty( $this->query_args['search'] ) ) { $this->query_args['fulltext_search'] = $this->query_args['search']; unset( $this->query_args['search'] ); add_filter( 'posts_clauses', [ $this, 'add_search_query_clause' ], 10, 2 ); } return new \WP_Query(); } /** * {@inheritDoc} */ public function get_ids_from_query() { // Run query and get IDs. $ids = $this->query->query( $this->query_args ); if ( ! empty( $this->query_args['fulltext_search'] ) ) { remove_filter( 'posts_clauses', [ $this, 'add_search_query_clause' ], 10 ); } remove_filter( 'posts_clauses', [ $this->products_query, 'add_query_clauses' ], 10 ); // If we're going backwards, we need to reverse the array. if ( ! empty( $this->args['last'] ) ) { $ids = array_reverse( $ids ); } return $ids; } /** * Returns meta keys to be used for connection ordering. * * @param bool $is_numeric Return numeric meta keys. Defaults to "true". * * @return array */ public function ordering_meta( $is_numeric = true ) { if ( ! $is_numeric ) { return apply_filters( 'woographql_product_connection_orderby_meta_keys', [] ); } return apply_filters( 'woographql_product_connection_orderby_numeric_meta_keys', [ '_sale_price_dates_from', '_sale_price_dates_to', 'total_sales', ] ); } /** * This function replaces the default product query search query clause with a clause searching the product's description, short description and slug. * * @param array $args The query arguments. * @param \WP_Query $wp_query The WP_Query object. * @return array */ public function add_search_query_clause( $args, $wp_query ) { global $wpdb; if ( empty( $wp_query->get( 'fulltext_search' ) ) ) { return $args; } $search = '%' . $wpdb->esc_like( $wp_query->get( 'fulltext_search' ) ) . '%'; $search_query = $wpdb->prepare( " AND ( $wpdb->posts.post_title LIKE %s OR $wpdb->posts.post_name LIKE %s OR wc_product_meta_lookup.sku LIKE %s OR $wpdb->posts.post_content LIKE %s OR $wpdb->posts.post_excerpt LIKE %s ) ", $search, $search, $search, $search, $search ); $args['where'] .= $search_query; if ( ! strstr( $args['join'], 'wc_product_meta_lookup' ) ) { $args['join'] .= " LEFT JOIN {$wpdb->wc_product_meta_lookup} wc_product_meta_lookup ON $wpdb->posts.ID = wc_product_meta_lookup.product_id "; } return $args; } /** * Process taxonomy filters for taxonomyFilter argument. * * @param array $filters Array of taxonomy filters. * @param string $relation The relation between filters (AND/OR). * * @return array */ private function process_taxonomy_filters( array $filters, string $relation ) { $tax_groups = []; foreach ( $filters as $filter ) { $common = [ 'taxonomy' => $filter['taxonomy'], 'operator' => ! empty( $filter['operator'] ) ? $filter['operator'] : 'IN', ]; if ( ! empty( $filter['ids'] ) ) { $tax_groups[] = array_merge( $common, [ 'field' => 'ID', 'terms' => $filter['ids'], ] ); } if ( ! empty( $filter['terms'] ) ) { $tax_groups[] = array_merge( $common, [ 'field' => 'slug', 'terms' => $filter['terms'], ] ); } }//end foreach if ( empty( $tax_groups ) ) { return []; } if ( 1 === count( $tax_groups ) ) { return $tax_groups[0]; } // Add relation if there are multiple groups. $tax_groups['relation'] = $relation; return $tax_groups; } /** * This sets up the "allowed" args, and translates the GraphQL-friendly keys to WP_Query * friendly keys. There's probably a cleaner/more dynamic way to approach this, but * this was quick. I'd be down to explore more dynamic ways to map this, but for * now this gets the job done. * * @param array $where_args - arguments being used to filter query. * * @return array */ public function sanitize_input_fields( array $where_args ) { $query_args = Utils::map_input( $where_args, [ 'slugIn' => 'post_name__in', 'minPrice' => 'min_price', 'maxPrice' => 'max_price', 'stockStatus' => 'stock_status', 'status' => 'post_status', 'include' => 'post__in', 'exclude' => 'post__not_in', 'parent' => 'post_parent', 'parentIn' => 'post_parent__in', 'parentNotIn' => 'post_parent__not_in', 'search' => 'search', ] ); if ( ! empty( $where_args['orderby'] ) ) { $default_order = isset( $this->args['last'] ) ? 'ASC' : 'DESC'; $orderby_input = current( $where_args['orderby'] ); $orderby = $orderby_input['field']; $order = ! empty( $orderby_input['order'] ) ? $orderby_input['order'] : $default_order; // Set the order to DESC if orderby is popularity. if ( 'popularity' === $orderby || 'rating' === $orderby ) { $order = 'DESC'; } $query_args = array_merge( $query_args, \wc()->query->get_catalog_ordering_args( $orderby, $order ) ); } if ( isset( $where_args['includeVariations'] ) && $where_args['includeVariations'] ) { $query_args['post_type'] = [ 'product', 'product_variation' ]; } $tax_query = []; $taxonomy_args = [ 'type' => 'product_type', 'typeIn' => 'product_type', 'typeNotIn' => 'product_type', 'category' => 'product_cat', 'categoryIn' => 'product_cat', 'categoryNotIn' => 'product_cat', 'categoryId' => 'product_cat', 'categoryIdIn' => 'product_cat', 'categoryIdNotIn' => 'product_cat', 'tag' => 'product_tag', 'tagIn' => 'product_tag', 'tagNotIn' => 'product_tag', 'tagId' => 'product_tag', 'tagIdIn' => 'product_tag', 'tagIdNotIn' => 'product_tag', 'productBrand' => 'product_brand', 'productBrandIn' => 'product_brand', 'productBrandNotIn' => 'product_brand', 'productBrandId' => 'product_brand', 'productBrandIdIn' => 'product_brand', 'productBrandIdNotIn' => 'product_brand', ]; foreach ( $taxonomy_args as $field => $taxonomy ) { if ( ! empty( $where_args[ $field ] ) ) { // Set tax query operator. switch ( true ) { case str_ends_with( $field, 'NotIn' ): $operator = 'NOT IN'; break; default: $operator = 'IN'; break; } // Set tax query config. switch ( $field ) { case 'type': // If the type is variation, we only need to set the post_type arg. if ( 'variation' === $where_args[ $field ] ) { $query_args['post_type'] = [ 'product_variation' ]; break; } // Otherwise continue to create a tax query. case 'typeIn': if ( is_array( $where_args[ $field ] ) && in_array( 'variation', $where_args[ $field ], true ) ) { $query_args['post_type'] = array_merge( $this->post_type, [ 'product_variation' ] ); } $tax_query[] = [ // phpcs:ignore SlevomatCodingStandard.Arrays.DisallowPartiallyKeyed.DisallowedPartiallyKeyed 'relation' => 'OR', [ 'taxonomy' => 'product_type', 'field' => 'slug', 'terms' => $where_args[ $field ], ], [ 'taxonomy' => 'product_type', 'field' => 'id', 'operator' => 'NOT EXISTS', ], ]; break; case 'typeNotIn': case 'category': case 'categoryIn': case 'categoryNotIn': case 'tag': case 'tagIn': case 'tagNotIn': case 'productBrand': case 'productBrandIn': case 'productBrandNotIn': // Get terms. $terms = $where_args[ $field ]; if ( ! is_array( $terms ) ) { $terms = [ $terms ]; } // Get term taxonomy IDs for complex tax queries. $term_taxonomy_ids = []; foreach ( $terms as $term_slug ) { $term = get_term_by( 'slug', $term_slug, $taxonomy ); if ( ! $term ) { continue; } $term_taxonomy_ids[] = $term->term_taxonomy_id; } $tax_query[] = [ 'taxonomy' => $taxonomy, 'field' => 'term_taxonomy_id', 'terms' => $term_taxonomy_ids, 'operator' => $operator, ]; break; case 'categoryId': case 'categoryIdIn': case 'categoryIdNotIn': case 'tagId': case 'tagIdIn': case 'tagIdNotIn': case 'productBrandId': case 'productBrandIdIn': case 'productBrandIdNotIn': $tax_query[] = [ 'taxonomy' => $taxonomy, 'field' => 'term_id', 'terms' => $where_args[ $field ], 'operator' => $operator, ]; break; }//end switch }//end if }//end foreach // Filter by attribute and term. if ( ! empty( $where_args['attribute'] ) && ! empty( $where_args['attributeTerm'] ) ) { graphql_debug( __( 'The "attribute" and "attributeTerm" arguments have been deprecated. Please use the "attributes" argument instead.', 'graphql-for-ecommerce' ), ); if ( in_array( $where_args['attribute'], \wc_get_attribute_taxonomy_names(), true ) ) { $tax_query[] = [ 'taxonomy' => $where_args['attribute'], 'field' => 'slug', 'terms' => $where_args['attributeTerm'], ]; } } // Filter by attributes. if ( ! empty( $where_args['attributes'] ) && ! empty( $where_args['attributes']['queries'] ) ) { $attributes = $where_args['attributes']['queries']; $att_queries = []; foreach ( $attributes as $attribute ) { if ( empty( $attribute['ids'] ) && empty( $attribute['terms'] ) ) { continue; } if ( ! in_array( $attribute['taxonomy'], \wc_get_attribute_taxonomy_names(), true ) ) { continue; } $operator = isset( $attribute['operator'] ) ? $attribute['operator'] : 'IN'; if ( ! empty( $attribute['terms'] ) ) { foreach ( $attribute['terms'] as $term ) { $att_queries[] = [ 'taxonomy' => $attribute['taxonomy'], 'field' => 'slug', 'terms' => $term, 'operator' => $operator, ]; } } if ( ! empty( $attribute['ids'] ) ) { foreach ( $attribute['ids'] as $id ) { $att_queries[] = [ 'taxonomy' => $attribute['taxonomy'], 'field' => 'term_id', 'terms' => $id, 'operator' => $operator, ]; } } } if ( 1 < count( $att_queries ) ) { $relation = ! empty( $where_args['attributes']['relation'] ) ? $where_args['attributes']['relation'] : 'AND'; if ( 'NOT_IN' === $relation ) { graphql_debug( __( 'The "NOT_IN" relation is not supported for attributes. Please use "IN" or "AND" instead.', 'graphql-for-ecommerce' ) ); $relation = 'IN'; } $tax_query[] = array_merge( [ 'relation' => $relation ], $att_queries ); } else { $tax_query = array_merge( $tax_query, $att_queries ); } } if ( empty( $where_args['type'] ) && empty( $where_args['typeIn'] ) && ! empty( $where_args['supportedTypesOnly'] ) && true === $where_args['supportedTypesOnly'] ) { $supported_types = array_keys( WP_GraphQL_WooCommerce::get_enabled_product_types() ); $terms = ! empty( $where_args['typeNotIn'] ) ? array_diff( $supported_types, $where_args['typeNotIn'] ) : $supported_types; $tax_query[] = [ 'taxonomy' => 'product_type', 'field' => 'slug', 'terms' => $terms, ]; } if ( isset( $where_args['featured'] ) ) { $product_visibility_term_ids = wc_get_product_visibility_term_ids(); if ( $where_args['featured'] ) { $tax_query[] = [ 'taxonomy' => 'product_visibility', 'field' => 'term_taxonomy_id', 'terms' => [ $product_visibility_term_ids['featured'] ], ]; $tax_query[] = [ 'taxonomy' => 'product_visibility', 'field' => 'term_taxonomy_id', 'terms' => [ $product_visibility_term_ids['exclude-from-catalog'] ], 'operator' => 'NOT IN', ]; } else { $tax_query[] = [ 'taxonomy' => 'product_visibility', 'field' => 'term_taxonomy_id', 'terms' => [ $product_visibility_term_ids['featured'] ], 'operator' => 'NOT IN', ]; } }//end if // Handle visibility. if ( ! empty( $where_args['visibility'] ) ) { switch ( $where_args['visibility'] ) { case 'search': $tax_query[] = [ 'taxonomy' => 'product_visibility', 'field' => 'slug', 'terms' => [ 'exclude-from-search' ], 'operator' => 'NOT IN', ]; break; case 'catalog': $tax_query[] = [ 'taxonomy' => 'product_visibility', 'field' => 'slug', 'terms' => [ 'exclude-from-catalog' ], 'operator' => 'NOT IN', ]; break; case 'visible': $tax_query[] = [ 'taxonomy' => 'product_visibility', 'field' => 'slug', 'terms' => [ 'exclude-from-catalog', 'exclude-from-search' ], 'operator' => 'NOT IN', ]; break; case 'hidden': $tax_query[] = [ 'taxonomy' => 'product_visibility', 'field' => 'slug', 'terms' => [ 'exclude-from-catalog', 'exclude-from-search' ], 'operator' => 'AND', ]; break; }//end switch }//end if if ( ! empty( $where_args['rating'] ) ) { $rating = $where_args['rating']; $rating_terms = []; foreach ( $rating as $value ) { $rating_terms[] = 'rated-' . $value; } $tax_query[] = [ 'taxonomy' => 'product_visibility', 'field' => 'name', 'terms' => $rating_terms, ]; } // Process "taxonomyFilter". $tax_filter_query = []; if ( ! empty( $where_args['taxonomyFilter'] ) ) { $taxonomy_query = $where_args['taxonomyFilter']; // Handle new "or" and "and" syntax. if ( ! empty( $taxonomy_query['or'] ) ) { $tax_filter_query = $this->process_taxonomy_filters( $taxonomy_query['or'], 'OR' ); } elseif ( ! empty( $taxonomy_query['and'] ) ) { $tax_filter_query = $this->process_taxonomy_filters( $taxonomy_query['and'], 'AND' ); } elseif ( ! empty( $taxonomy_query['filters'] ) ) { // Handle legacy "relation" + "filters" syntax. $relation = ! empty( $taxonomy_query['relation'] ) ? $taxonomy_query['relation'] : 'AND'; $tax_filter_query = $this->process_taxonomy_filters( $taxonomy_query['filters'], $relation ); } }//end if if ( ! empty( $tax_filter_query ) ) { $tax_query[] = $tax_filter_query; } if ( 1 < count( $tax_query ) ) { $tax_query['relation'] = 'AND'; } if ( ! empty( $tax_query ) ) { // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query $query_args['tax_query'] = $tax_query; } $meta_query = []; if ( ! empty( $where_args['sku'] ) ) { $meta_query[] = [ 'key' => '_sku', 'value' => $where_args['sku'], 'compare' => 'LIKE', ]; } if ( ! empty( $where_args['minPrice'] ) ) { $query_args['min_price'] = number_format( $where_args['minPrice'], 2, '', '' ); } if ( ! empty( $where_args['maxPrice'] ) ) { $query_args['max_price'] = number_format( $where_args['maxPrice'], 2, '', '' ); } if ( isset( $where_args['stockStatus'] ) ) { $meta_query[] = [ 'key' => '_stock_status', 'value' => $where_args['stockStatus'], 'compare' => is_array( $where_args['stockStatus'] ) ? 'IN' : '=', ]; } if ( ! empty( $meta_query ) ) { // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query $query_args['meta_query'] = $meta_query; } if ( isset( $where_args['onSale'] ) && is_bool( $where_args['onSale'] ) ) { $on_sale_key = $where_args['onSale'] ? 'post__in' : 'post__not_in'; $on_sale_ids = \wc_get_product_ids_on_sale(); $on_sale_ids = empty( $on_sale_ids ) ? [ 0 ] : $on_sale_ids; $query_args[ $on_sale_key ] = $on_sale_ids; } /** * {@inheritDoc} */ $query_args = apply_filters( 'graphql_map_input_fields_to_wp_query', $query_args, $where_args, $this->source, $this->args, $this->context, $this->info, $this->post_type ); /** * Filter the input fields * This allows plugins/themes to hook in and alter what $args should be allowed to be passed * from a GraphQL Query to the WP_Query * * @param array $args The mapped query arguments * @param array $where_args Query "where" args * @param mixed $source The query results for a query calling this * @param array $all_args All of the arguments for the query (not just the "where" args) * @param \WPGraphQL\AppContext $context The AppContext object * @param \GraphQL\Type\Definition\ResolveInfo $info The ResolveInfo object * @param mixed|string|array $post_type The post type for the query */ $query_args = apply_filters_deprecated( 'graphql_map_input_fields_to_product_query', [ $query_args, $where_args, $this->source, $this->args, $this->context, $this->info, $this->post_type, ], '0.9.0', 'graphql_map_input_fields_to_wp_query' ); return $query_args; } /** * Wrapper for "WC_Connection_Functions::is_valid_post_offset()" * * @param integer $offset Post ID. * * @return bool */ public function is_valid_offset( $offset ) { return (bool) wc_get_product( $offset ); } /** * Adds meta query to the query args. * * @param array $value Meta query. * * @return \WPGraphQL\WooCommerce\Data\Connection\Product_Connection_Resolver */ public function add_meta_query( $value ) { if ( ! empty( $this->query_args['meta_query'] ) ) { $this->query_args['meta_query'] = $value; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query } else { $this->query_args['meta_query'][] = $value; $this->query_args['meta_query']['relation'] = 'AND'; } return $this; } /** * Adds tax query to the query args. * * @param array $value Tax query. * * @return \WPGraphQL\WooCommerce\Data\Connection\Product_Connection_Resolver */ public function add_tax_query( $value ) { if ( empty( $this->query_args['tax_query'] ) ) { $this->query_args['tax_query'] = $value; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query } else { $this->query_args['tax_query'][] = $value; $this->query_args['tax_query']['relation'] = 'AND'; } return $this; } /** * {@inheritDoc} */ public function get_page_info() { $page_info = parent::get_page_info(); $page_info['found'] = $this->query->found_posts; return $page_info; } }