bnks.xyz

Menu

Tag: mysql

Selecting title, meta and multiple term names with MySQL

I often run one off reports for clients directly from the MySQL database behind their (normally WordPress) websites. Most are straightforward, perhaps requiring data from the wp_posts and wp_postmeta tables. However it get's a lot more complicated if term data is needed too. This is because determining which terms belong to an object, what taxonomy they are, and what the human readable values are requires accessing 3 seperate database tables.

I'm going to step you through an example that generates a list of WooCommerce products with thier title, SKU, price and categories. You can skip the the full code if you don't want the explanation. While this example is based around WooCommerce, it would equally apply to creating a list of and post type with meta and taxonomy data.

The aim

For each product we are going to be retrieving:

  • post_title from the wp_posts table
  • meta_value from the wp_postmeta table where the meta_key is _sku
  • meta_value from the wp_postmeta table where the meta_key is _regular_price
  • name value(s) from the wp_terms table

Post title

To start with, let's get the Product names – this is the post_title field. We'll also restrict it to published Products.

SELECT
	wp_posts.post_title
	FROM wp_posts
WHERE wp_posts.post_type = 'product'
AND wp_posts.post_status = 'publish'

SKU and Price

The SKU and Price can be pulled in from the wp_postmeta table. To make sure that we only get the postmeta values we need we'll use a LEFT JOIN that matches the ID column from wp_posts to the post_id column, and restrict it to values where the meta_key is the one we are looking for. This is done once for each piece of meta.

SELECT
	wp_posts.post_title
	wp_postmeta1.meta_value
	wp_postmeta2.meta_value
FROM wp_posts
LEFT JOIN wp_postmeta wp_postmeta1
	ON wp_postmeta1.post_id = wp_posts.ID
	AND wp_postmeta1.meta_key = '_sku'
LEFT JOIN wp_postmeta wp_postmeta2
	ON wp_postmeta2.post_id = wp_posts.ID
	AND wp_postmeta2.meta_key = '_regular_price'
WHERE wp_posts.post_type = 'product'
AND wp_posts.post_status = 'publish'

You can see that the LEFT JOIN is also naming the table during the join as wp_postmeta1/2 – this means that we can reference them seperately in the SELECT.

Product category

This is where it gets a bit more complex. As before, LEFT JOINs are used to access specific data from other tables – wp_term_relationships, wp_term_taxonomy and wp_terms.

First we match object_id from wp_term_relationships against the ID of each post. This allows access to the term_taxonomy_id for each term assigned to each product.

LEFT JOIN wp_term_relationships
	ON wp_term_relationships.object_id = wp_posts.ID

Next we match the term_taxonomy_id against the same column of the wp_term_taxonomy table so that we can access the relevant term_id. Additionally we restrict it to terms that belong to the right taxonomy – product_cat.

LEFT JOIN wp_term_taxonomy
	ON wp_term_relationships.term_taxonomy_id = wp_term_taxonomy.term_taxonomy_id
	AND wp_term_taxonomy.taxonomy = 'product_cat'

Finally we can match that term_id against the term_id in the wp_terms table which gives us access to the human readable name of each term.

LEFT JOIN wp_terms
	ON wp_term_taxonomy.term_id = wp_terms.term_id

Our SELECT statement can now be extended to get the term names with wp_terms.name

Putting that all together gives us:

SELECT
	wp_posts.post_title,
	wp_postmeta1.meta_value,
	wp_postmeta2.meta_value,
	wp_terms.name
FROM wp_posts
LEFT JOIN wp_postmeta wp_postmeta1
	ON wp_postmeta1.post_id = wp_posts.ID
	AND wp_postmeta1.meta_key = '_sku'
LEFT JOIN wp_postmeta wp_postmeta2
	ON wp_postmeta2.post_id = wp_posts.ID
	AND wp_postmeta2.meta_key = '_regular_price'
LEFT JOIN wp_term_relationships
	ON wp_term_relationships.object_id = wp_posts.ID
LEFT JOIN wp_term_taxonomy
	ON wp_term_relationships.term_taxonomy_id = wp_term_taxonomy.term_taxonomy_id
	AND wp_term_taxonomy.taxonomy = 'product_cat'
LEFT JOIN wp_terms
	ON wp_term_taxonomy.term_id = wp_terms.term_id
WHERE wp_posts.post_type = 'product'
AND wp_posts.post_status = 'publish'

Tidying up

We have all the data now, but the returned results are very messy. There will be multiple rows for each Product if they have more than one category, and they are in no particular order.

Sorting out the ordering is as simple as ORDER BY wp_posts.post_title ASC. To aggregate the rows we can use GROUP BY wp_posts.ID, but this then means that only 1 category will ever be shown. To solve that we can use the GROUP_CONCAT operator. Combining it with ORDER BY and supplying the optional SEPERATOR allows the categories to appear in a single column, in alphabetical order, and seperated with a comma and space.

GROUP_CONCAT( wp_terms.name ORDER BY wp_terms.name SEPARATOR ', ' )

The complete code

