'column_name' ] for flat-column tables * * Ported from HPCT_Query_Interceptor_Base with WPDO enhancements: * - Uses TMDO_Feature_Flags (7-state) for query-active check * - Uses TMDO_DB::table() for table name resolution * - Query var prefix: wpdo_qi_ (avoids collision with HPCT) */ abstract class TMDO_Query_Interceptor_Base { /** * Returns the module identifier for feature flag lookups. * * @return string Module name. */ abstract protected function get_module(): string; /** * Returns the post types this interceptor handles. * * @return string[] Array of post type slugs. */ abstract protected function get_post_types(): array; /** * Custom table name without $wpdb->prefix. * Passed through TMDO_DB::table() at runtime. */ abstract protected function get_table(): string; /** * Returns the column name used to join to wp_posts.ID. * * @return string Join column name, default 'post_id'. */ protected function get_join_column(): string { return 'post_id'; } /** * Meta_key to custom table column map (flat-column tables only). * Return empty array for KV tables (override posts_where instead). * * @return array Map of meta_key to column name. */ abstract protected function get_meta_key_map(): array; // ── Hook registration ───────────────────────────────────────────────── /** * Registers all WordPress query hooks. * * @return void */ public function register_hooks(): void { add_action( 'pre_get_posts', array( $this, 'pre_get_posts' ), 10, 1 ); add_filter( 'posts_join', array( $this, 'posts_join' ), 10, 2 ); add_filter( 'posts_where', array( $this, 'posts_where' ), 10, 2 ); add_filter( 'posts_groupby', array( $this, 'posts_groupby' ), 10, 2 ); } // ── Hook handlers ───────────────────────────────────────────────────── /** * Intercepts pre_get_posts to rewrite meta_query conditions for our custom tables. * * @param \WP_Query $query The WP_Query object. * @return void */ public function pre_get_posts( \WP_Query $query ): void { if ( ! $this->should_intercept( $query ) ) { return; } $raw_meta_query = (array) $query->get( 'meta_query' ); if ( empty( $raw_meta_query ) ) { return; } $key_map = $this->get_meta_key_map(); $our_clauses = array(); $remaining = array(); foreach ( $raw_meta_query as $k => $clause ) { if ( 'relation' === $k || ! is_array( $clause ) || ! isset( $clause['key'] ) ) { $remaining[ $k ] = $clause; continue; } if ( isset( $key_map[ $clause['key'] ] ) ) { $our_clauses[] = array( 'column' => $key_map[ $clause['key'] ], 'value' => $clause['value'] ?? '', 'compare' => strtoupper( trim( $clause['compare'] ?? '=' ) ), 'type' => strtoupper( trim( $clause['type'] ?? 'CHAR' ) ), ); } else { $remaining[ $k ] = $clause; } } if ( empty( $our_clauses ) ) { return; } if ( isset( $raw_meta_query['relation'] ) && ! isset( $remaining['relation'] ) ) { $remaining['relation'] = $raw_meta_query['relation']; } $query->set( 'meta_query', $remaining ); $query->set( $this->query_var(), $our_clauses ); } /** * Appends a LEFT JOIN to the custom table when this interceptor has active clauses. * * @param string $join Current JOIN SQL. * @param \WP_Query $query The WP_Query object. * @return string Modified JOIN SQL. */ public function posts_join( ?string $join, \WP_Query $query ): string { $join = (string) ( $join ?? '' ); if ( ! $this->has_clauses( $query ) ) { return $join; } global $wpdb; $alias = $this->table_alias(); if ( false === strpos( $join, "`{$alias}`" ) ) { $table = TMDO_DB::table( $this->get_table() ); $join_col = esc_sql( $this->get_join_column() ); $join .= " LEFT JOIN `{$table}` AS `{$alias}`" . " ON (`{$wpdb->posts}`.`ID` = `{$alias}`.`{$join_col}`)"; } return $join; } /** * Appends WHERE conditions for the custom table columns. * * @param string $where Current WHERE SQL. * @param \WP_Query $query The WP_Query object. * @return string Modified WHERE SQL. */ public function posts_where( ?string $where, \WP_Query $query ): string { $where = (string) ( $where ?? '' ); $clauses = $this->get_clauses( $query ); if ( empty( $clauses ) ) { return $where; } $alias = $this->table_alias(); foreach ( $clauses as $clause ) { $col = '`' . $alias . '`.`' . esc_sql( $clause['column'] ) . '`'; $compare = $this->sanitize_compare( $clause['compare'] ); $type = $clause['type']; $value = $clause['value']; $where .= $this->build_condition( $col, $compare, $type, $value ); } return $where; } /** * Ensures GROUP BY is set to prevent duplicates from LEFT JOINs. * * @param string $groupby Current GROUP BY SQL. * @param \WP_Query $query The WP_Query object. * @return string Modified GROUP BY SQL. */ public function posts_groupby( ?string $groupby, \WP_Query $query ): string { $groupby = (string) ( $groupby ?? '' ); if ( ! $this->has_clauses( $query ) ) { return $groupby; } global $wpdb; if ( '' === trim( $groupby ) ) { $groupby = "`{$wpdb->posts}`.`ID`"; } return $groupby; } // ── Internal helpers ────────────────────────────────────────────────── /** * Determines whether this interceptor should handle the given query. * * @param \WP_Query $query The WP_Query object. * @return bool True if the query should be intercepted. */ protected function should_intercept( \WP_Query $query ): bool { if ( ! TMDO_Feature_Flags::is_query_active( $this->get_module() ) ) { return false; } $post_types = (array) $query->get( 'post_type' ); return ! empty( array_intersect( $post_types, $this->get_post_types() ) ); } /** * Returns the query var key used to store intercepted clauses. * * @return string Query var name. */ protected function query_var(): string { return 'wpdo_qi_' . $this->get_module(); } /** * Returns the SQL alias for the custom table in JOIN clauses. * * @return string Table alias. */ protected function table_alias(): string { return 'wpdo_' . $this->get_module(); } /** * Checks whether the query has any intercepted clauses stored. * * @param \WP_Query $query The WP_Query object. * @return bool True if there are stored clauses. */ protected function has_clauses( \WP_Query $query ): bool { $clauses = $query->get( $this->query_var() ); return ! empty( $clauses ); } /** * Retrieves stored intercepted clauses from the query. * * @param \WP_Query $query The WP_Query object. * @return array[] Array of clause definitions. */ protected function get_clauses( \WP_Query $query ): array { $val = $query->get( $this->query_var() ); return is_array( $val ) ? $val : array(); } /** * Build a single WHERE condition string. * All values go through $wpdb->prepare(). * * @param string $col Column reference (escaped). * @param string $compare Comparison operator. * @param string $type Meta type for placeholder selection. * @param mixed $value Value(s) to compare against. * @return string SQL WHERE condition fragment. */ protected function build_condition( string $col, string $compare, string $type, $value ): string { global $wpdb; $ph = $this->placeholder( $type ); switch ( $compare ) { case 'IN': case 'NOT IN': $vals = array_values( (array) $value ); if ( empty( $vals ) ) { return ' AND 1=0'; } $phs = implode( ',', array_fill( 0, count( $vals ), $ph ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare return $wpdb->prepare( " AND {$col} {$compare} ({$phs})", ...$vals ); case 'BETWEEN': case 'NOT BETWEEN': $vals = array_values( (array) $value ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $col/$compare/$ph are sanitized; $ph is a literal placeholder string. return $wpdb->prepare( " AND {$col} {$compare} {$ph} AND {$ph}", $vals[0], $vals[1] ?? $vals[0] ); case 'EXISTS': return " AND {$col} IS NOT NULL"; case 'NOT EXISTS': return " AND {$col} IS NULL"; default: // =, !=, >, >=, <, <=, LIKE, NOT LIKE // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $col/$compare/$ph are sanitized; $ph is a literal placeholder string. return $wpdb->prepare( " AND {$col} {$compare} {$ph}", $value ); } } /** * Returns the SQL placeholder string for a given meta type. * * @param string $type Meta type (NUMERIC, SIGNED, etc. or CHAR). * @return string SQL placeholder (%d or %s). */ protected function placeholder( string $type ): string { $int_types = array( 'NUMERIC', 'SIGNED', 'UNSIGNED', 'INTEGER' ); return in_array( $type, $int_types, true ) ? '%d' : '%s'; } /** * Sanitizes a comparison operator against an allowed list. * * @param string $compare Comparison operator string. * @return string Sanitized comparison operator, defaults to '='. */ protected function sanitize_compare( string $compare ): string { $allowed = array( '=', '!=', '>', '>=', '<', '<=', 'LIKE', 'NOT LIKE', 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN', 'EXISTS', 'NOT EXISTS', ); return in_array( $compare, $allowed, true ) ? $compare : '='; } }