Commit 60752e94fb3 for woocommerce
commit 60752e94fb37896084ccba533fe10e550783d9d7
Author: louwie17 <lourensschep@gmail.com>
Date: Fri Sep 18 15:15:59 2026 +0200
Fix HPOS legacy report builder not translating bare post_status/post_parent/post_type (#67015)
fix: translate bare post_status/post_parent/post_type in HPOS report builder
`HposLegacyOrderReportQueryBuilder::translate_legacy_sql_fragment()` had two
translation passes: a qualified pass covering all five legacy `posts` columns,
and a bare-token pass covering only `ID` and `post_date`. Legacy report
consumers write these columns unqualified as often as they qualify them, so a
bare `post_status` (or `post_parent` / `post_type`) reached the generated SQL
untranslated and ran against `wc_orders`, which has no such column. MySQL
raised "Unknown column", `wpdb` returned an empty set, and the report rendered
zeros with no visible error.
WooCommerce Subscriptions 9.0.1 hits this: every dataset in
`WCS_Report_Subscription_Events_By_Date` (new subscriptions, renewals,
resubscribes, switches) passes a bare
`post_status NOT IN ( 'trash', 'auto-draft' )` where-predicate. On HPOS stores
with compatibility sync enabled those reports worked before #65493 (they ran
against the still-populated CPT tables) and broke after it.
Both passes now derive their alternation from `legacy_to_hpos_column_map()`, so
the two can no longer drift apart. Word boundaries are unchanged, so tokens
embedded in longer identifiers (`product_ID`, `posts.post_date_gmt`) are still
left alone.
No core report uses a bare `post_status` where-key, which is why the shipped
tests did not cover this shape.
Bug introduced in PR #65493.
Claude-Session: https://claude.ai/code/session_011NfEfQibsHJwf2ozvh22Gu
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/fix-hpos-legacy-report-bare-post-column-keys b/plugins/woocommerce/changelog/fix-hpos-legacy-report-bare-post-column-keys
new file mode 100644
index 00000000000..5e29d10ab1d
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-hpos-legacy-report-bare-post-column-keys
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix legacy admin reports that filter, group or sort on an unqualified `post_status`, `post_parent` or `post_type` column returning no data under HPOS, by translating those bare tokens to their `wc_orders` equivalents.
diff --git a/plugins/woocommerce/src/Internal/Admin/Reports/HposLegacyOrderReportQueryBuilder.php b/plugins/woocommerce/src/Internal/Admin/Reports/HposLegacyOrderReportQueryBuilder.php
index a0889fe7668..4e272439a39 100644
--- a/plugins/woocommerce/src/Internal/Admin/Reports/HposLegacyOrderReportQueryBuilder.php
+++ b/plugins/woocommerce/src/Internal/Admin/Reports/HposLegacyOrderReportQueryBuilder.php
@@ -838,35 +838,34 @@ class HposLegacyOrderReportQueryBuilder {
}
/**
- * Translate legacy `posts.<col>` (and bare `ID` / `post_date`) references in an arbitrary SQL fragment.
+ * Translate legacy `posts.<col>` and bare `<col>` references in an arbitrary SQL fragment.
*
- * Matches are bounded by `\b` so tokens embedded in longer identifiers
- * (e.g. `product_ID`, `posts.post_date_gmt`) are left untouched.
+ * Both passes cover every column in {@see self::legacy_to_hpos_column_map()}: legacy callers
+ * often leave these unqualified (e.g. WooCommerce Subscriptions before 7.8.0 passed a bare
+ * `post_status` where-key), and an untranslated bare token names a column `wc_orders` lacks.
+ *
+ * Tokens inside longer identifiers (`product_ID`, `posts.post_date_gmt`), qualified by another
+ * table alias (`p.post_type`, joined via a query filter), or quoted are left untouched.
*
* @param string $fragment Caller-supplied SQL fragment.
*
* @return string Translated fragment safe to drop into an HPOS query.
*/
private function translate_legacy_sql_fragment( string $fragment ): string {
- $map = $this->legacy_to_hpos_column_map();
+ $map = $this->legacy_to_hpos_column_map();
+ $alternation = implode( '|', array_keys( $map ) );
// Qualified `posts.<col>` references first, then the bare tokens legacy
- // callers use unqualified. Callbacks avoid `$`/`\` replacement-string pitfalls.
- $fragment = (string) preg_replace_callback(
- '/\bposts\.(ID|post_date|post_parent|post_status|post_type)\b/',
- static function ( $matches ) use ( $map ) {
- return $map[ $matches[1] ];
- },
- $fragment
- );
+ // callers use unqualified. The callback avoids `$`/`\` replacement-string pitfalls.
+ $replace = static function ( $matches ) use ( $map ) {
+ return $map[ $matches[1] ];
+ };
- return (string) preg_replace_callback(
- '/\b(ID|post_date)\b/',
- static function ( $matches ) use ( $map ) {
- return $map[ $matches[1] ];
- },
- $fragment
- );
+ // `\b` alone matches after `.` or a quote, which would rewrite `p.post_type`,
+ // a quoted alias like `post_status` or a string literal.
+ $fragment = (string) preg_replace_callback( '/(?<![\w.`\'"])posts\.(' . $alternation . ')\b/', $replace, $fragment );
+
+ return (string) preg_replace_callback( '/(?<![\w.`\'"])(' . $alternation . ')\b/', $replace, $fragment );
}
/**
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/Reports/HposLegacyOrderReportQueryBuilderTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/Reports/HposLegacyOrderReportQueryBuilderTest.php
index f13a68ad4ac..9e08e31a53a 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/Reports/HposLegacyOrderReportQueryBuilderTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/Reports/HposLegacyOrderReportQueryBuilderTest.php
@@ -539,7 +539,89 @@ class HposLegacyOrderReportQueryBuilderTest extends WC_Unit_Test_Case {
}
/**
- * @testdox Should translate only word-bounded ID and post_date tokens, leaving longer identifiers untouched.
+ * Legacy report consumers often leave `post_data` columns unqualified (e.g. WooCommerce
+ * Subscriptions before 7.8.0 passed a bare `post_status` where-key), which used to reach
+ * SQL untranslated against a column `wc_orders` does not have.
+ *
+ * @testdox Should translate bare post_status, post_parent and post_type where keys to HPOS columns.
+ */
+ public function test_build_query_translates_bare_post_column_where_keys(): void {
+ OrderHelper::toggle_cot_feature_and_usage( true );
+
+ $query = ( new HposLegacyOrderReportQueryBuilder() )->build_query(
+ array(
+ 'data' => array(
+ 'ID' => array(
+ 'type' => 'post_data',
+ 'function' => 'COUNT',
+ 'name' => 'count',
+ 'distinct' => true,
+ ),
+ ),
+ 'where' => array(
+ array(
+ 'key' => 'post_status',
+ 'operator' => 'NOT IN',
+ 'value' => array( 'trash', 'auto-draft' ),
+ ),
+ array(
+ 'key' => 'post_parent',
+ 'operator' => '>',
+ 'value' => 0,
+ ),
+ array(
+ 'key' => 'post_type',
+ 'operator' => '=',
+ 'value' => 'shop_subscription',
+ ),
+ ),
+ 'filter_range' => false,
+ 'order_types' => array( 'shop_subscription' ),
+ ),
+ 0,
+ 0
+ );
+
+ $this->assertStringContainsString( "orders.status NOT IN ('trash', 'auto-draft')", $query['where'] );
+ $this->assertStringContainsString( 'orders.parent_order_id >', $query['where'] );
+ $this->assertStringContainsString( "orders.type = 'shop_subscription'", $query['where'] );
+
+ // No bare legacy token survives into SQL that runs against `wc_orders`.
+ $this->assertDoesNotMatchRegularExpression( '/\bpost_status\b/', $query['where'] );
+ $this->assertDoesNotMatchRegularExpression( '/\bpost_parent\b/', $query['where'] );
+ $this->assertDoesNotMatchRegularExpression( '/\bpost_type\b/', $query['where'] );
+ }
+
+ /**
+ * @testdox Should translate bare post_status and post_type tokens in group_by and order_by fragments.
+ */
+ public function test_build_query_translates_bare_post_column_group_and_order_fragments(): void {
+ OrderHelper::toggle_cot_feature_and_usage( true );
+
+ $query = ( new HposLegacyOrderReportQueryBuilder() )->build_query(
+ array(
+ 'data' => array(
+ 'ID' => array(
+ 'type' => 'post_data',
+ 'function' => 'COUNT',
+ 'name' => 'count',
+ ),
+ ),
+ 'group_by' => 'post_status, post_type',
+ 'order_by' => 'post_status ASC',
+ 'filter_range' => false,
+ 'order_types' => array( 'shop_order' ),
+ ),
+ 0,
+ 0
+ );
+
+ $this->assertSame( 'GROUP BY orders.status, orders.type', $query['group_by'] );
+ $this->assertSame( 'ORDER BY orders.status ASC', $query['order_by'] );
+ }
+
+ /**
+ * @testdox Should translate only word-bounded legacy tokens, leaving longer identifiers and other table aliases untouched.
*/
public function test_translate_legacy_sql_fragment_respects_word_boundaries(): void {
OrderHelper::toggle_cot_feature_and_usage( true );
@@ -555,7 +637,14 @@ class HposLegacyOrderReportQueryBuilderTest extends WC_Unit_Test_Case {
'name' => 'id',
),
),
- 'group_by' => 'posts.post_date_gmt, product_ID, ID, YEAR(posts.post_date)',
+ 'where' => array(
+ array(
+ 'key' => 'product_posts.post_status',
+ 'operator' => '=',
+ 'value' => 'publish',
+ ),
+ ),
+ 'group_by' => "posts.post_date_gmt, product_ID, p.post_type, p.ID, `post_status`, FIELD(kind, 'post_type'), ID, YEAR(posts.post_date)",
'order_by' => 'posts.ID DESC',
'filter_range' => false,
'order_types' => array( 'shop_order' ),
@@ -569,6 +658,11 @@ class HposLegacyOrderReportQueryBuilderTest extends WC_Unit_Test_Case {
// Tokens embedded in longer identifiers survive untranslated.
$this->assertStringContainsString( 'posts.post_date_gmt', $query['group_by'] );
$this->assertStringContainsString( 'product_ID', $query['group_by'] );
+ // Columns qualified by another table alias (e.g. one joined via a query filter) survive too.
+ $this->assertStringContainsString( ' p.post_type, p.ID,', $query['group_by'] );
+ $this->assertStringContainsString( "product_posts.post_status = 'publish'", $query['where'] );
+ // Quoted identifiers and string literals survive too.
+ $this->assertStringContainsString( " `post_status`, FIELD(kind, 'post_type'),", $query['group_by'] );
// Word-bounded tokens translate as before.
$this->assertStringContainsString( "YEAR({$local_date_expression})", $query['group_by'] );
$this->assertStringContainsString( ' orders.id,', $query['group_by'] );