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'] );