defineTables(); $this->cache = Cache::for('term_relationship', WEEK_IN_SECONDS); $this->cache->connect('terms'); // Ensure the table exists // $this->create_table_if_not_exists(); add_action('init', [$this, 'init']); } protected function defineTables():void { $relationships = CustomTable::for('taxonomy_relationships'); $relationships->setColumns([ 'id' => 'bigint(20) unsigned NOT NULL AUTO_INCREMENT', 'term_id' => "{$relationships->getTermIDType()} NOT NULL", 'related_term_id' => "{$relationships->getTermIDType()} NOT NULL", 'taxonomy' => 'varchar(32) NOT NULL', 'related_taxonomy' => 'varchar(32) NOT NULL', 'post_count' => 'int(11) NOT NULL DEFAULT 0', 'is_direct' => 'tinyint(1) NOT NULL DEFAULT 1', 'is_hierarchical' => 'tinyint(1) NOT NULL DEFAULT 0', 'last_updated' => 'timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP' ]); $relationships->setKeys([ ['key'=>'PRIMARY', 'value' => '(`id`)'], ['key'=>'UNIQUE', 'value' => '`relation` (`term_id`, `related_term_id`, `taxonomy`, `related_taxonomy`)'], '`term_id` (`term_id`)', '`related_term_id` (`related_term_id`)', '`taxonomy` (`taxonomy`)', '`related_taxonomy` (`related_taxonomy`)' ]); $base = BASE; $relationships->setConstraints([ "CONSTRAINT {$base}tax_rel_term_id FOREIGN KEY (`term_id`) REFERENCES `{$relationships->getTermTable()}` (`term_id`) ON DELETE CASCADE", "CONSTRAINT `{$base}tax_rel_related_id` FOREIGN KEY (`related_term_id`) REFERENCES `{$relationships->getTermTable()}` (`term_id`) ON DELETE CASCADE" ]); $relationships->defineTable(); $this->relationships = $relationships; } /** * Hook into term and post saves * @return void */ public function init():void { add_action('save_post', [$this, 'updatePostRelationships'], 10, 2); add_action('before_delete_post', [$this, 'updatePostRelationships'], 10, 2); add_action('delete_term', [$this, 'deleteTermRelationships']); add_filter(BASE.'handle_bulk_operation', [$this, 'processOperation'], 10, 3); } // Update relationships for a post /** * @param int $post_id * * @return void */ public function updatePostRelationships(int $post_id, WP_Post $post):void { // Skip autosaves and revisions if (wp_is_post_autosave($post_id) || wp_is_post_revision($post_id)) { return; } $post_type = $post->post_type; if (in_array($post_type, jvbIgnoredPostTypes())) { return; } // Get all taxonomies for this post type $taxonomies = get_object_taxonomies($post_type); if (empty($taxonomies)) { return; } // Get terms for each taxonomy $taxonomy_terms = []; foreach ($taxonomies as $taxonomy) { $terms = wp_get_post_terms($post_id, $taxonomy, ['fields' => 'ids']); if (!is_wp_error($terms) && !empty($terms)) { $taxonomy_terms[$taxonomy] = $terms; // Add parent terms for hierarchical taxonomies if (is_taxonomy_hierarchical($taxonomy)) { foreach ($terms as $term_id) { $ancestors = get_ancestors($term_id, $taxonomy, 'taxonomy'); if (!empty($ancestors)) { $taxonomy_terms[$taxonomy] = array_merge($taxonomy_terms[$taxonomy], $ancestors); } } // Remove duplicates $taxonomy_terms[$taxonomy] = array_unique($taxonomy_terms[$taxonomy]); } } } // Create relationships between all taxonomy terms foreach ($taxonomy_terms as $taxonomy => $terms) { foreach ($terms as $term_id) { foreach ($taxonomy_terms as $related_taxonomy => $related_terms) { if ($taxonomy === $related_taxonomy) { continue; // Skip same taxonomy relationships } foreach ($related_terms as $related_term_id) { $this->updateRelationship((int)$term_id, (int)$related_term_id, $taxonomy, $related_taxonomy); } } } } } /** * @param int $term_id * @param int $related_term_id * @param string $taxonomy * @param string $related_taxonomy * * @return bool */ public function updateRelationship(int $term_id, int $related_term_id, string $taxonomy, string $related_taxonomy):bool { // Make sure both terms exist $term = get_term($term_id, $taxonomy); $related_term = get_term($related_term_id, $related_taxonomy); if (is_wp_error($term) || is_wp_error($related_term) || empty($term) || empty($related_term)) { // One or both terms don't exist, so don't create a relationship return false; } // Check if term is parent of related term $is_hierarchical = 0; if ($taxonomy === $related_taxonomy) { $ancestors = get_ancestors($related_term_id, $taxonomy, 'taxonomy'); if (in_array($term_id, $ancestors)) { $is_hierarchical = 1; } } // Calculate number of shared posts $shared_posts_count = $this->countSharedPosts($term_id, $related_term_id, $taxonomy, $related_taxonomy); $updated = $this->relationships->findOrCreate([ 'term_id' => $term_id, 'related_term_id' => $related_term_id, 'taxonomy' => $taxonomy, 'related_taxonomy' => $related_taxonomy ],[ 'is_hierarchical' => $is_hierarchical, 'post_count' => $shared_posts_count ]); return (bool)$updated; } /** * @param int $term_id * @param int $related_term_id * @param string $taxonomy * @param string $related_taxonomy * * @return int */ private function countSharedPosts(int $term_id, int $related_term_id, string $taxonomy, string $related_taxonomy):int { $term_posts = $this->cache->remember( $this->cache->generateKey(['term' => $term_id, 'taxonomy' => $taxonomy]), function () use ($term_id, $taxonomy) { global $wpdb; return $wpdb->get_col($wpdb->prepare( "SELECT object_id FROM {$wpdb->term_relationships} tr JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id WHERE tt.term_id = %d AND tt.taxonomy = %s", $term_id, $taxonomy )); } ); $related_term_posts = $this->cache->remember( $this->cache->generateKey(['term' => $related_term_id, 'taxonomy' => $related_taxonomy]), function () use($related_term_id, $related_taxonomy) { global $wpdb; return $wpdb->get_col($wpdb->prepare( "SELECT object_id FROM {$wpdb->term_relationships} tr JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id WHERE tt.term_id = %d AND tt.taxonomy = %s", $related_term_id, $related_taxonomy )); }); return count(array_intersect($term_posts, $related_term_posts)); } // Get related terms based on a term ID and desired taxonomy /** * @param int $term_id * @param string $desired_taxonomy * @param bool $include_hierarchical * * @return array */ public function getRelatedTerms(int $term_id, string $desired_taxonomy, bool $include_hierarchical = true):array { $where = [ 'term_id' => $term_id, 'related_taxonomy' => $desired_taxonomy ]; if (!$include_hierarchical) { $where['is_hierarchical'] = 0; } return $this->relationships->pluck('related_term_id', $where, 'post_count', 'DESC'); } // Get all related terms based on multiple source terms (for "any" match) /** * @param array $term_ids * @param string $taxonomy * * @return array */ public function getAnyRelatedTerms(array $term_ids, string $taxonomy):array { $term_ids = array_filter(array_map('absint', $term_ids)); return array_unique($this->relationships->pluck('related_term_id', ['term_id' => ['IN' => $term_ids], 'related_taxonomy' => $taxonomy], 'post_count', 'DESC')); } // Rebuild all relationships (useful for initial setup) /** * @return true */ public function rebuildAllRelationships():bool { $this->cache->flush(); global $wpdb; // Clear existing relationships $wpdb->query("TRUNCATE TABLE {$this->relationships->getFullTableName()}"); $posts = new WP_Query([ 'post_status' => 'publish', 'posts_per_page'=> -1, 'fields' => 'ids' ]); if (!$posts->have_posts()) { wp_reset_postdata(); return true; } $ids = $posts->posts; wp_reset_postdata(); // Queue the operation $queue = JVB()->queue(); $queue->queueOperation( 'taxonomy_relationships', 1, [ 'posts' => $ids, ], [ 'chunk_key' => 'posts', 'chunk_size' => 50 ] ); $this->cache->flush(); return true; } /** * @param WP_Error|array $result * @param object $operation * @param array $data * * @return WP_Error|array */ public function processOperation(WP_Error|array $result, object $operation, array $data):WP_Error|array { if ($operation->type !== 'taxonomy_relationships') { return $result; } try { foreach ($data['posts'] as $postID) { $post = get_post($postID); $this->updatePostRelationships($postID, $post); } } catch (Exception $e) { return [ 'success' => false, 'message' => $e->getMessage() ]; } return [ 'success' => true ]; } // Hook this to term deletion /** * @param int $term_id * * @return void */ public function deleteTermRelationships(int $term_id):void { $termIDs = $this->relationships->delete(['term_id' => $term_id]); $related = $this->relationships->delete(['related_term_id' => $term_id]); } }