| 1 |
<?php |
| 2 |
|
| 3 |
namespace WPGraphQL\Data\Connection; |
| 4 |
|
| 5 |
use GraphQL\Error\InvariantViolation; |
| 6 |
use GraphQL\Type\Definition\ResolveInfo; |
| 7 |
use WPGraphQL\AppContext; |
| 8 |
use WPGraphQL\Model\Post; |
| 9 |
use WPGraphQL\Utils\Utils; |
| 10 |
|
| 11 |
/** |
| 12 |
* Class PostObjectConnectionResolver |
| 13 |
* |
| 14 |
* @package WPGraphQL\Data\Connection |
| 15 |
* @extends \WPGraphQL\Data\Connection\AbstractConnectionResolver<\WP_Query> |
| 16 |
*/ |
| 17 |
class PostObjectConnectionResolver extends AbstractConnectionResolver { |
| 18 |
|
| 19 |
/** |
| 20 |
* The name of the post type, or array of post types the connection resolver is resolving for |
| 21 |
* |
| 22 |
* @var mixed|string|string[] |
| 23 |
*/ |
| 24 |
protected $post_type; |
| 25 |
|
| 26 |
/** |
| 27 |
* {@inheritDoc} |
| 28 |
* |
| 29 |
* @param mixed|string|string[] $post_type The post type to resolve for |
| 30 |
*/ |
| 31 |
public function __construct( $source, array $args, AppContext $context, ResolveInfo $info, $post_type = 'any' ) { |
| 32 |
|
| 33 |
/** |
| 34 |
* The $post_type can either be a single value or an array of post_types to |
| 35 |
* pass to WP_Query. |
| 36 |
* |
| 37 |
* If the value is revision or attachment, we will leave the value |
| 38 |
* as a string, as we validate against this later. |
| 39 |
* |
| 40 |
* If the value is anything else, we cast as an array. For example |
| 41 |
* |
| 42 |
* $post_type = 'post' would become [ 'post ' ], as we check later |
| 43 |
* for `in_array()` if the $post_type is not "attachment" or "revision" |
| 44 |
*/ |
| 45 |
if ( 'revision' === $post_type || 'attachment' === $post_type ) { |
| 46 |
$this->post_type = $post_type; |
| 47 |
} elseif ( 'any' === $post_type ) { |
| 48 |
$post_types = \WPGraphQL::get_allowed_post_types(); |
| 49 |
$this->post_type = ! empty( $post_types ) ? array_values( $post_types ) : []; |
| 50 |
} else { |
| 51 |
$post_type = is_array( $post_type ) ? $post_type : [ $post_type ]; |
| 52 |
unset( $post_type['attachment'] ); |
| 53 |
unset( $post_type['revision'] ); |
| 54 |
$this->post_type = $post_type; |
| 55 |
} |
| 56 |
|
| 57 |
/** |
| 58 |
* Call the parent construct to setup class data |
| 59 |
*/ |
| 60 |
parent::__construct( $source, $args, $context, $info ); |
| 61 |
} |
| 62 |
|
| 63 |
/** |
| 64 |
* {@inheritDoc} |
| 65 |
*/ |
| 66 |
protected function loader_name(): string { |
| 67 |
return 'post'; |
| 68 |
} |
| 69 |
|
| 70 |
/** |
| 71 |
* {@inheritDoc} |
| 72 |
*/ |
| 73 |
protected function query_class(): string { |
| 74 |
return \WP_Query::class; |
| 75 |
} |
| 76 |
|
| 77 |
/** |
| 78 |
* {@inheritDoc} |
| 79 |
* |
| 80 |
* @throws \GraphQL\Error\InvariantViolation If the query has been modified to suppress_filters. |
| 81 |
*/ |
| 82 |
protected function query( array $query_args ) { |
| 83 |
$query = parent::query( $query_args ); |
| 84 |
|
| 85 |
if ( isset( $query->query_vars['suppress_filters'] ) && true === $query->query_vars['suppress_filters'] ) { |
| 86 |
throw new InvariantViolation( esc_html__( 'WP_Query has been modified by a plugin or theme to suppress_filters, which will cause issues with WPGraphQL Execution. If you need to suppress filters for a specific reason within GraphQL, consider registering a custom field to the WPGraphQL Schema with a custom resolver.', 'wp-graphql' ) ); |
| 87 |
} |
| 88 |
|
| 89 |
return $query; |
| 90 |
} |
| 91 |
|
| 92 |
/** |
| 93 |
* {@inheritDoc} |
| 94 |
*/ |
| 95 |
public function get_ids_from_query() { |
| 96 |
/** |
| 97 |
* @todo This is for b/c. We can just use $this->get_query(). |
| 98 |
*/ |
| 99 |
$query = isset( $this->query ) ? $this->query : $this->get_query(); |
| 100 |
|
| 101 |
/** @var int[] */ |
| 102 |
$ids = ! empty( $query->posts ) ? $query->posts : []; |
| 103 |
|
| 104 |
// If we're going backwards, we need to reverse the array. |
| 105 |
$args = $this->get_args(); |
| 106 |
|
| 107 |
if ( ! empty( $args['last'] ) ) { |
| 108 |
$ids = array_reverse( $ids ); |
| 109 |
} |
| 110 |
|
| 111 |
return $ids; |
| 112 |
} |
| 113 |
|
| 114 |
/** |
| 115 |
* {@inheritDoc} |
| 116 |
*/ |
| 117 |
public function should_execute() { |
| 118 |
/** |
| 119 |
* If the post_type is not revision we can just return the parent::should_execute(). |
| 120 |
* |
| 121 |
* @todo This works because AbstractConnectionResolver::pre_should_execute does a permission check on the `Post` model ) |
| 122 |
*/ |
| 123 |
if ( ! isset( $this->post_type ) || 'revision' !== $this->post_type ) { |
| 124 |
return parent::should_execute(); |
| 125 |
} |
| 126 |
|
| 127 |
// If the connection is from the RootQuery (i.e. it doesn't have a `Post` source), check if the user has the 'edit_posts' capability. |
| 128 |
if ( ! $this->source instanceof Post && current_user_can( 'edit_posts' ) ) { |
| 129 |
return true; |
| 130 |
} |
| 131 |
|
| 132 |
// For revisions, we only want to execute the connection query if the user has access to edit the parent post. |
| 133 |
if ( $this->source instanceof Post && isset( $this->source->post_type ) ) { |
| 134 |
$parent_post_type_obj = get_post_type_object( $this->source->post_type ); |
| 135 |
|
| 136 |
if ( isset( $parent_post_type_obj->cap->edit_post ) && current_user_can( $parent_post_type_obj->cap->edit_post, $this->source->databaseId ) ) { |
| 137 |
return true; |
| 138 |
} |
| 139 |
} |
| 140 |
|
| 141 |
return false; |
| 142 |
} |
| 143 |
|
| 144 |
/** |
| 145 |
* {@inheritDoc} |
| 146 |
*/ |
| 147 |
protected function prepare_query_args( array $args ): array { |
| 148 |
/** |
| 149 |
* Prepare for later use |
| 150 |
*/ |
| 151 |
$last = ! empty( $args['last'] ) ? $args['last'] : null; |
| 152 |
|
| 153 |
$query_args = []; |
| 154 |
/** |
| 155 |
* Ignore sticky posts by default |
| 156 |
*/ |
| 157 |
$query_args['ignore_sticky_posts'] = true; |
| 158 |
|
| 159 |
/** |
| 160 |
* Set the post_type for the query based on the type of post being queried |
| 161 |
*/ |
| 162 |
$query_args['post_type'] = ! empty( $this->post_type ) ? $this->post_type : 'post'; |
| 163 |
|
| 164 |
/** |
| 165 |
* Don't calculate the total rows, it's not needed and can be expensive |
| 166 |
*/ |
| 167 |
$query_args['no_found_rows'] = true; |
| 168 |
|
| 169 |
/** |
| 170 |
* Set the post_status to "publish" by default |
| 171 |
*/ |
| 172 |
$query_args['post_status'] = 'publish'; |
| 173 |
|
| 174 |
/** |
| 175 |
* Set posts_per_page the highest value of $first and $last, with a (filterable) max of 100 |
| 176 |
*/ |
| 177 |
$query_args['posts_per_page'] = $this->one_to_one ? 1 : $this->get_query_amount() + 1; |
| 178 |
|
| 179 |
// set the graphql cursor args |
| 180 |
$query_args['graphql_cursor_compare'] = ! empty( $last ) ? '>' : '<'; |
| 181 |
$query_args['graphql_after_cursor'] = $this->get_after_offset(); |
| 182 |
$query_args['graphql_before_cursor'] = $this->get_before_offset(); |
| 183 |
|
| 184 |
/** |
| 185 |
* If the cursor offsets not empty, |
| 186 |
* ignore sticky posts on the query |
| 187 |
*/ |
| 188 |
if ( ! empty( $this->get_after_offset() ) || ! empty( $this->get_before_offset() ) ) { |
| 189 |
$query_args['ignore_sticky_posts'] = true; |
| 190 |
} |
| 191 |
|
| 192 |
/** |
| 193 |
* Pass the graphql $args to the WP_Query |
| 194 |
*/ |
| 195 |
$query_args['graphql_args'] = $args; |
| 196 |
|
| 197 |
/** |
| 198 |
* Collect the input_fields and sanitize them to prepare them for sending to the WP_Query |
| 199 |
*/ |
| 200 |
$input_fields = []; |
| 201 |
if ( ! empty( $args['where'] ) ) { |
| 202 |
$input_fields = $this->sanitize_input_fields( $args['where'] ); |
| 203 |
} |
| 204 |
|
| 205 |
/** |
| 206 |
* If the post_type is "attachment", use 'any' status to catch all attachment statuses. |
| 207 |
* |
| 208 |
* Most attachments have 'inherit' status (default), but plugins may change |
| 209 |
* attachment status to 'publish' or other statuses. Using 'any' ensures we catch |
| 210 |
* attachments regardless of their status. The Model's is_private() method will handle |
| 211 |
* privacy checks based on the attachment's parent and status. |
| 212 |
* |
| 213 |
* For revisions, keep the default 'inherit' status. |
| 214 |
*/ |
| 215 |
if ( 'attachment' === $this->post_type ) { |
| 216 |
$query_args['post_status'] = 'any'; |
| 217 |
} elseif ( 'revision' === $this->post_type ) { |
| 218 |
$query_args['post_status'] = 'inherit'; |
| 219 |
} |
| 220 |
|
| 221 |
/** |
| 222 |
* Unset the "post_parent" for attachments, as we don't really care if they |
| 223 |
* have a post_parent set by default |
| 224 |
*/ |
| 225 |
if ( 'attachment' === $this->post_type && isset( $input_fields['parent'] ) ) { |
| 226 |
unset( $input_fields['parent'] ); |
| 227 |
} |
| 228 |
|
| 229 |
/** |
| 230 |
* Merge the input_fields with the default query_args |
| 231 |
*/ |
| 232 |
if ( ! empty( $input_fields ) ) { |
| 233 |
$query_args = array_merge( $query_args, $input_fields ); |
| 234 |
} |
| 235 |
|
| 236 |
/** |
| 237 |
* If the query is a search, the source is not another Post, and the parent input $arg is not |
| 238 |
* explicitly set in the query, unset the $query_args['post_parent'] so the search |
| 239 |
* can search all posts, not just top level posts. |
| 240 |
* |
| 241 |
* The search input arg is mapped to `s` before this point (see sanitize_input_fields). |
| 242 |
*/ |
| 243 |
if ( ! $this->source instanceof \WP_Post && isset( $query_args['s'] ) && ! isset( $input_fields['parent'] ) ) { |
| 244 |
unset( $query_args['post_parent'] ); |
| 245 |
} |
| 246 |
|
| 247 |
if ( empty( $args['where']['orderby'] ) && ! empty( $query_args['post__in'] ) ) { |
| 248 |
$post_in = $query_args['post__in']; |
| 249 |
// Make sure the IDs are integers |
| 250 |
$post_in = array_map( |
| 251 |
static function ( $id ) { |
| 252 |
return absint( $id ); |
| 253 |
}, |
| 254 |
$post_in |
| 255 |
); |
| 256 |
|
| 257 |
// If we're coming backwards, let's reverse the IDs |
| 258 |
if ( ! empty( $args['last'] ) || ! empty( $args['before'] ) ) { |
| 259 |
$post_in = array_reverse( $post_in ); |
| 260 |
} |
| 261 |
|
| 262 |
$cursor_offset = $this->get_offset_for_cursor( $args['after'] ?? ( $args['before'] ?? 0 ) ); |
| 263 |
|
| 264 |
if ( ! empty( $cursor_offset ) ) { |
| 265 |
// Determine if the offset is in the array |
| 266 |
$key = array_search( $cursor_offset, $post_in, true ); |
| 267 |
|
| 268 |
// If the offset is in the array |
| 269 |
if ( false !== $key ) { |
| 270 |
$key = absint( $key ); |
| 271 |
$post_in = array_slice( $post_in, $key + 1, null, true ); |
| 272 |
} |
| 273 |
} |
| 274 |
|
| 275 |
$query_args['post__in'] = $post_in; |
| 276 |
$query_args['orderby'] = 'post__in'; |
| 277 |
$query_args['order'] = isset( $last ) ? 'ASC' : 'DESC'; |
| 278 |
} |
| 279 |
|
| 280 |
/** |
| 281 |
* Map the orderby inputArgs to the WP_Query |
| 282 |
*/ |
| 283 |
if ( isset( $args['where']['orderby'] ) && is_array( $args['where']['orderby'] ) ) { |
| 284 |
$query_args['orderby'] = []; |
| 285 |
|
| 286 |
foreach ( $args['where']['orderby'] as $orderby_input ) { |
| 287 |
// Create a type hint for orderby_input. This is an array with a field and order key. |
| 288 |
/** @var array<string,string> $orderby_input */ |
| 289 |
if ( empty( $orderby_input['field'] ) ) { |
| 290 |
continue; |
| 291 |
} |
| 292 |
|
| 293 |
/** |
| 294 |
* These orderby options should not include the order parameter. |
| 295 |
*/ |
| 296 |
if ( in_array( |
| 297 |
$orderby_input['field'], |
| 298 |
[ |
| 299 |
'post__in', |
| 300 |
'post_name__in', |
| 301 |
'post_parent__in', |
| 302 |
], |
| 303 |
true |
| 304 |
) ) { |
| 305 |
$query_args['orderby'] = esc_sql( $orderby_input['field'] ); |
| 306 |
|
| 307 |
// If we're ordering explicitly, there's no reason to check other orderby inputs. |
| 308 |
break; |
| 309 |
} |
| 310 |
|
| 311 |
$order = $orderby_input['order']; |
| 312 |
|
| 313 |
if ( isset( $query_args['graphql_args']['last'] ) && ! empty( $query_args['graphql_args']['last'] ) ) { |
| 314 |
if ( 'ASC' === $order ) { |
| 315 |
$order = 'DESC'; |
| 316 |
} else { |
| 317 |
$order = 'ASC'; |
| 318 |
} |
| 319 |
} |
| 320 |
|
| 321 |
$query_args['orderby'][ esc_sql( $orderby_input['field'] ) ] = esc_sql( $order ); |
| 322 |
} |
| 323 |
} |
| 324 |
|
| 325 |
/** |
| 326 |
* Convert meta_value_num to separate meta_value value field which our |
| 327 |
* graphql_wp_term_query_cursor_pagination_support knowns how to handle |
| 328 |
*/ |
| 329 |
if ( isset( $query_args['orderby'] ) && 'meta_value_num' === $query_args['orderby'] ) { |
| 330 |
$query_args['orderby'] = [ |
| 331 |
'meta_value' => empty( $query_args['order'] ) ? 'DESC' : $query_args['order'], // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value |
| 332 |
]; |
| 333 |
unset( $query_args['order'] ); |
| 334 |
$query_args['meta_type'] = 'NUMERIC'; |
| 335 |
} |
| 336 |
|
| 337 |
/** |
| 338 |
* If there's no orderby params in the inputArgs, set order based on the first/last argument |
| 339 |
*/ |
| 340 |
if ( empty( $query_args['orderby'] ) ) { |
| 341 |
$query_args['order'] = ! empty( $last ) ? 'ASC' : 'DESC'; |
| 342 |
} |
| 343 |
|
| 344 |
/** |
| 345 |
* NOTE: Only IDs should be queried here as the Deferred resolution will handle |
| 346 |
* fetching the full objects, either from cache of from a follow-up query to the DB |
| 347 |
*/ |
| 348 |
$query_args['fields'] = 'ids'; |
| 349 |
|
| 350 |
/** |
| 351 |
* Filter the $query args to allow folks to customize queries programmatically |
| 352 |
* |
| 353 |
* @param array<string,mixed> $query_args The args that will be passed to the WP_Query |
| 354 |
* @param mixed $source The source that's passed down the GraphQL queries |
| 355 |
* @param array<string,mixed> $args The inputArgs on the field |
| 356 |
* @param \WPGraphQL\AppContext $context The AppContext passed down the GraphQL tree |
| 357 |
* @param \GraphQL\Type\Definition\ResolveInfo $info The ResolveInfo passed down the GraphQL tree |
| 358 |
* |
| 359 |
* @hookGroup connections |
| 360 |
* @since 0.0.6 |
| 361 |
*/ |
| 362 |
return apply_filters( 'graphql_post_object_connection_query_args', $query_args, $this->source, $args, $this->context, $this->info ); |
| 363 |
} |
| 364 |
|
| 365 |
/** |
| 366 |
* This sets up the "allowed" args, and translates the GraphQL-friendly keys to WP_Query |
| 367 |
* friendly keys. There's probably a cleaner/more dynamic way to approach this, but |
| 368 |
* this was quick. I'd be down to explore more dynamic ways to map this, but for |
| 369 |
* now this gets the job done. |
| 370 |
* |
| 371 |
* @param array<string,mixed> $where_args The args passed to the connection |
| 372 |
* |
| 373 |
* @return array<string,mixed> |
| 374 |
* @since 0.0.5 |
| 375 |
*/ |
| 376 |
public function sanitize_input_fields( array $where_args ) { |
| 377 |
$arg_mapping = [ |
| 378 |
'authorIn' => 'author__in', |
| 379 |
'authorName' => 'author_name', |
| 380 |
'authorNotIn' => 'author__not_in', |
| 381 |
'categoryId' => 'cat', |
| 382 |
'categoryIn' => 'category__in', |
| 383 |
'categoryName' => 'category_name', |
| 384 |
'categoryNotIn' => 'category__not_in', |
| 385 |
'contentTypes' => 'post_type', |
| 386 |
'dateQuery' => 'date_query', |
| 387 |
'hasPassword' => 'has_password', |
| 388 |
'id' => 'p', |
| 389 |
'in' => 'post__in', |
| 390 |
'mimeType' => 'post_mime_type', |
| 391 |
'nameIn' => 'post_name__in', |
| 392 |
'notIn' => 'post__not_in', |
| 393 |
'parent' => 'post_parent', |
| 394 |
'parentIn' => 'post_parent__in', |
| 395 |
'parentNotIn' => 'post_parent__not_in', |
| 396 |
'password' => 'post_password', |
| 397 |
'search' => 's', |
| 398 |
'stati' => 'post_status', |
| 399 |
'status' => 'post_status', |
| 400 |
'tagId' => 'tag_id', |
| 401 |
'tagIds' => 'tag__and', |
| 402 |
'tagIn' => 'tag__in', |
| 403 |
'tagNotIn' => 'tag__not_in', |
| 404 |
'tagSlugAnd' => 'tag_slug__and', |
| 405 |
'tagSlugIn' => 'tag_slug__in', |
| 406 |
]; |
| 407 |
|
| 408 |
/** |
| 409 |
* Map and sanitize the input args to the WP_Query compatible args |
| 410 |
*/ |
| 411 |
$query_args = Utils::map_input( $where_args, $arg_mapping ); |
| 412 |
|
| 413 |
if ( ! empty( $query_args['post_status'] ) ) { |
| 414 |
$allowed_stati = $this->sanitize_post_stati( $query_args['post_status'] ); |
| 415 |
$query_args['post_status'] = ! empty( $allowed_stati ) ? $allowed_stati : [ 'publish' ]; |
| 416 |
} |
| 417 |
|
| 418 |
/** |
| 419 |
* Filter the result set by sticky posts. |
| 420 |
* |
| 421 |
* WPGraphQL never floats sticky posts to the top of the results (ignore_sticky_posts |
| 422 |
* is always true, which is also cursor-pagination safe). Instead `isSticky` filters |
| 423 |
* the result set, modeling the WP REST API `sticky` parameter: true limits the query |
| 424 |
* to sticky posts, false excludes them. |
| 425 |
*/ |
| 426 |
if ( isset( $where_args['isSticky'] ) ) { |
| 427 |
$sticky_posts = get_option( 'sticky_posts', [] ); |
| 428 |
$sticky_posts = is_array( $sticky_posts ) ? array_map( 'absint', $sticky_posts ) : []; |
| 429 |
|
| 430 |
if ( true === $where_args['isSticky'] ) { |
| 431 |
// Limit to sticky posts, intersecting with any explicit `in` filter. |
| 432 |
$query_args['post__in'] = ! empty( $query_args['post__in'] ) |
| 433 |
? array_intersect( $sticky_posts, $query_args['post__in'] ) |
| 434 |
: $sticky_posts; |
| 435 |
|
| 436 |
// WP_Query ignores an empty post__in, so force an impossible ID to return no results. |
| 437 |
if ( empty( $query_args['post__in'] ) ) { |
| 438 |
$query_args['post__in'] = [ 0 ]; |
| 439 |
} |
| 440 |
} elseif ( ! empty( $sticky_posts ) ) { |
| 441 |
// Exclude sticky posts, merging with any explicit `notIn` filter. |
| 442 |
$query_args['post__not_in'] = array_merge( // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in |
| 443 |
! empty( $query_args['post__not_in'] ) ? $query_args['post__not_in'] : [], |
| 444 |
$sticky_posts |
| 445 |
); |
| 446 |
} |
| 447 |
} |
| 448 |
|
| 449 |
/** |
| 450 |
* Filter the connection by the per-post template assignment (`_wp_page_template`), |
| 451 |
* which covers both classic page templates and block-theme custom templates. |
| 452 |
* |
| 453 |
* This is the one place a core `where` arg introduces a meta_query. It is bounded to a |
| 454 |
* single indexed meta key, but a meta_query is still more expensive than the indexed |
| 455 |
* post columns the other args use. |
| 456 |
* |
| 457 |
* TODO (Query Cost): when the query complexity / cost analysis system lands |
| 458 |
* (see plans/001-query-complexity-validation-rule.md and the filter/sort RFC #1385), |
| 459 |
* this arg MUST be assigned a cost weighting so meta_query-backed filters can be priced |
| 460 |
* and guarded. It is intentionally shipped without one now because that system does not |
| 461 |
* yet exist. |
| 462 |
*/ |
| 463 |
if ( isset( $where_args['template'] ) && is_string( $where_args['template'] ) && '' !== $where_args['template'] ) { |
| 464 |
if ( 'default' === $where_args['template'] ) { |
| 465 |
// Content on the default template has no specific template assigned: the meta is |
| 466 |
// absent, empty, or the literal "default". |
| 467 |
$query_args['meta_query'][] = [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query, SlevomatCodingStandard.Arrays.DisallowPartiallyKeyed.DisallowedPartiallyKeyed |
| 468 |
'relation' => 'OR', |
| 469 |
[ |
| 470 |
'key' => '_wp_page_template', |
| 471 |
'compare' => 'NOT EXISTS', |
| 472 |
], |
| 473 |
[ |
| 474 |
'key' => '_wp_page_template', |
| 475 |
'value' => [ '', 'default' ], |
| 476 |
'compare' => 'IN', |
| 477 |
], |
| 478 |
]; |
| 479 |
} else { |
| 480 |
$query_args['meta_query'][] = [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query |
| 481 |
'key' => '_wp_page_template', |
| 482 |
'value' => $where_args['template'], |
| 483 |
]; |
| 484 |
} |
| 485 |
} |
| 486 |
|
| 487 |
/** |
| 488 |
* Filter the input fields |
| 489 |
* This allows plugins/themes to hook in and alter what $args should be allowed to be passed |
| 490 |
* from a GraphQL Query to the WP_Query |
| 491 |
* |
| 492 |
* @param array<string,mixed> $query_args The mapped query arguments |
| 493 |
* @param array<string,mixed> $args Query "where" args |
| 494 |
* @param mixed $source The query results for a query calling this |
| 495 |
* @param array<string,mixed> $all_args All of the arguments for the query (not just the "where" args) |
| 496 |
* @param \WPGraphQL\AppContext $context The AppContext object |
| 497 |
* @param \GraphQL\Type\Definition\ResolveInfo $info The ResolveInfo object |
| 498 |
* @param mixed|string|string[] $post_type The post type for the query |
| 499 |
* |
| 500 |
* @hookGroup connections |
| 501 |
* @since 0.0.5 |
| 502 |
*/ |
| 503 |
$query_args = apply_filters( 'graphql_map_input_fields_to_wp_query', $query_args, $where_args, $this->source, $this->get_args(), $this->context, $this->info, $this->post_type ); |
| 504 |
|
| 505 |
/** |
| 506 |
* Return the Query Args |
| 507 |
*/ |
| 508 |
return ! empty( $query_args ) && is_array( $query_args ) ? $query_args : []; |
| 509 |
} |
| 510 |
|
| 511 |
/** |
| 512 |
* Limit the status of posts a user can query. |
| 513 |
* |
| 514 |
* By default, published posts are public, and other statuses require permission to access. |
| 515 |
* |
| 516 |
* This strips the status from the query_args if the user doesn't have permission to query for |
| 517 |
* posts of that status. |
| 518 |
* |
| 519 |
* @param string[]|string $stati The status(es) to sanitize. |
| 520 |
* |
| 521 |
* @return string[]|null |
| 522 |
*/ |
| 523 |
public function sanitize_post_stati( $stati ) { |
| 524 |
/** |
| 525 |
* If no stati is explicitly set by the input, default to publish. This will be the |
| 526 |
* most common scenario. |
| 527 |
*/ |
| 528 |
if ( empty( $stati ) ) { |
| 529 |
$stati = [ 'publish' ]; |
| 530 |
} |
| 531 |
|
| 532 |
/** |
| 533 |
* Parse the list of stati |
| 534 |
*/ |
| 535 |
$statuses = wp_parse_slug_list( $stati ); |
| 536 |
|
| 537 |
/** |
| 538 |
* Get the Post Type object |
| 539 |
*/ |
| 540 |
$post_type_objects = []; |
| 541 |
if ( is_array( $this->post_type ) ) { |
| 542 |
foreach ( $this->post_type as $post_type ) { |
| 543 |
$post_type_objects[] = get_post_type_object( $post_type ); |
| 544 |
} |
| 545 |
} else { |
| 546 |
$post_type_objects[] = get_post_type_object( $this->post_type ); |
| 547 |
} |
| 548 |
|
| 549 |
/** |
| 550 |
* Make sure the statuses are allowed to be queried by the current user. If so, allow it, |
| 551 |
* otherwise remove it from the $allowed_statuses that will be passed to WP_Query. |
| 552 |
* |
| 553 |
* For connections spanning multiple post types, a status is only allowed if the current |
| 554 |
* user can query it for every post type in the connection (the most restrictive choice). |
| 555 |
*/ |
| 556 |
$allowed_statuses = array_values( |
| 557 |
array_filter( |
| 558 |
$statuses, |
| 559 |
function ( $status ) use ( $post_type_objects ) { |
| 560 |
foreach ( $post_type_objects as $post_type_object ) { |
| 561 |
if ( ! $this->can_query_post_status( (string) $status, $post_type_object ) ) { |
| 562 |
return false; |
| 563 |
} |
| 564 |
} |
| 565 |
|
| 566 |
return true; |
| 567 |
} |
| 568 |
) |
| 569 |
); |
| 570 |
|
| 571 |
/** |
| 572 |
* Filters the post statuses the current user is allowed to query in this connection. |
| 573 |
* |
| 574 |
* Allows extensions to adjust which statuses are queryable (for example to expose an |
| 575 |
* additional custom status to specific users, or to further restrict access). |
| 576 |
* |
| 577 |
* @param string[] $allowed_statuses The statuses determined to be queryable by the current user. |
| 578 |
* @param string[] $requested_statuses The statuses requested via the connection `stati` where arg. |
| 579 |
* @param array<\WP_Post_Type|null> $post_type_objects The post type objects the connection resolves. |
| 580 |
* @param \WPGraphQL\Data\Connection\PostObjectConnectionResolver $resolver The connection resolver instance. |
| 581 |
* |
| 582 |
* @hookGroup connections |
| 583 |
* @since 2.17.0 |
| 584 |
*/ |
| 585 |
$allowed_statuses = apply_filters( 'graphql_allowed_post_stati', $allowed_statuses, $statuses, $post_type_objects, $this ); |
| 586 |
|
| 587 |
/** |
| 588 |
* If there are no allowed statuses to pass to WP_Query, prevent the connection |
| 589 |
* from executing |
| 590 |
* |
| 591 |
* For example, if a subscriber tries to query: |
| 592 |
* |
| 593 |
* { |
| 594 |
* posts( where: { stati: [ DRAFT ] } ) { |
| 595 |
* ...fields |
| 596 |
* } |
| 597 |
* } |
| 598 |
* |
| 599 |
* We can safely prevent the execution of the query because they are asking for content |
| 600 |
* in a status that we know they can't ask for. |
| 601 |
*/ |
| 602 |
if ( empty( $allowed_statuses ) ) { |
| 603 |
$this->should_execute = false; |
| 604 |
} |
| 605 |
|
| 606 |
/** |
| 607 |
* Return the $allowed_statuses to the query args |
| 608 |
*/ |
| 609 |
return $allowed_statuses; |
| 610 |
} |
| 611 |
|
| 612 |
/** |
| 613 |
* Determines whether the current user can query posts of the given status for the given post type. |
| 614 |
* |
| 615 |
* Published content, and any status whose `public` flag is true, are queryable by everyone, |
| 616 |
* the same way they are exposed on the WordPress front-end and the REST API (e.g. custom |
| 617 |
* statuses registered with `'public' => true`). The `private` status requires the post type's |
| 618 |
* `read_private_posts` capability. All other statuses (draft, pending, future, trash, and |
| 619 |
* custom non-public statuses) require the post type's `edit_posts` capability. |
| 620 |
* |
| 621 |
* @param string $status The post status to check. |
| 622 |
* @param \WP_Post_Type|null $post_type_object The post type object the status is being queried for. |
| 623 |
*/ |
| 624 |
protected function can_query_post_status( string $status, $post_type_object ): bool { |
| 625 |
// An unregistered/invalid post type can't be queried. |
| 626 |
if ( ! $post_type_object instanceof \WP_Post_Type ) { |
| 627 |
return false; |
| 628 |
} |
| 629 |
|
| 630 |
if ( 'publish' === $status ) { |
| 631 |
return true; |
| 632 |
} |
| 633 |
|
| 634 |
// A status flagged public is queryable by anyone, mirroring the WP front-end and REST API. |
| 635 |
$status_object = get_post_status_object( $status ); |
| 636 |
if ( $status_object instanceof \stdClass && true === $status_object->public ) { |
| 637 |
return true; |
| 638 |
} |
| 639 |
|
| 640 |
// The private status is queryable by users who can read private posts (CPT-aware via the cap object). |
| 641 |
if ( 'private' === $status ) { |
| 642 |
return isset( $post_type_object->cap->read_private_posts ) && current_user_can( $post_type_object->cap->read_private_posts ); |
| 643 |
} |
| 644 |
|
| 645 |
// All other statuses require edit capabilities. |
| 646 |
return isset( $post_type_object->cap->edit_posts ) && current_user_can( $post_type_object->cap->edit_posts ); |
| 647 |
} |
| 648 |
|
| 649 |
/** |
| 650 |
* {@inheritDoc} |
| 651 |
*/ |
| 652 |
protected function prepare_args( array $args ): array { |
| 653 |
if ( ! empty( $args['where'] ) ) { |
| 654 |
// Ensure all IDs are converted to database IDs. |
| 655 |
foreach ( $args['where'] as $input_key => $input_value ) { |
| 656 |
if ( empty( $input_value ) ) { |
| 657 |
continue; |
| 658 |
} |
| 659 |
|
| 660 |
switch ( $input_key ) { |
| 661 |
case 'in': |
| 662 |
case 'notIn': |
| 663 |
case 'parent': |
| 664 |
case 'parentIn': |
| 665 |
case 'parentNotIn': |
| 666 |
case 'authorIn': |
| 667 |
case 'authorNotIn': |
| 668 |
case 'categoryIn': |
| 669 |
case 'categoryNotIn': |
| 670 |
case 'tagId': |
| 671 |
case 'tagIn': |
| 672 |
case 'tagNotIn': |
| 673 |
if ( is_array( $input_value ) ) { |
| 674 |
$args['where'][ $input_key ] = array_map( |
| 675 |
static function ( $id ) { |
| 676 |
return Utils::get_database_id_from_id( $id ); |
| 677 |
}, |
| 678 |
$input_value |
| 679 |
); |
| 680 |
break; |
| 681 |
} |
| 682 |
|
| 683 |
$args['where'][ $input_key ] = Utils::get_database_id_from_id( $input_value ); |
| 684 |
break; |
| 685 |
} |
| 686 |
} |
| 687 |
} |
| 688 |
|
| 689 |
/** |
| 690 |
* Filters the GraphQL args before they are used in get_query_args(). |
| 691 |
* |
| 692 |
* @param array<string,mixed> $args The GraphQL args passed to the resolver. |
| 693 |
* @param self $resolver Instance of the ConnectionResolver. |
| 694 |
* @param array<string,mixed> $unfiltered_args Array of arguments input in the field as part of the GraphQL query. |
| 695 |
* |
| 696 |
* @hookGroup connections |
| 697 |
* @since 1.11.0 |
| 698 |
*/ |
| 699 |
return apply_filters( 'graphql_post_object_connection_args', $args, $this, $this->get_unfiltered_args() ); |
| 700 |
} |
| 701 |
|
| 702 |
/** |
| 703 |
* {@inheritDoc} |
| 704 |
* |
| 705 |
* @param int $offset The ID of the node used in the cursor offset. |
| 706 |
*/ |
| 707 |
public function is_valid_offset( $offset ) { |
| 708 |
return (bool) get_post( absint( $offset ) ); |
| 709 |
} |
| 710 |
} |
| 711 |
|