'bogus'`) is * treated leniently instead, per the spike's own ยง5.2 guidance: that one filter * dimension is simply not applied (skipped, logged), the rest of the query still runs. * This mirrors both of FluentCart's own native query paths, which are themselves * silently permissive about unrecognized enum values (`when()`-guarded, never a thrown * exception for a bad string). * - FluentCart unavailable (`FluentCart\App\Models\Product` missing) also returns * {@see ProductQueryResult::empty()}, matching `ProductContext::find()`'s own existing * guard style. Never a fatal, ever. */ final class ProductQueryAdapter { private const RECOGNIZED_ARGS = [ 'post_status', 'posts_per_page', 'paged', 'offset', 'tax_query', 'payment_type', 'fulfillment_type', 'product_type', 'stock_availability', 'on_sale', 'price_min', 'price_max', 'sort', 'include_ids', 'exclude_ids', ]; /** Live-verified against FluentCart\App\Modules\Data\ProductQuery's own scopeByVariantTypes()/ShopResource::get() vocabulary. */ private const PAYMENT_TYPES = ['onetime', 'subscription']; private const FULFILLMENT_TYPES = ['physical', 'digital']; /** * Live-verified exact strings: FluentCart\App\Helpers\Helper::PRODUCT_TYPE_SIMPLE / * PRODUCT_TYPE_SIMPLE_VARIATION / PRODUCT_TYPE_ADVANCE_VARIATION constants * ('simple' / 'simple_variations' / 'advanced_variations'). Note this differs in two * spellings from g1k-probes.md par. 6.1's schema-doc prose ("simple_variation" / * "advance_variation", singular); the Helper class constants are the actual values * FluentCart's own code compares against and are trusted here over the doc prose. */ private const PRODUCT_TYPES = ['simple', 'simple_variations', 'advanced_variations']; /** * Only these two values are ever evidenced as real `fct_product_details.stock_availability` * writes anywhere in the audited FluentCart source (WooCommerceMigratorCli.php, * StockManagement.php); a third value ("backorder") appears only in g1k-probes.md's * schema-doc prose with no corresponding write site found by grep. Restricted to the * evidenced pair; an unrecognized value (including "backorder") is treated as an * invalid enum value per this class's documented leniency rule, not as a fatal. */ private const STOCK_AVAILABILITY = ['in-stock', 'out-of-stock']; private const SORTS = ['name-asc', 'name-desc', 'price-low', 'price-high', 'date-newest', 'date-oldest']; /** Matches ShopResource::get()'s own default sort exactly (docs/audit/wp5-query-spike.md par. 2.1). */ private const DEFAULT_SORT = 'name-asc'; private const DEFAULT_PER_PAGE = 12; private const MAX_PER_PAGE = 200; /** * Documented comfort ceiling for the manual/curated `include_ids` list, per the * spike's own measured `ORDER BY FIELD(...)` cost curve (docs/audit/wp5-query-spike.md * par. 3.2: ~91ms at 10,000 entries, super-linear growth). This is NOT an enforced * hard cap (a "Handmatig" curated source should never legitimately need more than a * few hundred entries); crossing it only logs a debug notice and the query still runs. */ private const MANUAL_ID_CEILING = 500; /** * @param array $args See the class docblock and * docs/audit/wp5-query-spike.md par. 5.2 for the full, authoritative arg schema. */ public static function query(array $args): ProductQueryResult { $perPage = self::normalizePerPage($args['posts_per_page'] ?? self::DEFAULT_PER_PAGE); $page = self::normalizePage($args['paged'] ?? 1); if (!class_exists('FluentCart\\App\\Models\\Product')) { self::log('FluentCart\\App\\Models\\Product is unavailable; returning an empty resultset.'); return ProductQueryResult::empty($perPage, $page); } $unknownKeys = array_diff(array_keys($args), self::RECOGNIZED_ARGS); if ($unknownKeys) { self::log( 'unrecognized filter key(s) [' . implode(', ', $unknownKeys) . '] passed to ' . 'ProductQueryAdapter::query(); returning an empty, logged resultset rather than ' . 'guessing at intent (binding error-behavior rule).' ); return ProductQueryResult::empty($perPage, $page); } $excludeIds = self::normalizeIdList($args['exclude_ids'] ?? []); if (!empty($args['include_ids'])) { return self::queryManual($args, $perPage, $page, $excludeIds); } return self::queryDynamic($args, $perPage, $page, $excludeIds); } /** * Approach (b): manual/curated source. Never touches BreakdanceQueryBridge/the fct_* * EXISTS/JOIN machinery (tests/query/run-query.php asserts this structural separation * directly). * * @param array $args * @param int[] $excludeIds */ private static function queryManual(array $args, int $perPage, int $page, array $excludeIds): ProductQueryResult { $ids = self::normalizeIdList($args['include_ids']); if ($excludeIds) { $ids = array_values(array_diff($ids, $excludeIds)); } if (!$ids) { return ProductQueryResult::empty($perPage, $page); } if (count($ids) > self::MANUAL_ID_CEILING) { self::log(sprintf( 'manual/curated include_ids list has %d entries, past the documented %d-entry comfort ' . 'ceiling (docs/audit/wp5-query-spike.md par. 3.2, ORDER BY FIELD() cost curve); ' . 'proceeding, but a smaller curated set is recommended.', count($ids), self::MANUAL_ID_CEILING )); } $sort = self::normalizeSort($args['sort'] ?? null); if ($sort === 'price-low' || $sort === 'price-high') { $ids = self::presortManualIdsByPrice($ids, $sort); } $queryArgs = [ 'post_type' => 'fluent-products', 'post_status' => self::normalizePostStatus($args['post_status'] ?? null), 'post__in' => $ids, 'orderby' => 'post__in', 'posts_per_page' => $perPage, 'paged' => $page, 'ignore_sticky_posts' => true, 'no_found_rows' => false, 'fields' => 'ids', ]; if (isset($args['tax_query']) && is_array($args['tax_query'])) { // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- Taxonomy filtering is the feature; results are bounded by the product query. $queryArgs['tax_query'] = $args['tax_query']; } $wpQuery = new \WP_Query($queryArgs); return self::resultFromWpQuery($wpQuery, $perPage, $page); } /** * Small, bounded pre-sort against fct_product_details for the curated ids ONLY (never * the whole catalog), per the spike's approach (b) recommendation (par. 3.2, 5.1): "a * price pre-sort step only if a price sort is additionally requested on top of a * manual list". Ids without a details row (should not normally happen) are appended * at the end, defensively, rather than dropped. * * @param int[] $ids * @return int[] */ private static function presortManualIdsByPrice(array $ids, string $sort): array { global $wpdb; if (!$ids || !isset($wpdb) || !is_object($wpdb) || !method_exists($wpdb, 'get_results')) { return $ids; } $placeholders = implode(',', array_fill(0, count($ids), '%d')); $table = $wpdb->prefix . 'fct_product_details'; // Two literal queries (no interpolated direction). $table is $wpdb->prefix plus a // fixed name and $placeholders only ever contains '%d' markers. if ($sort === 'price-low') { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- {$table} is $wpdb->prefix plus a fixed table name; {$placeholders} holds only %d markers, one per bound id. $sql = $wpdb->prepare("SELECT post_id, COALESCE(min_price, 0) AS sort_price FROM {$table} WHERE post_id IN ({$placeholders}) ORDER BY sort_price ASC", ...$ids); } else { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- {$table} is $wpdb->prefix plus a fixed table name; {$placeholders} holds only %d markers, one per bound id. $sql = $wpdb->prepare("SELECT post_id, COALESCE(min_price, 0) AS sort_price FROM {$table} WHERE post_id IN ({$placeholders}) ORDER BY sort_price DESC", ...$ids); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Reads a FluentCart table that has no WP API; $sql is prepared above and the result is request-scoped. PreparedSQL.NotPrepared and UnescapedDBParameter: $sql is the output of $wpdb->prepare() in the branches above. $rows = $wpdb->get_results($sql); if (!is_array($rows)) { return $ids; } $sortedIds = array_map(static fn($row): int => (int) ($row->post_id ?? 0), $rows); $missing = array_values(array_diff($ids, $sortedIds)); return array_merge($sortedIds, $missing); } /** * Approach (a): dynamic filter/sort path via BreakdanceQueryBridge's marker-scoped * fct_* SQL. * * @param array $args * @param int[] $excludeIds */ private static function queryDynamic(array $args, int $perPage, int $page, array $excludeIds): ProductQueryResult { $descriptors = []; self::maybeAppendEnumDescriptor($descriptors, 'payment_type', $args['payment_type'] ?? null, self::PAYMENT_TYPES); self::maybeAppendEnumDescriptor($descriptors, 'fulfillment_type', $args['fulfillment_type'] ?? null, self::FULFILLMENT_TYPES); self::maybeAppendEnumDescriptor($descriptors, 'product_type', $args['product_type'] ?? null, self::PRODUCT_TYPES); self::maybeAppendEnumDescriptor($descriptors, 'stock_availability', $args['stock_availability'] ?? null, self::STOCK_AVAILABILITY); if (array_key_exists('on_sale', $args) && $args['on_sale'] !== null) { $descriptors[] = ['field' => 'on_sale', 'value' => (bool) $args['on_sale']]; } $priceMin = self::normalizeMinorUnit($args['price_min'] ?? null); $priceMax = self::normalizeMinorUnit($args['price_max'] ?? null); if ($priceMin !== null || $priceMax !== null) { $descriptors[] = ['field' => 'price_range', 'min' => $priceMin, 'max' => $priceMax]; } $sort = self::normalizeSort($args['sort'] ?? null); $queryArgs = [ 'post_type' => 'fluent-products', 'post_status' => self::normalizePostStatus($args['post_status'] ?? null), 'posts_per_page' => $perPage, 'paged' => $page, 'ignore_sticky_posts' => true, 'no_found_rows' => false, 'fields' => 'ids', ]; if ($excludeIds) { // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in -- Excludes a handful of already-known ids; not a general exclusion query. $queryArgs['post__not_in'] = $excludeIds; } if (isset($args['tax_query']) && is_array($args['tax_query'])) { // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- Taxonomy filtering is the feature; results are bounded by the product query. $queryArgs['tax_query'] = $args['tax_query']; } if (isset($args['offset'])) { $queryArgs['offset'] = max(0, (int) $args['offset']); } self::applyNativeSort($queryArgs, $sort); $marker = null; if ($descriptors || in_array($sort, ['price-low', 'price-high'], true)) { $marker = BreakdanceQueryBridge::beginScope($descriptors, $sort); $queryArgs[BreakdanceQueryBridge::QUERY_VAR] = $marker; } try { $wpQuery = new \WP_Query($queryArgs); return self::resultFromWpQuery($wpQuery, $perPage, $page); } finally { if ($marker !== null) { BreakdanceQueryBridge::endScope($marker); } } } /** * Native (non-fct_*) sort mapping for title/date. Price sorts are fully overridden by * BreakdanceQueryBridge::filterPostsOrderby(); the 'ID'/'ASC' placeholder here only * ensures WP_Query has a well-formed default orderby before that marker-scoped filter * replaces it outright. * * @param array $queryArgs */ private static function applyNativeSort(array &$queryArgs, string $sort): void { switch ($sort) { case 'name-asc': $queryArgs['orderby'] = 'title'; $queryArgs['order'] = 'ASC'; break; case 'name-desc': $queryArgs['orderby'] = 'title'; $queryArgs['order'] = 'DESC'; break; case 'date-newest': $queryArgs['orderby'] = 'date'; $queryArgs['order'] = 'DESC'; break; case 'date-oldest': $queryArgs['orderby'] = 'date'; $queryArgs['order'] = 'ASC'; break; case 'price-low': case 'price-high': $queryArgs['orderby'] = 'ID'; $queryArgs['order'] = 'ASC'; break; } } private static function resultFromWpQuery(\WP_Query $wpQuery, int $perPage, int $page): ProductQueryResult { $products = []; foreach ((array) $wpQuery->posts as $post) { $id = $post instanceof \WP_Post ? (int) $post->ID : (int) $post; $product = ProductContext::find($id); if ($product) { $products[] = $product; } } return new ProductQueryResult( $products, (int) ($wpQuery->found_posts ?? count($products)), $perPage, $page, (int) ($wpQuery->max_num_pages ?? 0) ); } /** * @param array> $descriptors * @param string[] $allowed */ private static function maybeAppendEnumDescriptor(array &$descriptors, string $field, $value, array $allowed): void { if ($value === null || $value === '') { return; } $value = (string) $value; if (!in_array($value, $allowed, true)) { self::log(sprintf( 'unrecognized value "%s" for recognized filter key "%s" ignored (that one filter ' . 'dimension is skipped, the rest of the query still runs; spike-guided leniency, ' . 'docs/audit/wp5-query-spike.md par. 5.2).', $value, $field )); return; } $descriptors[] = ['field' => $field, 'value' => $value]; } /** * Args are already documented as minor units (matching Helper::toCent()) per the * interface sketch; this only validates/casts, it never multiplies by 100 itself * (that conversion, when starting from a major-unit user value, is ConditionRegistry's * job for the price condition). */ private static function normalizeMinorUnit($value): ?int { if ($value === null || $value === '' || !is_numeric($value)) { return null; } return (int) round((float) $value); } private static function normalizeSort($value): string { $value = is_string($value) ? $value : ''; return in_array($value, self::SORTS, true) ? $value : self::DEFAULT_SORT; } private static function normalizePostStatus($value): string { return (is_string($value) && $value !== '') ? $value : 'publish'; } /** @return int[] */ private static function normalizeIdList($value): array { if (!is_array($value)) { return []; } $ids = array_map('absint', $value); $ids = array_filter($ids, static fn(int $id): bool => $id > 0); return array_values(array_unique($ids)); } private static function normalizePerPage($value): int { $value = (int) $value; return $value > 0 ? min($value, self::MAX_PER_PAGE) : self::DEFAULT_PER_PAGE; } private static function normalizePage($value): int { $value = (int) $value; return $value > 0 ? $value : 1; } private static function log(string $message): void { 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\\ProductQueryAdapter: ' . $message); } } }