PluginProbe
Hostinger Tools / 3.0.78
Hostinger Tools v3.0.78
3.0.78 3.0.77 3.0.76 3.0.75 3.0.74 3.0.73 3.0.72 3.0.71 3.0.70 3.0.69 3.0.68 3.0.67 3.0.66 1.8.1 1.8.2 1.8.3 1.9.1 1.9.4 1.9.5 1.9.6 1.9.7 1.9.8 1.9.9 2.0.0 2.0.1 All 109 releases
hostinger / vendor / woocommerce / action-scheduler / classes / data-stores / ActionScheduler_wpPostStore.php

ActionScheduler_wpPostStore.php in Hostinger Tools 3.0.78, at vendor/woocommerce/action-scheduler/classes/data-stores/ActionScheduler_wpPostStore.php

1,106 lines 35.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Class ActionScheduler_wpPostStore
5 */
6 class ActionScheduler_wpPostStore extends ActionScheduler_Store {
7 const POST_TYPE = 'scheduled-action';
8 const GROUP_TAXONOMY = 'action-group';
9 const SCHEDULE_META_KEY = '_action_manager_schedule';
10 const DEPENDENCIES_MET = 'as-post-store-dependencies-met';
11
12 /**
13 * Used to share information about the before_date property of claims internally.
14 *
15 * This is used in preference to passing the same information as a method param
16 * for backwards-compatibility reasons.
17 *
18 * @var DateTime|null
19 */
20 private $claim_before_date = null;
21
22 /**
23 * Local Timezone.
24 *
25 * @var DateTimeZone
26 */
27 protected $local_timezone = null;
28
29 /**
30 * Save action.
31 *
32 * @param ActionScheduler_Action $action Scheduled Action.
33 * @param DateTime|null $scheduled_date Scheduled Date.
34 *
35 * @throws RuntimeException Throws an exception if the action could not be saved.
36 * @return int
37 */
38 public function save_action( ActionScheduler_Action $action, ?DateTime $scheduled_date = null ) {
39 try {
40 $this->validate_action( $action );
41 $post_array = $this->create_post_array( $action, $scheduled_date );
42 $post_id = $this->save_post_array( $post_array );
43 $this->save_post_schedule( $post_id, $action->get_schedule() );
44 $this->save_action_group( $post_id, $action->get_group() );
45 do_action( 'action_scheduler_stored_action', $post_id );
46 return $post_id;
47 } catch ( Exception $e ) {
48 /* translators: %s: action error message */
49 throw new RuntimeException( sprintf( __( 'Error saving action: %s', 'action-scheduler' ), $e->getMessage() ), 0 );
50 }
51 }
52
53 /**
54 * Create post array.
55 *
56 * @param ActionScheduler_Action $action Scheduled Action.
57 * @param DateTime|null $scheduled_date Scheduled Date.
58 *
59 * @return array Returns an array of post data.
60 */
61 protected function create_post_array( ActionScheduler_Action $action, ?DateTime $scheduled_date = null ) {
62 $post = array(
63 'post_type' => self::POST_TYPE,
64 'post_title' => $action->get_hook(),
65 'post_content' => wp_json_encode( $action->get_args() ),
66 'post_status' => ( $action->is_finished() ? 'publish' : 'pending' ),
67 'post_date_gmt' => $this->get_scheduled_date_string( $action, $scheduled_date ),
68 'post_date' => $this->get_scheduled_date_string_local( $action, $scheduled_date ),
69 );
70 return $post;
71 }
72
73 /**
74 * Save post array.
75 *
76 * @param array $post_array Post array.
77 * @return int Returns the post ID.
78 * @throws RuntimeException Throws an exception if the action could not be saved.
79 */
80 protected function save_post_array( $post_array ) {
81 add_filter( 'wp_insert_post_data', array( $this, 'filter_insert_post_data' ), 10, 1 );
82 add_filter( 'pre_wp_unique_post_slug', array( $this, 'set_unique_post_slug' ), 10, 5 );
83
84 $has_kses = false !== has_filter( 'content_save_pre', 'wp_filter_post_kses' );
85
86 if ( $has_kses ) {
87 // Prevent KSES from corrupting JSON in post_content.
88 kses_remove_filters();
89 }
90
91 $post_id = wp_insert_post( $post_array );
92
93 if ( $has_kses ) {
94 kses_init_filters();
95 }
96
97 remove_filter( 'wp_insert_post_data', array( $this, 'filter_insert_post_data' ), 10 );
98 remove_filter( 'pre_wp_unique_post_slug', array( $this, 'set_unique_post_slug' ), 10 );
99
100 if ( is_wp_error( $post_id ) || empty( $post_id ) ) {
101 throw new RuntimeException( __( 'Unable to save action.', 'action-scheduler' ) );
102 }
103 return $post_id;
104 }
105
106 /**
107 * Filter insert post data.
108 *
109 * @param array $postdata Post data to filter.
110 *
111 * @return array
112 */
113 public function filter_insert_post_data( $postdata ) {
114 if ( self::POST_TYPE === $postdata['post_type'] ) {
115 $postdata['post_author'] = 0;
116 if ( 'future' === $postdata['post_status'] ) {
117 $postdata['post_status'] = 'publish';
118 }
119 }
120 return $postdata;
121 }
122
123 /**
124 * Create a (probably unique) post name for scheduled actions in a more performant manner than wp_unique_post_slug().
125 *
126 * When an action's post status is transitioned to something other than 'draft', 'pending' or 'auto-draft, like 'publish'
127 * or 'failed' or 'trash', WordPress will find a unique slug (stored in post_name column) using the wp_unique_post_slug()
128 * function. This is done to ensure URL uniqueness. The approach taken by wp_unique_post_slug() is to iterate over existing
129 * post_name values that match, and append a number 1 greater than the largest. This makes sense when manually creating a
130 * post from the Edit Post screen. It becomes a bottleneck when automatically processing thousands of actions, with a
131 * database containing thousands of related post_name values.
132 *
133 * WordPress 5.1 introduces the 'pre_wp_unique_post_slug' filter for plugins to address this issue.
134 *
135 * We can short-circuit WordPress's wp_unique_post_slug() approach using the 'pre_wp_unique_post_slug' filter. This
136 * method is available to be used as a callback on that filter. It provides a more scalable approach to generating a
137 * post_name/slug that is probably unique. Because Action Scheduler never actually uses the post_name field, or an
138 * action's slug, being probably unique is good enough.
139 *
140 * For more backstory on this issue, see:
141 * - https://github.com/woocommerce/action-scheduler/issues/44 and
142 * - https://core.trac.wordpress.org/ticket/21112
143 *
144 * @param string $override_slug Short-circuit return value.
145 * @param string $slug The desired slug (post_name).
146 * @param int $post_ID Post ID.
147 * @param string $post_status The post status.
148 * @param string $post_type Post type.
149 * @return string
150 */
151 public function set_unique_post_slug( $override_slug, $slug, $post_ID, $post_status, $post_type ) {
152 if ( self::POST_TYPE === $post_type ) {
153 $override_slug = uniqid( self::POST_TYPE . '-', true ) . '-' . wp_generate_password( 32, false );
154 }
155 return $override_slug;
156 }
157
158 /**
159 * Save post schedule.
160 *
161 * @param int $post_id Post ID of the scheduled action.
162 * @param string $schedule Schedule to save.
163 *
164 * @return void
165 */
166 protected function save_post_schedule( $post_id, $schedule ) {
167 update_post_meta( $post_id, self::SCHEDULE_META_KEY, $schedule );
168 }
169
170 /**
171 * Save action group.
172 *
173 * @param int $post_id Post ID.
174 * @param string $group Group to save.
175 * @return void
176 */
177 protected function save_action_group( $post_id, $group ) {
178 if ( empty( $group ) ) {
179 wp_set_object_terms( $post_id, array(), self::GROUP_TAXONOMY, false );
180 } else {
181 wp_set_object_terms( $post_id, array( $group ), self::GROUP_TAXONOMY, false );
182 }
183 }
184
185 /**
186 * Fetch actions.
187 *
188 * @param int $action_id Action ID.
189 * @return object
190 */
191 public function fetch_action( $action_id ) {
192 $post = $this->get_post( $action_id );
193 if ( empty( $post ) || self::POST_TYPE !== $post->post_type ) {
194 return $this->get_null_action();
195 }
196
197 try {
198 $action = $this->make_action_from_post( $post );
199 } catch ( ActionScheduler_InvalidActionException $exception ) {
200 do_action( 'action_scheduler_failed_fetch_action', $post->ID, $exception );
201 return $this->get_null_action();
202 }
203
204 return $action;
205 }
206
207 /**
208 * Get post.
209 *
210 * @param string $action_id - Action ID.
211 * @return WP_Post|null
212 */
213 protected function get_post( $action_id ) {
214 if ( empty( $action_id ) ) {
215 return null;
216 }
217 return get_post( $action_id );
218 }
219
220 /**
221 * Get NULL action.
222 *
223 * @return ActionScheduler_NullAction
224 */
225 protected function get_null_action() {
226 return new ActionScheduler_NullAction();
227 }
228
229 /**
230 * Make action from post.
231 *
232 * @param WP_Post $post Post object.
233 * @return WP_Post
234 */
235 protected function make_action_from_post( $post ) {
236 $hook = $post->post_title;
237
238 $args = json_decode( $post->post_content, true );
239 $this->validate_args( $args, $post->ID );
240
241 $schedule = get_post_meta( $post->ID, self::SCHEDULE_META_KEY, true );
242 $this->validate_schedule( $schedule, $post->ID );
243
244 $group = wp_get_object_terms( $post->ID, self::GROUP_TAXONOMY, array( 'fields' => 'names' ) );
245 $group = empty( $group ) ? '' : reset( $group );
246
247 return ActionScheduler::factory()->get_stored_action( $this->get_action_status_by_post_status( $post->post_status ), $hook, $args, $schedule, $group );
248 }
249
250 /**
251 * Get action status by post status.
252 *
253 * @param string $post_status Post status.
254 *
255 * @throws InvalidArgumentException Throw InvalidArgumentException if $post_status not in known status fields returned by $this->get_status_labels().
256 * @return string
257 */
258 protected function get_action_status_by_post_status( $post_status ) {
259
260 switch ( $post_status ) {
261 case 'publish':
262 $action_status = self::STATUS_COMPLETE;
263 break;
264 case 'trash':
265 $action_status = self::STATUS_CANCELED;
266 break;
267 default:
268 if ( ! array_key_exists( $post_status, $this->get_status_labels() ) ) {
269 throw new InvalidArgumentException( sprintf( 'Invalid post status: "%s". No matching action status available.', $post_status ) );
270 }
271 $action_status = $post_status;
272 break;
273 }
274
275 return $action_status;
276 }
277
278 /**
279 * Get post status by action status.
280 *
281 * @param string $action_status Action status.
282 *
283 * @throws InvalidArgumentException Throws InvalidArgumentException if $post_status not in known status fields returned by $this->get_status_labels().
284 * @return string
285 */
286 protected function get_post_status_by_action_status( $action_status ) {
287
288 switch ( $action_status ) {
289 case self::STATUS_COMPLETE:
290 $post_status = 'publish';
291 break;
292 case self::STATUS_CANCELED:
293 $post_status = 'trash';
294 break;
295 default:
296 if ( ! array_key_exists( $action_status, $this->get_status_labels() ) ) {
297 throw new InvalidArgumentException( sprintf( 'Invalid action status: "%s".', $action_status ) );
298 }
299 $post_status = $action_status;
300 break;
301 }
302
303 return $post_status;
304 }
305
306 /**
307 * Returns the SQL statement to query (or count) actions.
308 *
309 * @param array $query - Filtering options.
310 * @param string $select_or_count - Whether the SQL should select and return the IDs or just the row count.
311 *
312 * @throws InvalidArgumentException - Throw InvalidArgumentException if $select_or_count not count or select.
313 * @return string SQL statement. The returned SQL is already properly escaped.
314 */
315 protected function get_query_actions_sql( array $query, $select_or_count = 'select' ) {
316
317 if ( ! in_array( $select_or_count, array( 'select', 'count' ), true ) ) {
318 throw new InvalidArgumentException( __( 'Invalid schedule. Cannot save action.', 'action-scheduler' ) );
319 }
320
321 $query = wp_parse_args(
322 $query,
323 array(
324 'hook' => '',
325 'args' => null,
326 'date' => null,
327 'date_compare' => '<=',
328 'modified' => null,
329 'modified_compare' => '<=',
330 'group' => '',
331 'status' => '',
332 'claimed' => null,
333 'per_page' => 5,
334 'offset' => 0,
335 'orderby' => 'date',
336 'order' => 'ASC',
337 'search' => '',
338 )
339 );
340
341 /**
342 * Global wpdb object.
343 *
344 * @var wpdb $wpdb
345 */
346 global $wpdb;
347 $sql = ( 'count' === $select_or_count ) ? 'SELECT count(p.ID)' : 'SELECT p.ID ';
348 $sql .= "FROM {$wpdb->posts} p";
349 $sql_params = array();
350 if ( empty( $query['group'] ) && 'group' === $query['orderby'] ) {
351 $sql .= " LEFT JOIN {$wpdb->term_relationships} tr ON tr.object_id=p.ID";
352 $sql .= " LEFT JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id=tt.term_taxonomy_id";
353 $sql .= " LEFT JOIN {$wpdb->terms} t ON tt.term_id=t.term_id";
354 } elseif ( ! empty( $query['group'] ) ) {
355 $sql .= " INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id=p.ID";
356 $sql .= " INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id=tt.term_taxonomy_id";
357 $sql .= " INNER JOIN {$wpdb->terms} t ON tt.term_id=t.term_id";
358 $sql .= ' AND t.slug=%s';
359 $sql_params[] = $query['group'];
360 }
361 $sql .= ' WHERE post_type=%s';
362 $sql_params[] = self::POST_TYPE;
363 if ( $query['hook'] ) {
364 $sql .= ' AND p.post_title=%s';
365 $sql_params[] = $query['hook'];
366 }
367 if ( ! is_null( $query['args'] ) ) {
368 $sql .= ' AND p.post_content=%s';
369 $sql_params[] = wp_json_encode( $query['args'] );
370 }
371
372 if ( $query['status'] ) {
373 $post_statuses = array_map( array( $this, 'get_post_status_by_action_status' ), (array) $query['status'] );
374 $placeholders = array_fill( 0, count( $post_statuses ), '%s' );
375 $sql .= ' AND p.post_status IN (' . join( ', ', $placeholders ) . ')';
376 $sql_params = array_merge( $sql_params, array_values( $post_statuses ) );
377 }
378
379 if ( $query['date'] instanceof DateTime ) {
380 $date = clone $query['date'];
381 $date->setTimezone( new DateTimeZone( 'UTC' ) );
382 $date_string = $date->format( 'Y-m-d H:i:s' );
383 $comparator = $this->validate_sql_comparator( $query['date_compare'] );
384 $sql .= " AND p.post_date_gmt $comparator %s";
385 $sql_params[] = $date_string;
386 }
387
388 if ( $query['modified'] instanceof DateTime ) {
389 $modified = clone $query['modified'];
390 $modified->setTimezone( new DateTimeZone( 'UTC' ) );
391 $date_string = $modified->format( 'Y-m-d H:i:s' );
392 $comparator = $this->validate_sql_comparator( $query['modified_compare'] );
393 $sql .= " AND p.post_modified_gmt $comparator %s";
394 $sql_params[] = $date_string;
395 }
396
397 if ( true === $query['claimed'] ) {
398 $sql .= " AND p.post_password != ''";
399 } elseif ( false === $query['claimed'] ) {
400 $sql .= " AND p.post_password = ''";
401 } elseif ( ! is_null( $query['claimed'] ) ) {
402 $sql .= ' AND p.post_password = %s';
403 $sql_params[] = $query['claimed'];
404 }
405
406 if ( ! empty( $query['search'] ) ) {
407 $sql .= ' AND (p.post_title LIKE %s OR p.post_content LIKE %s OR p.post_password LIKE %s)';
408 for ( $i = 0; $i < 3; $i++ ) {
409 $sql_params[] = sprintf( '%%%s%%', $query['search'] );
410 }
411 }
412
413 if ( 'select' === $select_or_count ) {
414 switch ( $query['orderby'] ) {
415 case 'hook':
416 $orderby = 'p.post_title';
417 break;
418 case 'group':
419 $orderby = 't.name';
420 break;
421 case 'status':
422 $orderby = 'p.post_status';
423 break;
424 case 'modified':
425 $orderby = 'p.post_modified';
426 break;
427 case 'claim_id':
428 $orderby = 'p.post_password';
429 break;
430 case 'schedule':
431 case 'date':
432 default:
433 $orderby = 'p.post_date_gmt';
434 break;
435 }
436 if ( 'ASC' === strtoupper( $query['order'] ) ) {
437 $order = 'ASC';
438 } else {
439 $order = 'DESC';
440 }
441 $sql .= " ORDER BY $orderby $order";
442 if ( $query['per_page'] > 0 ) {
443 $sql .= ' LIMIT %d, %d';
444 $sql_params[] = $query['offset'];
445 $sql_params[] = $query['per_page'];
446 }
447 }
448
449 return $wpdb->prepare( $sql, $sql_params ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
450 }
451
452 /**
453 * Query for action count or list of action IDs.
454 *
455 * @since 3.3.0 $query['status'] accepts array of statuses instead of a single status.
456 *
457 * @see ActionScheduler_Store::query_actions for $query arg usage.
458 *
459 * @param array $query Query filtering options.
460 * @param string $query_type Whether to select or count the results. Defaults to select.
461 *
462 * @return string|array|null The IDs of actions matching the query. Null on failure.
463 */
464 public function query_actions( $query = array(), $query_type = 'select' ) {
465 /**
466 * Global $wpdb object.
467 *
468 * @var wpdb $wpdb
469 */
470 global $wpdb;
471
472 $sql = $this->get_query_actions_sql( $query, $query_type );
473
474 return ( 'count' === $query_type ) ? $wpdb->get_var( $sql ) : $wpdb->get_col( $sql ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared
475 }
476
477 /**
478 * Get a count of all actions in the store, grouped by status
479 *
480 * @return array
481 */
482 public function action_counts() {
483
484 $action_counts_by_status = array();
485 $action_stati_and_labels = $this->get_status_labels();
486 $posts_count_by_status = (array) wp_count_posts( self::POST_TYPE, 'readable' );
487
488 foreach ( $posts_count_by_status as $post_status_name => $count ) {
489
490 try {
491 $action_status_name = $this->get_action_status_by_post_status( $post_status_name );
492 } catch ( Exception $e ) {
493 // Ignore any post statuses that aren't for actions.
494 continue;
495 }
496 if ( array_key_exists( $action_status_name, $action_stati_and_labels ) ) {
497 $action_counts_by_status[ $action_status_name ] = $count;
498 }
499 }
500
501 return $action_counts_by_status;
502 }
503
504 /**
505 * Cancel action.
506 *
507 * @param int $action_id Action ID.
508 *
509 * @throws InvalidArgumentException If $action_id is not identified.
510 */
511 public function cancel_action( $action_id ) {
512 $post = get_post( $action_id );
513 if ( empty( $post ) || ( self::POST_TYPE !== $post->post_type ) ) {
514 /* translators: %s is the action ID */
515 throw new InvalidArgumentException( sprintf( __( 'Unidentified action %s: we were unable to cancel this action. It may may have been deleted by another process.', 'action-scheduler' ), $action_id ) );
516 }
517 do_action( 'action_scheduler_canceled_action', $action_id );
518 add_filter( 'pre_wp_unique_post_slug', array( $this, 'set_unique_post_slug' ), 10, 5 );
519 wp_trash_post( $action_id );
520 remove_filter( 'pre_wp_unique_post_slug', array( $this, 'set_unique_post_slug' ), 10 );
521 }
522
523 /**
524 * Delete action.
525 *
526 * @param int $action_id Action ID.
527 * @return void
528 * @throws InvalidArgumentException If action is not identified.
529 */
530 public function delete_action( $action_id ) {
531 $post = get_post( $action_id );
532 if ( empty( $post ) || ( self::POST_TYPE !== $post->post_type ) ) {
533 /* translators: %s is the action ID */
534 throw new InvalidArgumentException( sprintf( __( 'Unidentified action %s: we were unable to delete this action. It may may have been deleted by another process.', 'action-scheduler' ), $action_id ) );
535 }
536 do_action( 'action_scheduler_deleted_action', $action_id );
537
538 wp_delete_post( $action_id, true );
539 }
540
541 /**
542 * Get date for claim id.
543 *
544 * @param int $action_id Action ID.
545 * @return ActionScheduler_DateTime The date the action is schedule to run, or the date that it ran.
546 */
547 public function get_date( $action_id ) {
548 $next = $this->get_date_gmt( $action_id );
549 return ActionScheduler_TimezoneHelper::set_local_timezone( $next );
550 }
551
552 /**
553 * Get Date GMT.
554 *
555 * @param int $action_id Action ID.
556 *
557 * @throws InvalidArgumentException If $action_id is not identified.
558 * @return ActionScheduler_DateTime The date the action is schedule to run, or the date that it ran.
559 */
560 public function get_date_gmt( $action_id ) {
561 $post = get_post( $action_id );
562 if ( empty( $post ) || ( self::POST_TYPE !== $post->post_type ) ) {
563 /* translators: %s is the action ID */
564 throw new InvalidArgumentException( sprintf( __( 'Unidentified action %s: we were unable to determine the date of this action. It may may have been deleted by another process.', 'action-scheduler' ), $action_id ) );
565 }
566 if ( 'publish' === $post->post_status ) {
567 return as_get_datetime_object( $post->post_modified_gmt );
568 } else {
569 return as_get_datetime_object( $post->post_date_gmt );
570 }
571 }
572
573 /**
574 * Stake claim.
575 *
576 * @param int $max_actions Maximum number of actions.
577 * @param DateTime|null $before_date Jobs must be schedule before this date. Defaults to now.
578 * @param array $hooks Claim only actions with a hook or hooks.
579 * @param string $group Claim only actions in the given group.
580 *
581 * @return ActionScheduler_ActionClaim
582 * @throws RuntimeException When there is an error staking a claim.
583 * @throws InvalidArgumentException When the given group is not valid.
584 */
585 public function stake_claim( $max_actions = 10, ?DateTime $before_date = null, $hooks = array(), $group = '' ) {
586 $this->claim_before_date = $before_date;
587 $claim_id = $this->generate_claim_id();
588 $this->claim_actions( $claim_id, $max_actions, $before_date, $hooks, $group );
589 $action_ids = $this->find_actions_by_claim_id( $claim_id );
590 $this->claim_before_date = null;
591
592 return new ActionScheduler_ActionClaim( $claim_id, $action_ids );
593 }
594
595 /**
596 * Get claim count.
597 *
598 * @return int
599 */
600 public function get_claim_count() {
601 global $wpdb;
602
603 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
604 return $wpdb->get_var(
605 $wpdb->prepare(
606 "SELECT COUNT(DISTINCT post_password) FROM {$wpdb->posts} WHERE post_password != '' AND post_type = %s AND post_status IN ('in-progress','pending')",
607 array( self::POST_TYPE )
608 )
609 );
610 }
611
612 /**
613 * Generate claim id.
614 *
615 * @return string
616 */
617 protected function generate_claim_id() {
618 $claim_id = md5( microtime( true ) . wp_rand( 0, 1000 ) );
619 return substr( $claim_id, 0, 20 ); // to fit in db field with 20 char limit.
620 }
621
622 /**
623 * Claim actions.
624 *
625 * @param string $claim_id Claim ID.
626 * @param int $limit Limit.
627 * @param DateTime|null $before_date Should use UTC timezone.
628 * @param array $hooks Claim only actions with a hook or hooks.
629 * @param string $group Claim only actions in the given group.
630 *
631 * @return int The number of actions that were claimed.
632 * @throws RuntimeException When there is a database error.
633 */
634 protected function claim_actions( $claim_id, $limit, ?DateTime $before_date = null, $hooks = array(), $group = '' ) {
635 // Set up initial variables.
636 $date = null === $before_date ? as_get_datetime_object() : clone $before_date;
637 $limit_ids = ! empty( $group );
638 $ids = $limit_ids ? $this->get_actions_by_group( $group, $limit, $date ) : array();
639
640 // If limiting by IDs and no posts found, then return early since we have nothing to update.
641 if ( $limit_ids && 0 === count( $ids ) ) {
642 return 0;
643 }
644
645 /**
646 * Global wpdb object.
647 *
648 * @var wpdb $wpdb
649 */
650 global $wpdb;
651
652 /*
653 * Build up custom query to update the affected posts. Parameters are built as a separate array
654 * to make it easier to identify where they are in the query.
655 *
656 * We can't use $wpdb->update() here because of the "ID IN ..." clause.
657 */
658 $update = "UPDATE {$wpdb->posts} SET post_password = %s, post_modified_gmt = %s, post_modified = %s";
659 $params = array(
660 $claim_id,
661 current_time( 'mysql', true ),
662 current_time( 'mysql' ),
663 );
664
665 // Build initial WHERE clause.
666 $where = "WHERE post_type = %s AND post_status = %s AND post_password = ''";
667 $params[] = self::POST_TYPE;
668 $params[] = ActionScheduler_Store::STATUS_PENDING;
669
670 if ( ! empty( $hooks ) ) {
671 $placeholders = array_fill( 0, count( $hooks ), '%s' );
672 $where .= ' AND post_title IN (' . join( ', ', $placeholders ) . ')';
673 $params = array_merge( $params, array_values( $hooks ) );
674 }
675
676 /*
677 * Add the IDs to the WHERE clause. IDs not escaped because they came directly from a prior DB query.
678 *
679 * If we're not limiting by IDs, then include the post_date_gmt clause.
680 */
681 if ( $limit_ids ) {
682 $where .= ' AND ID IN (' . join( ',', $ids ) . ')';
683 } else {
684 $where .= ' AND post_date_gmt <= %s';
685 $params[] = $date->format( 'Y-m-d H:i:s' );
686 }
687
688 // Add the ORDER BY clause and,ms limit.
689 $order = 'ORDER BY menu_order ASC, post_date_gmt ASC, ID ASC LIMIT %d';
690 $params[] = $limit;
691
692 // Run the query and gather results.
693 $rows_affected = $wpdb->query( $wpdb->prepare( "{$update} {$where} {$order}", $params ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
694
695 if ( false === $rows_affected ) {
696 throw new RuntimeException( __( 'Unable to claim actions. Database error.', 'action-scheduler' ) );
697 }
698
699 return (int) $rows_affected;
700 }
701
702 /**
703 * Get IDs of actions within a certain group and up to a certain date/time.
704 *
705 * @param string $group The group to use in finding actions.
706 * @param int $limit The number of actions to retrieve.
707 * @param DateTime $date DateTime object representing cutoff time for actions. Actions retrieved will be
708 * up to and including this DateTime.
709 *
710 * @return array IDs of actions in the appropriate group and before the appropriate time.
711 * @throws InvalidArgumentException When the group does not exist.
712 */
713 protected function get_actions_by_group( $group, $limit, DateTime $date ) {
714 // Ensure the group exists before continuing.
715 if ( ! term_exists( $group, self::GROUP_TAXONOMY ) ) {
716 /* translators: %s is the group name */
717 throw new InvalidArgumentException( sprintf( __( 'The group "%s" does not exist.', 'action-scheduler' ), $group ) );
718 }
719
720 // Set up a query for post IDs to use later.
721 $query = new WP_Query();
722 $query_args = array(
723 'fields' => 'ids',
724 'post_type' => self::POST_TYPE,
725 'post_status' => ActionScheduler_Store::STATUS_PENDING,
726 'has_password' => false,
727 'posts_per_page' => $limit * 3,
728 'suppress_filters' => true, // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.SuppressFilters_suppress_filters
729 'no_found_rows' => true,
730 'orderby' => array(
731 'menu_order' => 'ASC',
732 'date' => 'ASC',
733 'ID' => 'ASC',
734 ),
735 'date_query' => array(
736 'column' => 'post_date_gmt',
737 'before' => $date->format( 'Y-m-d H:i' ),
738 'inclusive' => true,
739 ),
740 'tax_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery
741 array(
742 'taxonomy' => self::GROUP_TAXONOMY,
743 'field' => 'slug',
744 'terms' => $group,
745 'include_children' => false,
746 ),
747 ),
748 );
749
750 return $query->query( $query_args );
751 }
752
753 /**
754 * Find actions by claim ID.
755 *
756 * @param string $claim_id Claim ID.
757 * @return array
758 */
759 public function find_actions_by_claim_id( $claim_id ) {
760 /**
761 * Global wpdb object.
762 *
763 * @var wpdb $wpdb
764 */
765 global $wpdb;
766
767 $action_ids = array();
768 $before_date = isset( $this->claim_before_date ) ? $this->claim_before_date : as_get_datetime_object();
769 $cut_off = $before_date->format( 'Y-m-d H:i:s' );
770
771 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
772 $results = $wpdb->get_results(
773 $wpdb->prepare(
774 "SELECT ID, post_date_gmt FROM {$wpdb->posts} WHERE post_type = %s AND post_password = %s",
775 array(
776 self::POST_TYPE,
777 $claim_id,
778 )
779 )
780 );
781
782 // Verify that the scheduled date for each action is within the expected bounds (in some unusual
783 // cases, we cannot depend on MySQL to honor all of the WHERE conditions we specify).
784 foreach ( $results as $claimed_action ) {
785 if ( $claimed_action->post_date_gmt <= $cut_off ) {
786 $action_ids[] = absint( $claimed_action->ID );
787 }
788 }
789
790 return $action_ids;
791 }
792
793 /**
794 * Release pending actions from a claim.
795 *
796 * @param ActionScheduler_ActionClaim $claim Claim object to release.
797 * @return void
798 * @throws RuntimeException When the claim is not unlocked.
799 */
800 public function release_claim( ActionScheduler_ActionClaim $claim ) {
801 /**
802 * Global wpdb object.
803 *
804 * @var wpdb $wpdb
805 */
806 global $wpdb;
807
808 $claim_id = $claim->get_id();
809 if ( trim( $claim_id ) === '' ) {
810 // Verify that the claim_id is valid before attempting to release it.
811 return;
812 }
813
814 // Only attempt to release pending actions to be claimed again. Running and complete actions are no longer relevant outside of admin/analytics.
815 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
816 $action_ids = $wpdb->get_col(
817 $wpdb->prepare(
818 "SELECT ID, post_date_gmt FROM {$wpdb->posts} WHERE post_type = %s AND post_password = %s AND post_status = %s",
819 self::POST_TYPE,
820 $claim_id,
821 self::STATUS_PENDING
822 )
823 );
824
825 if ( empty( $action_ids ) ) {
826 return; // nothing to do.
827 }
828 $action_id_string = implode( ',', array_map( 'intval', $action_ids ) );
829
830 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
831 $result = $wpdb->query(
832 $wpdb->prepare(
833 "UPDATE {$wpdb->posts} SET post_password = '' WHERE ID IN ($action_id_string) AND post_password = %s", //phpcs:ignore
834 array(
835 $claim->get_id(),
836 )
837 )
838 );
839 if ( false === $result ) {
840 /* translators: %s: claim ID */
841 throw new RuntimeException( sprintf( __( 'Unable to unlock claim %s. Database error.', 'action-scheduler' ), $claim->get_id() ) );
842 }
843 }
844
845 /**
846 * Unclaim action.
847 *
848 * @param string $action_id Action ID.
849 * @throws RuntimeException When unable to unlock claim on action ID.
850 */
851 public function unclaim_action( $action_id ) {
852 /**
853 * Global wpdb object.
854 *
855 * @var wpdb $wpdb
856 */
857 global $wpdb;
858
859 //phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
860 $result = $wpdb->query(
861 $wpdb->prepare(
862 "UPDATE {$wpdb->posts} SET post_password = '' WHERE ID = %d AND post_type = %s",
863 $action_id,
864 self::POST_TYPE
865 )
866 );
867 if ( false === $result ) {
868 /* translators: %s: action ID */
869 throw new RuntimeException( sprintf( __( 'Unable to unlock claim on action %s. Database error.', 'action-scheduler' ), $action_id ) );
870 }
871 }
872
873 /**
874 * Mark failure on action.
875 *
876 * @param int $action_id Action ID.
877 *
878 * @return void
879 * @throws RuntimeException When unable to mark failure on action ID.
880 */
881 public function mark_failure( $action_id ) {
882 /**
883 * Global wpdb object.
884 *
885 * @var wpdb $wpdb
886 */
887 global $wpdb;
888
889 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
890 $result = $wpdb->query(
891 $wpdb->prepare( "UPDATE {$wpdb->posts} SET post_status = %s WHERE ID = %d AND post_type = %s", self::STATUS_FAILED, $action_id, self::POST_TYPE )
892 );
893 if ( false === $result ) {
894 /* translators: %s: action ID */
895 throw new RuntimeException( sprintf( __( 'Unable to mark failure on action %s. Database error.', 'action-scheduler' ), $action_id ) );
896 }
897 }
898
899 /**
900 * Return an action's claim ID, as stored in the post password column
901 *
902 * @param int $action_id Action ID.
903 * @return mixed
904 */
905 public function get_claim_id( $action_id ) {
906 return $this->get_post_column( $action_id, 'post_password' );
907 }
908
909 /**
910 * Return an action's status, as stored in the post status column
911 *
912 * @param int $action_id Action ID.
913 *
914 * @return mixed
915 * @throws InvalidArgumentException When the action ID is invalid.
916 */
917 public function get_status( $action_id ) {
918 $status = $this->get_post_column( $action_id, 'post_status' );
919
920 if ( null === $status ) {
921 throw new InvalidArgumentException( __( 'Invalid action ID. No status found.', 'action-scheduler' ) );
922 }
923
924 return $this->get_action_status_by_post_status( $status );
925 }
926
927 /**
928 * Get post column
929 *
930 * @param string $action_id Action ID.
931 * @param string $column_name Column Name.
932 *
933 * @return string|null
934 */
935 private function get_post_column( $action_id, $column_name ) {
936 /**
937 * Global wpdb object.
938 *
939 * @var wpdb $wpdb
940 */
941 global $wpdb;
942
943 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
944 return $wpdb->get_var(
945 $wpdb->prepare(
946 "SELECT {$column_name} FROM {$wpdb->posts} WHERE ID=%d AND post_type=%s", // phpcs:ignore
947 $action_id,
948 self::POST_TYPE
949 )
950 );
951 }
952
953 /**
954 * Log Execution.
955 *
956 * @throws Exception If the action status cannot be updated to self::STATUS_RUNNING ('in-progress').
957 *
958 * @param string $action_id Action ID.
959 */
960 public function log_execution( $action_id ) {
961 /**
962 * Global wpdb object.
963 *
964 * @var wpdb $wpdb
965 */
966 global $wpdb;
967
968 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
969 $status_updated = $wpdb->query(
970 $wpdb->prepare(
971 "UPDATE {$wpdb->posts} SET menu_order = menu_order+1, post_status=%s, post_modified_gmt = %s, post_modified = %s WHERE ID = %d AND post_type = %s",
972 self::STATUS_RUNNING,
973 current_time( 'mysql', true ),
974 current_time( 'mysql' ),
975 $action_id,
976 self::POST_TYPE
977 )
978 );
979
980 if ( ! $status_updated ) {
981 throw new Exception(
982 sprintf(
983 /* translators: 1: action ID. 2: status slug. */
984 __( 'Unable to update the status of action %1$d to %2$s.', 'action-scheduler' ),
985 $action_id,
986 self::STATUS_RUNNING
987 )
988 );
989 }
990 }
991
992 /**
993 * Record that an action was completed.
994 *
995 * @param string $action_id ID of the completed action.
996 *
997 * @throws InvalidArgumentException When the action ID is invalid.
998 * @throws RuntimeException When there was an error executing the action.
999 */
1000 public function mark_complete( $action_id ) {
1001 $post = get_post( $action_id );
1002 if ( empty( $post ) || ( self::POST_TYPE !== $post->post_type ) ) {
1003 /* translators: %s is the action ID */
1004 throw new InvalidArgumentException( sprintf( __( 'Unidentified action %s: we were unable to mark this action as having completed. It may may have been deleted by another process.', 'action-scheduler' ), $action_id ) );
1005 }
1006 add_filter( 'wp_insert_post_data', array( $this, 'filter_insert_post_data' ), 10, 1 );
1007 add_filter( 'pre_wp_unique_post_slug', array( $this, 'set_unique_post_slug' ), 10, 5 );
1008 $result = wp_update_post(
1009 array(
1010 'ID' => $action_id,
1011 'post_status' => 'publish',
1012 ),
1013 true
1014 );
1015 remove_filter( 'wp_insert_post_data', array( $this, 'filter_insert_post_data' ), 10 );
1016 remove_filter( 'pre_wp_unique_post_slug', array( $this, 'set_unique_post_slug' ), 10 );
1017 if ( is_wp_error( $result ) ) {
1018 throw new RuntimeException( $result->get_error_message() );
1019 }
1020
1021 /**
1022 * Fires after a scheduled action has been completed.
1023 *
1024 * @since 3.4.2
1025 *
1026 * @param int $action_id Action ID.
1027 */
1028 do_action( 'action_scheduler_completed_action', $action_id );
1029 }
1030
1031 /**
1032 * Mark action as migrated when there is an error deleting the action.
1033 *
1034 * @param int $action_id Action ID.
1035 */
1036 public function mark_migrated( $action_id ) {
1037 wp_update_post(
1038 array(
1039 'ID' => $action_id,
1040 'post_status' => 'migrated',
1041 )
1042 );
1043 }
1044
1045 /**
1046 * Determine whether the post store can be migrated.
1047 *
1048 * @param [type] $setting - Setting value.
1049 * @return bool
1050 */
1051 public function migration_dependencies_met( $setting ) {
1052 global $wpdb;
1053
1054 $dependencies_met = get_transient( self::DEPENDENCIES_MET );
1055 if ( empty( $dependencies_met ) ) {
1056 $maximum_args_length = apply_filters( 'action_scheduler_maximum_args_length', 191 );
1057 $found_action = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1058 $wpdb->prepare(
1059 "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s AND CHAR_LENGTH(post_content) > %d LIMIT 1",
1060 $maximum_args_length,
1061 self::POST_TYPE
1062 )
1063 );
1064 $dependencies_met = $found_action ? 'no' : 'yes';
1065 set_transient( self::DEPENDENCIES_MET, $dependencies_met, DAY_IN_SECONDS );
1066 }
1067
1068 return 'yes' === $dependencies_met ? $setting : false;
1069 }
1070
1071 /**
1072 * InnoDB indexes have a maximum size of 767 bytes by default, which is only 191 characters with utf8mb4.
1073 *
1074 * Previously, AS wasn't concerned about args length, as we used the (unindex) post_content column. However,
1075 * as we prepare to move to custom tables, and can use an indexed VARCHAR column instead, we want to warn
1076 * developers of this impending requirement.
1077 *
1078 * @param ActionScheduler_Action $action Action object.
1079 */
1080 protected function validate_action( ActionScheduler_Action $action ) {
1081 try {
1082 parent::validate_action( $action );
1083 } catch ( Exception $e ) {
1084 /* translators: %s is the error message */
1085 $message = sprintf( __( '%s Support for strings longer than this will be removed in a future version.', 'action-scheduler' ), $e->getMessage() );
1086 _doing_it_wrong( 'ActionScheduler_Action::$args', esc_html( $message ), '2.1.0' );
1087 }
1088 }
1089
1090 /**
1091 * (@codeCoverageIgnore)
1092 */
1093 public function init() {
1094 add_filter( 'action_scheduler_migration_dependencies_met', array( $this, 'migration_dependencies_met' ) );
1095
1096 $post_type_registrar = new ActionScheduler_wpPostStore_PostTypeRegistrar();
1097 $post_type_registrar->register();
1098
1099 $post_status_registrar = new ActionScheduler_wpPostStore_PostStatusRegistrar();
1100 $post_status_registrar->register();
1101
1102 $taxonomy_registrar = new ActionScheduler_wpPostStore_TaxonomyRegistrar();
1103 $taxonomy_registrar->register();
1104 }
1105 }
1106