Remember – this is useful for ad hoc generation. If you want to retrieve this data regularly – whether to show it on the front end of the site or in the admin area, you should atleast use the native WordPress functions for accessing the database, and potentially take it from database queries to WP_Query and related abstrations.

Ignore spaces when group MySQL query results

Let's get a list of all duplicate postcodes (could be any string of course) out of a specific field in the database. This could be handled in PHP but, with a decently spec'd server especially, it's quicker to handle it in the database.

The basic query looks like this: (this example is from WordPress but the principle is the same regardless)

global $wpdb;
$meta_key = 'customer_address_postcode';
$postcode_counts = $wpdb->get_results( $wpdb->prepare(
"
SELECT meta_value as postcode, COUNT(*) as count
FROM $wpdb->postmeta
WHERE meta_key = %s
GROUP BY meta_value
HAVING count > 1
ORDER BY count DESC
",
$meta_key
) );

This will return an array of objects each with 2 values – postcode and count, in descending order of the number of times they occur, and only if they occur more than once.

However, spaces have no meaning in a string like this. BS1 3XX, BS13XX and even B S 1 3 X X are all the same postcode and need to be considered duplicates.

Ignoring spaces

I'm not a SQL expert, and there are a lot of functions that I have never heard of, but there is the very sensibly named REPLACE function that replaces all occurances of a specified string within a string. It would perhaps be more common to see it used when updating the database, but we can use it to ignore spaces in our GROUP BY.

global $wpdb;
$meta_key = 'customer_address_postcode';
$postcode_counts = $wpdb->get_results( $wpdb->prepare(
"
SELECT meta_value as postcode, COUNT(*) as count
FROM $wpdb->postmeta
WHERE meta_key = %s
GROUP BY REPLACE( meta_value, ' ', '' )
HAVING count > 1
ORDER BY count DESC
",
$meta_key
) );

This comes with a performance penalty of course but depending on how frequently you need to run the query, and if you can cache the results, it is an effective solution to matching like this. You could also add the same REPLACE into the SELECT if you want the results to be spaceless as well.

Reverse relationship query for last ACF repeater sub-field

Advanced Custom Fields (ACF) is a great WordPress plugin for adding custom meta fields. It has a very useful relationship field that can be used to denote a connection from one post to another – importantly this is a one-way relationship. When you are on PostA you can generate a list of all the posts that it is linked to.

Going in reverse

ACF documentation highlights how a clever WP_Query can be used to do a `reverse query`, i.e. when you view PostB you can get a list of all the posts that link to PostB.

What about sub-fields

The reverse query works fine as it is for top level fields, but does not work for sub-fields within, for example, a repeater. Luckily Elliot on the ACF support forum shared some code for doing a reverse query against a sub-field.
The key is just using a LIKE for the meta query.

Note – for a Relationship field, where the value is a serialised array, use:

'meta_query' => array(
	array(
		'key' => 'fieldName', // name of custom field
		'value' => '"' . get_the_ID() . '"', // matches exaclty "123", not just 123. This prevents a match for "1234"
		'compare' => 'LIKE'
	)
)

but for a Post Object field, where the value is an integer, use:

'meta_query' => array(
	array(
		'key' => 'fieldName', // name of custom field
		'value' => get_the_ID(),
		'compare' => '='
	)
)

Latest item in repeater only

Just to get more complicated, now let’s do a reverse relationship query, against a sub-field, but only the sub-field within the latest item in the repeater…
Imagine a post type of Business that has a repeater called ‘Audit’, and within it sub-fields for ‘Audit firm’ and ‘Fee’. The ‘Audit firm’ sub-field is a relationship to another post type called Auditor. On the single Auditor pages I want to show the name of each Business who they are currently auditing, i.e., where they are the ‘Audit firm’ in the last repeater entry.

To get a list of Business post IDs we have to use a $wpdb query; the key is the use of MAX(meta_key) to get the last item in the repeater. This works because ACF names it’s repeater fields repeaterName_X_fieldName, where X is the number denoting when the item was added.

The solution

The code below is heavily based on a Stack Overflow answer from Elliot (coincidence?) with added WordPress and ACF magic and help from Luke Oatham.

$meta_key = 'audit_%_audit_firm'; // meta_key for repeater sub-field.
$meta_value = '%"'. get_the_id() . '"%'; // meta_value, wrapped for use in a LIKE.
$post_status = 'publish';
$businesses = $wpdb->get_col( $wpdb->prepare(
	"
	SELECT businessMeta.post_id // Field we want in the returned column.
	FROM $wpdb->postmeta businessMeta
	INNER JOIN
		(SELECT post_id, MAX(meta_key) AS latestAuditRepeater
		FROM $wpdb->postmeta
		WHERE meta_key LIKE '%s'
		GROUP BY post_id) groupedBusinessMeta
	ON businessMeta.post_id = groupedBusinessMeta.post_id
	AND businessMeta.meta_key = groupedBusinessMeta.latestAuditRepeater
	WHERE meta_value LIKE '%s'
	AND abMeta.post_id IN
		(SELECT ID
		FROM $wpdb->posts
		WHERE post_status = '%s')
	",
	$meta_key,
	$meta_value,
	$post_status
) );