get('fcbd_query_marker')` * against that registry on EVERY `WP_Query` execution in the process and only ever mutate * SQL for the one matching instance -- never globally, never guessing at an anonymous * listener (architecture principle 5 / ELITE-PLAN.md par. 205, explicitly reused here for * WP5 per par. 258 of the spike). * * SQL shape (docs/audit/wp5-query-spike.md par. 3.1, 5.1, live-measured): * - `LEFT JOIN {prefix}fct_product_details AS fcbd_pd ON fcbd_pd.post_id = {posts}.ID`, * a 1:1 relation, safe to join directly. Used for the price sort AND for any filter that * genuinely lives on that table (`stock_availability`, `variation_type`/`product_type`, * `min_price`-based price range) -- these are direct `WHERE fcbd_pd. = ...` * comparisons, never a JOIN-multiplication risk since the relation is 1:1. * - `EXISTS (SELECT 1 FROM {prefix}fct_product_variations AS fcbd_v WHERE fcbd_v.post_id = * {posts}.ID AND ...)` for every variant-level (1:N) filter (`payment_type`, * `fulfillment_type`, `on_sale`). Never a naked `INNER/LEFT JOIN` against the variations * table: the spike's own live, reproduced anti-pattern proof (par. 3.1) shows a naive * JOIN there duplicates rows per matching variation, corrupting `LIMIT`/`OFFSET` and * `SQL_CALC_FOUND_ROWS`/`found_posts`; `EXISTS` never multiplies rows and needs no * `GROUP BY` to stay correct. * - Every value is bound through `$wpdb->prepare()`; column/table names interpolated * directly into the SQL string are always fixed literals selected by a PHP `switch` on a * known, closed set of descriptor `field` values (never a value derived from external * input), so this never becomes a SQL-injection vector. */ final class BreakdanceQueryBridge { /** * The inert query-arg key ConditionRegistry.php's query_builder `queryCallback` * closures append normalized filter descriptors into. WP_Query itself never reads * this key; {@see self::filterQueryControlQuery()} consumes and removes it before the * arguments ever reach `new WP_Query(...)`. */ public const CONDITIONS_KEY = 'fcbd_conditions'; /** * The custom WP_Query query var carrying the per-request marker. Any string WP_Query * does not recognize is preserved verbatim and readable via `$query->get()` * (confirmed, docs/audit/wp5-query-spike.md par. 5.3 step 3). */ public const QUERY_VAR = 'fcbd_query_marker'; /** * @var array>, sort: string|null}> */ private static array $registry = []; private static bool $sqlFiltersRegistered = false; private static int $markerCounter = 0; /** * Entry point 1 (recommended integrator wiring, see this fork's final report): * `add_filter('breakdance_query_control_query', [BreakdanceQueryBridge::class, * 'filterQueryControlQuery'])`. * * Reads `$query['fcbd_conditions']` (an array of `{field, value, negate?}` descriptors * appended by ConditionRegistry.php's `query_builder` conditions), converts it into a * marker-scoped registry entry, and swaps the key for the inert `fcbd_query_marker` * query var. Harmless no-op when `$query` is not an array (query-control.php's `text`/ * `php` query-source branches pass a bare string or a PHP-eval array through the SAME * filter, docs/audit/wp5-query-spike.md par. 5.3) or carries no conditions. * * @param mixed $query * @return mixed */ public static function filterQueryControlQuery($query) { if (!is_array($query) || empty($query[self::CONDITIONS_KEY]) || !is_array($query[self::CONDITIONS_KEY])) { return $query; } $descriptors = []; foreach ($query[self::CONDITIONS_KEY] as $raw) { if (is_array($raw) && isset($raw['field'])) { $descriptors[] = $raw; } } unset($query[self::CONDITIONS_KEY]); if (!$descriptors) { return $query; } $query[self::QUERY_VAR] = self::beginScope($descriptors, null); return $query; } /** * Entry point 2: ProductQueryAdapter's own direct dynamic-filter path. Registers a * fresh marker for this one WP_Query instance and ensures the global SQL filters are * hooked (idempotent). The caller is responsible for setting the returned marker as * the `fcbd_query_marker` query var on its own `WP_Query` args AND for calling * {@see self::endScope()} once that query has been executed and its results read * (ProductQueryAdapter does this in a `finally` block). * * Note for entry point 1: query-control.php's caller (`post-builder.php:71`) never * signals back when its own `WP_Query` is done, so that path's registry entries are * never explicitly cleaned up. This is a deliberate, documented, bounded trade-off: * entries are small, request-scoped (cleared at request end regardless), and keyed by * a unique marker, so a page with a handful of Posts Loop elements never accumulates * more than a handful of harmless leftover array entries. * * @param array> $filters */ public static function beginScope(array $filters, ?string $sort): string { self::registerSqlFilters(); $marker = self::generateMarker(); self::$registry[$marker] = ['filters' => array_values($filters), 'sort' => $sort]; return $marker; } public static function endScope(string $marker): void { unset(self::$registry[$marker]); } /** * Test/diagnostic helper: whether a marker is currently registered. Never used to * decide query behavior itself (that is always `$wp_query->get()` inside the SQL * filters below); only used by tests/query/run-query.php to assert marker cleanup. */ public static function hasScope(string $marker): bool { return isset(self::$registry[$marker]); } /** * Idempotent: safe to call from both entry points on every request without ever * double-registering the same filter callback. */ private static function registerSqlFilters(): void { if (self::$sqlFiltersRegistered || !function_exists('add_filter')) { return; } add_filter('posts_join', [self::class, 'filterPostsJoin'], 10, 2); add_filter('posts_where', [self::class, 'filterPostsWhere'], 10, 2); add_filter('posts_orderby', [self::class, 'filterPostsOrderby'], 10, 2); self::$sqlFiltersRegistered = true; } /** * @param \WP_Query $query */ public static function filterPostsJoin(string $join, $query): string { $marker = self::activeMarker($query); if ($marker === null) { return $join; } global $wpdb; $detailsTable = $wpdb->prefix . 'fct_product_details'; return $join . " LEFT JOIN {$detailsTable} AS fcbd_pd ON fcbd_pd.post_id = {$wpdb->posts}.ID"; } /** * @param \WP_Query $query */ public static function filterPostsWhere(string $where, $query): string { $marker = self::activeMarker($query); if ($marker === null) { return $where; } global $wpdb; $variationsTable = $wpdb->prefix . 'fct_product_variations'; $descriptors = self::$registry[$marker]['filters'] ?? []; foreach ($descriptors as $descriptor) { if (!is_array($descriptor)) { continue; } $where .= self::fragmentForDescriptor($wpdb, $descriptor, $wpdb->posts, $variationsTable); } return $where; } /** * @param \WP_Query $query */ public static function filterPostsOrderby(string $orderby, $query): string { $marker = self::activeMarker($query); if ($marker === null) { return $orderby; } $sort = self::$registry[$marker]['sort'] ?? null; global $wpdb; // Matches ShopResource::get()'s own precedent exactly (docs/audit/wp5-query-spike.md // par. 2.1/3.1): COALESCE(pd.min_price, 0), with an ID tiebreaker for deterministic // pagination across identically priced products. if ($sort === 'price-low') { return 'COALESCE(fcbd_pd.min_price, 0) ASC, ' . $wpdb->posts . '.ID ASC'; } if ($sort === 'price-high') { return 'COALESCE(fcbd_pd.min_price, 0) DESC, ' . $wpdb->posts . '.ID ASC'; } return $orderby; } /** * @param \WP_Query $query */ private static function activeMarker($query): ?string { if (!is_object($query) || !method_exists($query, 'get')) { return null; } $marker = (string) $query->get(self::QUERY_VAR, ''); return ($marker !== '' && isset(self::$registry[$marker])) ? $marker : null; } /** * The single, canonical descriptor-to-SQL translation both entry points share. * * @param \wpdb $wpdb * @param array $descriptor */ private static function fragmentForDescriptor($wpdb, array $descriptor, string $postsTable, string $variationsTable): string { $field = (string) ($descriptor['field'] ?? ''); $negate = !empty($descriptor['negate']); switch ($field) { // Variant-level (1:N): payment_type and fulfillment_type both live on // fct_product_variations, matching BOTH of FluentCart's own native query // paths (ShopResource::get(), Product::scopeByVariantTypes()), never a JOIN. case 'payment_type': case 'fulfillment_type': $value = (string) ($descriptor['value'] ?? ''); if ($value === '') { return ''; } // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names come from $wpdb->prefix and $field is one of two fixed column names (switch above); the value is bound with %s. $exists = $wpdb->prepare( "EXISTS (SELECT 1 FROM {$variationsTable} AS fcbd_v WHERE fcbd_v.post_id = {$postsTable}.ID AND fcbd_v.{$field} = %s)", $value ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared return ' AND ' . ($negate ? 'NOT ' : '') . $exists; // Product-level (1:1, via the already-joined fcbd_pd alias): variation_type // (adapter's "product_type") and stock_availability both live on // fct_product_details only (g1k-probes.md par. 6, NOT on the variations table). case 'product_type': $value = (string) ($descriptor['value'] ?? ''); if ($value === '') { return ''; } return $negate ? $wpdb->prepare(' AND fcbd_pd.variation_type != %s', $value) : $wpdb->prepare(' AND fcbd_pd.variation_type = %s', $value); case 'stock_availability': $value = (string) ($descriptor['value'] ?? ''); if ($value === '') { return ''; } return $negate ? $wpdb->prepare(' AND fcbd_pd.stock_availability != %s', $value) : $wpdb->prepare(' AND fcbd_pd.stock_availability = %s', $value); // Variant-level on_sale, mirroring ShopResource::get()'s exact guarded form // (compare_price > 0 AND item_price < compare_price). `negate` is not used // here: the boolean polarity is already carried directly in `value` (true = // on sale, false = "regular", i.e. NOT EXISTS the same predicate). case 'on_sale': $exists = "EXISTS (SELECT 1 FROM {$variationsTable} AS fcbd_v WHERE fcbd_v.post_id = {$postsTable}.ID" . ' AND fcbd_v.compare_price > 0 AND fcbd_v.item_price < fcbd_v.compare_price)'; return ' AND ' . (empty($descriptor['value']) ? 'NOT ' : '') . $exists; // Product-level (1:1, via fcbd_pd): mirrors ShopResource::get()'s own // price_range_from/price_range_to filter, which searches detail.min_price, // not variant item_price. case 'price_range': $sql = ''; $min = $descriptor['min'] ?? null; $max = $descriptor['max'] ?? null; if ($min !== null) { $sql .= $wpdb->prepare(' AND fcbd_pd.min_price >= %d', (int) $min); } if ($max !== null) { $sql .= $wpdb->prepare(' AND fcbd_pd.min_price <= %d', (int) $max); } return $sql; default: // Unknown descriptor field: never fatal, never silently apply an // unintended filter either. Logged so a future typo/regression is visible // in debug logs instead of silently producing an unfiltered/wrong-scoped // resultset. if (defined('WP_DEBUG') && WP_DEBUG) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Diagnostic logging, only reached when WP_DEBUG is enabled. error_log('FluentCartBreakdance\\Query\\BreakdanceQueryBridge: unrecognized query descriptor field "' . $field . '" ignored'); } return ''; } } private static function generateMarker(): string { self::$markerCounter++; $random = function_exists('wp_generate_password') ? wp_generate_password(8, false, false) : (string) mt_rand(100000, 999999); // phpcs:ignore WordPress.WP.AlternativeFunctions.rand_mt_rand -- Fallback only when WordPress is not loaded; not security relevant (query marker). return 'fcbd_qm_' . self::$markerCounter . '_' . $random; } }