PluginProbe
Yoast SEO – Advanced SEO with real-time guidance and built-in AI / trunk
Yoast SEO – Advanced SEO with real-time guidance and built-in AI vtrunk
28.5 28.4 28.3 28.2 28.1 28.0 27.9 27.8 27.7 27.6 27.5 trunk 18.0 18.1 18.2 18.3 18.4 18.4.1 18.5 18.5.1 18.6 18.7 18.8 18.9 19.0 All 129 releases
← All changes | src/bulk-editor/infrastructure/posts/post-meta-posts-collector.php +201 -26 28.1 → trunk View file →
@@ -25,20 +25,51 @@
25 25 */
26 26 private const META_PREFIX = '_yoast_wpseo_';
27 27
28 28 /**
29 - * The query var that flags our own query so the search filter only touches it.
29 + * Maps each "needs improvement" field key to its Yoast meta key suffix.
30 + *
31 + * @var array<string, string>
30 32 */
31 - private const SEARCH_FLAG = 'yoast_bulk_editor_search';
33 + private const FIELD_META_SUFFIXES = [
34 + 'seo_title' => 'title',
35 + 'meta_description' => 'metadesc',
36 + 'social_title' => 'opengraph-title',
37 + 'social_description' => 'opengraph-description',
38 + ];
32 39
33 40 /**
34 - * The prepared WHERE clause to append while our search query runs.
41 + * Maps the fields with a persisted per-field score to their score meta key suffix.
35 42 *
43 + * The social fields have no assessors, so they match on emptiness only.
44 + *
45 + * @var array<string, string>
46 + */
47 + private const FIELD_SCORE_META_SUFFIXES = [
48 + 'seo_title' => 'seo_title_score',
49 + 'meta_description' => 'meta_description_score',
50 + ];
51 +
52 + /**
53 + * The query var that flags our own query so the posts_where filter only touches it.
54 + */
55 + private const QUERY_FLAG = 'yoast_bulk_editor_query';
56 +
57 + /**
58 + * The prepared search WHERE clause to append while our query runs.
59 + *
36 60 * @var string
37 61 */
38 62 private $search_where = '';
39 63
40 64 /**
65 + * The prepared "needs improvement" WHERE clause to append while our query runs.
66 + *
67 + * @var string
68 + */
69 + private $needs_improvement_where = '';
70 +
71 + /**
41 72 * The resolver for the per-post edit permission.
42 73 *
43 74 * @var Post_Editability_Resolver
44 75 */
@@ -44,14 +75,26 @@
44 75 */
45 76 private $post_editability_resolver;
46 77
47 78 /**
79 + * The resolver for the post type's default SEO title / meta description template.
80 + *
81 + * @var Default_Template_Resolver
82 + */
83 + private $default_template_resolver;
84 +
85 + /**
48 86 * The constructor.
49 87 *
50 88 * @param Post_Editability_Resolver $post_editability_resolver The resolver for the per-post edit permission.
89 + * @param Default_Template_Resolver $default_template_resolver The resolver for the default SEO title / meta description template.
51 90 */
52 - public function __construct( Post_Editability_Resolver $post_editability_resolver ) {
91 + public function __construct(
92 + Post_Editability_Resolver $post_editability_resolver,
93 + Default_Template_Resolver $default_template_resolver
94 + ) {
53 95 $this->post_editability_resolver = $post_editability_resolver;
96 + $this->default_template_resolver = $default_template_resolver;
54 97 }
55 98
56 99 /**
57 100 * Collects a page of posts for the given query.
@@ -70,9 +113,9 @@
70 113 $editability = $this->post_editability_resolver->resolve( $post_ids );
71 114
72 115 $posts_list = new Posts_List();
73 116 foreach ( $post_ids as $post_id ) {
74 - $posts_list->add( $this->build_post( $post_id, ( $editability[ $post_id ] ?? false ) ) );
117 + $posts_list->add( $this->build_post( $post_id, ( $editability[ $post_id ] ?? false ), $query->are_scores_enabled() ) );
75 118 }
76 119
77 120 return new Posts_Page( $posts_list, (int) $wp_query->found_posts, $query->get_page(), $query->get_per_page() );
78 121 }
@@ -79,11 +122,12 @@
79 122
80 123 /**
81 124 * Runs the WP_Query for the given query.
82 125 *
83 - * When a search term is set, the catch-all clause is injected through a scoped posts_where filter:
84 - * WP_Query AND-joins its own 's' and 'meta_query', which would miss posts matching only one side, so
85 - * a single OR clause covering the post title and the Yoast meta is added instead.
126 + * The catch-all search and "needs improvement" clauses are both injected through a scoped posts_where
127 + * filter rather than WP_Query's own 's'/'meta_query'. WP_Query AND-joins those, which would miss posts
128 + * matching only one side, and WP_Meta_Query's INNER JOINs drop the very missing-meta-row posts the
129 + * "needs improvement" filter targets; a hand-built OR clause of correlated subqueries avoids both.
86 130 *
87 131 * @param Posts_Query $query The query describing the page to collect.
88 132 *
89 133 * @return WP_Query The executed query.
@@ -90,20 +134,23 @@
90 134 */
91 135 protected function run_query( Posts_Query $query ): WP_Query {
92 136 $args = $this->build_query_args( $query );
93 137
94 - if ( ! $query->has_search() ) {
138 + $this->search_where = $query->has_search() ? $this->build_search_where( $query->get_search() ) : '';
139 + $this->needs_improvement_where = $this->build_needs_improvement_where( $query->get_needs_improvement(), $query->are_scores_enabled(), $query->get_content_type() );
140 +
141 + if ( $this->search_where === '' && $this->needs_improvement_where === '' ) {
95 142 return new WP_Query( $args );
96 143 }
97 144
98 - $args[ self::SEARCH_FLAG ] = true;
99 - $this->search_where = $this->build_search_where( $query->get_search() );
145 + $args[ self::QUERY_FLAG ] = true;
100 146
101 147 \add_filter( 'posts_where', [ $this, 'filter_posts_where' ], 10, 2 );
102 148 $wp_query = new WP_Query( $args );
103 149 \remove_filter( 'posts_where', [ $this, 'filter_posts_where' ], 10 );
104 150
105 - $this->search_where = '';
151 + $this->search_where = '';
152 + $this->needs_improvement_where = '';
106 153
107 154 return $wp_query;
108 155 }
109 156
@@ -111,9 +158,9 @@
111 158 * Builds the WP_Query arguments for the given query.
112 159 *
113 160 * @param Posts_Query $query The query describing the page to collect.
114 161 *
115 - * @return array<string, string|int|bool|array<string>> The WP_Query arguments.
162 + * @return array<string, string|int|bool|array<string>|array<int>> The WP_Query arguments.
116 163 */
117 164 private function build_query_args( Posts_Query $query ): array {
118 165 $args = [
119 166 'post_type' => $query->get_content_type(),
@@ -134,24 +181,29 @@
134 181 if ( $query->has_author_filter() ) {
135 182 $args['author'] = $query->get_author_id();
136 183 }
137 184
185 + if ( $query->has_include() ) {
186 + $args['post__in'] = $query->get_include_ids();
187 + }
188 +
138 189 return $args;
139 190 }
140 191
141 192 /**
142 - * Appends the prepared search clause to our own query's WHERE.
193 + * Appends the prepared search and "needs improvement" clauses to our own query's WHERE.
143 194 *
144 195 * @param string $where The WHERE clause so far.
145 196 * @param WP_Query $wp_query The query being filtered.
146 197 *
147 - * @return string The WHERE clause, with the search clause appended for our query.
198 + * @return string The WHERE clause, with our clauses appended for our own query.
148 199 *
149 200 * @internal Only public because it is registered as a posts_where filter callback.
150 201 */
151 202 public function filter_posts_where( $where, $wp_query ): string {
152 - if ( $wp_query->get( self::SEARCH_FLAG ) ) {
203 + if ( $wp_query->get( self::QUERY_FLAG ) ) {
153 204 $where .= $this->search_where;
205 + $where .= $this->needs_improvement_where;
154 206 }
155 207
156 208 return $where;
157 209 }
@@ -161,22 +213,42 @@
161 213 *
162 214 * The SEO data and edit link of a post the current user cannot edit are withheld, so the post is
163 215 * shown in the list but stays locked and does not expose its metadata.
164 216 *
165 - * @param int $post_id The post ID.
166 - * @param bool $editable Whether the current user may edit the post.
217 + * @param int $post_id The post ID.
218 + * @param bool $editable Whether the current user may edit the post.
219 + * @param bool $scores_enabled Whether the per-field scores may back the needs-improvement verdict.
167 220 *
168 221 * @return Post The post.
169 222 */
170 - private function build_post( int $post_id, bool $editable ): Post {
171 - $post = \get_post( $post_id );
172 - $status = ( $post !== null ) ? (string) $post->post_status : '';
173 - $title = $this->get_normalized_title( $post_id );
223 + private function build_post( int $post_id, bool $editable, bool $scores_enabled ): Post {
224 + $post = \get_post( $post_id );
225 + $status = ( $post !== null ) ? (string) $post->post_status : '';
226 + $post_type = ( $post !== null ) ? (string) $post->post_type : '';
227 + $title = $this->get_normalized_title( $post_id );
174 228
175 229 if ( ! $editable ) {
176 230 return new Post( $post_id, $title, $status, '', '', '', '', '', '', false );
177 231 }
178 232
233 + // Read each field's value once from its meta suffix, keyed by field param, so the values can be reused for
234 + // the needs-improvement filter. Built from the suffix map to keep a single source of truth.
235 + $fields = [];
236 + foreach ( self::FIELD_META_SUFFIXES as $field => $suffix ) {
237 + $fields[ $field ] = $this->get_meta( $post_id, $suffix );
238 + }
239 +
240 + $raw_seo_title = $fields['seo_title'];
241 + $raw_meta_description = $fields['meta_description'];
242 + $raw_social_title = $fields['social_title'];
243 + $raw_social_description = $fields['social_description'];
244 +
245 + // Resolve templates for needs-improvement scoring and as display fallbacks when the stored value is empty.
246 + $fields['seo_title'] = $this->default_template_resolver->resolve_seo_title( $post_id, $post_type, $raw_seo_title );
247 + $fields['meta_description'] = $this->default_template_resolver->resolve_meta_description( $post_id, $post_type, $raw_meta_description );
248 + $fields['social_title'] = $this->default_template_resolver->resolve_social_title( $post_id, $post_type, $raw_social_title );
249 + $fields['social_description'] = $this->default_template_resolver->resolve_social_description( $post_id, $post_type, $raw_social_description );
250 +
179 251 return new Post(
180 252 $post_id,
181 253 $title,
182 254 $status,
@@ -181,14 +253,117 @@
181 253 $title,
182 254 $status,
183 255 (string) \get_edit_post_link( $post_id, 'raw' ),
184 256 $this->get_meta( $post_id, 'focuskw' ),
185 - $this->get_meta( $post_id, 'title' ),
186 - $this->get_meta( $post_id, 'metadesc' ),
187 - $this->get_meta( $post_id, 'opengraph-title' ),
188 - $this->get_meta( $post_id, 'opengraph-description' ),
257 + $raw_seo_title,
258 + $raw_meta_description,
259 + $raw_social_title,
260 + $raw_social_description,
189 261 true,
262 + $this->build_needs_improvement( $post_id, $fields, $scores_enabled ),
263 + ( $raw_seo_title === '' ) ? $fields['seo_title'] : '',
264 + ( $raw_meta_description === '' ) ? $fields['meta_description'] : '',
265 + ( $raw_social_title === '' ) ? $fields['social_title'] : '',
266 + ( $raw_social_description === '' ) ? $fields['social_description'] : '',
190 267 );
268 + }
269 +
270 + /**
271 + * Builds the per-field needs-improvement verdict for a post, keyed by field param.
272 + *
273 + * A field needs improvement when its value is empty, or when its score falls in the bad/ok range.
274 + *
275 + * @param int $post_id The post ID.
276 + * @param array<string, string> $fields The field values, keyed by field param.
277 + * @param bool $scores_enabled Whether the per-field scores may back the verdict.
278 + *
279 + * @return array<string, bool> Whether each field needs improvement, keyed by field param.
280 + */
281 + private function build_needs_improvement( int $post_id, array $fields, bool $scores_enabled ): array {
282 + $needs_improvement = [];
283 + foreach ( \array_keys( self::FIELD_META_SUFFIXES ) as $field ) {
284 + $is_empty = ( ( $fields[ $field ] ?? '' ) === '' );
285 +
286 + $is_bad_score = false;
287 + if ( $scores_enabled && isset( self::FIELD_SCORE_META_SUFFIXES[ $field ] ) ) {
288 + $score = (int) $this->get_meta( $post_id, self::FIELD_SCORE_META_SUFFIXES[ $field ] );
289 + $is_bad_score = ( $score >= self::NEEDS_IMPROVEMENT_MIN_SCORE && $score <= self::NEEDS_IMPROVEMENT_MAX_SCORE );
290 + }
291 +
292 + $needs_improvement[ $field ] = ( $is_empty || $is_bad_score );
293 + }
294 +
295 + return $needs_improvement;
296 + }
297 +
298 + /**
299 + * Builds the prepared "needs improvement" WHERE clause.
300 + *
301 + * A field needs improvement when its meta row is missing or stores an empty string, or — for fields
302 + * with a persisted per-field score and while scoring is enabled — when that score falls in the bad/ok
303 + * range. The selected fields are OR-ed so they broaden the result, and unknown field keys are ignored.
304 + *
305 + * Each field is matched through correlated subqueries rather than WP_Query's meta_query. A `NOT IN`
306 + * subquery over the non-empty rows matches both a missing meta row and a present-but-empty one in one
307 + * shot; a meta_query cannot, because WP_Meta_Query gives its value comparisons their own INNER JOIN,
308 + * which eliminates the missing-row posts before the OR-ed `NOT EXISTS` branch is ever evaluated.
309 + *
310 + * @param array<string> $fields The fields that need improvement.
311 + * @param bool $scores_enabled Whether the per-field scores may back the filter.
312 + * @param string $post_type The post type slug.
313 + *
314 + * @return string The prepared WHERE clause, or an empty string when no known field is selected.
315 + */
316 + protected function build_needs_improvement_where( array $fields, bool $scores_enabled, string $post_type = '' ): string {
317 + global $wpdb;
318 +
319 + $has_fallback = [
320 + 'seo_title' => $this->default_template_resolver->resolve_seo_title( 0, $post_type, '' ) !== '',
321 + 'meta_description' => $this->default_template_resolver->resolve_meta_description( 0, $post_type, '' ) !== '',
322 + 'social_title' => $this->default_template_resolver->resolve_social_title( 0, $post_type, '' ) !== '',
323 + 'social_description' => $this->default_template_resolver->resolve_social_description( 0, $post_type, '' ) !== '',
324 + ];
325 +
326 + $clauses = [];
327 + foreach ( $fields as $field ) {
328 + if ( ! isset( self::FIELD_META_SUFFIXES[ $field ] ) ) {
329 + continue;
330 + }
331 +
332 + $meta_key = self::META_PREFIX . self::FIELD_META_SUFFIXES[ $field ];
333 + $field_clauses = [];
334 +
335 + if ( ! ( $has_fallback[ $field ] ?? false ) ) {
336 + $field_clauses[] = $wpdb->prepare(
337 + '%i.ID NOT IN ( SELECT post_id FROM %i WHERE meta_key = %s AND meta_value <> %s )',
338 + $wpdb->posts,
339 + $wpdb->postmeta,
340 + $meta_key,
341 + '',
342 + );
343 + }
344 +
345 + if ( $scores_enabled && isset( self::FIELD_SCORE_META_SUFFIXES[ $field ] ) ) {
346 + $field_clauses[] = $wpdb->prepare(
347 + '%i.ID IN ( SELECT post_id FROM %i WHERE meta_key = %s AND CAST( meta_value AS SIGNED ) BETWEEN %d AND %d )',
348 + $wpdb->posts,
349 + $wpdb->postmeta,
350 + self::META_PREFIX . self::FIELD_SCORE_META_SUFFIXES[ $field ],
351 + self::NEEDS_IMPROVEMENT_MIN_SCORE,
352 + self::NEEDS_IMPROVEMENT_MAX_SCORE,
353 + );
354 + }
355 +
356 + // Always add a clause per field — use a false condition when no real predicate applies so the
357 + // field still participates in the outer OR group without incorrectly matching every row.
358 + $clauses[] = '( ' . ( ( $field_clauses !== [] ) ? \implode( ' OR ', $field_clauses ) : '0 = 1' ) . ' )';
359 + }
360 +
361 + if ( $clauses === [] ) {
362 + return '';
363 + }
364 +
365 + return ' AND ( ' . \implode( ' OR ', $clauses ) . ' )';
191 366 }
192 367
193 368 /**
194 369 * Builds the prepared catch-all search WHERE clause.