Database Schema
Term Meta Keys
Note: All term relationships are stored in the wp_htax_term_relationships table, not in term meta:
- Inclusion relationships (
relationship_type = 'includes') - Exclusion relationships (
relationship_type = 'excludes') - Redirects (
relationship_type = 'redirect') - Group membership (
relationship_type = 'member_of')
The following meta keys are for display and configuration only.
Display Keys:
_htax_is_hidden- Boolean flag (‘1’ or empty) for hidden terms_htax_icon_id- WordPress attachment ID for term icon_htax_placeholder_color- Stored color for a term’s placeholder square shown when no icon is set
Redirect Keys:
_htax_auto_redirect- Boolean flag (‘1’) marking a redirect that was auto-created by the term conversion feature (distinguishes it from a redirect set manually). Cleaned up automatically when the redirect row no longer exists; the actual redirect target lives inwp_htax_term_relationships, not in term meta.
Archive Settings Keys (any enabled taxonomy with customize_archives feature):
_htax_max_sticky_posts- Maximum sticky posts for this term’s archive (1-100)_htax_posts_per_page- Posts per page override for this term’s archive (1-100)_htax_sort_order- Default sort order for this term’s archive (e.g.date:desc,title:asc)_htax_show_description- Boolean flag (‘1’ or empty) to display description on archive
Tag Groups Migration Key (on group taxonomy terms only):
_htax_tag_groups_source_id- Site-scoped mapping key,{site_token}:{tag_groups_group_id}, linking an HT group term back to the Tag Groups group it was imported from.site_tokenis a random UUID generated once per site (seehtax_tag_groups_site_tokenoption below) and never carried by HT’s own export/import feature, so an imported mapping from another site fails to match here by construction and falls through to name-matching instead — seehtax_get_tag_groups_site_token()/htax_resolve_or_create_tag_group()inincludes/admin/import-tag-groups-handler.php. Enables rename-safe and removal-safe re-imports (see Options below for the related run-state options). Stripped (not the term) when the corresponding Tag Groups group is deleted — the HT term itself is never auto-deleted.
Post Meta Keys
_htax_sticky_{term_id}- Sticky assignment flag (value1) for a specific term. One row per assignment, e.g._htax_sticky_5 = 1means this post is sticky for term ID 5. Taxonomy is omitted from the key because WordPress term IDs are globally unique. Present on posts of any indexed post type with acustomize_archives-enabled taxonomy.
User Meta Keys
_htax_dismissed_taxopress_merge_notice(bool, value1) - Set when an admin dismisses the “use HyperTaxonomy’s merge instead” notice on TaxoPress’s own Manage Terms → Merge Terms screen (HyperTaxonomy_TaxoPress_Integration::ajax_dismiss_notice(),includes/integrations/class-taxopress-integration.php). Per-user, not per-site — each admin dismisses independently. Cleared for every user (delete_metadata('user', 0, ..., '', true)) when TaxoPress is deactivated, so the notice reappears (until re-dismissed) if TaxoPress is reactivated later._htax_dismissed_tag_groups_notice(bool) - Set when an admin dismisses the Tag Groups migration notice._htax_prefs_callout_dismissed(bool) - Set when a user dismisses the post editor’s display-preferences callout.{table prefix}persisted_preferences(core) - Block-editor preferences. HT stores its post-editor display preferences in thehtax/post-editscope, and its sidebar panels appear incore.openPanels/core.inactivePanelsashtax-*/…IDs.
All of these are removed on uninstall (the dismissals for every user; the htax/post-edit scope and htax-* panel IDs from each user’s preferences).
Options
htax_settings- Plugin settings arrayenabled_taxonomies- Array of enabled taxonomy slugs for custom taxonomy supportindexed_post_types- Array of post type slugs to include in the search index and term archive features (default:['post', 'page'])taxonomy_features- Per-taxonomy feature toggles:search_enhancement- Use for search enhancementicons- Enable term iconsvisibility- Enable term visibility options (hide from public)term_relationships- Enable all relationship features: direct inclusion/container relationships, group-based relationships (Included Groups & Container Groups), and custom relationship types. Gates all archive expansion — when not set,htax_build_expanded_tax_query()returnsnullimmediately.group_assignment- Assign terms to Term Groups (required for Tag Groups import)redirects- Allow term redirectscustomize_archives- Enable per-term archive customization: sticky posts, posts per page, and sort order. When disabled, existing settings are preserved but not applied.
max_sticky_posts- Global maximum sticky posts per archive (default: 10)disable_sticky_styles- Whether to disable default sticky post stylesshow_contributing_terms- Show contributing terms in archive titleshidden_terms_visibility- Hidden term visibility setting (all/editors/none)expand_secondary_queries- Enable automatic expansion of secondary/third-party queries (default: off); when on,htax_expand_secondary_queryandhtax_adjust_term_countsbecome active- (Post editor display preferences are now per-user, stored in the Gutenberg
core/preferencesstore underhtax/post-edit— not inhtax_settings. The five toggles (Inclusion Badges, Container Badges, Redirect Indicators, Hidden Terms, Sticky Pushpins) are accessible via the block editor’s ··· menu.) advertise_ht_presence(bool, defaulttrue) — whether to add theX-HyperTaxonomyheader to REST responses; disabling opts the site out of peer discoveryrest_include_relationship_data(bool, defaulttrue) — setting label: Term Inclusion Relationships. Disabling removeshtax_includes,htax_synonyms, andhtax_relationshipsfrom WP REST term responses.rest_peer_sync_enabled(bool, defaulttrue) — setting label: Custom Term Relationships and Relationship Types. Disabling unregistersGET htax/v1/relationship-typesandGET htax/v1/term/{id}/relationships, and additionally removeshtax_relationshipsfrom WP REST term responses.htax_relationshipsis therefore disabled if eitherrest_include_relationship_dataorrest_peer_sync_enabledis off.- Additional settings for search, icons, caching, etc.
htax_version- Installed versionhtax_db_version- Database schema versionhtax_tag_groups_site_token- Random UUID (wp_generate_uuid4()), generated once on first use of the Tag Groups migration tool. Scopes the_htax_tag_groups_source_idmapping key to this site specifically — see that meta key’s entry above. Internal state, not relationship data; deliberately not part of HT’s own export/import format.htax_tag_groups_integration_mode-independent(default) orlocked.independentdisables the re-run-overwrite locks on mapped groups entirely (see the meta key’s entry above);lockedlocks them between manual migration runs.htax_tag_groups_live_sync-1or0(default). Continuous sync checkbox, effective only when the integration mode islocked. When on, Tag Groups’ ownterm_group_saved/tag_groups_term_group_deleted/tag_groups_groups_of_term_savedhooks are registered (includes/admin/tag-groups-live-sync.php) and apply changes as they happen. Cleared whenever the mode is saved asindependent. Set on Settings → HyperTaxonomy → Export / Import → Sync (htax_save_tag_groups_sync_settingsAJAX action); enabling it triggers one full resync first, via the Migrate subtab.htax_tag_groups_sync_mode-keep(default) ormirror. Removal handling for continuous per-event sync; each manual migration run chooses its own mode in the import modal, pre-selected to this value while continuous sync is on. Treats a term’s previously-Tag-Groups-mapped group memberships that Tag Groups no longer reports:mirrorremoves them to match Tag Groups’ current state exactly;keeponly ever adds, never removes. Applies only to the Tag-Groups-mapped subset of a term’s groups — HT-only group memberships (groups Tag Groups has no knowledge of) are always preserved regardless of mode. Set via the radio control under “Sync continuously” on the Sync subtab.htax_tag_groups_last_import-{ groups: int, terms: int, time: int }snapshot stored after each successful Tag Groups import run, used to show a concrete diff (“3 new groups and 12 new term assignments since your last import”) on the migration screen instead of a generic reminder.htax_cleaned_panel_prefs_N(bool) - One-time-run marker forHyperTaxonomy_Upgrader::clean_stale_panel_prefs(), which strips removedPluginDocumentSettingPanelIDs out of every user’s{table prefix}persisted_preferencesuser meta (core.openPanelsarray). Gutenberg fetches that array asynchronously after initial render and re-renders panels in its stored order, so a stale entry (left behind when a panel is removed from the plugin) silently pushes active panels to the wrong position — client-side reposition JS can’t out-race this second render. Whenever aPluginDocumentSettingPanelis removed, add its ID (format{plugin-name}/{panel-name}) to the$stalearray in that method and bumpNso the cleanup re-runs for everyone. The cleaned preferences are stamped with a fresh_modified: the editor also keeps a copy in the browser’s localStorage and uses whichever copy is newer, so an unstamped server copy could be overwritten by a stale browser copy.
Indexes on Native WordPress Tables
HyperTaxonomy adds the following indexes to native WordPress tables at activation (via add_indexes() in class-activator.php):
wp_posts (checked by index name before creation):
htax_post_date (post_date, post_status, post_type)- helps date-ordered WP_Query archive resultshtax_post_modified (post_modified, post_status, post_type)- helps modified-date-ordered WP_Query archive results
Note: FULLTEXT indexes for post content scoring (htax_ft_title, htax_ft_content, htax_ft_excerpt, htax_ft_combined) are on wp_htax_post_text so that FULLTEXT operates on processed plain text rather than raw HTML post content. Processing preserves semantically useful content (link URLs, image alt text, abbreviation expansions) while stripping markup that would otherwise pollute search results.
wp_terms (checked by index name before creation):
htax_ft_term_name (name)- FULLTEXT for term name scoring and required/excluded term lookups in search
wp_term_taxonomy (checked by index name before creation):
htax_ft_term_desc (description)- FULLTEXT for term description scoring and required/excluded term lookups in search
wp_termmeta (error-suppressed; no-ops if already exist):
htax_icons (meta_key(50), term_id)- icon attachment ID lookups by meta keyhtax_term_meta (term_id, meta_key(50))- acceleratesget_term_meta()callsCustom Database Tables
{$wpdb->prefix}htax_term_relationships - Unified relationship storage with normalized architecture
- Columns:
source_term_id(bigint) - The term that initiates the relationshiptarget_term_id(bigint) - The term that receives the relationshiprelationship_type(varchar 100) - Built-in types: ‘includes’, ‘member_of’, ‘redirect’, ‘excludes’; user-defined type slugs stored as-issource_taxonomy(varchar 32) - Source term’s taxonomytarget_taxonomy(varchar 32) - Target term’s taxonomy
- Indexes:
- PRIMARY KEY (source_term_id, target_term_id, relationship_type)
- KEY
target_lookup(target_term_id, relationship_type) - KEY
source_taxonomy(source_taxonomy) - KEY
target_taxonomy(target_taxonomy) - KEY
relationship_type(relationship_type)
Normalized Architecture - Intent Storage: Relationships are stored as INTENT and expanded dynamically at query time via SQL JOINs.
Relationship Types:
- Direct Inclusion (A includes B):
- Storage:
source=A, target=B, type='includes' - Effect: Posts from B appear in A’s archive
- Storage:
- Group Membership (A is member of GroupX):
- Storage:
source=A, target=GroupX, type='member_of' - Effect: A can be queried as part of GroupX
- Storage:
- Included Groups (A includes GroupX):
- Storage:
source=A, target=GroupX, type='includes', target_taxonomy='group' - Effect: All members of GroupX appear in A’s archive (expanded via JOIN)
- Storage:
- Container Groups (A included into GroupX members):
- Storage:
source=GroupX, target=A, type='includes', source_taxonomy='group' - Effect: A appears in all GroupX members’ archives (REVERSE includes)
- Storage:
- Redirects (A redirects to B):
- Storage:
source=A, target=B, type='redirect' - Effect: 301 redirect from A’s archive to B’s archive
- Storage:
Query Pattern Examples:
-- Get terms included by Term A (direct + group expansion)
SELECT DISTINCT included_term_id, included_taxonomy
FROM (
-- Direct inclusions
SELECT target_term_id AS included_term_id, target_taxonomy AS included_taxonomy
FROM wp_htax_term_relationships
WHERE source_term_id = A
AND relationship_type = 'includes'
AND target_taxonomy != 'group'
UNION
-- Included group members
SELECT r2.source_term_id AS included_term_id, r2.source_taxonomy AS included_taxonomy
FROM wp_htax_term_relationships AS r1
INNER JOIN wp_htax_term_relationships AS r2
ON r1.target_term_id = r2.target_term_id
AND r1.target_taxonomy = 'group'
AND r2.relationship_type = 'member_of'
AND r2.target_taxonomy = 'group'
WHERE r1.source_term_id = A
AND r1.relationship_type = 'includes'
AND r1.target_taxonomy = 'group'
) AS all_included
WHERE included_term_id NOT IN (A, ancestors...)
htax_get_included_terms() and htax_get_container_terms() are mirrors of each other. Both build the full result set first, then apply exclusions last.
htax_get_included_terms(A) — what terms does A pull into its archive:
Inclusions:
- Members of included groups, with descendant subgroup expansion (
htax_get_terms_in_groups()) - Directly included individual terms
- Terms with container groups matching A’s member groups (expanded to subgroups via
htax_get_group_hierarchy()), found viahtax_get_terms_with_container_groups()
Exclusions (applied last, cannot be overridden):
- Directly excluded individual terms
- Self, ancestors, and descendants for hierarchical taxonomies. Ancestors would create semantic loops; descendants are already in the archive via WP’s native
include_childrenand do not represent cross-branch relationships. Both are excluded to keep the HT inclusion set strictly cross-branch.
htax_get_container_terms(B) — what terms include B in their archive:
Inclusions:
- Direct containers (terms with a direct
includesrelationship to B) - Group inclusion containers (B is member of GroupY; find terms that include GroupY)
- Container group members: B’s container groups expanded upward to ancestors via
htax_get_group_ancestors(); find all terms that are members of those groups
Exclusions (applied last):
- Self, descendants, and ancestors for hierarchical taxonomies. Descendants cannot be containers (they cannot include their own ancestors). Ancestors are excluded for symmetry: a term’s posts are already visible in ancestor archives via
include_children, so an ancestor appearing as a container would be redundant rather than a cross-branch relation. - Containers that explicitly exclude B
The two functions are consistent: if A includes B (per htax_get_included_terms), B lists A as a container (per htax_get_container_terms), and vice versa. The group hierarchy expansions are symmetric — member group expansion goes downward in htax_get_included_terms, container group expansion goes upward in htax_get_container_terms. Both functions restrict their results to strictly cross-branch relationships, with no overlap with the WP-native hierarchy.
Note: Group exclusion is not supported in the term relationship context (term edit screen). The display shortcodes (
[ht_term_list], etc.) have their ownexclude_groupsparameter that uses a separate processing path (htax_expand_shortcode_terms()).
Why PHP processing is required: Group hierarchy expansion uses WordPress’s native get_terms() / get_term_children() and cannot be done efficiently in a single SQL query without recursive CTEs (requires MySQL 8.0+).
Benefits of Normalized Architecture:
- Simplified schema (6 columns instead of 9)
- No sync functions needed - relationships computed at query time
- No data duplication or staleness issues
- Better SQL pagination support (filtering in SQL, not PHP)
- Single source of truth for all relationships
{$wpdb->prefix}htax_search_terms - Post-term association index for search and filtering
- Columns: post_id, term_id, source_term_id, taxonomy, relationship_type, model, is_hidden, strength
PRIMARY KEY (post_id, term_id, source_term_id, relationship_type, model)- No FULLTEXT indexes. B-tree indexes only:
idx_taxonomy(taxonomy),idx_term_lookup(term_id),idx_post_lookup(post_id),idx_relationship(relationship_type, taxonomy),idx_hidden(is_hidden, relationship_type),idx_source_term(source_term_id). InnoDB appends the primary key columns to every secondary index, somodelis available for filtering in each of them without a row lookup. model VARCHAR(64) DEFAULT 'htax_tf'— the scoring model that produced the row’sstrength. Strengths from several models are stored side by side; direct and indirect rows are both stored per model.htax_tf(constantHTAX_DEFAULT_STRENGTH_MODEL) is the built-in TF heuristic, maintained by HT. Other plugins register models and submit scores through the strength model API (includes/core/strength-models.php, see below).- Invariant:
htax_tfrows always form the complete structural set (every post/term/source/relationship-type combination). Any other model’s rows are a subset of those keys that differ only instrength; an external model only has rows for the directly assigned terms it has scored and the rows derived from them. - Strength reads filter on one model — any query that reads
strengthor aggregates it (noisy-OR,SUM,COUNT(*)) appendshtax_strength_model_sql( $alias, $purpose ), otherwise each row would count once per model. Purpose'search': PowerSearch term scoring (both the combined scan andbuild_term_search_subqueries(), via the shared$hst_hiddensuffix), search widget field scores, thehtax_term_strengthsREST field, and the WP-SKOS document endpoint. Purpose'related':query_posts_by_term_ids()(post list / related posts),htax_get_local_post_scores(),htax_get_post_term_strengths(). Both purposes currently read the single model selected in Settings → Search (htax_settings['strength_model']); the purpose is passed so they can be selected separately later. - Unscored pairs are excluded, not substituted: under an external model, a post–term pair it hasn’t scored contributes nothing to strength-ranked reads — there is no per-pair fallback to
htax_tf, so scores from different models are never mixed in one ranking. - Structural reads need no filter — queries that only test which posts reach a term (
IN/EXISTS/DISTINCT,COUNT(DISTINCT post_id)) return the same set whether or not other models’ rows exist, by the invariant above. - Deletes and
is_hiddenupdates apply to every model — they reflect structural changes (term unassigned or deleted, include removed, visibility changed) that hold regardless of which model scored the row. - Reindexing keeps external scores:
htax_index_post_terms()reads every external model’s direct strengths for the post before deleting its rows, writes thehtax_tfrows, then rewrites each external model’s direct strengths for terms that are still directly assigned and re-derives their indirect rows against the current structure. Afterwards it fireshtax_term_strength_missing( $post_id, $term_ids, $model )for each registered external model that lacks a strength for some directly assigned term. Scores of unregistered models are kept the same way but never read. - Full rebuilds keep external scores: both rebuild paths (
ajax_rebuild_indexes()and the WP Cronrebuild_indexes()) callclear_search_terms_for_rebuild()instead of truncating: it deletes allhtax_tfrows and the external rows of posts that won’t be reindexed (no longer published or no longer an indexed post type). Each post’s external rows are then re-derived as it is reindexed. - Indirect strength derivation is shared by every model:
htax_derive_indirect_strength( $source_strength, $taxonomy, $relationship_type )= source strength × the taxonomy’s explicit weight for that relationship type. Relevance weights are global, not per model.
- Invariant:
source_term_idis the directly-assigned term that led to this index entry — used for efficient cleanup, incremental updates, and visibility propagation. Term text is not stored here; it is read at query time fromwp_terms.nameandwp_term_taxonomy.descriptionstrength FLOAT DEFAULT 1.0— per-row relevance contribution score, baked in at index build time:- Direct rows (
relationship_type = 'direct'): TF-based score reflecting how central the term is to the post’s content. Formula:floor(0.2) + body_tf×0.4 + excerpt_hit×0.2 + title_hit×0.2, capped at 1.0. Body TF is normalized (occurrences / word_count × 100, capped at 1.0). Exact phrase match, case-insensitive. - Indirect rows (
hierarchy,container,redirect_target):source_tf × explicit_relationship_weight. The source term’s raw TF score propagated at the configured explicit weight (e.g. hierarchy=0.7, container=0.75, redirect=0.5–0.6). Explicit weights are baked in at index time, not applied at query time. - Strengths from other sources are supplied as a separate model via
htax_set_term_strengths(), not by overridinghtax_tfrows. - Filter hook
htax_strength_field_weights: override['floor', 'body', 'excerpt', 'title']coefficients (default 0.20/0.40/0.20/0.20; must sum ≤ 1.0). - Keeping strengths current: strength is only computed by the full per-post path (
htax_build_post_search_index()→htax_index_post_terms()). Everything else that changes a stored strength queues the affected posts for that path viaqueue_post_reindex(). The queue is deduplicated in memory per request, persisted at shutdown to thehtax_pending_reindexoption, and drained by the WP Cron eventhtax_process_reindex_batch(process_async_reindex_batch()) in batches ofREINDEX_BATCH_SIZE(100) posts per run. Each run writes the remainder back before processing (posts queued meanwhile merge into it) and schedules the next run while posts remain, so a large queue never has to fit in one request’s time or memory limits. Sources of queued posts:- Incremental structural writes — term assigned to a published post (
add_term_relationships_to_index()), parent change (on_taxonomy_hierarchy_changed()), redirect removed from an ancestor (on_hierarchy_parent_restored()), containers changed (update_container_index()), includes changed (on_includes_changed()), redirect target changed (htax_redirect_changed). These insert rows immediately (viahtax_upsert_search_rows(), which writes the column-default strength 1.0) so the structure is correct at once, then queue the posts so the default is replaced with a computed strength. - Content edits —
on_post_saved()(save_post, priority 20, afterhtax_sync_post_text_rowrefreshes the post text) queues the published post, because direct strengths are derived from its title, excerpt, and body. - Propagation weight changes —
on_relevance_weights_changed()(update_option_htax_settings) compares the effectivehierarchy/container/redirectexplicit weights per enabled taxonomy between the old and new settings (htax_get_relevance_weights( $taxonomy, $options )) and queues every published post with terms in a taxonomy whose weights changed. Text weights used by PowerSearch remain dynamic and apply immediately.
- Incremental structural writes — term assigned to a published post (
- Direct rows (
- Relevance aggregation — noisy-OR per
(post, term), thenSUMacross terms: every term-strength scorer (post list / related postsquery_posts_by_term_ids,htax_get_local_post_scores, the search widget field score, and the WP-SKOS document endpoint) computes a post’s relevance the same way. All rows sharing a(post_id, term_id)— regardless ofsource_term_idorrelationship_type— combine via noisy-OR:combined = 1 − Π(1 − strength), computed in SQL as1 - EXP(SUM(LN(GREATEST(1 - strength, 0.000001))))(MySQL has noPRODUCT()aggregate). The outerSUMacross distinctterm_ids preserves breadth — a post connected through more distinct query terms scores higher.- Why noisy-OR, not
SUM: a term reached by only one path (the common case) is unaffected — noisy-OR of a single value returns that value unchanged, so single-path results are unchanged from the oldSUMbehavior. A term reached by multiple paths (e.g. a container of several directly-tagged terms) still scores higher with more paths (monotonically increasing), but the combined value is mathematically guaranteed to stay strictly below 1.0 — the ceiling a single direct row can reach — no matter how many indirect paths contribute. A plainSUMhad no such ceiling: two container rows at the default 0.75 weight could sum past 1.0, letting an indirect term outscore the strongest possible direct term. Noisy-OR gives the “more paths ⇒ higher score” behavior without that crossover. - This also subsumes the old
MAX(strength)dedup step (for the rare case where one source term reaches a target via more than onerelationship_type): that row is now just one more input to the same noisy-OR combination, with no separate collapsing step needed — the combinator is agnostic torelationship_typethroughout. - A term that is both directly assigned to the post and reached indirectly is not a special case. Its
directrow (source_term_id = term_id) shares the same(post_id, term_id)group as anycontainer/hierarchy/redirect_targetrows for that term, so all of them combine via the identical noisy-OR formula — the direct row is just one more input, not a separately-tracked ceiling. Live example: post 8919, term 78 has adirectrow (strength 0.2) plus four indirect rows (threecontainerat 0.15, oneredirect_targetat 0.12); combined = 0.58 — higher than the direct row alone (0.2) or any single indirect row alone (0.15), still strictly below 1.0. - PowerSearch’s term-name/description text scoring reads the same per-row
strengthvalue but never re-aggregates multiple source paths for one term, so it is unaffected by this change.
- Why noisy-OR, not
- Always indexes all relationship types (
direct,container,hierarchy,redirect_target) regardless of which features are currently enabled for a taxonomy. Enabling or disabling inclusions/redirects does not require reindexing. - Taxonomy-gated writes:
htax_build_post_search_index()skips any taxonomy that failshtax_is_taxonomy_enabled()— including unregistered ones — so index rows for a suspended taxonomy are never added by incremental reindexes. When a taxonomy is disabled via Settings → Features,on_taxonomy_enabled_state_changeddeletes existing rows for that taxonomy immediately. When a taxonomy is newly enabled, posts carrying terms from it are discovered via awp_term_relationshipsJOIN and queued for reindex. - Feature gates applied at query time: All queries against this table filter
relationship_typeto only the types enabled for the queried taxonomy viahtax_taxonomy_has_feature(). Applies to: PowerSearch subqueries, search widget scoring subqueries, post list shortcode queries, and filter SQL subqueries. Term archives apply equivalent feature gates but read fromwp_htax_term_relationshipsdirectly rather than this index.- Multi-taxonomy gating (post list / related posts):
query_posts_by_term_ids()can span several taxonomies at once ([ht_related_posts]withtaxonomy=""or a CSV pools the post’s terms across taxonomies). Therelationship_typegate is then built per taxonomy and OR’d together, keyed on thetaxonomycolumn each row carries —(st.taxonomy = 'A' AND st.relationship_type IN (…A's enabled types…)) OR (st.taxonomy = 'B' AND …). Each term’s strength is summed across taxonomies, so sharing terms in more taxonomies raises a post’s score. A single-taxonomy query yields exactly one branch (a plaintaxonomy = … AND relationship_type IN (…)). Whentaxonomyis empty the taxonomy set is derived from the distincttaxonomyvalues of the query term IDs (wp_term_taxonomy), which also keeps AJAX pagination correct without threading a taxonomy list.
- Multi-taxonomy gating (post list / related posts):
- Supports field-specific operators (title:, content:, excerpt:, tags:, categories:, terms:)
- Post text scoring uses
wp_htax_post_text(HTML-stripped title/body/excerpt) via NATURAL LANGUAGE MODE FULLTEXT; taxonomy term name/description scoring useswp_terms/wp_term_taxonomyvia BOOLEAN MODE FULLTEXT (htax_ft_term_name,htax_ft_term_desc) — see “Indexes on Native WordPress Tables” and “Custom Database Tables” sections
{$wpdb->prefix}htax_post_text - Processed post text for FULLTEXT search and sort metadata
Why a separate table? MySQL’s FULLTEXT index operates on the raw stored text. wp_posts.post_content is raw HTML — every href URL, CSS class, and attribute value (target="_blank", class="wp-block-image") is tokenised as a searchable word. A search for +target would match every post that contains a link with target="_blank", regardless of content. Stripping HTML before indexing is not possible inside a MySQL FULLTEXT index (no function-based FULLTEXT support). The only option is a pre-stripped copy.
Storing the sort columns (post_date_gmt, post_modified_gmt, post_author, comment_count, last_comment_gmt) in the same table eliminates the JOIN to wp_posts on every query. The table stores published, draft, pending, future, and private posts. All public-facing queries filter pt.post_status = 'publish'; the draft rows exist so the post editor can query content-based tag suggestions against the same table.
- Columns:
post_id(PK),post_status,post_type,post_date_gmt,post_modified_gmt,post_author,comment_count,last_comment_gmt(NULL = no approved comments),title(plain text),body(post_contentprocessed byhtax_extract_post_body()),excerpt - Populated on rebuild via PHP loop across published + draft + pending + future + private posts; kept in sync by
htax_sync_post_text_row(save_posthook, useshtax_extract_post_body()) andhtax_sync_post_text_comment(wp_update_comment_counthook, updatescomment_count+last_comment_gmtin one write) - FULLTEXT indexes:
htax_ft_pt_title (title),htax_ft_pt_body (body),htax_ft_pt_excerpt (excerpt),htax_ft_pt_all (title, body, excerpt) - B-tree indexes:
htax_pt_status_type,htax_pt_date,htax_pt_modified,htax_pt_comments,htax_pt_last_comment
PowerSearch Architecture:
The PowerSearch system uses MySQL FULLTEXT indexes for performance with support for field-specific operators, minimum score thresholds, search scope restriction, and dynamic ordering.
Query Parsing (htax_parse_search_query in search.php, called from perform_indexed_text_search):
- Regex pattern:
/([+\-]?)\b(title|content|body|excerpt|tags?|categories?|terms?|...):("([^"]+)"|(\S+))/i - Singular aliases accepted:
tag:=tags:,category:=categories:,term:=terms:,body:=content: - Extracts field-specific terms with modifiers into arrays: title, body, excerpt, tags, categories, terms, general
- Each term stored as:
array('term' => 'word', 'required' => bool, 'excluded' => bool) - Modifiers:
+(required, also adds HAVING score > 0 for that field),-(excluded), no prefix (optional) - Supports quoted phrases:
title:"exact phrase"or+title:"exact phrase" - General terms (no operator) match in all allowed fields
- Helper functions in search.php:
htax_build_boolean_query_string()converts to MySQL BOOLEAN MODE syntax (taxonomy scoring, WHERE enforcement);htax_build_natural_language_query_string()produces operator-stripped bare words for NL MODE post text scoring
Search Scope Restriction (search_in parameter):
- Default:
'all'(searches title, content, excerpt, all enabled taxonomies) - Valid values:
all,title,content,excerpt,tags,categories,terms, or specific taxonomy slugs - Filters parsed query to remove restricted field operators (silently ignored)
- Admin can restrict search scope for security or UX reasons
Match Modes:
match="any"(default): OR logic - terms optional unless prefixed with+match="all": AND logic - auto-adds+to all non-excluded terms- Exclusions (
-) work in both modes
Dynamic Ordering (orderby parameter):
- Format:
field:direction,field:direction,... - Supported fields:
relevance,date,modified,title,author,comment_count,last_comment,last_activity,ID - Example:
relevance:desc,date:desc,title:asc
Query Execution (perform_indexed_text_search in class-power-search.php):
- Check 60-second transient cache; return cached result on hit. The cache key (
htax_ft_…) ismd5( $query . serialize( $config ) . '|' . (int) htax_can_see_hidden_terms() ). Baking the hidden-terms visibility flag into the key prevents cross-user contamination — a result set computed for a user who can see hidden terms is never served to one who cannot, and vice versa. - Get match mode from config; if not set, read the site-wide default from
htax_settings[default_match_mode]viahtax_get_default_match_mode()(configurable in Settings → Search → Default Match Mode; built-in default: ‘any’) - Parse search_in parameter and build allowed fields map
- Parse query with htax_parse_search_query() (supports field operators including singular aliases)
- Filter parsed query based on search_in restrictions
- If match=’all’, mark all non-excluded terms as required
- Read relevance multipliers from settings; auto-populate per-taxonomy defaults for newly enabled taxonomies
- Build NATURAL LANGUAGE MODE MATCH() clauses for
wp_htax_post_textfields (title, body, excerpt) usinghtax_build_natural_language_query_string()— operators stripped, excluded terms omitted. - Add WHERE enforcement for required and phrase terms (runs before both combined and field-specific taxonomy paths):
- Required general terms (
+wordor multi-word quoted phrases): pre-fetch term IDs matching the term via BOOLEAN MODE FULLTEXT onwp_terms.nameandwp_term_taxonomy.description; addWHERE (MATCH(pt.title, pt.body, pt.excerpt) AGAINST(term IN BOOLEAN MODE) > 0 OR pt.post_id IN (taxonomy lookup)). Quoted phrases always get this enforcement regardless of+prefix, because NL MODE cannot represent phrase constraints. - Required field-specific text terms (
+title:word,+content:word,+excerpt:word):WHERE MATCH(pt.field) AGAINST(term IN BOOLEAN MODE) > 0.
- Required general terms (
- Build taxonomy LEFT JOINs. Each subquery may contain up to three UNION branches per relationship type:
- Branch A (exact match):
hst.strength AS scorefor terms whoseLOWER(name) = LOWER(search_string)— no FULLTEXT scan, IDs pre-fetched viahtax_get_exact_match_term_ids(). Strength replaces the old flatexplicitweight; posts where the matched term is more central to the content rank higher. These terms are excluded from Branch B to prevent double-scoring. Branch A fires: always for field-specific operators (tags:target); only for quoted general terms ("target") to avoid mistaking post-content searches for term-label lookups. - Branch B (name):
MATCH(t.name) AGAINST(query IN BOOLEAN MODE) * name_weight * hst.strength— FT scan onwp_terms.nameviahtax_ft_term_name; JOIN tohtax_search_termsonsource_term_id.name_weight(text weight) remains dynamic;hst.strengthadds per-post centrality signal. Exact-match IDs excluded. - Branch C (description):
MATCH(tt.description) AGAINST(query IN BOOLEAN MODE) * description_weight * hst.strength— only whenuse_description=truefor that taxonomy. MAX(branch_score)per(post_id, source_term_id)— across branches, across the several index rows through which a post can reach one matched term (several of its terms included in it, or several relationship types), in every variant of both paths — so a term counts once per post, at its strongest, never more than a direct match on it.SUMacross terms yields the field score. In the field-specific path each relationship-type subquery returns(post_id, source_term_id, score)rows andsum_term_scores_sql()takes the MAX across relationship types before summing.- Combined scan (general queries with no field operators): single LEFT JOIN covering all taxonomies and rel-types, with separate CASE expressions for name/description text weights (the explicit relationship weight is baked into
hst.strength, not applied in the CASE). If all indexed taxonomies haveuse_description=false, the description branch is omitted entirely. All taxonomy scores collapse intoterms_score. - Field-specific path (explicit
tags:/categories:/terms:or custom-taxonomy operators): separate LEFT JOIN per field (tags, categories, terms, custom taxonomies). - Required field-taxonomy term enforcement (
+tags:word,+categories:word, etc.): FULLTEXT queries for taxonomy fields use OR mode so individual term-name rows can match independently. Each required term is enforced by a pre-fetchedWHERE pt.post_id IN (SELECT post_id FROM htax_search_terms WHERE source_term_id IN (...))clause. This correctly handles multiple required terms in the same field (+tags:A +tags:B→ posts carrying both tags). - Excluded term enforcement (
-wordand-field:word): matching term IDs are pre-fetched and added asWHERE pt.post_id NOT IN (SELECT post_id ... WHERE source_term_id IN (...)). For general unqualified excluded terms (-word) — in both the combined and the field-specific path — a second NOT IN againstMATCH(pt.title, pt.body, pt.excerpt)ensures posts where the excluded word appears in any text field are also removed, even when another field still scores positively. Field-specific excluded terms (-tags:word,-terms:word) only apply the taxonomy layer — post text is not checked.
- Branch A (exact match):
- Calculate overall relevance as sum of all field scores; apply HAVING clause (min score thresholds)
- Apply dynamic ORDER BY; execute with LIMIT/OFFSET
- For paginated requests: run count query with same structure but no LIMIT
- Store result in 60-second transient; return
Cumulative Relevance Scoring:
Post text fields (title, content, excerpt) use NATURAL LANGUAGE MODE so that IDF (inverse document frequency) and document-length normalisation prevent a single high-frequency term from dominating results. A post with “gift” ×600 and an 86 KB body does not outrank a shorter post that covers all query terms meaningfully. Taxonomy term fields continue to use BOOLEAN MODE (small corpus, phrase/operator support required). Required and excluded term enforcement for all fields is handled via explicit WHERE clauses — not by relying on the score being zero.
Field Score Calculation:
- title_score:
MATCH(pt.title) AGAINST(nl_query IN NATURAL LANGUAGE MODE) * title_weight - content_score:
MATCH(pt.body) AGAINST(nl_query IN NATURAL LANGUAGE MODE) * content_weight - excerpt_score:
MATCH(pt.excerpt) AGAINST(nl_query IN NATURAL LANGUAGE MODE) * excerpt_weight- All three fields come from
wp_htax_post_text(pt), processed byhtax_extract_post_body()— link URLs are appended as[URL], img alt text and abbr expansions are preserved, then all remaining HTML is stripped. All public queries filterpt.post_status = 'publish'. nl_query= query terms with+/-operators stripped and excluded terms removed; produced byhtax_build_natural_language_query_string()(in search.php). NL MODE does not support phrase syntax, so multi-word phrases contribute their individual words to the score; phrase presence is enforced separately via a WHERE guard (see below).
- All three fields come from
- tags_score / categories_score / terms_score / custom taxonomy scores: up to three branches per relationship type.
hst.strengthencodes the per-row relevance contribution (TF-based for direct rows;source_tf × explicit_weightfor indirect rows — seewp_htax_search_termsschema above):- Branch A (exact, conditional):
hst.strength AS scorewhenLOWER(t.name) = LOWER(search_string). Strength replaces the flatexplicitweight — posts where the matched term is central to the content rank higher. Fires for all non-excluded field-operator terms, and for quoted general terms (e.g."target"). Unquoted general terms do not trigger exact matching. - Branch B (name, always active):
MATCH(t.name) AGAINST(query IN BOOLEAN MODE) * name_weight * hst.strength. Text weight (name_weight) stays dynamic; strength adds centrality. Exact-match term IDs excluded when Branch A is active. - Branch C (description, when
use_description=true):MATCH(tt.description) AGAINST(query IN BOOLEAN MODE) * description_weight * hst.strength. Description weight defaults to ~70% of name weight. MAX(branch_score)per(post_id, source_term_id)— a term scores once per post, via its strongest branch, index row and relationship type;SUMacross terms per post yields the field score.- Relationship types: direct, hierarchy (hierarchical taxonomies), container, redirect_target — all gated by feature flags at query time
- Combined general scan uses a single LEFT JOIN with CASE expressions for name/description text weights (explicit weight is baked into
hst.strength, not in the CASE); all taxonomy scores collapse intoterms_score. Field-specific path produces separate tags_score, categories_score, terms_score columns.
- Branch A (exact, conditional):
Overall Relevance:
- relevance = title_score + content_score + excerpt_score + tags_score + categories_score + terms_score
- Multiple matching terms in the same field are cumulative (e.g., matching 3 terms in title gives higher title_score than matching 1)
- General terms (no field operator) contribute to the title, content and excerpt scores, and score each taxonomy once: in that taxonomy’s own field when the query has one, otherwise in
terms_score. Combined scan: custom-taxonomy fields in the query (colors:x) are filtered out of the scan ($general_field_taxonomies). Field-specific path: tags and categories always have their own field when there are general words, soterms_scorescores them — and custom-field taxonomies — withterms:words only. - Score range: 0-1+ (can exceed 1 when multiple terms match with high weights)
Minimum Score Thresholds:
- min_score: Overall minimum relevance threshold (default 0.01)
- min_title_score: Minimum weighted score for title matches (default 0)
- min_content_score: Minimum weighted score for content matches (default 0)
- min_excerpt_score: Minimum weighted score for excerpt matches (default 0)
- min_tags_score: Minimum cumulative score across all tag relationships (default 0)
- min_categories_score: Minimum cumulative score across all category relationships (default 0)
- min_terms_score: Minimum cumulative score across ALL taxonomy relationships (default 0) — compared in HAVING against
tags_score + categories_score + terms_score + <custom>_score, which don’t overlap (see above). Applied in the combined path and whenever the field-specific path buildsterms_score. - Valid range: 0.0 to 1.0+ (same scale as relevance multiplier settings)
- Each threshold is independent - all non-zero thresholds must be met (AND logic in HAVING clause)
- Applied via HAVING clause after weighted score calculation
Query Examples:
"+photography wordpress tutorial" (match="any")
→ "photography" REQUIRED, "wordpress" and "tutorial" optional (boost score)
"title:wordpress photography" (match="any")
→ Both optional, posts with both rank higher
"+title:\"getting started\" react" (match="any")
→ "getting started" phrase REQUIRED in title, "react" optional anywhere
"+tags:javascript content:tutorial" (match="any") [tag: also accepted]
→ "javascript" REQUIRED in tags (HAVING tags_score > 0 enforced), "tutorial" optional in post content
"photography -stock" (match="any")
→ Posts with "photography" but NOT "stock"
"wordpress react tutorial" (match="all")
→ All three terms REQUIRED (auto-adds + to each)
"+tags:amazon promo" (match="any")
→ "amazon" tag REQUIRED (no amazon tag → excluded even if text mentions amazon), "promo" optional in any field
Field operator aliases (all case-insensitive):
tag:=tags:,category:=categories:,term:=terms:body:=content:
Index Management:
- htax_build_post_search_index($post_id): Indexes single post, called on publish/update
- htax_index_post_terms($post_id, $relationships): Stores post-term association rows; handles any enabled taxonomy generically — hierarchy ancestors for any
is_taxonomy_hierarchical()taxonomy, containers for anyterm_relationships-enabled taxonomy - Bulk reindex via admin AJAX: truncate table, reindex all published posts (all enabled taxonomies processed uniformly)
- Delete on post deletion: removes from wp_htax_search_terms
htax_expand_term_hierarchy($term_ids, $taxonomy): Helper to expand term IDs to include all descendants for any taxonomy (replaces former category-only version)htax_upsert_search_rows($table_name, $placeholders, $values, $error_context): Shared helper for the standard 6-column upsert (post_id, term_id, source_term_id, taxonomy, relationship_type, is_hidden) withON DUPLICATE KEY UPDATE is_hidden = VALUES(is_hidden). Used by the hierarchy, container, includes, and redirect index writers. New rows get the column-defaultstrengthandmodel, so every caller also queues the affected posts for reindex to compute their strengths. The variant that includesstrength(full reindex path) does not use this helper.- Strength models (
includes/core/strength-models.php):- Registry (in memory, per request):
htax_register_strength_model( $key, $args ),htax_unregister_strength_model( $key ),htax_get_strength_models(),htax_validate_strength_model_key( $key ).htax_tfand every otherhtax_-prefixed key are reserved: registering or unregistering them returns false. - Selection:
htax_get_strength_model( $purpose )returnshtax_settings['strength_model']when that model is registered, otherwisehtax_tf.htax_strength_model_sql( $alias, $purpose )builds the ` AND {alias}.model = ‘…’condition. The Settings sanitizer only accepts a new selection with full coverage; code writinghtax_settings` directly bypasses that check. - Writes:
htax_set_term_strengths( $model, $post_id, $strengths )(validates every score and term first, then writes direct + derived rows in oneINSERT … ON DUPLICATE KEY UPDATE; returnstrue|WP_Error),htax_remove_term_strengths( $model, $post_id = 0, $term_ids = [] )(returns rows removed orWP_Error). Both refusehtax_tfand firehtax_term_strengths_updated. - Coverage:
htax_get_strength_model_coverage( $model )(per taxonomy:htax_tfdirect pairs vs. the model’s direct pairs, via a primary-key self-join),htax_strength_model_has_full_coverage( $model ). - Internal helpers shared with
htax_index_post_terms():htax_derive_indirect_strength(),htax_collect_strength_rows(),htax_write_strength_rows(),htax_get_external_direct_strengths(). htax_get_stored_strength_models(): distinct external model keys with stored rows, registered or not.- Admin: Settings → Search → Strength Model / Index Coverage / Index Rebuild render one panel per registered model plus one per inactive model (stored rows, not registered), and the dropdown shows the model in effect (
render_search_tab()). Inactive models are listed only so their scores can be cleared: the sanitizer never saves an unregistered key. The form sendsstrength_model_changed(set to 1 by JS on change); when it is 0 the saved selection is left alone, so saving the tab while a selected provider is inactive keeps its selection. A note under the dropdown appears when the saved model differs from the one in effect. - Clear Scores calls
htax_ajax_clear_strength_model()(admin/ajax.php, noncehtax-clear-strength-model), which also switches the selection back tohtax_tfif the cleared model was selected.
- Registry (in memory, per request):
Transients (with safe key generation)
Per-request copies: some getters also keep a per-request copy of a transient’s value (htax_request_cache() groups redirect_target for htax_redir_*, redirect_term_ids for htax_redirect_terms, synonyms for htax_syno_*). Whenever one of these transients is deleted — per term in htax_delete_term_transients() / htax_delete_term_transients_batch() / htax_clear_redirect_target_cache(), or in bulk in htax_clear_relationship_caches() / htax_clear_all_caches() — the matching per-request copy is cleared too, so later code in the same request (notably the search index handlers on htax_redirect_changed / htax_redirect_removed) reads the new state.
External object caches: all HyperTaxonomy code reads and writes transients through htax_get_transient() / htax_set_transient() / htax_delete_transient(), never the core functions directly. Bulk invalidation is a DELETE … LIKE on wp_options, which cannot reach transients held by a persistent external object cache (Redis, Memcached) — and many of these caches have no TTL. So when wp_using_ext_object_cache() is true, htax_transient_key() appends a generation token to every htax_* key: one global token plus one per family (htax_transient_family(): rel for the relationship caches, contrib, pl, total, ac, sticky, misc). Every bulk clear ends with htax_bump_cache_family(), which starts a new generation for the family (all from htax_clear_all_caches()), making every existing key in it unreachable; the cache’s own eviction reclaims them. Pattern clears that are per-term in the database (htax_invalidate_contrib_cache(), htax_invalidate_post_list_cache(), htax_delete_count_cache_for_ids()) bump the whole family, since object-cache keys can’t be matched by pattern. Tokens are random, so an evicted token is replaced rather than reset and never revalidates old entries. Without an external object cache keys are unchanged, and htax_bump_cache_family() instead drops WordPress’s runtime options cache group so code later in the same request doesn’t read the deleted values.
Note: Relationship data lives in database tables; the following transients are used for performance optimization:
htax_archive_terms_{taxonomy}_{term_id}- Archive term data (includes children, included terms, group expansions)htax_contrib_{term_id}_p{page}[H]- Contributing terms by page (suffixHwhen viewer can see hidden terms). Stores an array of term name strings used to build the archive title. Invalidated viahtax_invalidate_contrib_cache($term_id), which is hooked tohtax_term_archive_changed— the unified signal that covers relationship changes, first-redirect, redirect removal, term hierarchy changes, post assignments, and sticky post changes. Also invalidated directly when a term’sposts_per_pagesetting changes (page boundaries shift)htax_crel_{slug}_{term_id}- Outgoing custom relationship rows for a term and type, with group targets already expanded to member terms (non-core types only). No TTL. Invalidated by:htax_add_term_relationship/htax_remove_term_relationship(source’s cache);htax_save_term_relationshipsedit form path for changed sources;htax_delete_term_transientsfor the term itself;htax_invalidate_term_caches_on_deletefor source terms that had a row pointing to the deleted term; type-prefix clear on type save/delete;htax_invalidate_group_relationship_cacheswhen group membership or hierarchy changes.htax_crel_rev_{slug}_{term_id}- Incoming custom relationship rows for a term and type, with group sources already expanded to member terms (non-core types only). No TTL. Invalidated by:htax_add_term_relationship/htax_remove_term_relationship(target’s cache);htax_save_term_relationshipsedit form path for changed targets;htax_delete_term_transientsfor the term itself;htax_invalidate_term_caches_on_deletefor target terms that had a row from the deleted term; type-prefix clear on type save/delete;htax_invalidate_group_relationship_cacheswhen group membership or hierarchy changes.htax_direct_rel_{slug}_{term_id}- Raw T→U rows for a relationship type, non-group targets only. Works for any slug including core'includes'. No TTL. Invalidated byhtax_delete_custom_rel_caches_for_term(called fromhtax_delete_term_transients). Used by the term edit screen and the PRE-CLEAR warm-cache path inhtax_invalidate_term_caches_on_delete.htax_reverse_rel_{slug}_{term_id}- Raw A→T rows for a relationship type, non-group sources only. Works for any slug including core'includes'. No TTL. Invalidated byhtax_delete_custom_rel_caches_for_term. Used by the term edit screen and surgically byhtax_invalidate_term_caches_on_deleteto clear a target’s cache when its source term is deleted.htax_direct_rel_grp_{slug}_{term_id}- Raw T→G rows for a relationship type, group targets only. Works for any slug including core'includes'. No TTL. Invalidated byhtax_delete_custom_rel_caches_for_term. Used by the PRE-CLEAR warm-cache path inhtax_invalidate_term_caches_on_deleteto avoid a DB query for group targets.htax_reverse_rel_grp_{slug}_{term_id}- Raw G→T rows for a relationship type, group sources only. Works for any slug including core'includes'. No TTL. Invalidated byhtax_delete_custom_rel_caches_for_term.htax_ac_*/htax_ac_grp_*- Autocomplete caches for admin UI (term and group autocomplete, respectively)htax_ss_{hash}- Search state of a POST-mode search too large for an inline results-page token (htax_search_state_encode(); the token isk{hash}, a 16-hex content hash). TTL 30 days (HTAX_SEARCH_STATE_TTL); an expired token makes the results URL render as if no search was submitted.htax_pl_t{ids}_f{ids}_p{page}_n{ppp}_m{max}_o{orderby}_x{excludes}- Post list shortcode query results (see below)
Post List Shortcode Cache (htax_pl_*)
The [ht_post_list] and [ht_related_posts] shortcodes cache the result of query_posts_by_term_ids() — the search-index query that returns a paginated list of post IDs with a total count. No TTL is set; caches are invalidated explicitly.
What is stored:
array(
'post_ids' => int[], // Ordered post IDs for the requested page
'total' => int, // Total matching posts (respects max_results cap)
'max_pages' => int, // Total page count
)
Key format: htax_pl_t{sorted-main-ids}_f{sorted-filter-term-ids}_g{sorted-filter-group-ids}_fo{sorted-filter-out-term-ids}_fog{sorted-filter-out-group-ids}_p{page}_n{ppp}_m{max}_o{orderby}_x{exclude-ids}
t{id}— one segment per main term ID (sorted ascending), e.g.t3_t7f{id}— one segment per individual filter term ID fromfilter_termsattr (sorted ascending); omitted if emptyg{id}— one segment per filter GROUP ID fromfilter_groupsattr (sorted ascending); omitted if empty. The group IDs themselves — not expanded member term IDs — are stored here, becausefilter_groupssemantics are AND-across-groups/OR-within-group: merging all member term IDs into a flat list would conflate different configurations that happen to share the same union of members.fo{id}— one segment per individual exclusion term ID fromfilter_out_termsattr (sorted ascending); omitted if emptyfog{id}— one segment per exclusion GROUP ID fromfilter_out_groupsattr (sorted ascending); omitted if empty. Group IDs stored for same reason asg{id}above.p{n}— page number (visible in key so all pages for a given query can be pattern-matched)n{n}— posts_per_pagem{n}— max_results (0 = unlimited)o{key}— compact orderby: first char of each field +D/Afor direction, segments joined by-. Examples:date:DESC→dD;date:DESC,title:ASC→dD-tA;date→dx{ids}— exclude post IDs joined by-; omitted if empty
Caching is skipped if the key exceeds 172 characters (WordPress transient key limit). Hidden term visibility is permission-aware: htax_get_hidden_term_filter_clause() returns AND st.is_hidden = 0 for unprivileged users and an empty string for privileged users. To prevent cross-user cache contamination, H is appended to the cache key when htax_can_see_hidden_terms() is true, so privileged users get a separate cache entry that includes hidden-term posts.
Invalidation:
IDs are embedded directly in the key, enabling LIKE '%{prefix}{id}\_%' pattern-based deletion. The \_ escaped underscore prevents false matches (e.g. t1 matching t10). Five prefix patterns are checked per ID: t, f, g, fo, fog.
htax_invalidate_post_list_cache( int[] $term_ids ) deletes all matching transients. It is called from:
| Trigger | Hook | Handler | Notes |
|---|---|---|---|
| Post-term assignment changed | set_object_terms (6 args) | htax_post_list_cache_on_object_terms_set | Post list: merges direct term IDs with search-index term IDs (index has container/hierarchy/redirect associations). Contrib: fires htax_term_archive_changed for each ID returned by htax_expand_term_ids_for_contrib_invalidation on the direct terms |
Term relationships deleted outside set_object_terms | deleted_term_relationships | htax_post_list_cache_on_term_relationships_deleted | Skips if set_object_terms is currently running. Same dual strategy as above |
| Post published or unpublished | transition_post_status | htax_post_list_cache_on_post_status_change | Only fires on publish↔non-publish transitions. Post list: search-index IDs. Contrib: fires htax_term_archive_changed per taxonomy via htax_expand_term_ids_for_contrib_invalidation |
| Any HyperTaxonomy term relationship or visibility changed | via htax_delete_term_transients() | — | Ensures post list caches are invalidated when inclusions or group memberships change |
| Global cache clear | htax_clear_all_caches() | — | Covered by the _transient_htax_ prefix pattern |
Post list vs contrib invalidation strategies differ by design. Post list uses the search index (pre-computes all relationship types for the post) because it needs every term whose shortcode query would return the post. Contrib uses htax_expand_term_ids_for_contrib_invalidation (queries htax_get_container_terms() + get_ancestors() directly) because the search index is built asynchronously and may not yet reflect a newly-added term assignment.