db = $db ?: $wpdb; $this->repo = $repo ?: new Repository($this->db); $this->posts = $this->db->posts; $this->configuredFacets = $configuredFacets; $this->categories = $categories; $this->query = $query; $configured = $maxFacets === null ? (int) get_option('facetcore_max_facets', 10) : $maxFacets; $this->maxFacets = max(1, min(10, $configured)); } public function search(array $filters, int $page = 1, int $perPage = 20): array { $filters = $this->normalizeFilters($filters); if ($this->configuredFacets !== null) { $allowed = array_map('intval', array_column($this->configuredFacets, 'id')); $filters = array_intersect_key($filters, array_flip($allowed)); // Only accept indexed values belonging to the configured facet. foreach ($filters as $facetId => $valueId) { $valid = (int) $this->db->get_var($this->db->prepare('SELECT COUNT(*) FROM ' . $this->repo->valueTable() . ' WHERE id=%d AND facet_id=%d', $valueId, $facetId)); if (!$valid) { unset($filters[$facetId]); } } } $page = max(1, $page); $perPage = max(1, min(100, $perPage)); $started = microtime(true); $tmp = $this->temporaryTableName(); if ($this->db->query("CREATE TEMPORARY TABLE {$tmp} (product_id BIGINT(20) UNSIGNED NOT NULL PRIMARY KEY) ENGINE=InnoDB") === false) { throw new \RuntimeException("Product query unavailable."); } try { $this->fillCandidates($tmp, $filters); $total = (int) $this->db->get_var("SELECT COUNT(*) FROM {$tmp}"); $facetRows = $this->availableFacets($tmp, $filters); $facets = $this->hydrateFacetValues($tmp, $facetRows, $filters); $pages = $perPage > 0 ? (int) ceil($total / $perPage) : 0; $page = min($page, max(1, $pages)); $offset = ($page - 1) * $perPage; $ids = array_map('intval', $this->db->get_col( $this->db->prepare("SELECT product_id FROM {$tmp} ORDER BY product_id ASC LIMIT %d OFFSET %d", $perPage, $offset) ) ?: []); return [ 'ids' => $ids, 'total' => $total, 'filters' => $filters, 'facets' => $facets, 'page' => $page, 'per_page' => $perPage, 'pages' => $pages, 'stats' => [ 'max_facets' => $this->maxFacets, 'elapsed_ms' => round((microtime(true) - $started) * 1000, 3), ], ]; } finally { $this->db->query("DROP TEMPORARY TABLE IF EXISTS {$tmp}"); } } public function normalizeFilters(array $filters): array { $out = []; foreach ($filters as $facetId => $valueId) { $facetId = (int) $facetId; if (is_array($valueId)) { $valueId = reset($valueId); } $valueId = (int) $valueId; if ($facetId > 0 && $valueId > 0) { $out[$facetId] = $valueId; } } ksort($out, SORT_NUMERIC); if (count($out) > 10) { $out = array_slice($out, 0, 10, true); } return $out; } private function fillCandidates(string $tmp, array $filters): void { [$sql, $args] = $this->candidateSelect($filters); $prepared = $args ? $this->db->prepare($sql, ...$args) : $sql; if ($this->db->query("INSERT IGNORE INTO {$tmp}(product_id) {$prepared}") === false) { throw new \RuntimeException("Product query failed."); } } /** @return array{0:string,1:array} */ private function candidateSelect(array $filters): array { $pv = $this->repo->productValueTable(); $visibility = wc_get_product_visibility_term_ids(); $excluded = [(int) ($visibility['exclude-from-catalog'] ?? 0)]; if (get_option('woocommerce_hide_out_of_stock_items') === 'yes') { $excluded[] = (int) ($visibility['outofstock'] ?? 0); } $excluded = implode(',', array_filter($excluded)) ?: '0'; $visible = " AND p.post_password='' AND NOT EXISTS (SELECT 1 FROM {$this->db->term_relationships} visibility WHERE visibility.object_id=p.ID AND visibility.term_taxonomy_id IN ({$excluded}))"; $scopeArgs = []; if ($this->categories !== null) { $categoryIds = implode(',', array_map('intval', $this->categories)) ?: '0'; $visible .= " AND EXISTS (SELECT 1 FROM {$this->db->term_relationships} cr INNER JOIN {$this->db->term_taxonomy} ct ON ct.term_taxonomy_id=cr.term_taxonomy_id WHERE cr.object_id=p.ID AND ct.taxonomy='product_cat' AND ct.term_id IN ({$categoryIds}))"; } if ($this->query !== '') { $like = '%' . $this->db->esc_like($this->query) . '%'; $lookup = $this->db->prefix . 'wc_product_meta_lookup'; $visible .= " AND (LOWER(p.post_title) LIKE LOWER(%s) OR EXISTS (SELECT 1 FROM {$lookup} sk WHERE sk.product_id=p.ID AND LOWER(sk.sku) LIKE LOWER(%s)))"; $scopeArgs = [$like, $like]; } if (!$filters) { // Configured tables source all public Woo products, including products without attributes. // Legacy no-ID tables retain the original indexed-catalog source. if ($this->configuredFacets !== null) { return ["SELECT p.ID AS product_id FROM {$this->posts} p WHERE p.post_type='product' AND p.post_status='publish' {$visible}", $scopeArgs]; } return [ "SELECT DISTINCT pv.product_id FROM {$pv} pv INNER JOIN {$this->posts} p ON p.ID=pv.product_id WHERE p.post_type='product' AND p.post_status='publish' {$visible}", $scopeArgs, ]; } $parts = []; $args = $scopeArgs; foreach ($filters as $facetId => $valueId) { $parts[] = '(pv.facet_id=%d AND pv.value_id=%d)'; $args[] = $facetId; $args[] = $valueId; } $args[] = count($filters); return [ "SELECT pv.product_id FROM {$pv} pv INNER JOIN {$this->posts} p ON p.ID=pv.product_id WHERE p.post_type='product' AND p.post_status='publish' {$visible} AND (" . implode(' OR ', $parts) . ") GROUP BY pv.product_id HAVING COUNT(DISTINCT pv.facet_id)=%d", $args, ]; } private function availableFacets(string $tmp, array $filters): array { if ($this->configuredFacets !== null) { // Configuration replaces automatic facet ranking, not value-count/query logic. return array_map(static function (array $facet) use ($filters): array { $id = (int) $facet['id']; return ['facet_id' => $id, 'name' => $facet['name'], 'slug' => $facet['slug'], 'selected' => isset($filters[$id])]; }, $this->configuredFacets); } $pv = $this->repo->productValueTable(); $facets = $this->repo->facetTable(); $selectedIds = array_keys($filters); $rows = $this->db->get_results( "SELECT pv.facet_id, f.name, f.slug, f.priority, COUNT(DISTINCT pv.product_id) AS products, COUNT(DISTINCT pv.value_id) AS value_count FROM {$pv} pv INNER JOIN {$tmp} c ON c.product_id=pv.product_id INNER JOIN {$facets} f ON f.id=pv.facet_id AND f.enabled=1 GROUP BY pv.facet_id,f.name,f.slug,f.priority ORDER BY products DESC,f.priority DESC,pv.facet_id ASC", ARRAY_A ) ?: []; $byId = []; foreach ($rows as $row) { $id = (int) $row['facet_id']; if ((int) $row['value_count'] < 2 && !isset($filters[$id])) { continue; } $row['facet_id'] = $id; $row['products'] = (int) $row['products']; $row['value_count'] = (int) $row['value_count']; $row['selected'] = isset($filters[$id]); $byId[$id] = $row; } /* Selected facets must remain controllable even if their current result * has only one value. Add metadata directly when the current-candidate * grouping omitted one for any reason. */ foreach ($selectedIds as $facetId) { if (isset($byId[$facetId])) { continue; } $facet = $this->repo->facet($facetId); if (!$facet || !(int) $facet['enabled']) { continue; } $byId[$facetId] = [ 'facet_id' => $facetId, 'name' => $facet['name'], 'slug' => $facet['slug'], 'priority' => (int) $facet['priority'], 'products' => 0, 'value_count' => 1, 'selected' => true, ]; } uasort($byId, static function (array $a, array $b): int { if ($a['products'] !== $b['products']) { return $b['products'] <=> $a['products']; } if ((int) $a['priority'] !== (int) $b['priority']) { return (int) $b['priority'] <=> (int) $a['priority']; } return $a['facet_id'] <=> $b['facet_id']; }); $limit = min(10, max($this->maxFacets, count($selectedIds))); $chosen = []; foreach ($selectedIds as $selected) { if (isset($byId[$selected])) { $chosen[] = $byId[$selected]; } } foreach ($byId as $id => $row) { if (count($chosen) >= $limit) { break; } if (!isset($filters[$id])) { $chosen[] = $row; } } usort($chosen, static function (array $a, array $b): int { if ($a['products'] !== $b['products']) { return $b['products'] <=> $a['products']; } if ((int) $a['priority'] !== (int) $b['priority']) { return (int) $b['priority'] <=> (int) $a['priority']; } return $a['facet_id'] <=> $b['facet_id']; }); return $chosen; } private function hydrateFacetValues(string $tmp, array $facetRows, array $filters): array { if (!$facetRows) { return []; } $selected = array_keys($filters); $unselected = []; foreach ($facetRows as $row) { if (!isset($filters[$row['facet_id']])) { $unselected[] = (int) $row['facet_id']; } } $valueMap = []; if ($unselected) { $pv = $this->repo->productValueTable(); $values = $this->repo->valueTable(); $in = implode(',', array_map('intval', $unselected)); $rows = $this->db->get_results( "SELECT pv.facet_id,pv.value_id,v.label,COUNT(DISTINCT pv.product_id) AS products FROM {$pv} pv INNER JOIN {$tmp} c ON c.product_id=pv.product_id INNER JOIN {$values} v ON v.id=pv.value_id WHERE pv.facet_id IN ({$in}) GROUP BY pv.facet_id,pv.value_id,v.label,v.sort_order HAVING products > 0 ORDER BY pv.facet_id,v.sort_order ASC,products DESC,v.label ASC", ARRAY_A ) ?: []; foreach ($rows as $row) { $valueMap[(int) $row['facet_id']][] = [ 'id' => (int) $row['value_id'], 'label' => $row['label'], 'products' => (int) $row['products'], ]; } } foreach ($selected as $facetId) { if (!array_filter($facetRows, static fn (array $r): bool => $r['facet_id'] === $facetId)) { continue; } $context = $filters; unset($context[$facetId]); [$candidateSql, $args] = $this->candidateSelect($context); $pv = $this->repo->productValueTable(); $values = $this->repo->valueTable(); $sql = "SELECT pv.value_id,v.label,COUNT(DISTINCT pv.product_id) AS products FROM {$pv} pv INNER JOIN ({$candidateSql}) c ON c.product_id=pv.product_id INNER JOIN {$values} v ON v.id=pv.value_id WHERE pv.facet_id=%d GROUP BY pv.value_id,v.label,v.sort_order HAVING products > 0 ORDER BY v.sort_order ASC,products DESC,v.label ASC"; $args[] = $facetId; $rows = $this->db->get_results($this->db->prepare($sql, ...$args), ARRAY_A) ?: []; $valueMap[$facetId] = array_map(static function (array $row): array { return [ 'id' => (int) $row['value_id'], 'label' => $row['label'], 'products' => (int) $row['products'], ]; }, $rows); } foreach ($facetRows as &$row) { $id = (int) $row['facet_id']; $row['selected_value'] = $filters[$id] ?? null; $row['values'] = $valueMap[$id] ?? []; unset($row['priority'], $row['value_count']); } unset($row); return $facetRows; } private function temporaryTableName(): string { return 'facetcore_candidates_' . substr(md5(uniqid('', true)), 0, 12); } }