fix(2meet-data-optimizer): rewrite only standalone posts.ID tie-breaks in ORDER BY

- flat_first and the copy route share ties_on_post_id(): a posts.ID term
  alone (optionally with a direction) reads the flat post_id; posts.ID inside
  an expression a plugin added (FIELD(), IN) is left alone — inserting a
  direction there made the SQL invalid and the page empty
- PCRE failures return the text given instead of '' (the query kept its
  shape rather than becoming an empty request)
- Found in the 2.4 correctness review (Medium) and security review (Low L1)
- Verify: 2 new integration tests (the flat-first one runs the SQL), router suites 69 OK
- PR: PR-131
This commit is contained in:
2026-09-28 00:29:30 +08:00
parent bed3f906da
commit 33addd1e49
3 changed files with 79 additions and 24 deletions
+53 -24
View File
@@ -778,22 +778,58 @@ class TMDO_Post_Query_Router {
if ( empty( $copy ) ) {
return $request;
}
return self::ties_on_post_id( self::without_group_by( $request ), self::table_alias( $copy['post_type'], $copy['group'] ), '' );
}
/**
* ORDER BY terms that are posts.ID alone (with or without a direction)
* read as the equal flat post_id — in $direction, or each in its own when
* $direction is '' — and only once. posts.ID inside an expression a plugin
* added (FIELD(), IN …) is left alone: a direction inserted there breaks
* the SQL.
*
* @param string $request SELECT statement.
* @param string $alias Flat table alias.
* @param string $direction 'ASC', 'DESC' or '' (keep each term's own).
* @return string
*/
private static function ties_on_post_id( string $request, string $alias, string $direction ): string {
global $wpdb;
$tie = '`' . self::table_alias( $copy['post_type'], $copy['group'] ) . '`.`post_id` ';
return (string) preg_replace_callback(
'/\bORDER BY\b(?!.*\bORDER BY\b).*$/s',
static function ( $m ) use ( $wpdb, $tie ) {
$order = (string) preg_replace_callback(
'/`?' . preg_quote( $wpdb->posts, '/' ) . '`?\.`?ID`?(?!\w)(?:\s+(ASC|DESC)\b)?/i',
static fn( $id ) => $tie . ( 'DESC' === strtoupper( $id[1] ?? '' ) ? 'DESC' : 'ASC' ),
$m[0]
);
return (string) preg_replace( '/(,\s*' . preg_quote( $tie, '/' ) . '(ASC|DESC))(\s*,\s*' . preg_quote( $tie, '/' ) . '\2)+/', '$1', $order );
},
self::without_group_by( $request )
$tie = "`{$alias}`.`post_id` ";
$id = '`?' . preg_quote( $wpdb->posts, '/' ) . '`?\.`?ID`?';
return self::replaced(
preg_replace_callback(
'/\bORDER BY\b(?!.*\bORDER BY\b).*$/s',
static function ( $m ) use ( $id, $tie, $direction ) {
$order = self::replaced(
preg_replace_callback(
'/(?<=ORDER BY |,)(\s*)' . $id . '(?:\s+(ASC|DESC)\b)?(?=\s*(?:,|\bLIMIT\b|$))/i',
static fn( $t ) => $t[1] . $tie . ( '' !== $direction ? $direction : ( 'DESC' === strtoupper( $t[2] ?? '' ) ? 'DESC' : 'ASC' ) ),
$m[0]
),
$m[0]
);
return self::replaced( preg_replace( '/(,\s*' . preg_quote( $tie, '/' ) . '(ASC|DESC))(\s*,\s*' . preg_quote( $tie, '/' ) . '\2)+/', '$1', $order ), $order );
},
$request
),
$request
);
}
/**
* A preg_* result, or the text it was given when PCRE failed (backtrack
* or JIT stack limit on a very long request): the query stays unrouted
* in shape rather than empty.
*
* @param string|null $result preg_* return value.
* @param string $fallback Input text.
* @return string
*/
private static function replaced( ?string $result, string $fallback ): string {
return $result ?? $fallback;
}
/**
* WordPress groups by posts.ID for any tax or meta query. With a flat row
* (one per post) as the only join there is nothing to collapse, and the
@@ -808,7 +844,7 @@ class TMDO_Post_Query_Router {
if ( 1 !== preg_match_all( '/\bJOIN\b/i', $request ) ) {
return $request;
}
return (string) preg_replace( '/\s+GROUP BY\s+`?' . preg_quote( $wpdb->posts, '/' ) . '`?\.`?ID`?(?=\s)/', ' ', $request, 1 );
return self::replaced( preg_replace( '/\s+GROUP BY\s+`?' . preg_quote( $wpdb->posts, '/' ) . '`?\.`?ID`?(?=\s)/', ' ', $request, 1 ), $request );
}
/**
@@ -882,16 +918,9 @@ class TMDO_Post_Query_Router {
// post_id, once, in the sort's direction: a (column, post_id) index then
// returns rows in order with no filesort. Ties are ordered by ID the same
// way as the column (natively ID DESC); pagination stays stable.
$tie = "`{$alias}`.`post_id` " . ( 'ASC' === strtoupper( (string) $sort['order'] ) ? 'ASC' : 'DESC' );
$flat_first = preg_replace_callback(
'/\bORDER BY\b(?!.*\bORDER BY\b).*$/s',
static function ( $m ) use ( $wpdb, $tie ) {
$order = (string) preg_replace( '/`?' . preg_quote( $wpdb->posts, '/' ) . '`?\.`?ID`?(?!\w)(\s+(ASC|DESC)\b)?/i', $tie, $m[0] );
return (string) preg_replace( '/(,\s*' . preg_quote( $tie, '/' ) . ')(\s*,\s*' . preg_quote( $tie, '/' ) . ')+/', '$1', $order );
},
(string) $flat_first
);
return (string) preg_replace( '/^\s*SELECT\s/', 'SELECT STRAIGHT_JOIN ', (string) $flat_first, 1 );
$direction = 'ASC' === strtoupper( (string) $sort['order'] ) ? 'ASC' : 'DESC';
$flat_first = self::ties_on_post_id( $flat_first, $alias, $direction );
return self::replaced( preg_replace( '/^\s*SELECT\s/', 'SELECT STRAIGHT_JOIN ', $flat_first, 1 ), $request );
}
/**
@@ -901,7 +930,7 @@ class TMDO_Post_Query_Router {
* @return string
*/
private static function without_found_rows( string $request ): string {
return (string) preg_replace( '/\bSQL_CALC_FOUND_ROWS\s+/', '', $request, 1 );
return self::replaced( preg_replace( '/\bSQL_CALC_FOUND_ROWS\s+/', '', $request, 1 ), $request );
}
/**