termsAssigned($objectId, [], $ttIds, $taxonomy); }, 30, 3); foreach (['created_term', 'edited_term', 'delete_term'] as $hook) { add_action($hook, [$this, 'termChanged'], 30, 3); } add_action('woocommerce_attribute_updated', [$this, 'attributeChanged']); add_action('woocommerce_attribute_deleted', [$this, 'attributeDeleted']); } public function facet(int $attributeId): ?array { global $wpdb; $repo = new Repository(); $id = (int) $wpdb->get_var($wpdb->prepare('SELECT id FROM %i WHERE slug=%s', $repo->facetTable(), 'wc-attribute-' . $attributeId)); return $id ? $repo->facet($id) : null; } public function ensure(array $config): void { $attrs = TableConfiguration::attributes(); foreach ($config['filters'] as $key) { if (isset($attrs[$key])) { $this->rebuild($attrs[$key]); } } } public function rebuild(array $attribute): void { global $wpdb; $previous = $wpdb->suppress_errors(true); try { $this->rebuildAttribute($attribute); } catch (\Throwable $error) { $this->recordSync(true); throw $error; } finally { $wpdb->suppress_errors($previous); } } private function rebuildAttribute(array $attribute): void { global $wpdb; $repo = new Repository(); $facet = $this->facet($attribute['id']); $id = $facet ? (int) $facet['id'] : $repo->createFacet($attribute['label'], 'wc-attribute-' . $attribute['id']); if (!$id) { throw new \RuntimeException('Cannot create attribute index.'); } if ($wpdb->update($repo->facetTable(), ['name' => $attribute['label'], 'enabled' => 0], ['id' => $id]) === false) { throw new \RuntimeException('Cannot update attribute index.'); } $terms = get_terms(['taxonomy' => $attribute['taxonomy'], 'hide_empty' => false]); if (is_wp_error($terms)) { throw new \RuntimeException('Attribute terms unavailable.'); } $map = []; $old = (array) get_option('facetcore_attribute_terms_' . $attribute['id'], []); $wpdb->query('START TRANSACTION'); try { foreach ($terms as $term) { $value = isset($old[$term->term_id]) ? (int) $old[$term->term_id] : 0; $valid = $value && (int) $wpdb->get_var($wpdb->prepare('SELECT COUNT(*) FROM %i WHERE id=%d AND facet_id=%d', $repo->valueTable(), $value, $id)); $same = (int) $wpdb->get_var($wpdb->prepare('SELECT id FROM %i WHERE facet_id=%d AND label=%s', $repo->valueTable(), $id, $term->name)); if ($same) { $value = $same; } elseif ($valid) { if ($wpdb->update($repo->valueTable(), ['label' => $term->name], ['id' => $value]) === false) { throw new \RuntimeException('Cannot update attribute value.'); } } else { $value = $repo->createValue($id, $term->name); } if (!$value) { throw new \RuntimeException('Cannot index attribute value.'); } $map[$term->term_id] = $value; } if ($wpdb->delete($repo->productValueTable(), ['facet_id' => $id]) === false) { throw new \RuntimeException('Cannot refresh product attributes.'); } foreach ($terms as $term) { $sql = $wpdb->prepare("INSERT IGNORE INTO %i (product_id,facet_id,value_id) SELECT r.object_id,%d,%d FROM %i r INNER JOIN %i p ON p.ID=r.object_id WHERE r.term_taxonomy_id=%d AND p.post_type='product'", $repo->productValueTable(), $id, $map[$term->term_id], $wpdb->term_relationships, $wpdb->posts, $term->term_taxonomy_id); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $sql was built by $wpdb->prepare() on the previous line. if ($wpdb->query($sql) === false) { throw new \RuntimeException('Cannot index product attributes.'); } } $keep = array_values(array_unique(array_map('intval', $map))) ?: [0]; // The placeholder count is generated from the integer ID list; all identifiers and values remain bound. $keepPlaceholders = implode(',', array_fill(0, count($keep), '%d')); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $keepPlaceholders contains only generated %d placeholders; %i, facet ID and every keep ID are bound below. if ($wpdb->query($wpdb->prepare('DELETE FROM %i WHERE facet_id=%d AND id NOT IN (' . $keepPlaceholders . ')', $repo->valueTable(), $id, ...$keep)) === false) { throw new \RuntimeException('Cannot refresh attribute values.'); } $wpdb->query('COMMIT'); update_option('facetcore_attribute_terms_' . $attribute['id'], $map, false); $this->recordSync(false); } catch (\Throwable $e) { $wpdb->query('ROLLBACK'); throw $e; } } public function syncProduct(int $productId, ?string $taxonomy = null): void { if (get_post_type($productId) !== 'product') { return; } global $wpdb; $repo = new Repository(); $previousErrors = $wpdb->suppress_errors(true); $transaction = false; try { $attributes = TableConfiguration::attributes(); $indexed = []; $facets = $repo->facets(); if ($wpdb->last_error !== '') { throw new \RuntimeException('Attribute index unavailable.'); } foreach ($facets as $facet) { if (strpos($facet['slug'], 'wc-attribute-') !== 0) { continue; } $attributeId = (int) substr($facet['slug'], strlen('wc-attribute-')); $attribute = $attributes['attribute:' . $attributeId] ?? null; if (!$attribute || ($taxonomy !== null && $taxonomy !== $attribute['taxonomy'])) { continue; } $indexed[$attribute['taxonomy']] = ['facet' => (int) $facet['id'], 'attribute' => $attributeId]; } if (!$indexed) { return; } // One taxonomy read and one existing-assignment read replace per-attribute reads. $terms = wp_get_object_terms($productId, array_keys($indexed)); if (is_wp_error($terms)) { throw new \RuntimeException('Attribute terms unavailable.'); } $existing = $repo->productValues($productId); $assignments = []; foreach ($indexed as $tax => $index) { if (isset($existing[$index['facet']]) || $taxonomy === $tax) { $assignments[$index['facet']] = []; } } foreach ($terms as $term) { $index = $indexed[$term->taxonomy]; $map = (array) get_option('facetcore_attribute_terms_' . $index['attribute'], []); if (isset($map[$term->term_id])) { $assignments[$index['facet']][] = (int) $map[$term->term_id]; } } if (!$assignments) { return; } $wpdb->query('START TRANSACTION'); $transaction = true; $facetIds = array_map('intval', array_keys($assignments)); $facetPlaceholders = implode(',', array_fill(0, count($facetIds), '%d')); // The placeholder count follows the non-empty assignment facet ID list. // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $facetPlaceholders contains only generated %d placeholders; %i, product ID and every facet ID are bound below. if ($wpdb->query($wpdb->prepare('DELETE FROM %i WHERE product_id=%d AND facet_id IN (' . $facetPlaceholders . ')', $repo->productValueTable(), $productId, ...$facetIds)) === false) { throw new \RuntimeException('Index update failed.'); } $rows = []; foreach ($assignments as $facetId => $values) { foreach (array_unique($values) as $valueId) { $rows[] = [$productId, (int) $facetId, (int) $valueId]; } } if ($rows) { $rowPlaceholders = implode(',', array_fill(0, count($rows), '(%d,%d,%d)')); $values = array_merge(...$rows); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $rowPlaceholders is only repeated literal (%d,%d,%d) tuples; the table and all tuple values are bound in this prepare() call. if ($wpdb->query($wpdb->prepare('INSERT IGNORE INTO %i (product_id,facet_id,value_id) VALUES ' . $rowPlaceholders, $repo->productValueTable(), ...$values)) === false) { throw new \RuntimeException('Index update failed.'); } } $wpdb->query('COMMIT'); $transaction = false; $this->recordSync(false); } catch (\Throwable $error) { if ($transaction) { $wpdb->query('ROLLBACK'); } $this->recordSync(true); } finally { $wpdb->suppress_errors($previousErrors); } } public function termsAssigned($objectId, $terms, $ttIds, $taxonomy): void { if (strpos((string) $taxonomy, 'pa_') === 0) { $this->syncProduct((int) $objectId, (string) $taxonomy); } } public function termChanged($termId, $ttId, $taxonomy): void { $this->safeSync(function () use ($taxonomy): void { foreach (TableConfiguration::attributes() as $attr) { if ($attr['taxonomy'] === $taxonomy && $this->facet($attr['id'])) { $this->rebuild($attr); } } }); } public function attributeDeleted($id): void { $this->safeSync(function () use ($id): void { $facet = $this->facet((int) $id); if ($facet) { (new Repository())->deleteFacet((int) $facet['id']); } delete_option('facetcore_attribute_terms_' . (int) $id); }); } public function attributeChanged($id): void { // WooCommerce finishes renaming taxonomy data before shutdown. add_action('shutdown', function () use ($id): void { $this->safeSync(function () use ($id): void { delete_transient('wc_attribute_taxonomies'); \WC_Cache_Helper::invalidate_cache_group('woocommerce-attributes'); foreach (TableConfiguration::attributes() as $attr) { if ($attr['id'] === (int) $id && $this->facet((int) $id)) { if (!taxonomy_exists($attr['taxonomy'])) { register_taxonomy($attr['taxonomy'], 'product'); } $this->rebuild($attr); } } }); }); } private function recordSync(bool $error): void { // A later successful product save cannot repair a previously failed product. // Only a complete explicit rebuild clears an outstanding health warning. $previous = (array) get_option('facetcore_sync_status', []); update_option('facetcore_sync_status', ['time' => current_time('mysql'), 'error' => $error || !empty($previous['error'])], false); } private function safeSync(callable $operation): void { global $wpdb; $previous = $wpdb->suppress_errors(true); try { $operation(); if ($wpdb->last_error !== '') { $this->recordSync(true); } } catch (\Throwable $error) { $this->recordSync(true); } finally { $wpdb->suppress_errors($previous); } } }