Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.67% covered (success)
94.67%
71 / 75
90.00% covered (success)
90.00%
9 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Content_Coverage
94.67% covered (success)
94.67%
71 / 75
90.00% covered (success)
90.00%
9 / 10
24.09
0.00% covered (danger)
0.00%
0 / 1
 post_types
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 meta_keys
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 get
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 is_valid
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 compute
100.00% covered (success)
100.00%
40 / 40
100.00% covered (success)
100.00%
1 / 1
2
 register_invalidation
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 invalidate_on_status_change
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 invalidate_on_delete
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 invalidate_on_meta_change
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 invalidate
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * The Overview's content-coverage counts: how many published supported content
4 * items have each SEO field set, computed in a single aggregate query and
5 * cached in a transient that post/meta writes invalidate.
6 *
7 * @package automattic/jetpack-seo-package
8 */
9
10namespace Automattic\Jetpack\SEO;
11
12/**
13 * Computes, caches, and invalidates the content-coverage counts.
14 */
15class Content_Coverage {
16
17    /**
18     * Post-meta keys mirrored from `Jetpack_SEO_Posts` (in plugins/jetpack).
19     * Duplicated here as literals on purpose: that plugin class is NOT reliably
20     * loaded in this package's admin context (the `Jetpack_SEO_Utils`
21     * `class_exists` guard in {@see Dashboard_Data::get_overview_data()} is there
22     * for the same reason), so referencing its constants would fatal.
23     * Content-coverage counting only needs the key strings, which are stable.
24     */
25    const META_DESCRIPTION = 'advanced_seo_description';
26    const META_SCHEMA_TYPE = 'jetpack_seo_schema_type';
27    const META_TITLE       = 'jetpack_seo_html_title';
28    const META_NOINDEX     = 'jetpack_seo_noindex';
29
30    /**
31     * Transient holding the coverage counts.
32     *
33     * Versioned, so a future change to the payload's shape can't read a stale array
34     * written by an older version of this code.
35     *
36     * @var string
37     */
38    const TRANSIENT = 'jetpack_seo_content_coverage_counts_v1';
39
40    /**
41     * How long the counts survive without being invalidated.
42     *
43     * @var int
44     */
45    const TTL = HOUR_IN_SECONDS;
46
47    /**
48     * Post types the counts span.
49     *
50     * @return string[]
51     */
52    private static function post_types() {
53        return Post_Types::get_supported_content_types();
54    }
55
56    /**
57     * The SEO post-meta keys the counts read.
58     *
59     * @return string[]
60     */
61    private static function meta_keys() {
62        return array(
63            self::META_SCHEMA_TYPE,
64            self::META_TITLE,
65            self::META_DESCRIPTION,
66            self::META_NOINDEX,
67        );
68    }
69
70    /**
71     * Factual content-coverage counts for the Overview card: how many published
72     * supported content items have each SEO field set. State, not a score — the
73     * card shows proportions + raw counts and lets the admin decide what matters.
74     *
75     * Served from {@see self::TRANSIENT} when it's warm. The counts are read on
76     * every load of the SEO page — and by every tab of it, since the dashboard preloads
77     * all of its REST reads at once — so without a cache a plain reload pays for the
78     * query again having changed nothing.
79     *
80     * @return array{total:int,with_schema:int,with_title:int,with_description:int,with_search_visible:int}
81     */
82    public static function get() {
83        $cached = get_transient( self::TRANSIENT );
84
85        if ( self::is_valid( $cached ) ) {
86            return $cached;
87        }
88
89        $coverage = self::compute();
90
91        set_transient( self::TRANSIENT, $coverage, self::TTL );
92
93        return $coverage;
94    }
95
96    /**
97     * Whether a value read back from the cache is a coverage payload this code can use.
98     *
99     * @param mixed $value Value read from the transient.
100     * @return bool
101     */
102    private static function is_valid( $value ) {
103        if ( ! is_array( $value ) ) {
104            return false;
105        }
106
107        foreach ( array( 'total', 'with_schema', 'with_title', 'with_description', 'with_search_visible' ) as $key ) {
108            if ( ! isset( $value[ $key ] ) || ! is_int( $value[ $key ] ) ) {
109                return false;
110            }
111        }
112
113        return true;
114    }
115
116    /**
117     * Count the coverage metrics straight from the database.
118     *
119     * @return array{total:int,with_schema:int,with_title:int,with_description:int,with_search_visible:int}
120     */
121    private static function compute() {
122        global $wpdb;
123
124        $post_types = self::post_types();
125        $meta_keys  = self::meta_keys();
126
127        $meta_key_placeholders  = implode( ', ', array_fill( 0, count( $meta_keys ), '%s' ) );
128        $post_type_placeholders = implode( ', ', array_fill( 0, count( $post_types ), '%s' ) );
129
130        /*
131         * Driven from `wp_postmeta`, not `wp_posts`: most sites have far more published
132         * posts than SEO fields set, so the join starts from the small side, where the
133         * `meta_key` index serves `meta_key IN (…)` directly.
134         *
135         * The aggregate has no GROUP BY, so it still returns its single row — counts at
136         * zero, `total` intact — on a site with no SEO meta at all.
137         *
138         * COUNT( DISTINCT p.ID ) because a post can carry more than one row for the same
139         * meta key. `<> ''` counts a field as set; noindex alone is an exact `= '1'`.
140         */
141        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
142        $sql = $wpdb->prepare(
143            "SELECT
144                (
145                    SELECT COUNT(*)
146                    FROM {$wpdb->posts}
147                    WHERE post_status = 'publish' AND post_type IN ( {$post_type_placeholders} )
148                ) AS total,
149                COUNT( DISTINCT CASE WHEN pm.meta_key = %s AND pm.meta_value <> '' THEN p.ID END ) AS with_schema,
150                COUNT( DISTINCT CASE WHEN pm.meta_key = %s AND pm.meta_value <> '' THEN p.ID END ) AS with_title,
151                COUNT( DISTINCT CASE WHEN pm.meta_key = %s AND pm.meta_value <> '' THEN p.ID END ) AS with_description,
152                COUNT( DISTINCT CASE WHEN pm.meta_key = %s AND pm.meta_value = '1' THEN p.ID END ) AS noindexed
153            FROM {$wpdb->postmeta} pm
154            INNER JOIN {$wpdb->posts} p
155                ON p.ID = pm.post_id
156                AND p.post_status = 'publish'
157                AND p.post_type IN ( {$post_type_placeholders} )
158            WHERE pm.meta_key IN ( {$meta_key_placeholders} )",
159            array_merge(
160                $post_types,
161                // The CASE arms above, in the order they appear.
162                array( self::META_SCHEMA_TYPE, self::META_TITLE, self::META_DESCRIPTION, self::META_NOINDEX ),
163                $post_types,
164                $meta_keys
165            )
166        );
167        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
168
169        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- Aggregate count with no core API equivalent; $sql is the prepared statement built directly above. The result is cached in self::TRANSIENT by the get() wrapper, which is the only caller — the sniff just can't see across the two methods.
170        $row = $wpdb->get_row( $sql, ARRAY_A );
171
172        // Defaults, so a query that returns nothing at all reads as an empty site rather
173        // than fataling on a missing key.
174        $counts = array_map(
175            'intval',
176            array_merge(
177                array(
178                    'total'            => 0,
179                    'with_schema'      => 0,
180                    'with_title'       => 0,
181                    'with_description' => 0,
182                    'noindexed'        => 0,
183                ),
184                is_array( $row ) ? $row : array()
185            )
186        );
187
188        return array(
189            'total'               => $counts['total'],
190            'with_schema'         => $counts['with_schema'],
191            'with_title'          => $counts['with_title'],
192            'with_description'    => $counts['with_description'],
193            // Search-engine visibility is the inverse of the per-post noindex meta: a
194            // post is visible unless it's explicitly set to noindex (stored as '1'), so
195            // most posts (no meta row) count as visible.
196            'with_search_visible' => max( 0, $counts['total'] - $counts['noindexed'] ),
197        );
198    }
199
200    /**
201     * Hook the writes that can move the coverage counts.
202     *
203     * @return void
204     */
205    public static function register_invalidation() {
206        // Covers publish, unpublish, trash, untrash and scheduled posts going live — every
207        // route by which a post enters or leaves the published set.
208        add_action( 'transition_post_status', array( __CLASS__, 'invalidate_on_status_change' ), 10, 3 );
209        add_action( 'deleted_post', array( __CLASS__, 'invalidate_on_delete' ), 10, 2 );
210
211        foreach ( array( 'added_post_meta', 'updated_post_meta', 'deleted_post_meta' ) as $hook ) {
212            add_action( $hook, array( __CLASS__, 'invalidate_on_meta_change' ), 10, 3 );
213        }
214    }
215
216    /**
217     * Drop the cached counts when a post enters or leaves the published set.
218     *
219     * Known limitation: this hook only ever sees the post's new type, so converting a
220     * published post to an uncounted post type (or the reverse) isn't caught here and
221     * leaves `total` stale until the next tracked write or the transient's TTL expiry.
222     * A direct `set_post_type()` bypasses every hook anyway, so the TTL backstop is what
223     * ultimately bounds that staleness.
224     *
225     * @param string        $new_status Status the post is moving to.
226     * @param string        $old_status Status the post is moving from.
227     * @param \WP_Post|null $post       The post being transitioned.
228     * @return void
229     */
230    public static function invalidate_on_status_change( $new_status, $old_status, $post ) {
231        if ( ! $post instanceof \WP_Post || ! in_array( $post->post_type, self::post_types(), true ) ) {
232            return;
233        }
234
235        // Draft to draft, pending to draft, and the like never touch the counts.
236        if ( 'publish' !== $new_status && 'publish' !== $old_status ) {
237            return;
238        }
239
240        self::invalidate();
241    }
242
243    /**
244     * Drop the cached counts when a post is deleted outright.
245     *
246     * Trashing already goes through `transition_post_status`; this catches a hard delete,
247     * which for an already-trashed post transitions nothing.
248     *
249     * @param int           $post_id Deleted post ID.
250     * @param \WP_Post|null $post    The post that was deleted.
251     * @return void
252     */
253    public static function invalidate_on_delete( $post_id, $post = null ) {
254        if ( ! $post instanceof \WP_Post || ! in_array( $post->post_type, self::post_types(), true ) ) {
255            return;
256        }
257
258        self::invalidate();
259    }
260
261    /**
262     * Drop the cached counts when one of the SEO fields they count is written.
263     *
264     * @param int|int[] $meta_id   Meta row ID, or IDs on delete. Unused.
265     * @param int       $object_id Post the meta belongs to. Unused.
266     * @param string    $meta_key  Meta key written.
267     * @return void
268     */
269    public static function invalidate_on_meta_change( $meta_id, $object_id, $meta_key ) {
270        if ( ! in_array( $meta_key, self::meta_keys(), true ) ) {
271            return;
272        }
273
274        self::invalidate();
275    }
276
277    /**
278     * Drop the cached counts.
279     *
280     * @return void
281     */
282    private static function invalidate() {
283        delete_transient( self::TRANSIENT );
284    }
285}