Connect Product Bundle Items in Your WooCommerce GraphQL API

Introduction

This post is to demonstrate how to create a GraphQL Connection Type between Product Bundles and Bundle Items in WPGraphQL within the context of the WooGraphQL extension.

tldr

The final implementation is on this gist: https://gist.github.com/jacobarriola/76ba565039c0e32f484be385969d4aca

Connections in GraphQL

In GraphQL - because we think about our data in a graph - a Connection is a joining of two nodes within our graph.

Caleb Meredith has an awesome post that explains GraphQL Connections.

In my case, I'm building an extension for WooCommerce Product Bundles for the WooGraphQL extension, so I needed a way to connect a product bundle to its children (also known as bundle items).

Really quick, if you're not familiar with Product Bundles, it's basically a parent-child relationship between product post types. It gives ecommerce owners the ability to group multiple products together. The parent is the bundle, and the underlying children are their bundle items.

Product Bundle Architecture High level overview of product bundles and their bundle items

Shape of our query

Cool, now that we have a sense of the architecture, let's get down to solving our problem.

Ideally, here's the shape of the query that we'll be shooting for (the bundleItems field is where we'll be living):

{
  products {
    nodes {
      name
      price
      productId
      ...on BundleProduct {
        bundleItems {
          edges {
            quantityMin
            quantityMax
            node {
              name
              productId
            }
          }
        }
      }
    } 
}

Basically, what we're querying is: "go get me the first n products, if a product is a product bundle, go get me its children, along with some data that's relevent to their relationship".

Keep an eye on those two quantityMin and quantityMax edge fields. We'll touch upon those later.

Steps to create the connection

There will be two steps we'll be taking to create our connection: registering and resolving.

Step one: register the connection

WPGraphQL has a function called register_graphql_connection() that we'll use to create the connection. We can safely call this function as part of a callback during the graphql_register_types action.

add_action( 'graphql_register_types', function () {
	register_graphql_connection( [
		'fromType'      => 'BundleProduct',
		'toType'        => 'Product',
		'fromFieldName' => 'bundleItems',
		'resolve'       => function ( $source, $args, $context, $info ) {
			return 'GraphQL is lit!';
		},
	] );
} );

The WPGraphQL docs does a good job of defining the parameters of the the function, so read up on the definitions. But basically, we're defining where the connection is coming from and where it's going to.

Step two: configure the resolver

The resolve function feels like WordPress pre_get_posts-land. In other words, this is an opportunity for us to short-circuit the query before it's run.

In our case, we want to adjust the query variables to only include certain posts (our bundle items).

The resolver essentially needs two things: instantiate a new connection resovler class (either your own or one that's pre-built) and return the get_connection() method for that class. The connection resolver class is responsible for setting up the query and connecting to a data loader.

For our case, I ended up using the existing WooGraphQL Product_Connection_Resolver Class, since my $source is a product post type and all I simply need to do is adjust one query parameter (I tried to mess around with creating my own resolver class, but quickly got lost in underlying assumptions that I didn't know about. It works for now, but I'll revisit later).

Here's what the resolver configuration looks like after our update:

add_action( 'graphql_register_types', function () {
	register_graphql_connection( [
		'fromType'      => 'BundleProduct',
		'toType'        => 'Product',
		'fromFieldName' => 'bundleItems',
		'resolve'       => function ( $source, $args, $context, $info ) {
			$resolver = new Product_Connection_Resolver( $source, $args, $context, $info );
			
			return $resolver->get_connection();
		},
	] );
} );

Once we update our resolver, the query will now get the first 10 (or whatever your global per_page settings is set to) products. This is good, but we still need adjust the query to only retrieve the products that belong/relate to this bundle (ie the bundle items).

Figure out how to get the bundle items that belong to the current product bundle

Ok, so in this resolve callback, we know that the current $source value is a BundleProduct Type, which is a product post. In the database, the relationships are stored in a custom table called woocommcere_bundle_items. Therefore, we'll need to write a query to get all of the bundle items that belong to our current bundle in the resolve callback.

Our query can look something like this:

$wpdb->get_results( $wpdb->prepare( "
		SELECT
			product_id
		FROM
			{$wpdb->prefix}woocommerce_bundled_items
		WHERE
			bundle_id = 123456
		LIMIT 10
		"
) );

Nothing too crazy here. All we're looking to get back is an array of product IDs that are associated with our Product Bundle.

Update the resolver with our bundle item IDs

Now that we have an array of IDs, we can update our resolver (throwing in sample IDs for demo purposes):

add_action( 'graphql_register_types', function () {
	register_graphql_connection( [
		'fromType'      => 'BundleProduct',
		'toType'        => 'Product',
		'fromFieldName' => 'bundleItems',
		'resolve'       => function ( $source, $args, $context, $info ) {
			$resolver = new Product_Connection_Resolver( $source, $args, $context, $info );
			
			$resolver->set_query_arg( 'post__in', [ 123, 456 ] );
			
			return $resolver->get_connection();
		},
	] );
} );

The set_query_arg() method is the mechanism that allows us to safely set the query arguments before it's run. You can use any WP_Query parameter here. Why? If you explore the Product_Connection_Resolver and look at the get_query() method, you'll see that it eventually runs WP_Query.

set_query_arg does not work here

In a perfect world, this would be all we need; however, despite setting the post__in query argument, my query was still returning back the first 10 product posts. I have an issue on GitHub that documents this.

Workaround

Luckily, we can hook into a query args filter that runs really late to safely add our post__in argument.

add_filter( 'graphql_product_connection_query_args', function ( $query_args, $source, $args, $context, $info ) {
	
	// Bail if this isn't a bundle. This filter runs on all product types
	if ( 'bundle' !== $source->type ) {
		return $query_args;
	}
	
	$query_args['post__in'] = [ 123, 456 ];
	
	return $query_args;
}, 10, 5 );

Edge fields

Remember our two productMin and productMax edge fields? I struggled a lot with this concept and how it relates to the problem we're trying to solve.

Edge fields represent data in the context of the relationship between nodes. The line between two nodes are called edges; any data that lives on that virutal line is called an edge field.

📺 Jason Bahl has a good explanation of this concepet on YouTube within the scope of WordPress

Say you have a property rental application where a node could be a User Type and a Property Type. A Property will have a relationship with a User because a user will rent a property. While each of the Types will contain data about itself (User: name, DOB, ID, etc; Property: address, beds, baths, etc), there is data that is based on context of the relationship: rental start date, rental end date, rental ID, etc. This data doesn't really belong to the User or the Property; it belongs to their relationship. That's edge data!

Back to our case. The BundleProduct and Product connection have properties of their context. This includes things such as quantityMin, quantityMax, amongst others. Because it's a property of the relationship, we're delibertaly exposing the data on the edge.

How to add edge fields to the bundle items

There's an edgeFields property in the register_graphql_connection method configuration where we pass in an array a fields we want to expose to the edge schema.

Inside each field we declare, there's a friendly resolve property, which we'll use to write our logic for the given field.

register_graphql_connection( [
	'fromType'      => 'BundleProduct',
	'toType'        => 'Product',
	'fromFieldName' => 'bundleItems',
	'resolve'       => function ( $source, $args, $context, $info ) {
		$resolver = new Product_Connection_Resolver( $source, $args, $context, $info );
		
		return $resolver->get_connection();
	},
	'edgeFields'    => [
		'quantityMin' => [
			'type'    => 'Int',
			'resolve' => function () {				
				return 'GraphQL is LIT';
			},
		],
	],
] );

The $source parameter returns an array with four items:

  • cursor - a hash used for pagination
  • node - the product itself, an instance WPGraphQL\WooCommerce\Model\Product
  • source - the product bundle, an instance WPGraphQL\WooCommerce\Model\Product
  • connection - our connection resolver Product_Connection_Resolver

Unfortunately, the data for bundle items isn't stored in postmeta, so we can't fire off a quick get_post_meta( $source['node']->ID, 'quantity_min', true ) and call it a day. Instead, data is stored in a custom meta table, so we need to figure out a way to efficiently get that data.

We don't want to write custom SQL or instantiate classes at each field; rather, we need a way to expose all the data to this resolver beforehand, so that fields can simply pluck the data they need and return it. Filters to the rescue!

The filter we'll be using is graphql_connection_edges, which is run at the Product_Connection_Resolver level as it's setting all of the data for the edges. What we want to do is instantiate a pre-built WC_Bundled_Item_Data class on our given node and expose it to the resolver's $source attribute.

This filter runs on all connections, so we need to run some checks to ensure our logic on runs on bundle items.

/**
 * Inject the WC_Bundled_Item_Data Class into the resolver so that fields can access
 * edge data
 */
add_filter( 'graphql_connection_edges', function ( $edges, $resolver ) {
	$source = $resolver->getSource();
	
	// Bail if our $source isn't a bundle
	if ( 'bundle' !== $source->type ) {
		return $edges;
	}
	
	// Get some info about the $resolver
	$resolverInfo = $resolver->getInfo();
	
	// Get the name of the field we are resolving
	$fieldName = $resolverInfo->fieldName;
	
	// Bail if our field isn't bundleItems
	if ( 'bundleItems' !== $fieldName ) {
		return $edges;
	}
	
  // Go get all of the bundle items for this product bundle
	$bundled_data_items = $resolver->getSource()->get_bundled_data_items();
	
	// Go through each edge, find the bundle item id, and set the bundleItem
	foreach ( $edges as $key => $edge ) {
		
		$raw_bundled_data_item = array_filter( $bundled_data_items, function ( WC_Bundled_Item_Data $item ) use ( $edge ) {
			return $item->get_product_id() === $edge['node']->ID;
		} );
		
		if ( empty( $raw_bundled_data_item ) ) {
			$edges[ $key ]['bundledItem'] = [];
			continue;
		}
		
		$bundled_data_item = array_values( $raw_bundled_data_item );
		
		// Expose the bundleItem so that the resolver can get edge data
		$edges[ $key ]['bundledItem'] = WC_PB_DB::get_bundled_item( $bundled_data_item[0]->get_bundled_item_id() );
	}
	
	return $edges;
}, 10, 2 );

Lots happening here, but the meat of our logic is happening on the foreach, where we find the bundled item, get its id and set the bundle item data so that it's available on the resolver.

Now, our each resolver function has access to the WC_Bundle_Item_Data instance for its bundle item, and can properly resolve data. Great success!

'quantityMin' => [
  'type'    => 'Int',
  'resolve' => function ( $source ) {
    /* @var $bundledItem WC_Bundled_Item_Data */
    $bundledItem = $source['bundledItem'];

    return $bundledItem->get_meta( 'quantity_min' );
  },
],

Conclusion

Before I started, I knew nothing about connections, loaders and edges. But with some help with XDEBUG, I was able to open up the source code and get a sense of what was happening behind the scenes.

As always, there's room for improvement. Ideally I'd like to make my own connection resolver (and subsequent data loader) class because running all my logic via filters feels a bit hacky, especially with all of the checks that I have to do. Both WPGraphQL and WooGraphQL write their own classes, so I'll follow their pattern.

Next steps are to see how cart, checkout and orders need to be altered to accomodate product bundles.

The final implementation is on this gist: https://gist.github.com/jacobarriola/76ba565039c0e32f484be385969d4aca