fix(2meet-data-optimizer): filter REST listings collection by post_status and post_type

- GET /wpdo/v1/listings rejects unregistered or non-viewable post types (400) and joins posts so Zone A only lists published posts of that type
- WP_Query fallback now returns only show_in_rest hot columns, matching the Zone A path
- Verify: core unit 605 / integration 432 OK (new RestApi tests); 1M bench: page 7ms, total COUNT 0.12s → 1.7s (tracked in PR-030)
- PR: PR-015
This commit is contained in:
2026-09-25 23:10:46 +08:00
parent b407f235ac
commit 9dc4516ca0
5 changed files with 129 additions and 15 deletions
+23 -12
View File
@@ -530,6 +530,15 @@ class TMDO_REST_API {
*/
public function get_listings( WP_REST_Request $request ): WP_REST_Response {
$post_type = $request->get_param( 'post_type' ) ?? 'hp_listing';
if ( ! post_type_exists( $post_type ) || ! is_post_type_viewable( $post_type ) ) {
return new WP_REST_Response(
array(
'code' => 'rest_invalid_post_type',
'message' => 'Invalid post type.',
),
400
);
}
// Defense-in-depth: clamp per_page even if validate_callback in listings_args() fails open.
// Filter `wpdo_rest_max_per_page` allows site owners to adjust the upper bound.
$max_per_page = (int) apply_filters( 'wpdo_rest_max_per_page', 100 );
@@ -1365,6 +1374,10 @@ class TMDO_REST_API {
/**
* Query Zone A flat table directly.
*
* Rows are joined to the posts table so only published posts of the
* requested type are listed (the hot table also holds drafts, private
* and trashed posts).
*
* @param string $post_type Post type slug.
* @param int $per_page Number of items per page.
* @param int $offset Database offset.
@@ -1396,6 +1409,8 @@ class TMDO_REST_API {
}
$where_sql = $where ? 'WHERE ' . implode( ' AND ', $where ) : '';
$join_sql = "INNER JOIN `{$wpdb->posts}` p ON p.ID = t.post_id AND p.post_type = %s AND p.post_status = 'publish'";
array_unshift( $params, $post_type );
// Validate orderby against known columns + post_id.
$allowed_orderby = array_merge( array( 'post_id' ), array_keys( $columns ) );
@@ -1404,16 +1419,12 @@ class TMDO_REST_API {
// Count total.
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- table from TMDO_Zone_Hot::table(); where_sql/orderby_col/order_dir are sanitized/validated.
if ( $params ) {
$total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM `{$table}` {$where_sql}", ...$params ) );
} else {
$total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" );
}
$total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM `{$table}` t {$join_sql} {$where_sql}", ...$params ) );
// Fetch rows.
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM `{$table}` {$where_sql} ORDER BY `{$orderby_col}` {$order_dir} LIMIT %d OFFSET %d",
"SELECT t.* FROM `{$table}` t {$join_sql} {$where_sql} ORDER BY t.`{$orderby_col}` {$order_dir} LIMIT %d OFFSET %d",
...array_merge( $params, array( $per_page, $offset ) )
),
ARRAY_A
@@ -1476,8 +1487,8 @@ class TMDO_REST_API {
'id' => $post->ID,
'post_type' => $post->post_type,
);
$cols = TMDO_Schema_Registry::instance()->get_hot_columns( $post_type );
foreach ( array_keys( $cols ) as $col ) {
// Same show_in_rest restriction as the Zone A path.
foreach ( TMDO_Schema_Registry::instance()->get_rest_visible_hot_columns( $post_type ) as $col ) {
$item[ $col ] = get_post_meta( $post->ID, $col, true );
}
$items[] = $item;
@@ -1507,7 +1518,7 @@ class TMDO_REST_API {
$min = $request->get_param( $col . '_min' );
if ( null !== $min && is_numeric( $min ) ) {
$filters[] = array(
'sql' => "`{$col}` >= %f",
'sql' => "t.`{$col}` >= %f",
'value' => (float) $min,
);
}
@@ -1515,7 +1526,7 @@ class TMDO_REST_API {
$max = $request->get_param( $col . '_max' );
if ( null !== $max && is_numeric( $max ) ) {
$filters[] = array(
'sql' => "`{$col}` <= %f",
'sql' => "t.`{$col}` <= %f",
'value' => (float) $max,
);
}
@@ -1524,12 +1535,12 @@ class TMDO_REST_API {
if ( null !== $exact && '' !== $exact ) {
if ( is_numeric( $exact ) ) {
$filters[] = array(
'sql' => "`{$col}` = %f",
'sql' => "t.`{$col}` = %f",
'value' => (float) $exact,
);
} else {
$filters[] = array(
'sql' => "`{$col}` = %s",
'sql' => "t.`{$col}` = %s",
'value' => (string) $exact,
);
}
+13 -1
View File
@@ -426,6 +426,18 @@ if ( ! function_exists( 'wp_cache_delete' ) ) {
function wp_cache_delete( $key, $group = '' ): bool { unset( $GLOBALS['_wp_cache'][ $group ][ $key ] ); return true; }
}
// Registered post types: name => publicly viewable (bool).
if ( ! function_exists( 'post_type_exists' ) ) {
function post_type_exists( string $post_type ): bool {
return isset( $GLOBALS['_wp_registered_post_types'][ $post_type ] );
}
}
if ( ! function_exists( 'is_post_type_viewable' ) ) {
function is_post_type_viewable( $post_type ): bool {
return ! empty( $GLOBALS['_wp_registered_post_types'][ (string) $post_type ] );
}
}
// ── Stub classes ───────────────────────────────────────────────────────────
if ( ! class_exists( 'WP_Query' ) ) {
@@ -434,7 +446,7 @@ if ( ! class_exists( 'WP_Query' ) ) {
public int $found_posts = 0;
public int $max_num_pages = 0;
private array $args = [];
public function __construct( array $args = [] ) { $this->args = $args; }
public function __construct( array $args = [] ) { $this->args = $args; $this->posts = $GLOBALS['_wp_query_posts'] ?? []; }
public function get( string $key, $default = '' ) { return $this->args[ $key ] ?? $default; }
public function set( string $key, $value ): void { $this->args[ $key ] = $value; }
}
+52 -2
View File
@@ -72,12 +72,29 @@ class RestApiIntegrationTest extends TestCase {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
// Seed 5 rows.
for ( $i = 1; $i <= 5; $i++ ) {
// Posts table: listings are filtered by post_status against it.
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->posts}`" );
$wpdb->query(
"CREATE TABLE `{$wpdb->posts}` (
ID BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
post_type VARCHAR(20) NOT NULL DEFAULT 'post',
post_status VARCHAR(20) NOT NULL DEFAULT 'publish',
PRIMARY KEY (ID)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
// Seed 5 published rows, plus draft/private/trash rows (ids 6-8) and
// a published post of another type (id 9) that must never be listed.
$statuses = array( 6 => 'draft', 7 => 'private', 8 => 'trash' );
for ( $i = 1; $i <= 8; $i++ ) {
$price = $i * 100;
$featured = $i % 2;
$status = $statuses[ $i ] ?? 'publish';
$wpdb->query( "INSERT INTO `{$wpdb->posts}` (ID, post_type, post_status) VALUES ($i, 'restapi', '$status')" );
$wpdb->query( "INSERT INTO `" . self::$hot_table . "` (post_id, rp_price, rp_featured) VALUES ($i, $price, $featured)" );
}
$wpdb->query( "INSERT INTO `{$wpdb->posts}` (ID, post_type, post_status) VALUES (9, 'post', 'publish')" );
$wpdb->query( "INSERT INTO `" . self::$hot_table . "` (post_id, rp_price, rp_featured) VALUES (9, 900, 1)" );
// Set module to cutover so REST API reads from Zone A.
WPDO_Feature_Flags::set( 'hot_restapi', 'cutover' );
@@ -87,6 +104,7 @@ class RestApiIntegrationTest extends TestCase {
global $wpdb;
$wpdb->query( "DROP TABLE IF EXISTS `" . self::$hot_table . "`" );
$wpdb->query( "DROP TABLE IF EXISTS `" . self::$warm_table . "`" );
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->posts}`" );
WPDO_Feature_Flags::reset( 'hot_restapi' );
}
@@ -97,6 +115,7 @@ class RestApiIntegrationTest extends TestCase {
$GLOBALS['_wp_current_user_can'] = [];
$GLOBALS['_wp_valid_nonces'] = [];
$GLOBALS['_wp_transients'] = []; // Reset rate-limit transients between tests.
$GLOBALS['_wp_registered_post_types'] = [ 'restapi' => true ];
// Note: do NOT reset _wp_options here — Feature Flags state is stored there.
// Invalidate Feature Flags static request cache so each test reads fresh.
@@ -232,6 +251,37 @@ class RestApiIntegrationTest extends TestCase {
$this->assertSame( $sorted, $prices );
}
public function test_listings_excludes_unpublished_posts(): void {
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
$req->set_param( 'post_type', 'restapi' );
$req->set_param( 'per_page', 20 );
$response = self::$api->get_listings( $req );
$ids = array_column( $response->get_data(), 'id' );
sort( $ids );
$this->assertSame( array( 1, 2, 3, 4, 5 ), $ids, 'draft/private/trash and other post types must not be listed.' );
$this->assertSame( '5', $response->get_headers()['X-WP-Total'] );
}
public function test_listings_filters_still_apply_with_status_join(): void {
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
$req->set_param( 'post_type', 'restapi' );
$req->set_param( 'rp_price_min', 400 );
$req->set_param( 'per_page', 20 );
$ids = array_column( self::$api->get_listings( $req )->get_data(), 'id' );
sort( $ids );
$this->assertSame( array( 4, 5 ), $ids );
}
public function test_listings_rejects_unregistered_post_type(): void {
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
$req->set_param( 'post_type', 'nope' );
$this->assertSame( 400, self::$api->get_listings( $req )->get_status() );
}
// ── GET /listings/{id} ───────────────────────────────────────────────────
public function test_get_listing_404_for_unknown_post(): void {
+3
View File
@@ -337,6 +337,9 @@ if ( ! function_exists( 'is_post_publicly_viewable' ) ) {
return 'publish' === ( $GLOBALS['_wp_post_status'][ (int) $post_id ] ?? 'publish' );
}
}
// Registered post types: name => publicly viewable (bool).
if ( ! function_exists( 'post_type_exists' ) ) { function post_type_exists( string $post_type ): bool { return isset( $GLOBALS['_wp_registered_post_types'][ $post_type ] ); } }
if ( ! function_exists( 'is_post_type_viewable' ) ) { function is_post_type_viewable( $post_type ): bool { return ! empty( $GLOBALS['_wp_registered_post_types'][ (string) $post_type ] ); } }
if ( ! function_exists( 'current_user_can' ) ) {
function current_user_can( string $cap, ...$args ): bool {
if ( ! empty( $args ) ) { $key = $cap . ':' . implode( ',', array_map( 'strval', $args ) ); if ( isset( $GLOBALS['_wp_current_user_can'][ $key ] ) ) { return (bool) $GLOBALS['_wp_current_user_can'][ $key ]; } }
+38
View File
@@ -21,6 +21,8 @@ class RestApiTest extends TestCase {
$GLOBALS['_wp_post_types'] = [];
$GLOBALS['_wp_current_user_can'] = [];
$GLOBALS['_wp_valid_nonces'] = [];
$GLOBALS['_wp_query_posts'] = [];
$GLOBALS['_wp_registered_post_types'] = [ 'hp_listing' => true ];
$_COOKIE = [];
// Reset Feature Flags cache via reflection.
@@ -153,6 +155,42 @@ class RestApiTest extends TestCase {
$this->assertIsArray( $response->get_data() );
}
public function test_get_listings_rejects_unregistered_post_type(): void {
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
$req->set_param( 'post_type', 'no_such_type' );
$response = $this->api->get_listings( $req );
$this->assertSame( 400, $response->get_status() );
}
public function test_get_listings_rejects_non_viewable_post_type(): void {
$GLOBALS['_wp_registered_post_types']['hp_internal'] = false;
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
$req->set_param( 'post_type', 'hp_internal' );
$response = $this->api->get_listings( $req );
$this->assertSame( 400, $response->get_status() );
}
public function test_listings_fallback_only_returns_show_in_rest_columns(): void {
$registry = WPDO_Schema_Registry::instance();
$registry->register( 'unit_rest', array( 'post_type' => 'hp_listing', 'meta_key' => 'hp_price', 'zone' => 'hot', 'column' => 'hp_price' ) );
$registry->register( 'unit_rest', array( 'post_type' => 'hp_listing', 'meta_key' => 'hp_secret', 'zone' => 'hot', 'column' => 'hp_secret', 'show_in_rest' => false ) );
$post = new stdClass();
$post->ID = 21;
$post->post_type = 'hp_listing';
$GLOBALS['_wp_query_posts'] = array( $post );
$GLOBALS['_wp_postmeta'][21] = array( 'hp_price' => '99', 'hp_secret' => 'hidden' );
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
$req->set_param( 'post_type', 'hp_listing' );
$data = $this->api->get_listings( $req )->get_data();
$this->assertSame( '99', $data[0]['hp_price'] );
$this->assertArrayNotHasKey( 'hp_secret', $data[0] );
}
// ── Pagination headers ────────────────────────────────────────────────────
public function test_listings_fallback_sets_pagination_headers(): void {