PluginProbe
WANotifier for Forms and Actions / 3.1.1
WANotifier for Forms and Actions v3.1.1
3.1.1 3.1.0 3.0.4 2.7.10 2.7.11 2.7.12 2.7.13 2.7.2 2.7.3 2.7.4 2.7.5 2.7.6 2.7.7 2.7.8 2.7.9 3.0.0 3.0.1 3.0.2 3.0.3 trunk 0.1.0 0.1.1 1.0.0 1.0.1 1.0.2 All 67 releases
notifier / libraries / action-scheduler / classes / data-stores / ActionScheduler_wpPostStore.php

ActionScheduler_wpPostStore.php in WANotifier for Forms and Actions 3.1.1, at libraries/action-scheduler/classes/data-stores/ActionScheduler_wpPostStore.php

1,164 lines 37.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 = $this->get_schedule_from_post_meta( $post->ID );
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 * Read a scheduled action's schedule from post meta without instantiating unexpected classes.
252 *
253 * Using get_post_meta() would run the stored blob through maybe_unserialize(), instantiating
254 * whatever classes it names before we can vet them. Instead we read the raw serialized value and
255 * hand it to ActionScheduler_ScheduleDeserializer, which only instantiates a valid schedule (and its
256 * vetted supporting classes). @see https://github.com/woocommerce/action-scheduler/issues/1318
257 *
258 * @param int $post_id Post ID of the scheduled action.
259 * @return mixed The schedule object, false if unusable, or the raw meta value when it is not a
260 * serialized object (left for validate_schedule() to reject, as before).
261 */
262 protected function get_schedule_from_post_meta( $post_id ) {
263 global $wpdb;
264
265 // Direct, uncached query is intentional: we need the raw serialized value before the meta API
266 // runs it through maybe_unserialize(), so we bypass get_post_meta() and its cache here.
267 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
268 $raw = $wpdb->get_var(
269 $wpdb->prepare(
270 "SELECT meta_value FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s ORDER BY meta_id ASC LIMIT 1",
271 $post_id,
272 self::SCHEDULE_META_KEY
273 )
274 );
275
276 if ( null === $raw ) {
277 return false;
278 }
279
280 // Schedule objects are stored serialized by the meta API. A non-serialized value cannot be a
281 // schedule object, so there is nothing to protect against — return it as-is for validation.
282 if ( ! is_serialized( $raw ) ) {
283 return $raw;
284 }
285
286 return ActionScheduler_ScheduleDeserializer::unserialize( $raw );
287 }
288
289 /**
290 * Get action status by post status.
291 *
292 * @param string $post_status Post status.
293 *
294 * @throws InvalidArgumentException Throw InvalidArgumentException if $post_status not in known status fields returned by $this->get_status_labels().
295 * @return string
296 */
297 protected function get_action_status_by_post_status( $post_status ) {
298
299 switch ( $post_status ) {
300 case 'publish':
301 $action_status = self::STATUS_COMPLETE;
302 break;
303 case 'trash':
304 $action_status = self::STATUS_CANCELED;
305 break;
306 default:
307 if ( ! array_key_exists( $post_status, $this->get_status_labels() ) ) {
308 throw new InvalidArgumentException( sprintf( 'Invalid post status: "%s". No matching action status available.', $post_status ) );
309 }
310 $action_status = $post_status;
311 break;
312 }
313
314 return $action_status;
315 }
316
317 /**
318 * Get post status by action status.
319 *
320 * @param string $action_status Action status.
321 *
322 * @throws InvalidArgumentException Throws InvalidArgumentException if $post_status not in known status fields returned by $this->get_status_labels().
323 * @return string
324 */
325 protected function get_post_status_by_action_status( $action_status ) {
326
327 switch ( $action_status ) {
328 case self::STATUS_COMPLETE:
329 $post_status = 'publish';
330 break;
331 case self::STATUS_CANCELED:
332 $post_status = 'trash';
333 break;
334 default:
335 if ( ! array_key_exists( $action_status, $this->get_status_labels() ) ) {
336 throw new InvalidArgumentException( sprintf( 'Invalid action status: "%s".', $action_status ) );
337 }
338 $post_status = $action_status;
339 break;
340 }
341
342 return $post_status;
343 }
344
345 /**
346 * Returns the SQL statement to query (or count) actions.
347 *
348 * @param array $query - Filtering options.
349 * @param string $select_or_count - Whether the SQL should select and return the IDs or just the row count.
350 *
351 * @throws InvalidArgumentException - Throw InvalidArgumentException if $select_or_count not count or select.
352 * @return string SQL statement. The returned SQL is already properly escaped.
353 */
354 protected function get_query_actions_sql( array $query, $select_or_count = 'select' ) {
355
356 if ( ! in_array( $select_or_count, array( 'select', 'count' ), true ) ) {
357 throw new InvalidArgumentException( __( 'Invalid schedule. Cannot save action.', 'action-scheduler' ) );
358 }
359
360 $query = wp_parse_args(
361 $query,
362 array(
363 'hook' => '',
364 'args' => null,
365 'date' => null,
366 'date_compare' => '<=',
367 'modified' => null,
368 'modified_compare' => '<=',
369 'group' => '',
370 'status' => '',
371 'claimed' => null,
372 'per_page' => 5,
373 'offset' => 0,
374 'orderby' => 'date',
375 'order' => 'ASC',
376 'search' => '',
377 )
378 );
379
380 /**
381 * Global wpdb object.
382 *
383 * @var wpdb $wpdb
384 */
385 global $wpdb;
386 $sql = ( 'count' === $select_or_count ) ? 'SELECT count(p.ID)' : 'SELECT p.ID ';
387 $sql .= "FROM {$wpdb->posts} p";
388 $sql_params = array();
389 if ( empty( $query['group'] ) && 'group' === $query['orderby'] ) {
390 $sql .= " LEFT JOIN {$wpdb->term_relationships} tr ON tr.object_id=p.ID";
391 $sql .= " LEFT JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id=tt.term_taxonomy_id";
392 $sql .= " LEFT JOIN {$wpdb->terms} t ON tt.term_id=t.term_id";
393 } elseif ( ! empty( $query['group'] ) ) {
394 $sql .= " INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id=p.ID";
395 $sql .= " INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id=tt.term_taxonomy_id";
396 $sql .= " INNER JOIN {$wpdb->terms} t ON tt.term_id=t.term_id";
397 $sql .= ' AND t.slug=%s';
398 $sql_params[] = $query['group'];
399 }
400 $sql .= ' WHERE post_type=%s';
401 $sql_params[] = self::POST_TYPE;
402 if ( $query['hook'] ) {
403 $sql .= ' AND p.post_title=%s';
404 $sql_params[] = $query['hook'];
405 }
406 if ( ! is_null( $query['args'] ) ) {
407 $sql .= ' AND p.post_content=%s';
408 $sql_params[] = wp_json_encode( $query['args'] );
409 }
410
411 if ( $query['status'] ) {
412 $post_statuses = array_map( array( $this, 'get_post_status_by_action_status' ), (array) $query['status'] );
413 $placeholders = array_fill( 0, count( $post_statuses ), '%s' );
414 $sql .= ' AND p.post_status IN (' . join( ', ', $placeholders ) . ')';
415 $sql_params = array_merge( $sql_params, array_values( $post_statuses ) );
416 }
417
418 if ( $query['date'] instanceof DateTime ) {
419 $date = clone $query['date'];
420 $date->setTimezone( new DateTimeZone( 'UTC' ) );
421 $date_string = $date->format( 'Y-m-d H:i:s' );
422 $comparator = $this->validate_sql_comparator( $query['date_compare'] );
423 $sql .= " AND p.post_date_gmt $comparator %s";
424 $sql_params[] = $date_string;
425 }
426
427 if ( $query['modified'] instanceof DateTime ) {
428 $modified = clone $query['modified'];
429 $modified->setTimezone( new DateTimeZone( 'UTC' ) );
430 $date_string = $modified->format( 'Y-m-d H:i:s' );
431 $comparator = $this->validate_sql_comparator( $query['modified_compare'] );
432 $sql .= " AND p.post_modified_gmt $comparator %s";
433 $sql_params[] = $date_string;
434 }
435
436 if ( true === $query['claimed'] ) {
437 $sql .= " AND p.post_password != ''";
438 } elseif ( false === $query['claimed'] ) {
439 $sql .= " AND p.post_password = ''";
440 } elseif ( ! is_null( $query['claimed'] ) ) {
441 $sql .= ' AND p.post_password = %s';
442 $sql_params[] = $query['claimed'];
443 }
444
445 if ( ! empty( $query['search'] ) ) {
446 $sql .= ' AND (p.post_title LIKE %s OR p.post_content LIKE %s OR p.post_password LIKE %s';
447 for ( $i = 0; $i < 3; $i++ ) {
448 $sql_params[] = sprintf( '%%%s%%', $query['search'] );
449 }
450
451 $search_action_id = (int) $query['search'];
452 if ( $search_action_id ) {
453 $sql .= ' OR p.ID = %d';
454 $sql_params[] = $search_action_id;
455 }
456
457 $sql .= ')';
458 }
459
460 if ( 'select' === $select_or_count ) {
461 switch ( $query['orderby'] ) {
462 case 'hook':
463 $orderby = 'p.post_title';
464 break;
465 case 'group':
466 $orderby = 't.name';
467 break;
468 case 'status':
469 $orderby = 'p.post_status';
470 break;
471 case 'modified':
472 $orderby = 'p.post_modified';
473 break;
474 case 'claim_id':
475 $orderby = 'p.post_password';
476 break;
477 case 'schedule':
478 case 'date':
479 default:
480 $orderby = 'p.post_date_gmt';
481 break;
482 }
483 if ( 'ASC' === strtoupper( $query['order'] ) ) {
484 $order = 'ASC';
485 } else {
486 $order = 'DESC';
487 }
488 $sql .= " ORDER BY $orderby $order";
489 if ( $query['per_page'] > 0 ) {
490 $sql .= ' LIMIT %d, %d';
491 $sql_params[] = $query['offset'];
492 $sql_params[] = $query['per_page'];
493 }
494 }
495
496 return $wpdb->prepare( $sql, $sql_params ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
497 }
498
499 /**
500 * Query for action count or list of action IDs.
501 *
502 * @since 3.3.0 $query['status'] accepts array of statuses instead of a single status.
503 *
504 * @see ActionScheduler_Store::query_actions for $query arg usage.
505 *
506 * @param array $query Query filtering options.
507 * @param string $query_type Whether to select or count the results. Defaults to select.
508 *
509 * @return string|array|null The IDs of actions matching the query. Null on failure.
510 */
511 public function query_actions( $query = array(), $query_type = 'select' ) {
512 /**
513 * Global $wpdb object.
514 *
515 * @var wpdb $wpdb
516 */
517 global $wpdb;
518
519 $sql = $this->get_query_actions_sql( $query, $query_type );
520
521 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
522 }
523
524 /**
525 * Get a count of all actions in the store, grouped by status
526 *
527 * @return array
528 */
529 public function action_counts() {
530
531 $action_counts_by_status = array();
532 $action_stati_and_labels = $this->get_status_labels();
533 $posts_count_by_status = (array) wp_count_posts( self::POST_TYPE, 'readable' );
534
535 foreach ( $posts_count_by_status as $post_status_name => $count ) {
536
537 try {
538 $action_status_name = $this->get_action_status_by_post_status( $post_status_name );
539 } catch ( Exception $e ) {
540 // Ignore any post statuses that aren't for actions.
541 continue;
542 }
543 if ( array_key_exists( $action_status_name, $action_stati_and_labels ) ) {
544 $action_counts_by_status[ $action_status_name ] = $count;
545 }
546 }
547
548 return $action_counts_by_status;
549 }
550
551 /**
552 * Cancel action.
553 *
554 * @param int $action_id Action ID.
555 *
556 * @throws InvalidArgumentException If $action_id is not identified.
557 */
558 public function cancel_action( $action_id ) {
559 $post = get_post( $action_id );
560 if ( empty( $post ) || ( self::POST_TYPE !== $post->post_type ) ) {
561 /* translators: %s is the action ID */
562 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 ) );
563 }
564 do_action( 'action_scheduler_canceled_action', $action_id );
565 add_filter( 'pre_wp_unique_post_slug', array( $this, 'set_unique_post_slug' ), 10, 5 );
566 wp_trash_post( $action_id );
567 remove_filter( 'pre_wp_unique_post_slug', array( $this, 'set_unique_post_slug' ), 10 );
568 }
569
570 /**
571 * Delete action.
572 *
573 * @param int $action_id Action ID.
574 * @return void
575 * @throws InvalidArgumentException If action is not identified.
576 */
577 public function delete_action( $action_id ) {
578 $post = get_post( $action_id );
579 if ( empty( $post ) || ( self::POST_TYPE !== $post->post_type ) ) {
580 /* translators: %s is the action ID */
581 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 ) );
582 }
583 do_action( 'action_scheduler_deleted_action', $action_id );
584
585 wp_delete_post( $action_id, true );
586 }
587
588 /**
589 * Get date for claim id.
590 *
591 * @param int $action_id Action ID.
592 * @return ActionScheduler_DateTime The date the action is schedule to run, or the date that it ran.
593 */
594 public function get_date( $action_id ) {
595 $next = $this->get_date_gmt( $action_id );
596 return ActionScheduler_TimezoneHelper::set_local_timezone( $next );
597 }
598
599 /**
600 * Get Date GMT.
601 *
602 * @param int $action_id Action ID.
603 *
604 * @throws InvalidArgumentException If $action_id is not identified.
605 * @return ActionScheduler_DateTime The date the action is schedule to run, or the date that it ran.
606 */
607 public function get_date_gmt( $action_id ) {
608 $post = get_post( $action_id );
609 if ( empty( $post ) || ( self::POST_TYPE !== $post->post_type ) ) {
610 /* translators: %s is the action ID */
611 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 ) );
612 }
613 if ( 'publish' === $post->post_status ) {
614 return as_get_datetime_object( $post->post_modified_gmt );
615 } else {
616 return as_get_datetime_object( $post->post_date_gmt );
617 }
618 }
619
620 /**
621 * Stake claim.
622 *
623 * @param int $max_actions Maximum number of actions.
624 * @param DateTime|null $before_date Jobs must be schedule before this date. Defaults to now.
625 * @param array $hooks Claim only actions with a hook or hooks.
626 * @param string $group Claim only actions in the given group.
627 *
628 * @return ActionScheduler_ActionClaim
629 * @throws RuntimeException When there is an error staking a claim.
630 * @throws InvalidArgumentException When the given group is not valid.
631 */
632 public function stake_claim( $max_actions = 10, ?DateTime $before_date = null, $hooks = array(), $group = '' ) {
633 $this->claim_before_date = $before_date;
634 $claim_id = $this->generate_claim_id();
635 $this->claim_actions( $claim_id, $max_actions, $before_date, $hooks, $group );
636 $action_ids = $this->find_actions_by_claim_id( $claim_id );
637 $this->claim_before_date = null;
638
639 return new ActionScheduler_ActionClaim( $claim_id, $action_ids );
640 }
641
642 /**
643 * Get claim count.
644 *
645 * @return int
646 */
647 public function get_claim_count() {
648 global $wpdb;
649
650 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
651 return $wpdb->get_var(
652 $wpdb->prepare(
653 "SELECT COUNT(DISTINCT post_password) FROM {$wpdb->posts} WHERE post_password != '' AND post_type = %s AND post_status IN ('in-progress','pending')",
654 array( self::POST_TYPE )
655 )
656 );
657 }
658
659 /**
660 * Generate claim id.
661 *
662 * @return string
663 */
664 protected function generate_claim_id() {
665 $claim_id = md5( microtime( true ) . wp_rand( 0, 1000 ) );
666 return substr( $claim_id, 0, 20 ); // to fit in db field with 20 char limit.
667 }
668
669 /**
670 * Claim actions.
671 *
672 * @param string $claim_id Claim ID.
673 * @param int $limit Limit.
674 * @param DateTime|null $before_date Should use UTC timezone.
675 * @param array $hooks Claim only actions with a hook or hooks.
676 * @param string $group Claim only actions in the given group.
677 *
678 * @return int The number of actions that were claimed.
679 * @throws RuntimeException When there is a database error.
680 */
681 protected function claim_actions( $claim_id, $limit, ?DateTime $before_date = null, $hooks = array(), $group = '' ) {
682 // Set up initial variables.
683 $date = null === $before_date ? as_get_datetime_object() : clone $before_date;
684 $limit_ids = ! empty( $group );
685 $ids = $limit_ids ? $this->get_actions_by_group( $group, $limit, $date ) : array();
686
687 // If limiting by IDs and no posts found, then return early since we have nothing to update.
688 if ( $limit_ids && 0 === count( $ids ) ) {
689 return 0;
690 }
691
692 /**
693 * Global wpdb object.
694 *
695 * @var wpdb $wpdb
696 */
697 global $wpdb;
698
699 /*
700 * Build up custom query to update the affected posts. Parameters are built as a separate array
701 * to make it easier to identify where they are in the query.
702 *
703 * We can't use $wpdb->update() here because of the "ID IN ..." clause.
704 */
705 $update = "UPDATE {$wpdb->posts} SET post_password = %s, post_modified_gmt = %s, post_modified = %s";
706 $params = array(
707 $claim_id,
708 current_time( 'mysql', true ),
709 current_time( 'mysql' ),
710 );
711
712 // Build initial WHERE clause.
713 $where = "WHERE post_type = %s AND post_status = %s AND post_password = ''";
714 $params[] = self::POST_TYPE;
715 $params[] = ActionScheduler_Store::STATUS_PENDING;
716
717 if ( ! empty( $hooks ) ) {
718 $placeholders = array_fill( 0, count( $hooks ), '%s' );
719 $where .= ' AND post_title IN (' . join( ', ', $placeholders ) . ')';
720 $params = array_merge( $params, array_values( $hooks ) );
721 }
722
723 /*
724 * Add the IDs to the WHERE clause. IDs not escaped because they came directly from a prior DB query.
725 *
726 * If we're not limiting by IDs, then include the post_date_gmt clause.
727 */
728 if ( $limit_ids ) {
729 $where .= ' AND ID IN (' . join( ',', $ids ) . ')';
730 } else {
731 $where .= ' AND post_date_gmt <= %s';
732 $params[] = $date->format( 'Y-m-d H:i:s' );
733 }
734
735 // Add the ORDER BY clause and,ms limit.
736 $order = 'ORDER BY menu_order ASC, post_date_gmt ASC, ID ASC LIMIT %d';
737 $params[] = $limit;
738
739 // Run the query and gather results.
740 $rows_affected = $wpdb->query( $wpdb->prepare( "{$update} {$where} {$order}", $params ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
741
742 if ( false === $rows_affected ) {
743 $error = empty( $wpdb->last_error )
744 ? _x( 'unknown', 'database error', 'action-scheduler' )
745 : $wpdb->last_error;
746 throw new RuntimeException(
747 esc_html(
748 sprintf(
749 /* translators: %s database error. */
750 __( 'Unable to claim actions. Database error: %s.', 'action-scheduler' ),
751 $error
752 )
753 )
754 );
755 }
756
757 return (int) $rows_affected;
758 }
759
760 /**
761 * Get IDs of actions within a certain group and up to a certain date/time.
762 *
763 * @param string $group The group to use in finding actions.
764 * @param int $limit The number of actions to retrieve.
765 * @param DateTime $date DateTime object representing cutoff time for actions. Actions retrieved will be
766 * up to and including this DateTime.
767 *
768 * @return array IDs of actions in the appropriate group and before the appropriate time.
769 * @throws InvalidArgumentException When the group does not exist.
770 */
771 protected function get_actions_by_group( $group, $limit, DateTime $date ) {
772 // Ensure the group exists before continuing.
773 if ( ! term_exists( $group, self::GROUP_TAXONOMY ) ) {
774 /* translators: %s is the group name */
775 throw new InvalidArgumentException( sprintf( __( 'The group "%s" does not exist.', 'action-scheduler' ), $group ) );
776 }
777
778 // Set up a query for post IDs to use later.
779 $query = new WP_Query();
780 $query_args = array(
781 'fields' => 'ids',
782 'post_type' => self::POST_TYPE,
783 'post_status' => ActionScheduler_Store::STATUS_PENDING,
784 'has_password' => false,
785 'posts_per_page' => $limit * 3,
786 'suppress_filters' => true, // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.SuppressFilters_suppress_filters
787 'no_found_rows' => true,
788 'orderby' => array(
789 'menu_order' => 'ASC',
790 'date' => 'ASC',
791 'ID' => 'ASC',
792 ),
793 'date_query' => array(
794 'column' => 'post_date_gmt',
795 'before' => $date->format( 'Y-m-d H:i' ),
796 'inclusive' => true,
797 ),
798 'tax_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery
799 array(
800 'taxonomy' => self::GROUP_TAXONOMY,
801 'field' => 'slug',
802 'terms' => $group,
803 'include_children' => false,
804 ),
805 ),
806 );
807
808 return $query->query( $query_args );
809 }
810
811 /**
812 * Find actions by claim ID.
813 *
814 * @param string $claim_id Claim ID.
815 * @return array
816 */
817 public function find_actions_by_claim_id( $claim_id ) {
818 /**
819 * Global wpdb object.
820 *
821 * @var wpdb $wpdb
822 */
823 global $wpdb;
824
825 $action_ids = array();
826 $before_date = isset( $this->claim_before_date ) ? $this->claim_before_date : as_get_datetime_object();
827 $cut_off = $before_date->format( 'Y-m-d H:i:s' );
828
829 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
830 $results = $wpdb->get_results(
831 $wpdb->prepare(
832 "SELECT ID, post_date_gmt FROM {$wpdb->posts} WHERE post_type = %s AND post_password = %s",
833 array(
834 self::POST_TYPE,
835 $claim_id,
836 )
837 )
838 );
839
840 // Verify that the scheduled date for each action is within the expected bounds (in some unusual
841 // cases, we cannot depend on MySQL to honor all of the WHERE conditions we specify).
842 foreach ( $results as $claimed_action ) {
843 if ( $claimed_action->post_date_gmt <= $cut_off ) {
844 $action_ids[] = absint( $claimed_action->ID );
845 }
846 }
847
848 return $action_ids;
849 }
850
851 /**
852 * Release pending actions from a claim.
853 *
854 * @param ActionScheduler_ActionClaim $claim Claim object to release.
855 * @return void
856 * @throws RuntimeException When the claim is not unlocked.
857 */
858 public function release_claim( ActionScheduler_ActionClaim $claim ) {
859 /**
860 * Global wpdb object.
861 *
862 * @var wpdb $wpdb
863 */
864 global $wpdb;
865
866 $claim_id = $claim->get_id();
867 if ( trim( $claim_id ) === '' ) {
868 // Verify that the claim_id is valid before attempting to release it.
869 return;
870 }
871
872 // Only attempt to release pending actions to be claimed again. Running and complete actions are no longer relevant outside of admin/analytics.
873 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
874 $action_ids = $wpdb->get_col(
875 $wpdb->prepare(
876 "SELECT ID, post_date_gmt FROM {$wpdb->posts} WHERE post_type = %s AND post_password = %s AND post_status = %s",
877 self::POST_TYPE,
878 $claim_id,
879 self::STATUS_PENDING
880 )
881 );
882
883 if ( empty( $action_ids ) ) {
884 return; // nothing to do.
885 }
886 $action_id_string = implode( ',', array_map( 'intval', $action_ids ) );
887
888 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
889 $result = $wpdb->query(
890 $wpdb->prepare(
891 "UPDATE {$wpdb->posts} SET post_password = '' WHERE ID IN ($action_id_string) AND post_password = %s", //phpcs:ignore
892 array(
893 $claim->get_id(),
894 )
895 )
896 );
897 if ( false === $result ) {
898 /* translators: %s: claim ID */
899 throw new RuntimeException( sprintf( __( 'Unable to unlock claim %s. Database error.', 'action-scheduler' ), $claim->get_id() ) );
900 }
901 }
902
903 /**
904 * Unclaim action.
905 *
906 * @param string $action_id Action ID.
907 * @throws RuntimeException When unable to unlock claim on action ID.
908 */
909 public function unclaim_action( $action_id ) {
910 /**
911 * Global wpdb object.
912 *
913 * @var wpdb $wpdb
914 */
915 global $wpdb;
916
917 //phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
918 $result = $wpdb->query(
919 $wpdb->prepare(
920 "UPDATE {$wpdb->posts} SET post_password = '' WHERE ID = %d AND post_type = %s",
921 $action_id,
922 self::POST_TYPE
923 )
924 );
925 if ( false === $result ) {
926 /* translators: %s: action ID */
927 throw new RuntimeException( sprintf( __( 'Unable to unlock claim on action %s. Database error.', 'action-scheduler' ), $action_id ) );
928 }
929 }
930
931 /**
932 * Mark failure on action.
933 *
934 * @param int $action_id Action ID.
935 *
936 * @return void
937 * @throws RuntimeException When unable to mark failure on action ID.
938 */
939 public function mark_failure( $action_id ) {
940 /**
941 * Global wpdb object.
942 *
943 * @var wpdb $wpdb
944 */
945 global $wpdb;
946
947 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
948 $result = $wpdb->query(
949 $wpdb->prepare( "UPDATE {$wpdb->posts} SET post_status = %s WHERE ID = %d AND post_type = %s", self::STATUS_FAILED, $action_id, self::POST_TYPE )
950 );
951 if ( false === $result ) {
952 /* translators: %s: action ID */
953 throw new RuntimeException( sprintf( __( 'Unable to mark failure on action %s. Database error.', 'action-scheduler' ), $action_id ) );
954 }
955 }
956
957 /**
958 * Return an action's claim ID, as stored in the post password column
959 *
960 * @param int $action_id Action ID.
961 * @return mixed
962 */
963 public function get_claim_id( $action_id ) {
964 return $this->get_post_column( $action_id, 'post_password' );
965 }
966
967 /**
968 * Return an action's status, as stored in the post status column
969 *
970 * @param int $action_id Action ID.
971 *
972 * @return mixed
973 * @throws InvalidArgumentException When the action ID is invalid.
974 */
975 public function get_status( $action_id ) {
976 $status = $this->get_post_column( $action_id, 'post_status' );
977
978 if ( null === $status ) {
979 throw new InvalidArgumentException( __( 'Invalid action ID. No status found.', 'action-scheduler' ) );
980 }
981
982 return $this->get_action_status_by_post_status( $status );
983 }
984
985 /**
986 * Get post column
987 *
988 * @param string $action_id Action ID.
989 * @param string $column_name Column Name.
990 *
991 * @return string|null
992 */
993 private function get_post_column( $action_id, $column_name ) {
994 /**
995 * Global wpdb object.
996 *
997 * @var wpdb $wpdb
998 */
999 global $wpdb;
1000
1001 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1002 return $wpdb->get_var(
1003 $wpdb->prepare(
1004 "SELECT {$column_name} FROM {$wpdb->posts} WHERE ID=%d AND post_type=%s", // phpcs:ignore
1005 $action_id,
1006 self::POST_TYPE
1007 )
1008 );
1009 }
1010
1011 /**
1012 * Log Execution.
1013 *
1014 * @throws Exception If the action status cannot be updated to self::STATUS_RUNNING ('in-progress').
1015 *
1016 * @param string $action_id Action ID.
1017 */
1018 public function log_execution( $action_id ) {
1019 /**
1020 * Global wpdb object.
1021 *
1022 * @var wpdb $wpdb
1023 */
1024 global $wpdb;
1025
1026 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1027 $status_updated = $wpdb->query(
1028 $wpdb->prepare(
1029 "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",
1030 self::STATUS_RUNNING,
1031 current_time( 'mysql', true ),
1032 current_time( 'mysql' ),
1033 $action_id,
1034 self::POST_TYPE
1035 )
1036 );
1037
1038 if ( ! $status_updated ) {
1039 throw new Exception(
1040 sprintf(
1041 /* translators: 1: action ID. 2: status slug. */
1042 __( 'Unable to update the status of action %1$d to %2$s.', 'action-scheduler' ),
1043 $action_id,
1044 self::STATUS_RUNNING
1045 )
1046 );
1047 }
1048 }
1049
1050 /**
1051 * Record that an action was completed.
1052 *
1053 * @param string $action_id ID of the completed action.
1054 *
1055 * @throws InvalidArgumentException When the action ID is invalid.
1056 * @throws RuntimeException When there was an error executing the action.
1057 */
1058 public function mark_complete( $action_id ) {
1059 $post = get_post( $action_id );
1060 if ( empty( $post ) || ( self::POST_TYPE !== $post->post_type ) ) {
1061 /* translators: %s is the action ID */
1062 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 ) );
1063 }
1064 add_filter( 'wp_insert_post_data', array( $this, 'filter_insert_post_data' ), 10, 1 );
1065 add_filter( 'pre_wp_unique_post_slug', array( $this, 'set_unique_post_slug' ), 10, 5 );
1066 $result = wp_update_post(
1067 array(
1068 'ID' => $action_id,
1069 'post_status' => 'publish',
1070 ),
1071 true
1072 );
1073 remove_filter( 'wp_insert_post_data', array( $this, 'filter_insert_post_data' ), 10 );
1074 remove_filter( 'pre_wp_unique_post_slug', array( $this, 'set_unique_post_slug' ), 10 );
1075 if ( is_wp_error( $result ) ) {
1076 throw new RuntimeException( $result->get_error_message() );
1077 }
1078
1079 /**
1080 * Fires after a scheduled action has been completed.
1081 *
1082 * @since 3.4.2
1083 *
1084 * @param int $action_id Action ID.
1085 */
1086 do_action( 'action_scheduler_completed_action', $action_id );
1087 }
1088
1089 /**
1090 * Mark action as migrated when there is an error deleting the action.
1091 *
1092 * @param int $action_id Action ID.
1093 */
1094 public function mark_migrated( $action_id ) {
1095 wp_update_post(
1096 array(
1097 'ID' => $action_id,
1098 'post_status' => 'migrated',
1099 )
1100 );
1101 }
1102
1103 /**
1104 * Determine whether the post store can be migrated.
1105 *
1106 * @param [type] $setting - Setting value.
1107 * @return bool
1108 */
1109 public function migration_dependencies_met( $setting ) {
1110 global $wpdb;
1111
1112 $dependencies_met = get_transient( self::DEPENDENCIES_MET );
1113 if ( empty( $dependencies_met ) ) {
1114 $maximum_args_length = apply_filters( 'action_scheduler_maximum_args_length', 191 );
1115 $found_action = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1116 $wpdb->prepare(
1117 "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s AND CHAR_LENGTH(post_content) > %d LIMIT 1",
1118 $maximum_args_length,
1119 self::POST_TYPE
1120 )
1121 );
1122 $dependencies_met = $found_action ? 'no' : 'yes';
1123 set_transient( self::DEPENDENCIES_MET, $dependencies_met, DAY_IN_SECONDS );
1124 }
1125
1126 return 'yes' === $dependencies_met ? $setting : false;
1127 }
1128
1129 /**
1130 * InnoDB indexes have a maximum size of 767 bytes by default, which is only 191 characters with utf8mb4.
1131 *
1132 * Previously, AS wasn't concerned about args length, as we used the (unindex) post_content column. However,
1133 * as we prepare to move to custom tables, and can use an indexed VARCHAR column instead, we want to warn
1134 * developers of this impending requirement.
1135 *
1136 * @param ActionScheduler_Action $action Action object.
1137 */
1138 protected function validate_action( ActionScheduler_Action $action ) {
1139 try {
1140 parent::validate_action( $action );
1141 } catch ( Exception $e ) {
1142 /* translators: %s is the error message */
1143 $message = sprintf( __( '%s Support for strings longer than this will be removed in a future version.', 'action-scheduler' ), $e->getMessage() );
1144 _doing_it_wrong( 'ActionScheduler_Action::$args', esc_html( $message ), '2.1.0' );
1145 }
1146 }
1147
1148 /**
1149 * (@codeCoverageIgnore)
1150 */
1151 public function init() {
1152 add_filter( 'action_scheduler_migration_dependencies_met', array( $this, 'migration_dependencies_met' ) );
1153
1154 $post_type_registrar = new ActionScheduler_wpPostStore_PostTypeRegistrar();
1155 $post_type_registrar->register();
1156
1157 $post_status_registrar = new ActionScheduler_wpPostStore_PostStatusRegistrar();
1158 $post_status_registrar->register();
1159
1160 $taxonomy_registrar = new ActionScheduler_wpPostStore_TaxonomyRegistrar();
1161 $taxonomy_registrar->register();
1162 }
1163 }
1164