Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
82.19% covered (warning)
82.19%
120 / 146
33.33% covered (danger)
33.33%
3 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
Custom_Taxonomy_Slot_Mapping
82.19% covered (warning)
82.19%
120 / 146
33.33% covered (danger)
33.33%
3 / 9
67.27
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 get_map
92.86% covered (success)
92.86%
26 / 28
0.00% covered (danger)
0.00%
0 / 1
10.04
 resolve_slot
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 reset_cache_for_testing
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 register_slot_taxonomies
92.86% covered (success)
92.86%
26 / 28
0.00% covered (danger)
0.00%
0 / 1
7.02
 mirror_assignment
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
4.10
 mirror_removal
72.73% covered (warning)
72.73%
8 / 11
0.00% covered (danger)
0.00%
0 / 1
4.32
 mirror_deletion
69.23% covered (warning)
69.23%
9 / 13
0.00% covered (danger)
0.00%
0 / 1
8.43
 backfill
70.45% covered (warning)
70.45%
31 / 44
0.00% covered (danger)
0.00%
0 / 1
20.80
1<?php
2/**
3 * Custom taxonomy → reserved Jetpack Search slot mapping.
4 *
5 * @package automattic/jetpack-search
6 */
7
8namespace Automattic\Jetpack\Search;
9
10/**
11 * Power-user escape hatch for taxonomies that aren't natively indexed by
12 * Jetpack Search.
13 *
14 * A site declares a mapping like
15 *
16 *     add_filter( 'jetpack_search_custom_taxonomy_map', function ( $map ) {
17 *         $map['genre'] = 'jetpack-search-tag1';
18 *         return $map;
19 *     } );
20 *
21 * Then this class:
22 *
23 *  1. Registers each in-use slot (`jetpack-search-tag0`…`jetpack-search-tag9`)
24 *     as a private shadow taxonomy on the same object types as its
25 *     user-side source.
26 *  2. Mirrors term assignments from the user-side taxonomy onto the slot
27 *     on `set_object_terms`, `deleted_term_relationships`, and `delete_term`
28 *     so Sync ships the slot rows to the WPCOM replicastore, where the
29 *     Jetpack Search indexer picks them up (the slot taxonomies are on
30 *     `Sync\Modules\Search::get_all_taxonomies()`; the user-facing slug
31 *     usually isn't).
32 *  3. Resolves a user-facing slug to its slot at query-build time
33 *     (`Filter_Checkbox::build_config()` stores it on the filterConfig as
34 *     `effectiveSlug`) so the front-end aggregates against the slot field.
35 *
36 * Default `apply_filters( 'jetpack_search_custom_taxonomy_map', array() )`
37 * returns empty, so the feature is **off by default**: no slot taxonomies
38 * registered, no mirroring, no query rewrite. The filter doubles as the
39 * data declaration and the on/off switch — a site that doesn't add an
40 * entry pays no runtime cost beyond a cached `isset()` check inside the
41 * `set_object_terms` handler.
42 *
43 * See https://jetpack.com/support/search/frequently-asked-questions/#troubleshoot-custom-tax
44 */
45class Custom_Taxonomy_Slot_Mapping {
46
47    /**
48     * Backfill modes accepted by `backfill()`.
49     *
50     * - `mirror`: default. Per-post replacement only. For each post that
51     *   currently has at least one user-side term, `wp_set_object_terms()`
52     *   resets the slot's post-set for that post to match the current
53     *   user-side names. **Posts that lost every user-side term during a
54     *   gap when the auto-mirror was inactive are *not* visited** — their
55     *   stale slot relationships orphan. Suitable for the common case:
56     *   one-time initialization on a site that has data predating the
57     *   mapping.
58     * - `rebuild`: full sweep. Every term in the slot taxonomy is deleted
59     *   first (which cascades to drop every slot term-relationship), then
60     *   the per-post mirror runs over the current user-side state. The
61     *   resulting slot is byte-for-byte a fresh projection of the user-side
62     *   taxonomy with no orphans. Use when a site has had the mapping
63     *   toggle on and off, changed slot, or otherwise believes the slot has
64     *   drifted. Costly on large sites — runs N deletes for N slot terms.
65     */
66    const BACKFILL_MODES = array( 'mirror', 'rebuild' );
67
68    /**
69     * Per-request memo backing `get_map()`. The map is validated once per
70     * request — re-running the validation on every filter-block render and
71     * on every API request would be wasted work, and the
72     * `_doing_it_wrong()` notices for a misconfigured map would multiply.
73     *
74     * @var array<string, string>|null
75     */
76    private static $map_cache = null;
77
78    /**
79     * Wire the bootstrap and mirror hooks. Called once from
80     * `Search_Blocks::init()`.
81     *
82     * The `set_object_terms` / `deleted_term_relationships` / `delete_term`
83     * hooks attach unconditionally — they short-circuit on `! isset( $map[ $taxonomy ] )`
84     * before any work, so the per-request cost on sites without a mapping
85     * is one cached array read + one `isset` call. Attaching unconditionally
86     * also avoids a load-order foot-gun where a site declares the map after
87     * `init` has already fired.
88     *
89     * The slot taxonomy registration runs at priority 20 so user-side
90     * taxonomies declared on the default priority 10 are present when we
91     * read their `object_type`.
92     */
93    public static function init(): void {
94        add_action( 'init', array( static::class, 'register_slot_taxonomies' ), 20 );
95        add_action( 'set_object_terms', array( static::class, 'mirror_assignment' ), 10, 6 );
96        // `wp_remove_object_terms()` (e.g. from `wp post term remove`) fires
97        // `deleted_term_relationships` instead of `set_object_terms`, so the
98        // slot would drift unless we mirror this path too. Block-editor saves
99        // go through `wp_set_object_terms()` (the full replace path) and are
100        // already covered by the `set_object_terms` hook above.
101        add_action( 'deleted_term_relationships', array( static::class, 'mirror_removal' ), 10, 3 );
102        add_action( 'delete_term', array( static::class, 'mirror_deletion' ), 10, 4 );
103    }
104
105    /**
106     * Map of user-facing custom taxonomy slug → reserved Jetpack Search
107     * index slot (`jetpack-search-tag0`…`jetpack-search-tag9`).
108     *
109     * Validation:
110     *  - Slot value must match `jetpack-search-tag[0-9]` exactly. Anything
111     *    else is dropped with a `_doing_it_wrong()` notice — silently
112     *    accepting an arbitrary string would route queries to a field that
113     *    doesn't exist in the index.
114     *  - Two user-slugs pointing at the same slot are rejected (only the
115     *    first wins) — both would merge their term spaces in the index and
116     *    the second filter would silently return results from the first.
117     *
118     * @return array<string, string>
119     */
120    public static function get_map(): array {
121        if ( null !== self::$map_cache ) {
122            return self::$map_cache;
123        }
124
125        /**
126         * Map custom taxonomy slugs to a reserved Jetpack Search index slot.
127         *
128         * Default is an empty array, which leaves the slot-mapping feature
129         * entirely off — no slot taxonomies registered, no auto-mirror, no
130         * query rewrite. A site enables the feature by returning a non-empty
131         * map from this filter.
132         *
133         * @since 0.60.0
134         *
135         * @param array<string, string> $map Empty by default; entries shape
136         *                                   `[ 'user_slug' => 'jetpack-search-tagN' ]`.
137         */
138        $raw = apply_filters( 'jetpack_search_custom_taxonomy_map', array() );
139        if ( ! is_array( $raw ) ) {
140            $msg = esc_html__( 'The jetpack_search_custom_taxonomy_map filter must return an array of user-slug => jetpack-search-tagN pairs.', 'jetpack-search-pkg' );
141            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $msg is esc_html__() output.
142            _doing_it_wrong( 'jetpack_search_custom_taxonomy_map', $msg, 'jetpack-search-pkg 0.60.0' );
143            self::$map_cache = array();
144            return self::$map_cache;
145        }
146
147        $map        = array();
148        $slot_owner = array();
149        foreach ( $raw as $user_slug => $slot ) {
150            if ( ! is_string( $user_slug ) || '' === $user_slug || ! is_string( $slot ) ) {
151                continue;
152            }
153            $user_slug = sanitize_key( $user_slug );
154            if ( '' === $user_slug ) {
155                continue;
156            }
157            if ( ! preg_match( '/^jetpack-search-tag[0-9]$/', $slot ) ) {
158                /* translators: 1: invalid slot value, 2: user-facing taxonomy slug */
159                $msg = sprintf( esc_html__( 'Invalid Jetpack Search slot "%1$s" for taxonomy "%2$s"; expected one of jetpack-search-tag0…jetpack-search-tag9.', 'jetpack-search-pkg' ), esc_html( $slot ), esc_html( $user_slug ) );
160                // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $msg is sprintf() of esc_html__() with esc_html()-wrapped args.
161                _doing_it_wrong( 'jetpack_search_custom_taxonomy_map', $msg, 'jetpack-search-pkg 0.60.0' );
162                continue;
163            }
164            if ( isset( $slot_owner[ $slot ] ) ) {
165                /* translators: 1: slot, 2: first user-facing slug that owns the slot, 3: second user-facing slug attempting to claim it */
166                $msg = sprintf( esc_html__( 'Slot "%1$s" is already mapped to "%2$s"; ignoring duplicate mapping from "%3$s".', 'jetpack-search-pkg' ), esc_html( $slot ), esc_html( $slot_owner[ $slot ] ), esc_html( $user_slug ) );
167                // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $msg is sprintf() of esc_html__() with esc_html()-wrapped args.
168                _doing_it_wrong( 'jetpack_search_custom_taxonomy_map', $msg, 'jetpack-search-pkg 0.60.0' );
169                continue;
170            }
171            $map[ $user_slug ]   = $slot;
172            $slot_owner[ $slot ] = $user_slug;
173        }
174
175        self::$map_cache = $map;
176        return $map;
177    }
178
179    /**
180     * Resolve a user-facing taxonomy slug to the Elasticsearch field slug
181     * that should be queried for it. Returns the matching
182     * `jetpack-search-tagN` slot when the slug has an entry in `get_map()`,
183     * otherwise the slug itself. Built-in slugs that have their own
184     * dedicated variations (`category`, `post_tag`, and the WC product
185     * taxonomies) are returned verbatim so a stray map entry can never
186     * silently redirect a built-in filter onto a slot.
187     *
188     * Empty input returns empty so callers can pass the raw block attribute
189     * without a guard.
190     *
191     * @param string $taxonomy User-facing taxonomy slug.
192     * @return string Effective ES field slug.
193     */
194    public static function resolve_slot( string $taxonomy ): string {
195        if ( '' === $taxonomy ) {
196            return '';
197        }
198        // Built-ins are anchored to their canonical field paths regardless
199        // of whether a map entry tries to redirect them.
200        if ( in_array( $taxonomy, Search_Blocks::BUILT_IN_CUSTOM_TAXONOMY_EXCLUSIONS, true ) ) {
201            return $taxonomy;
202        }
203        $map = self::get_map();
204        return $map[ $taxonomy ] ?? $taxonomy;
205    }
206
207    /**
208     * Reset the `get_map()` memo. Tests only — production WP runs a single
209     * request per process and the map is derived purely from a filter
210     * hook, so callers should never need to clear the cache.
211     *
212     * @internal
213     */
214    public static function reset_cache_for_testing(): void {
215        self::$map_cache = null;
216    }
217
218    /**
219     * Register each in-use Jetpack Search slot as a private shadow taxonomy
220     * on the same object types as its user-side source.
221     *
222     * The slot taxonomies need to be real registered taxonomies on the
223     * source site so `wp_set_object_terms()` accepts them and Sync's
224     * normal Terms / Term-Relationships modules ship them to the WPCOM
225     * replicastore. They're intentionally invisible — no UI, no REST, no
226     * rewrites, no admin column, no query var, no nav-menu surface —
227     * because authors only ever edit the user-side taxonomy (e.g. `genre`);
228     * `mirror_assignment()` keeps the slot taxonomy in lockstep behind
229     * the scenes.
230     *
231     * Hierarchical: forced flat. The WPCOM search proxy aggregates slot
232     * taxonomies as bag-of-terms and a parent/child relationship between
233     * slot terms wouldn't survive the round-trip anyway.
234     */
235    public static function register_slot_taxonomies(): void {
236        $map = self::get_map();
237        if ( empty( $map ) ) {
238            return;
239        }
240        // Collect the object_type union for each slot — a slot can shadow
241        // taxonomies attached to different post types in principle (rare),
242        // and registering the slot on the union is harmless when a single
243        // taxonomy is involved.
244        $object_types_by_slot = array();
245        foreach ( $map as $user_slug => $slot ) {
246            $tax = get_taxonomy( $user_slug );
247            if ( ! $tax ) {
248                continue;
249            }
250            foreach ( (array) $tax->object_type as $object_type ) {
251                $object_types_by_slot[ $slot ][ $object_type ] = true;
252            }
253        }
254        foreach ( $object_types_by_slot as $slot => $object_types ) {
255            if ( taxonomy_exists( $slot ) ) {
256                continue;
257            }
258            register_taxonomy(
259                $slot,
260                array_keys( $object_types ),
261                array(
262                    'public'            => false,
263                    'show_ui'           => false,
264                    'show_in_menu'      => false,
265                    'show_in_rest'      => false,
266                    'show_in_nav_menus' => false,
267                    'show_admin_column' => false,
268                    'rewrite'           => false,
269                    'query_var'         => false,
270                    'hierarchical'      => false,
271                )
272            );
273        }
274    }
275
276    /**
277     * Mirror term assignments from a mapped user-facing taxonomy onto the
278     * reserved slot. Fires on `set_object_terms` for every
279     * `wp_set_object_terms()` write; cheap no-op when the taxonomy isn't
280     * mapped (vast majority of calls).
281     *
282     * Uses term names rather than slugs / ids: the slot terms need to display
283     * the same label as the user-side terms (e.g. "Fantasy") in search
284     * results, and `wp_set_object_terms()` will create matching slot terms
285     * by name when none exist. Idempotent — re-running with the same source
286     * assignment is a no-op on the slot.
287     *
288     * Recursion is bounded by the `isset( $map[ $taxonomy ] )` gate: the
289     * inner `wp_set_object_terms()` call fires `set_object_terms` again
290     * with `$taxonomy = jetpack-search-tagN`, which is never a key in the
291     * user-facing map, so the second invocation returns immediately.
292     *
293     * @param int    $object_id  Post (or other object) id receiving the terms.
294     * @param array  $terms      Raw input from the caller (ignored — re-fetched).
295     * @param array  $tt_ids     Term taxonomy ids (unused).
296     * @param string $taxonomy   Taxonomy slug the assignment targeted.
297     * @param bool   $append     Whether the caller appended (unused — full mirror).
298     * @param array  $old_tt_ids Previous term taxonomy ids (unused).
299     */
300    public static function mirror_assignment( $object_id, $terms, $tt_ids, $taxonomy, $append, $old_tt_ids ): void {
301        unset( $terms, $tt_ids, $append, $old_tt_ids );
302
303        $map = self::get_map();
304        if ( ! isset( $map[ $taxonomy ] ) ) {
305            return;
306        }
307        $slot = $map[ $taxonomy ];
308        if ( ! taxonomy_exists( $slot ) ) {
309            return;
310        }
311        $names = wp_get_object_terms( (int) $object_id, $taxonomy, array( 'fields' => 'names' ) );
312        if ( is_wp_error( $names ) ) {
313            return;
314        }
315        wp_set_object_terms( (int) $object_id, $names, $slot, false );
316    }
317
318    /**
319     * Mirror term *removals* (as opposed to full assignment replacements)
320     * from a mapped user-facing taxonomy onto the slot. Re-reads the
321     * canonical post-set rather than diffing the removed tt_ids so the slot
322     * always reflects the current post-set on the source side — same shape
323     * `mirror_assignment()` uses for the add/replace path.
324     *
325     * @param int    $object_id Post receiving the removal.
326     * @param array  $tt_ids    Term taxonomy ids that were removed (unused).
327     * @param string $taxonomy  Taxonomy the removal targeted.
328     */
329    public static function mirror_removal( $object_id, $tt_ids, $taxonomy ): void {
330        unset( $tt_ids );
331
332        $map = self::get_map();
333        if ( ! isset( $map[ $taxonomy ] ) ) {
334            return;
335        }
336        $slot = $map[ $taxonomy ];
337        if ( ! taxonomy_exists( $slot ) ) {
338            return;
339        }
340        $names = wp_get_object_terms( (int) $object_id, $taxonomy, array( 'fields' => 'names' ) );
341        if ( is_wp_error( $names ) ) {
342            return;
343        }
344        wp_set_object_terms( (int) $object_id, $names, $slot, false );
345    }
346
347    /**
348     * Mirror term deletions from a mapped user-facing taxonomy onto the
349     * slot. Without this, deleting a "Fantasy" term in `genre` leaves an
350     * orphan "Fantasy" term in the slot taxonomy that ES would keep
351     * returning as a (zero-doc) bucket on retained-option lists.
352     *
353     * @param int    $term_id      User-side term id (unused — match by name).
354     * @param int    $tt_id        Term taxonomy id (unused).
355     * @param string $taxonomy     Taxonomy the term lived in.
356     * @param object $deleted_term Term object as it existed just before deletion.
357     */
358    public static function mirror_deletion( $term_id, $tt_id, $taxonomy, $deleted_term ): void {
359        unset( $term_id, $tt_id );
360
361        $map = self::get_map();
362        if ( ! isset( $map[ $taxonomy ] ) ) {
363            return;
364        }
365        $slot = $map[ $taxonomy ];
366        if ( ! taxonomy_exists( $slot ) ) {
367            return;
368        }
369        // Match by slug rather than name: `wp_set_object_terms()` creates the
370        // slot term with `sanitize_title( $name )` as its slug regardless of
371        // the user-side name's case, and `get_term_by( 'name', ... )` is
372        // case-sensitive on case-sensitive collations — slug-based lookup
373        // avoids missing "fantasy" when the source term is "Fantasy".
374        $slug = isset( $deleted_term->slug ) ? (string) $deleted_term->slug : '';
375        if ( '' === $slug ) {
376            return;
377        }
378        $slot_term = get_term_by( 'slug', $slug, $slot );
379        if ( $slot_term && ! is_wp_error( $slot_term ) ) {
380            wp_delete_term( (int) $slot_term->term_id, $slot );
381        }
382    }
383
384    /**
385     * One-shot backfill: walk every post that carries a term in a mapped
386     * user-facing taxonomy and mirror its current assignment onto the slot.
387     * Use after first introducing a mapping on a site whose posts were
388     * tagged before the auto-mirror was active.
389     *
390     * Idempotent — `wp_set_object_terms()` replaces the post-set on the slot
391     * each call, so re-running picks up later edits cleanly. Not hooked
392     * automatically; sites with millions of posts shouldn't pay this cost on
393     * every request. Call from a one-off script or `wp eval`.
394     *
395     * The default `mirror` mode walks user-side terms only and won't clean
396     * up orphan slot rows from posts that have lost all their user-side
397     * terms. Pass `rebuild` to wipe the slot taxonomy first and re-project
398     * from scratch — slower but guarantees no drift survives.
399     *
400     * @param string $mode One of `self::BACKFILL_MODES` — `mirror` (default) or `rebuild`.
401     * @return int Number of (post, taxonomy) pairs mirrored. Slot wipes in
402     *             `rebuild` mode are not counted; the return value is the
403     *             count of fresh per-post writes either way.
404     */
405    public static function backfill( string $mode = 'mirror' ): int {
406        if ( ! in_array( $mode, self::BACKFILL_MODES, true ) ) {
407            /* translators: %s: invalid mode value passed to backfill(). */
408            $msg = sprintf( esc_html__( 'Unknown backfill mode "%s"; expected one of mirror | rebuild.', 'jetpack-search-pkg' ), esc_html( $mode ) );
409            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $msg is sprintf() of esc_html__() with esc_html()-wrapped args.
410            _doing_it_wrong( __METHOD__, $msg, 'jetpack-search-pkg 0.60.0' );
411            $mode = 'mirror';
412        }
413
414        $map = self::get_map();
415        if ( empty( $map ) ) {
416            return 0;
417        }
418        $mirrored = 0;
419        foreach ( $map as $user_slug => $slot ) {
420            if ( ! taxonomy_exists( $user_slug ) || ! taxonomy_exists( $slot ) ) {
421                continue;
422            }
423            // Rebuild mode: drop every term in the slot taxonomy *before*
424            // the user-side walk. `wp_delete_term()` cascades to remove
425            // each term's term_relationship rows, leaving the slot
426            // post-set empty so the mirror loop projects a fresh copy of
427            // the current user-side state with no orphans. The inner
428            // deletes fire `delete_term` on slot taxonomies; the mirror
429            // handler's `isset( $map[ $taxonomy ] )` gate (map keys are
430            // user-side slugs, never slot slugs) prevents recursion.
431            if ( 'rebuild' === $mode ) {
432                // @phan-suppress-next-line PhanAccessMethodInternal @phan-suppress-current-line UnusedSuppression -- Fixed in WP 6.9, but then we need a suppression for the WP 6.8 compat run. @todo Remove this suppression when we drop WP <6.9.
433                $existing_slot_terms = get_terms(
434                    array(
435                        'taxonomy'   => $slot,
436                        'hide_empty' => false,
437                        'fields'     => 'ids',
438                    )
439                );
440                if ( ! is_wp_error( $existing_slot_terms ) ) {
441                    foreach ( (array) $existing_slot_terms as $slot_term_id ) {
442                        wp_delete_term( (int) $slot_term_id, $slot );
443                    }
444                }
445            }
446            // @phan-suppress-next-line PhanAccessMethodInternal @phan-suppress-current-line UnusedSuppression -- Fixed in WP 6.9, but then we need a suppression for the WP 6.8 compat run. @todo Remove this suppression when we drop WP <6.9.
447            $terms = get_terms(
448                array(
449                    'taxonomy'   => $user_slug,
450                    'hide_empty' => false,
451                    'fields'     => 'all',
452                )
453            );
454            // Bail explicitly on `WP_Error` (and on the empty case) rather than
455            // relying on `wp_list_pluck()` silently returning `[]` for an
456            // error input — keeps the failure path readable.
457            if ( is_wp_error( $terms ) || empty( $terms ) ) {
458                continue;
459            }
460            $object_ids = get_objects_in_term(
461                wp_list_pluck( $terms, 'term_id' ),
462                $user_slug
463            );
464            if ( is_wp_error( $object_ids ) || empty( $object_ids ) ) {
465                continue;
466            }
467            foreach ( array_unique( array_map( 'intval', (array) $object_ids ) ) as $object_id ) {
468                $names = wp_get_object_terms( $object_id, $user_slug, array( 'fields' => 'names' ) );
469                if ( is_wp_error( $names ) ) {
470                    continue;
471                }
472                wp_set_object_terms( $object_id, $names, $slot, false );
473                ++$mirrored;
474            }
475        }
476        return $mirrored;
477    }
478}