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.
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
andquantityMax
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