PluginProbe
WPGraphQL / trunk
WPGraphQL vtrunk
2.22.3 2.22.2 2.22.1 2.22.0 2.21.1 2.21.0 2.20.0 2.19.0 2.18.0 2.17.0 2.16.0 2.15.1 2.15.0 2.14.1 2.14.0 2.13.0 2.2.0 2.3.0 2.3.3 2.3.6 2.3.8 2.5.0 2.5.1 2.5.2 2.5.3 All 177 releases
wp-graphql / src / Data / Cursor / PostObjectCursor.php

PostObjectCursor.php in WPGraphQL trunk, at src/Data/Cursor/PostObjectCursor.php

508 lines 14.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace WPGraphQL\Data\Cursor;
4
5 /**
6 * Post Cursor
7 *
8 * This class generates the SQL AND operators for cursor based pagination for posts
9 *
10 * @package WPGraphQL\Data\Cursor
11 */
12 class PostObjectCursor extends AbstractCursor {
13 /**
14 * @var ?\WP_Post
15 */
16 public $cursor_node;
17
18 /**
19 * Counter for meta value joins
20 *
21 * @var int
22 */
23 public $meta_join_alias = 0;
24
25 /**
26 * {@inheritDoc}
27 */
28 public function __construct( $query_vars, $cursor = 'after' ) {
29 // @todo remove in 3.0.0
30 if ( $query_vars instanceof \WP_Query ) {
31 _doing_it_wrong(
32 __METHOD__,
33 esc_html__( 'The first argument should be an array of $query_vars, not the WP_Query object. This will throw an error in the next major release', 'wp-graphql' ),
34 '1.9.0'
35 );
36 $query_vars = $query_vars->query_vars;
37 }
38
39 // Initialize the class properties.
40 parent::__construct( $query_vars, $cursor );
41
42 // Set ID key.
43 $this->id_key = "{$this->wpdb->posts}.ID";
44 }
45
46 /**
47 * {@inheritDoc}
48 *
49 * @return ?\WP_Post
50 */
51 public function get_cursor_node() {
52 // Bail if no offset.
53 if ( ! $this->cursor_offset ) {
54 return null;
55 }
56
57 /**
58 * If pre-hooked, return filtered node.
59 *
60 * @param \WP_Post|null $pre_post The pre-filtered post node.
61 * @param int $offset The cursor offset.
62 * @param \WPGraphQL\Data\Cursor\PostObjectCursor $node The cursor instance.
63 *
64 * @hookGroup connections
65 * @since 0.0.5
66 *
67 * @return \WP_Post|null
68 */
69 $pre_post = apply_filters( 'graphql_pre_post_cursor_node', null, $this->cursor_offset, $this );
70 if ( null !== $pre_post ) {
71 return $pre_post;
72 }
73
74 // Get cursor node.
75 $post = \WP_Post::get_instance( $this->cursor_offset );
76
77 return false !== $post ? $post : null;
78 }
79
80 /**
81 * {@inheritDoc}
82 */
83 public function to_sql() {
84 $orderby = isset( $this->query_vars['orderby'] ) ? $this->query_vars['orderby'] : null;
85
86 $orderby_should_not_convert_to_sql = isset( $orderby ) && in_array(
87 $orderby,
88 [
89 'post__in',
90 'post_name__in',
91 'post_parent__in',
92 ],
93 true
94 );
95
96 if ( true === $orderby_should_not_convert_to_sql ) {
97 return '';
98 }
99
100 $sql = $this->builder->to_sql();
101
102 if ( empty( $sql ) ) {
103 return '';
104 }
105
106 return ' AND ' . $sql;
107 }
108
109 /**
110 * {@inheritDoc}
111 */
112 public function get_where() {
113 // If we have a bad cursor, just skip.
114 if ( ! $this->is_valid_offset_and_node() ) {
115 return '';
116 }
117
118 $orderby = $this->get_query_var( 'orderby' );
119 $order = $this->get_query_var( 'order' );
120
121 if ( 'menu_order' === $orderby ) {
122 if ( '>' === $this->compare ) {
123 $order = 'DESC';
124 $this->compare = '<';
125 } elseif ( '<' === $this->compare ) {
126 $this->compare = '>';
127 $order = 'ASC';
128 }
129 }
130
131 if ( ! empty( $orderby ) && is_array( $orderby ) ) {
132
133 /**
134 * Loop through all order keys if it is an array
135 */
136 foreach ( $orderby as $by => $order ) {
137 $this->compare_with( $by, $order );
138 }
139 } elseif ( ! empty( $orderby ) && is_string( $orderby ) ) {
140
141 /**
142 * If $orderby is just a string just compare with it directly as DESC
143 */
144 $this->compare_with( $orderby, $order );
145 }
146
147 /**
148 * If there's no orderby specified yet, compare with the following fields.
149 */
150 if ( ! $this->builder->has_fields() ) {
151 $this->compare_with_cursor_fields(
152 [
153 [
154 'key' => "{$this->wpdb->posts}.post_date",
155 'value' => $this->cursor_node ? $this->cursor_node->post_date : null,
156 'type' => 'DATETIME',
157 ],
158 ]
159 );
160 }
161
162 $this->compare_with_id_field();
163
164 /**
165 * When WP_Query applies its search relevance ordering, the relevance expression
166 * is the primary sort, ahead of the date/ID comparisons collected above, so the
167 * cursor cutoff must compare against it first or pages will drop and duplicate
168 * results whenever relevance order differs from date order.
169 *
170 * @see https://github.com/wp-graphql/wp-graphql/issues/1818
171 */
172 $relevance = $this->get_search_relevance_compare_config();
173
174 if ( null !== $relevance ) {
175 $inner_sql = $this->builder->to_sql();
176
177 if ( '' !== trim( $inner_sql ) ) {
178 /**
179 * The multi-term CASE expression sorts ascending while the rest of the
180 * ordering (and the single-term boolean expression) sorts descending, so
181 * the CASE comparison is the inverse of the cursor's base compare. This
182 * holds for backward pagination too, where Config inverts the relevance
183 * expression in the ORDER BY (see graphql_wp_query_cursor_pagination_search_orderby).
184 */
185 $compare = $relevance['ascending'] ? ( '<' === $this->compare ? '>' : '<' ) : $this->compare;
186
187 // Mirrors CursorBuilder::to_sql() nesting: rows past the cursor either rank
188 // differently, or rank the same and resolve via the date/ID comparisons.
189 return ' AND ' . sprintf(
190 ' %1$s %2$s= %3$d AND ( %1$s %2$s %3$d OR (%4$s ) ) ',
191 $relevance['expression'],
192 $compare,
193 $relevance['value'],
194 $inner_sql
195 );
196 }
197 }
198
199 return $this->to_sql();
200 }
201
202 /**
203 * When WP_Query's native search relevance ordering is active for this query, returns
204 * the relevance expression (matching what WP_Query::parse_search_order() generates),
205 * the cursor node's computed rank, and the expression's sort direction. Returns null
206 * when relevance ordering is not in play.
207 *
208 * @since 2.17.0
209 *
210 * @return array{expression:string,value:int,ascending:bool}|null
211 */
212 private function get_search_relevance_compare_config() {
213 $search = $this->get_query_var( 's' );
214 $orderby = $this->get_query_var( 'orderby' );
215 $search_orderby_title = $this->get_query_var( 'search_orderby_title' );
216
217 // Mirrors WP_Query: relevance ordering applies when searching, title ordering
218 // clauses exist, and no other orderby is specified (or it is explicitly 'relevance').
219 if ( empty( $search ) || ! is_string( $search ) || empty( $search_orderby_title ) || ! is_array( $search_orderby_title ) ) {
220 return null;
221 }
222
223 if ( ! empty( $orderby ) && 'relevance' !== $orderby ) {
224 return null;
225 }
226
227 $node = $this->cursor_node;
228
229 if ( ! $node instanceof \WP_Post ) {
230 return null;
231 }
232
233 $terms_count = absint( $this->get_query_var( 'search_terms_count' ) ?? 1 );
234
235 if ( $terms_count > 1 ) {
236 $expression = $this->get_multi_term_relevance_expression( $search, $search_orderby_title );
237
238 if ( '' === $expression ) {
239 return null;
240 }
241
242 return [
243 'expression' => $expression,
244 'value' => $this->get_multi_term_relevance_rank( $node, $search, $search_orderby_title ),
245 'ascending' => true,
246 ];
247 }
248
249 // Single word or sentence search: the expression is a boolean title match, sorted DESC.
250 $exact = (bool) $this->get_query_var( 'exact' );
251 $matches = $exact ? 0 === strcasecmp( $node->post_title, $search ) : false !== stripos( $node->post_title, $search );
252
253 return [
254 'expression' => '(' . reset( $search_orderby_title ) . ')',
255 'value' => $matches ? 1 : 0,
256 'ascending' => false,
257 ];
258 }
259
260 /**
261 * Builds the same CASE expression WP_Query::parse_search_order() generates for
262 * multi-term searches, reusing the prepared title LIKE clauses WP_Query stored in
263 * the query vars during parse_search().
264 *
265 * @since 2.17.0
266 *
267 * @param string $search The raw search string (`s` query var).
268 * @param string[] $search_orderby_title The prepared post_title LIKE clauses from WP_Query.
269 */
270 private function get_multi_term_relevance_expression( string $search, array $search_orderby_title ): string {
271 $num_terms = count( $search_orderby_title );
272
273 // If the search terms contain negative queries, WP doesn't order by sentence matches.
274 $like = '';
275 if ( ! preg_match( '/(?:\s|^)\-/', $search ) ) {
276 $like = "'%" . esc_sql( $this->wpdb->esc_like( $search ) ) . "%'";
277 }
278
279 $expression = '';
280
281 // Sentence match in 'post_title'.
282 if ( $like ) {
283 $expression .= "WHEN {$this->wpdb->posts}.post_title LIKE {$like} THEN 1 "; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
284 }
285
286 // Sanity limit, sort as sentence when more than 6 terms.
287 if ( $num_terms < 7 ) {
288 // All words in title.
289 $expression .= 'WHEN ' . implode( ' AND ', $search_orderby_title ) . ' THEN 2 ';
290 // Any word in title, not needed when $num_terms == 1.
291 if ( $num_terms > 1 ) {
292 $expression .= 'WHEN ' . implode( ' OR ', $search_orderby_title ) . ' THEN 3 ';
293 }
294 }
295
296 // Sentence match in 'post_content' and 'post_excerpt'.
297 if ( $like ) {
298 $expression .= "WHEN {$this->wpdb->posts}.post_excerpt LIKE {$like} THEN 4 "; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
299 $expression .= "WHEN {$this->wpdb->posts}.post_content LIKE {$like} THEN 5 "; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
300 }
301
302 return '' !== $expression ? '(CASE ' . $expression . 'ELSE 6 END)' : '';
303 }
304
305 /**
306 * Computes the cursor node's rank under the multi-term relevance CASE expression,
307 * evaluating the same conditions in the same order as the SQL.
308 *
309 * @since 2.17.0
310 *
311 * @param \WP_Post $node The cursor node post.
312 * @param string $search The raw search string (`s` query var).
313 * @param string[] $search_orderby_title The prepared post_title LIKE clauses from WP_Query.
314 */
315 private function get_multi_term_relevance_rank( \WP_Post $node, string $search, array $search_orderby_title ): int {
316 $num_terms = count( $search_orderby_title );
317
318 $sentence_match_applies = ! preg_match( '/(?:\s|^)\-/', $search );
319
320 // Sentence match in 'post_title'.
321 if ( $sentence_match_applies && false !== stripos( $node->post_title, $search ) ) {
322 return 1;
323 }
324
325 if ( $num_terms < 7 ) {
326 $terms = $this->get_query_var( 'search_terms' );
327 $terms = is_array( $terms ) ? array_values(
328 array_filter(
329 $terms,
330 static function ( $term ) {
331 return is_string( $term ) && '' !== $term && '-' !== $term[0];
332 }
333 )
334 ) : [];
335
336 if ( ! empty( $terms ) ) {
337 $matched = 0;
338 foreach ( $terms as $term ) {
339 if ( false !== stripos( $node->post_title, $term ) ) {
340 ++$matched;
341 }
342 }
343
344 // All words in title.
345 if ( count( $terms ) === $matched ) {
346 return 2;
347 }
348
349 // Any word in title.
350 if ( $num_terms > 1 && $matched > 0 ) {
351 return 3;
352 }
353 }
354 }
355
356 // Sentence match in 'post_excerpt' and 'post_content'.
357 if ( $sentence_match_applies && false !== stripos( $node->post_excerpt, $search ) ) {
358 return 4;
359 }
360
361 if ( $sentence_match_applies && false !== stripos( $node->post_content, $search ) ) {
362 return 5;
363 }
364
365 return 6;
366 }
367
368 /**
369 * Get AND operator for given order by key
370 *
371 * @param string $by The order by key
372 * @param string $order The order direction ASC or DESC
373 */
374 private function compare_with( $by, $order ): void {
375 // Bail early, if "key" and "value" provided in query_vars.
376 $key = $this->get_query_var( "graphql_cursor_compare_by_{$by}_key" );
377 $value = $this->get_query_var( "graphql_cursor_compare_by_{$by}_value" );
378 if ( ! empty( $key ) && ! empty( $value ) ) {
379 $this->builder->add_field( $key, $value, null, $order );
380 return;
381 }
382
383 /**
384 * Find out whether this is a post field
385 */
386 $orderby_post_fields = [
387 'post_author',
388 'post_title',
389 'post_type',
390 'post_name',
391 'post_modified',
392 'post_date',
393 'post_parent',
394 'menu_order',
395 ];
396 if ( in_array( $by, $orderby_post_fields, true ) ) {
397 $key = "{$this->wpdb->posts}.{$by}";
398 $value = $this->cursor_node->{$by} ?? null;
399 }
400
401 /**
402 * If key or value are null, check whether this is a meta key based ordering before bailing.
403 */
404 if ( null === $key || null === $value ) {
405 $meta_key = $this->get_meta_key( $by );
406 if ( $meta_key ) {
407 $this->compare_with_meta_field( $meta_key, $order );
408 }
409 return;
410 }
411
412 // Add field to build.
413 $this->builder->add_field( $key, $value, null, $order );
414 }
415
416 /**
417 * Compare with meta key field
418 *
419 * @param string $meta_key post meta key
420 * @param string $order The comparison string
421 */
422 private function compare_with_meta_field( string $meta_key, string $order ): void {
423 $meta_type = $this->get_query_var( 'meta_type' );
424 $meta_value = get_post_meta( $this->cursor_offset, $meta_key, true );
425
426 $key = "{$this->wpdb->postmeta}.meta_value";
427
428 /**
429 * WP uses mt1, mt2 etc. style aliases for additional meta value joins.
430 */
431 $meta_query = $this->get_query_var( 'meta_query' );
432 if ( ! empty( $meta_query ) && is_array( $meta_query ) ) {
433 if ( ! empty( $meta_query['relation'] ) ) {
434 unset( $meta_query['relation'] );
435 }
436
437 $meta_keys = array_column( $meta_query, 'key' );
438 $index = array_search( $meta_key, $meta_keys, true );
439
440 if ( $index && 1 < count( $meta_query ) ) {
441 $key = "mt{$index}.meta_value";
442 }
443 }
444
445 /**
446 * Allow filtering the meta key used for cursor based pagination
447 *
448 * @param string $key The meta key to use for cursor based pagination
449 * @param string $meta_key The original meta key
450 * @param string $meta_type The meta type
451 * @param string $order The order direction
452 * @param object $cursor The PostObjectCursor instance
453 *
454 * @hookGroup connections
455 * @since 0.0.5
456 */
457 $key = apply_filters( 'graphql_post_object_cursor_meta_key', $key, $meta_key, $meta_type, $order, $this );
458
459 $this->builder->add_field( $key, $meta_value, $meta_type, $order, $this );
460 }
461
462 /**
463 * Get the actual meta key if any
464 *
465 * @param string $by The order by key
466 *
467 * @return string|null
468 */
469 private function get_meta_key( $by ) {
470 if ( 'meta_value' === $by || 'meta_value_num' === $by ) {
471 return $this->get_query_var( 'meta_key' );
472 }
473
474 /**
475 * Check for the WP 4.2+ style meta clauses
476 * https://make.wordpress.org/core/2015/03/30/query-improvements-in-wp-4-2-orderby-and-meta_query/
477 */
478 if ( ! isset( $this->query_vars['meta_query'][ $by ] ) ) {
479 return null;
480 }
481
482 $clause = $this->query_vars['meta_query'][ $by ];
483
484 return empty( $clause['key'] ) ? null : $clause['key'];
485 }
486
487 /**
488 * @todo Remove in 3.0.0
489 * @deprecated 1.9.0
490 * @codeCoverageIgnore
491 *
492 * @return ?\WP_Post
493 */
494 public function get_cursor_post() {
495 _doing_it_wrong(
496 __METHOD__,
497 sprintf(
498 // translators: %s is the method name
499 esc_html__( 'This method will be removed in the next major release. Use %s instead.', 'wp-graphql' ),
500 self::class . '::get_cursor_node()'
501 ),
502 '1.9.0'
503 );
504
505 return $this->cursor_node;
506 }
507 }
508