PluginProbe
Yoast SEO – Advanced SEO with real-time guidance and built-in AI / 28.2
Yoast SEO – Advanced SEO with real-time guidance and built-in AI v28.2
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
wordpress-seo / src / bulk-editor / infrastructure / posts / indexable-posts-collector.php

indexable-posts-collector.php in Yoast SEO – Advanced SEO with real-time guidance and built-in AI 28.2, at src/bulk-editor/infrastructure/posts/indexable-posts-collector.php

202 lines 7.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 // phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.
4 namespace Yoast\WP\SEO\Bulk_Editor\Infrastructure\Posts;
5
6 use Yoast\WP\Lib\ORM;
7 use Yoast\WP\SEO\Bulk_Editor\Application\Posts\Posts_Collector_Interface;
8 use Yoast\WP\SEO\Bulk_Editor\Domain\Posts\Post;
9 use Yoast\WP\SEO\Bulk_Editor\Domain\Posts\Posts_List;
10 use Yoast\WP\SEO\Bulk_Editor\Domain\Posts\Posts_Page;
11 use Yoast\WP\SEO\Bulk_Editor\Domain\Posts\Posts_Query;
12 use Yoast\WP\SEO\Models\Indexable;
13 use Yoast\WP\SEO\Repositories\Indexable_Repository;
14
15 /**
16 * Collects bulk editor posts by reading from the indexable table.
17 *
18 * This is the default used when indexables are active.
19 */
20 class Indexable_Posts_Collector implements Posts_Collector_Interface {
21
22 use Post_Title_Trait;
23 use Searchable_Fields_Trait;
24
25 /**
26 * The indexable repository.
27 *
28 * @var Indexable_Repository
29 */
30 private $indexable_repository;
31
32 /**
33 * The resolver for the per-post edit permission.
34 *
35 * @var Post_Editability_Resolver
36 */
37 private $post_editability_resolver;
38
39 /**
40 * The constructor.
41 *
42 * @param Indexable_Repository $indexable_repository The indexable repository.
43 * @param Post_Editability_Resolver $post_editability_resolver The resolver for the per-post edit permission.
44 */
45 public function __construct(
46 Indexable_Repository $indexable_repository,
47 Post_Editability_Resolver $post_editability_resolver
48 ) {
49 $this->indexable_repository = $indexable_repository;
50 $this->post_editability_resolver = $post_editability_resolver;
51 }
52
53 /**
54 * Collects a page of posts for the given query.
55 *
56 * A single page is fetched and counted through the database; the per-post edit permission is then
57 * resolved for that page so posts the user cannot edit are returned locked and without their SEO data.
58 *
59 * @param Posts_Query $query The query describing the page to collect.
60 *
61 * @return Posts_Page The collected posts together with the totals for pagination.
62 */
63 public function get_posts( Posts_Query $query ): Posts_Page {
64 $indexables = $this->build_query( $query )
65 ->order_by_desc( 'object_id' )
66 ->limit( $query->get_per_page() )
67 ->offset( $query->get_offset() )
68 ->find_many();
69
70 // Deduplicate on the post, keeping the query order.
71 $indexables_by_id = [];
72 foreach ( $indexables as $indexable ) {
73 $indexables_by_id[ (int) $indexable->object_id ] = $indexable;
74 }
75
76 $total = $this->resolve_total( $query, \count( $indexables ), \count( $indexables_by_id ) );
77
78 $editability = $this->post_editability_resolver->resolve( \array_keys( $indexables_by_id ) );
79
80 $posts_list = new Posts_List();
81 foreach ( $indexables_by_id as $object_id => $indexable ) {
82 $posts_list->add( $this->build_post( $indexable, ( $editability[ $object_id ] ?? false ) ) );
83 }
84
85 return new Posts_Page( $posts_list, $total, $query->get_page(), $query->get_per_page() );
86 }
87
88 /**
89 * Resolves the total number of matching posts.
90 *
91 * A partially-filled page means the result set ended within it, so the total is known without a
92 * second query: the offset plus the distinct posts on this page. Whether the page ended is judged
93 * on the number of rows fetched, before duplicates are removed, since that is what reveals the
94 * database had no more rows. A full or empty page does not reveal the total, so it falls back to a
95 * count query. Non-editable posts are shown locked rather than removed, so they still count.
96 *
97 * @param Posts_Query $query The query that produced the page.
98 * @param int $fetched The number of rows the page returned, before duplicates are removed.
99 * @param int $distinct The number of distinct posts on the page, after duplicates are removed.
100 *
101 * @return int The total number of matching posts.
102 */
103 private function resolve_total( Posts_Query $query, int $fetched, int $distinct ): int {
104 if ( $fetched > 0 && $fetched < $query->get_per_page() ) {
105 return ( $query->get_offset() + $distinct );
106 }
107
108 return (int) $this->build_query( $query )->count();
109 }
110
111 /**
112 * Builds the filtered indexable query for the given query, without ordering or paging.
113 *
114 * Built fresh for each use so the same filters back both the total count and the page of rows.
115 *
116 * @param Posts_Query $query The query describing the filters to apply.
117 *
118 * @return ORM The filtered query.
119 */
120 private function build_query( Posts_Query $query ): ORM {
121 $builder = $this->indexable_repository->query()
122 ->where( 'object_type', 'post' )
123 ->where( 'object_sub_type', $query->get_content_type() )
124 ->where_in( 'post_status', $query->get_statuses() )
125 // Password-protected posts (is_protected) are not shown in bulk editing.
126 ->where( 'is_protected', 0 );
127
128 if ( $query->has_author_filter() ) {
129 $builder->where( 'author_id', $query->get_author_id() );
130 }
131
132 if ( $query->has_search() ) {
133 $this->apply_search( $builder, $query->get_search() );
134 }
135
136 return $builder;
137 }
138
139 /**
140 * Adds the catch-all search clause to the query.
141 *
142 * The post title lives in the posts table, so it is matched through a subquery while the remaining
143 * fields are matched directly on the indexable. All clauses are OR-ed inside a single group so they
144 * do not interfere with the other filters.
145 *
146 * @param ORM $builder The query to add the search clause to.
147 * @param string $search The search term.
148 *
149 * @return void
150 */
151 private function apply_search( ORM $builder, string $search ): void {
152 global $wpdb;
153
154 $like = '%' . $wpdb->esc_like( $search ) . '%';
155
156 // The post title lives in the posts table, so match it through a subquery; the rest are indexable columns.
157 $clauses = [ 'object_id IN ( SELECT ID FROM ' . $wpdb->posts . ' WHERE post_title LIKE %s )' ];
158 foreach ( \array_keys( $this->searchable_fields() ) as $column ) {
159 $clauses[] = $column . ' LIKE %s';
160 }
161
162 // The ORM binds each %s through $wpdb->prepare; one bound value per clause.
163 $builder->where_raw(
164 '( ' . \implode( ' OR ', $clauses ) . ' )',
165 \array_fill( 0, \count( $clauses ), $like ),
166 );
167 }
168
169 /**
170 * Builds a post from an indexable.
171 *
172 * The SEO data and edit link of a post the current user cannot edit are withheld, so the post is
173 * shown in the list but stays locked and does not expose its metadata.
174 *
175 * @param Indexable $indexable The indexable.
176 * @param bool $editable Whether the current user may edit the post.
177 *
178 * @return Post The post.
179 */
180 private function build_post( Indexable $indexable, bool $editable ): Post {
181 $object_id = (int) $indexable->object_id;
182 $title = $this->get_normalized_title( $object_id );
183
184 if ( ! $editable ) {
185 return new Post( $object_id, $title, (string) $indexable->post_status, '', '', '', '', '', '', false );
186 }
187
188 return new Post(
189 $object_id,
190 $title,
191 (string) $indexable->post_status,
192 (string) \get_edit_post_link( $object_id, 'raw' ),
193 (string) $indexable->primary_focus_keyword,
194 (string) $indexable->title,
195 (string) $indexable->description,
196 (string) $indexable->open_graph_title,
197 (string) $indexable->open_graph_description,
198 true,
199 );
200 }
201 }
202