PluginProbe
WANotifier for Forms and Actions / 2.7.5
WANotifier for Forms and Actions v2.7.5
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_DBStore.php

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

1,170 lines 36.6 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_DBStore
5 *
6 * Action data table data store.
7 *
8 * @since 3.0.0
9 */
10 class ActionScheduler_DBStore extends ActionScheduler_Store {
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 /** @var int */
23 protected static $max_args_length = 8000;
24
25 /** @var int */
26 protected static $max_index_length = 191;
27
28 /** @var array List of claim filters. */
29 protected $claim_filters = [
30 'group' => '',
31 'hooks' => '',
32 'exclude-groups' => '',
33 ];
34
35 /**
36 * Initialize the data store
37 *
38 * @codeCoverageIgnore
39 */
40 public function init() {
41 $table_maker = new ActionScheduler_StoreSchema();
42 $table_maker->init();
43 $table_maker->register_tables();
44 }
45
46 /**
47 * Save an action, checks if this is a unique action before actually saving.
48 *
49 * @param ActionScheduler_Action $action Action object.
50 * @param \DateTime $scheduled_date Optional schedule date. Default null.
51 *
52 * @return int Action ID.
53 * @throws RuntimeException Throws exception when saving the action fails.
54 */
55 public function save_unique_action( ActionScheduler_Action $action, \DateTime $scheduled_date = null ) {
56 return $this->save_action_to_db( $action, $scheduled_date, true );
57 }
58
59 /**
60 * Save an action. Can save duplicate action as well, prefer using `save_unique_action` instead.
61 *
62 * @param ActionScheduler_Action $action Action object.
63 * @param \DateTime $scheduled_date Optional schedule date. Default null.
64 *
65 * @return int Action ID.
66 * @throws RuntimeException Throws exception when saving the action fails.
67 */
68 public function save_action( ActionScheduler_Action $action, \DateTime $scheduled_date = null ) {
69 return $this->save_action_to_db( $action, $scheduled_date, false );
70 }
71
72 /**
73 * Save an action.
74 *
75 * @param ActionScheduler_Action $action Action object.
76 * @param ?DateTime $date Optional schedule date. Default null.
77 * @param bool $unique Whether the action should be unique.
78 *
79 * @return int Action ID.
80 * @throws RuntimeException Throws exception when saving the action fails.
81 */
82 private function save_action_to_db( ActionScheduler_Action $action, DateTime $date = null, $unique = false ) {
83 global $wpdb;
84
85 try {
86 $this->validate_action( $action );
87
88 $data = array(
89 'hook' => $action->get_hook(),
90 'status' => ( $action->is_finished() ? self::STATUS_COMPLETE : self::STATUS_PENDING ),
91 'scheduled_date_gmt' => $this->get_scheduled_date_string( $action, $date ),
92 'scheduled_date_local' => $this->get_scheduled_date_string_local( $action, $date ),
93 'schedule' => serialize( $action->get_schedule() ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
94 'group_id' => current( $this->get_group_ids( $action->get_group() ) ),
95 );
96
97 $args = wp_json_encode( $action->get_args() );
98 if ( strlen( $args ) <= static::$max_index_length ) {
99 $data['args'] = $args;
100 } else {
101 $data['args'] = $this->hash_args( $args );
102 $data['extended_args'] = $args;
103 }
104
105 $insert_sql = $this->build_insert_sql( $data, $unique );
106
107 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $insert_sql should be already prepared.
108 $wpdb->query( $insert_sql );
109 $action_id = $wpdb->insert_id;
110
111 if ( is_wp_error( $action_id ) ) {
112 throw new \RuntimeException( $action_id->get_error_message() );
113 } elseif ( empty( $action_id ) ) {
114 if ( $unique ) {
115 return 0;
116 }
117 throw new \RuntimeException( $wpdb->last_error ? $wpdb->last_error : __( 'Database error.', 'action-scheduler' ) );
118 }
119
120 do_action( 'action_scheduler_stored_action', $action_id );
121
122 return $action_id;
123 } catch ( \Exception $e ) {
124 /* translators: %s: error message */
125 throw new \RuntimeException( sprintf( __( 'Error saving action: %s', 'action-scheduler' ), $e->getMessage() ), 0 );
126 }
127 }
128
129 /**
130 * Helper function to build insert query.
131 *
132 * @param array $data Row data for action.
133 * @param bool $unique Whether the action should be unique.
134 *
135 * @return string Insert query.
136 */
137 private function build_insert_sql( array $data, $unique ) {
138 global $wpdb;
139 $columns = array_keys( $data );
140 $values = array_values( $data );
141 $placeholders = array_map( array( $this, 'get_placeholder_for_column' ), $columns );
142
143 $table_name = ! empty( $wpdb->actionscheduler_actions ) ? $wpdb->actionscheduler_actions : $wpdb->prefix . 'actionscheduler_actions';
144
145 $column_sql = '`' . implode( '`, `', $columns ) . '`';
146 $placeholder_sql = implode( ', ', $placeholders );
147 $where_clause = $this->build_where_clause_for_insert( $data, $table_name, $unique );
148 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $column_sql and $where_clause are already prepared. $placeholder_sql is hardcoded.
149 $insert_query = $wpdb->prepare(
150 "
151 INSERT INTO $table_name ( $column_sql )
152 SELECT $placeholder_sql FROM DUAL
153 WHERE ( $where_clause ) IS NULL",
154 $values
155 );
156 // phpcs:enable
157
158 return $insert_query;
159 }
160
161 /**
162 * Helper method to build where clause for action insert statement.
163 *
164 * @param array $data Row data for action.
165 * @param string $table_name Action table name.
166 * @param bool $unique Where action should be unique.
167 *
168 * @return string Where clause to be used with insert.
169 */
170 private function build_where_clause_for_insert( $data, $table_name, $unique ) {
171 global $wpdb;
172
173 if ( ! $unique ) {
174 return 'SELECT NULL FROM DUAL';
175 }
176
177 $pending_statuses = array(
178 ActionScheduler_Store::STATUS_PENDING,
179 ActionScheduler_Store::STATUS_RUNNING,
180 );
181 $pending_status_placeholders = implode( ', ', array_fill( 0, count( $pending_statuses ), '%s' ) );
182
183 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $pending_status_placeholders is hardcoded.
184 $where_clause = $wpdb->prepare(
185 "
186 SELECT action_id FROM $table_name
187 WHERE status IN ( $pending_status_placeholders )
188 AND hook = %s
189 AND `group_id` = %d
190 ",
191 array_merge(
192 $pending_statuses,
193 array(
194 $data['hook'],
195 $data['group_id'],
196 )
197 )
198 );
199 // phpcs:enable
200
201 return "$where_clause" . ' LIMIT 1';
202 }
203
204 /**
205 * Helper method to get $wpdb->prepare placeholder for a given column name.
206 *
207 * @param string $column_name Name of column in actions table.
208 *
209 * @return string Placeholder to use for given column.
210 */
211 private function get_placeholder_for_column( $column_name ) {
212 $string_columns = array(
213 'hook',
214 'status',
215 'scheduled_date_gmt',
216 'scheduled_date_local',
217 'args',
218 'schedule',
219 'last_attempt_gmt',
220 'last_attempt_local',
221 'extended_args',
222 );
223
224 return in_array( $column_name, $string_columns ) ? '%s' : '%d';
225 }
226
227 /**
228 * Generate a hash from json_encoded $args using MD5 as this isn't for security.
229 *
230 * @param string $args JSON encoded action args.
231 * @return string
232 */
233 protected function hash_args( $args ) {
234 return md5( $args );
235 }
236
237 /**
238 * Get action args query param value from action args.
239 *
240 * @param array $args Action args.
241 * @return string
242 */
243 protected function get_args_for_query( $args ) {
244 $encoded = wp_json_encode( $args );
245 if ( strlen( $encoded ) <= static::$max_index_length ) {
246 return $encoded;
247 }
248 return $this->hash_args( $encoded );
249 }
250 /**
251 * Get a group's ID based on its name/slug.
252 *
253 * @param string|array $slugs The string name of a group, or names for several groups.
254 * @param bool $create_if_not_exists Whether to create the group if it does not already exist. Default, true - create the group.
255 *
256 * @return array The group IDs, if they exist or were successfully created. May be empty.
257 */
258 protected function get_group_ids( $slugs, $create_if_not_exists = true ) {
259 $slugs = (array) $slugs;
260 $group_ids = array();
261
262 if ( empty( $slugs ) ) {
263 return array();
264 }
265
266 /** @var \wpdb $wpdb */
267 global $wpdb;
268
269 foreach ( $slugs as $slug ) {
270 $group_id = (int) $wpdb->get_var( $wpdb->prepare( "SELECT group_id FROM {$wpdb->actionscheduler_groups} WHERE slug=%s", $slug ) );
271
272 if ( empty( $group_id ) && $create_if_not_exists ) {
273 $group_id = $this->create_group( $slug );
274 }
275
276 if ( $group_id ) {
277 $group_ids[] = $group_id;
278 }
279 }
280
281 return $group_ids;
282 }
283
284 /**
285 * Create an action group.
286 *
287 * @param string $slug Group slug.
288 *
289 * @return int Group ID.
290 */
291 protected function create_group( $slug ) {
292 /** @var \wpdb $wpdb */
293 global $wpdb;
294 $wpdb->insert( $wpdb->actionscheduler_groups, array( 'slug' => $slug ) );
295
296 return (int) $wpdb->insert_id;
297 }
298
299 /**
300 * Retrieve an action.
301 *
302 * @param int $action_id Action ID.
303 *
304 * @return ActionScheduler_Action
305 */
306 public function fetch_action( $action_id ) {
307 /** @var \wpdb $wpdb */
308 global $wpdb;
309 $data = $wpdb->get_row(
310 $wpdb->prepare(
311 "SELECT a.*, g.slug AS `group` FROM {$wpdb->actionscheduler_actions} a LEFT JOIN {$wpdb->actionscheduler_groups} g ON a.group_id=g.group_id WHERE a.action_id=%d",
312 $action_id
313 )
314 );
315
316 if ( empty( $data ) ) {
317 return $this->get_null_action();
318 }
319
320 if ( ! empty( $data->extended_args ) ) {
321 $data->args = $data->extended_args;
322 unset( $data->extended_args );
323 }
324
325 // Convert NULL dates to zero dates.
326 $date_fields = array(
327 'scheduled_date_gmt',
328 'scheduled_date_local',
329 'last_attempt_gmt',
330 'last_attempt_gmt',
331 );
332 foreach ( $date_fields as $date_field ) {
333 if ( is_null( $data->$date_field ) ) {
334 $data->$date_field = ActionScheduler_StoreSchema::DEFAULT_DATE;
335 }
336 }
337
338 try {
339 $action = $this->make_action_from_db_record( $data );
340 } catch ( ActionScheduler_InvalidActionException $exception ) {
341 do_action( 'action_scheduler_failed_fetch_action', $action_id, $exception );
342 return $this->get_null_action();
343 }
344
345 return $action;
346 }
347
348 /**
349 * Create a null action.
350 *
351 * @return ActionScheduler_NullAction
352 */
353 protected function get_null_action() {
354 return new ActionScheduler_NullAction();
355 }
356
357 /**
358 * Create an action from a database record.
359 *
360 * @param object $data Action database record.
361 *
362 * @return ActionScheduler_Action|ActionScheduler_CanceledAction|ActionScheduler_FinishedAction
363 */
364 protected function make_action_from_db_record( $data ) {
365
366 $hook = $data->hook;
367 $args = json_decode( $data->args, true );
368 $schedule = unserialize( $data->schedule ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize
369
370 $this->validate_args( $args, $data->action_id );
371 $this->validate_schedule( $schedule, $data->action_id );
372
373 if ( empty( $schedule ) ) {
374 $schedule = new ActionScheduler_NullSchedule();
375 }
376 $group = $data->group ? $data->group : '';
377
378 return ActionScheduler::factory()->get_stored_action( $data->status, $data->hook, $args, $schedule, $group );
379 }
380
381 /**
382 * Returns the SQL statement to query (or count) actions.
383 *
384 * @since 3.3.0 $query['status'] accepts array of statuses instead of a single status.
385 *
386 * @param array $query Filtering options.
387 * @param string $select_or_count Whether the SQL should select and return the IDs or just the row count.
388 *
389 * @return string SQL statement already properly escaped.
390 * @throws InvalidArgumentException If the query is invalid.
391 */
392 protected function get_query_actions_sql( array $query, $select_or_count = 'select' ) {
393
394 if ( ! in_array( $select_or_count, array( 'select', 'count' ), true ) ) {
395 throw new InvalidArgumentException( __( 'Invalid value for select or count parameter. Cannot query actions.', 'action-scheduler' ) );
396 }
397
398 $query = wp_parse_args( $query, array(
399 'hook' => '',
400 'args' => null,
401 'partial_args_matching' => 'off', // can be 'like' or 'json'
402 'date' => null,
403 'date_compare' => '<=',
404 'modified' => null,
405 'modified_compare' => '<=',
406 'group' => '',
407 'status' => '',
408 'claimed' => null,
409 'per_page' => 5,
410 'offset' => 0,
411 'orderby' => 'date',
412 'order' => 'ASC',
413 ) );
414
415 /** @var \wpdb $wpdb */
416 global $wpdb;
417
418 $db_server_info = is_callable( array( $wpdb, 'db_server_info' ) ) ? $wpdb->db_server_info() : $wpdb->db_version();
419 if ( false !== strpos( $db_server_info, 'MariaDB' ) ) {
420 $supports_json = version_compare(
421 PHP_VERSION_ID >= 80016 ? $wpdb->db_version() : preg_replace( '/[^0-9.].*/', '', str_replace( '5.5.5-', '', $db_server_info ) ),
422 '10.2',
423 '>='
424 );
425 } else {
426 $supports_json = version_compare( $wpdb->db_version(), '5.7', '>=' );
427 }
428
429 $sql = ( 'count' === $select_or_count ) ? 'SELECT count(a.action_id)' : 'SELECT a.action_id';
430 $sql .= " FROM {$wpdb->actionscheduler_actions} a";
431 $sql_params = array();
432
433 if ( ! empty( $query['group'] ) || 'group' === $query['orderby'] ) {
434 $sql .= " LEFT JOIN {$wpdb->actionscheduler_groups} g ON g.group_id=a.group_id";
435 }
436
437 $sql .= " WHERE 1=1";
438
439 if ( ! empty( $query['group'] ) ) {
440 $sql .= " AND g.slug=%s";
441 $sql_params[] = $query['group'];
442 }
443
444 if ( ! empty( $query['hook'] ) ) {
445 $sql .= " AND a.hook=%s";
446 $sql_params[] = $query['hook'];
447 }
448
449 if ( ! is_null( $query['args'] ) ) {
450 switch ( $query['partial_args_matching'] ) {
451 case 'json':
452 if ( ! $supports_json ) {
453 throw new \RuntimeException( __( 'JSON partial matching not supported in your environment. Please check your MySQL/MariaDB version.', 'action-scheduler' ) );
454 }
455 $supported_types = array(
456 'integer' => '%d',
457 'boolean' => '%s',
458 'double' => '%f',
459 'string' => '%s',
460 );
461 foreach ( $query['args'] as $key => $value ) {
462 $value_type = gettype( $value );
463 if ( 'boolean' === $value_type ) {
464 $value = $value ? 'true' : 'false';
465 }
466 $placeholder = isset( $supported_types[ $value_type ] ) ? $supported_types[ $value_type ] : false;
467 if ( ! $placeholder ) {
468 throw new \RuntimeException( sprintf(
469 /* translators: %s: provided value type */
470 __( 'The value type for the JSON partial matching is not supported. Must be either integer, boolean, double or string. %s type provided.', 'action-scheduler' ),
471 $value_type
472 ) );
473 }
474 $sql .= ' AND JSON_EXTRACT(a.args, %s)='.$placeholder;
475 $sql_params[] = '$.'.$key;
476 $sql_params[] = $value;
477 }
478 break;
479 case 'like':
480 foreach ( $query['args'] as $key => $value ) {
481 $sql .= ' AND a.args LIKE %s';
482 $json_partial = $wpdb->esc_like( trim( json_encode( array( $key => $value ) ), '{}' ) );
483 $sql_params[] = "%{$json_partial}%";
484 }
485 break;
486 case 'off':
487 $sql .= " AND a.args=%s";
488 $sql_params[] = $this->get_args_for_query( $query['args'] );
489 break;
490 default:
491 throw new \RuntimeException( __( 'Unknown partial args matching value.', 'action-scheduler' ) );
492 }
493 }
494
495 if ( $query['status'] ) {
496 $statuses = (array) $query['status'];
497 $placeholders = array_fill( 0, count( $statuses ), '%s' );
498 $sql .= ' AND a.status IN (' . join( ', ', $placeholders ) . ')';
499 $sql_params = array_merge( $sql_params, array_values( $statuses ) );
500 }
501
502 if ( $query['date'] instanceof \DateTime ) {
503 $date = clone $query['date'];
504 $date->setTimezone( new \DateTimeZone( 'UTC' ) );
505 $date_string = $date->format( 'Y-m-d H:i:s' );
506 $comparator = $this->validate_sql_comparator( $query['date_compare'] );
507 $sql .= " AND a.scheduled_date_gmt $comparator %s";
508 $sql_params[] = $date_string;
509 }
510
511 if ( $query['modified'] instanceof \DateTime ) {
512 $modified = clone $query['modified'];
513 $modified->setTimezone( new \DateTimeZone( 'UTC' ) );
514 $date_string = $modified->format( 'Y-m-d H:i:s' );
515 $comparator = $this->validate_sql_comparator( $query['modified_compare'] );
516 $sql .= " AND a.last_attempt_gmt $comparator %s";
517 $sql_params[] = $date_string;
518 }
519
520 if ( true === $query['claimed'] ) {
521 $sql .= ' AND a.claim_id != 0';
522 } elseif ( false === $query['claimed'] ) {
523 $sql .= ' AND a.claim_id = 0';
524 } elseif ( ! is_null( $query['claimed'] ) ) {
525 $sql .= ' AND a.claim_id = %d';
526 $sql_params[] = $query['claimed'];
527 }
528
529 if ( ! empty( $query['search'] ) ) {
530 $sql .= ' AND (a.hook LIKE %s OR (a.extended_args IS NULL AND a.args LIKE %s) OR a.extended_args LIKE %s';
531 for ( $i = 0; $i < 3; $i++ ) {
532 $sql_params[] = sprintf( '%%%s%%', $query['search'] );
533 }
534
535 $search_claim_id = (int) $query['search'];
536 if ( $search_claim_id ) {
537 $sql .= ' OR a.claim_id = %d';
538 $sql_params[] = $search_claim_id;
539 }
540
541 $sql .= ')';
542 }
543
544 if ( 'select' === $select_or_count ) {
545 if ( 'ASC' === strtoupper( $query['order'] ) ) {
546 $order = 'ASC';
547 } else {
548 $order = 'DESC';
549 }
550 switch ( $query['orderby'] ) {
551 case 'hook':
552 $sql .= " ORDER BY a.hook $order";
553 break;
554 case 'group':
555 $sql .= " ORDER BY g.slug $order";
556 break;
557 case 'modified':
558 $sql .= " ORDER BY a.last_attempt_gmt $order";
559 break;
560 case 'none':
561 break;
562 case 'action_id':
563 $sql .= " ORDER BY a.action_id $order";
564 break;
565 case 'date':
566 default:
567 $sql .= " ORDER BY a.scheduled_date_gmt $order";
568 break;
569 }
570
571 if ( $query['per_page'] > 0 ) {
572 $sql .= ' LIMIT %d, %d';
573 $sql_params[] = $query['offset'];
574 $sql_params[] = $query['per_page'];
575 }
576 }
577
578 if ( ! empty( $sql_params ) ) {
579 $sql = $wpdb->prepare( $sql, $sql_params ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
580 }
581
582 return $sql;
583 }
584
585 /**
586 * Query for action count or list of action IDs.
587 *
588 * @since 3.3.0 $query['status'] accepts array of statuses instead of a single status.
589 *
590 * @see ActionScheduler_Store::query_actions for $query arg usage.
591 *
592 * @param array $query Query filtering options.
593 * @param string $query_type Whether to select or count the results. Defaults to select.
594 *
595 * @return string|array|null The IDs of actions matching the query. Null on failure.
596 */
597 public function query_actions( $query = array(), $query_type = 'select' ) {
598 /** @var wpdb $wpdb */
599 global $wpdb;
600
601 $sql = $this->get_query_actions_sql( $query, $query_type );
602
603 return ( 'count' === $query_type ) ? $wpdb->get_var( $sql ) : $wpdb->get_col( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.NoSql, WordPress.DB.DirectDatabaseQuery.NoCaching
604 }
605
606 /**
607 * Get a count of all actions in the store, grouped by status.
608 *
609 * @return array Set of 'status' => int $count pairs for statuses with 1 or more actions of that status.
610 */
611 public function action_counts() {
612 global $wpdb;
613
614 $sql = "SELECT a.status, count(a.status) as 'count'";
615 $sql .= " FROM {$wpdb->actionscheduler_actions} a";
616 $sql .= ' GROUP BY a.status';
617
618 $actions_count_by_status = array();
619 $action_stati_and_labels = $this->get_status_labels();
620
621 foreach ( $wpdb->get_results( $sql ) as $action_data ) { // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
622 // Ignore any actions with invalid status.
623 if ( array_key_exists( $action_data->status, $action_stati_and_labels ) ) {
624 $actions_count_by_status[ $action_data->status ] = $action_data->count;
625 }
626 }
627
628 return $actions_count_by_status;
629 }
630
631 /**
632 * Cancel an action.
633 *
634 * @param int $action_id Action ID.
635 *
636 * @return void
637 * @throws \InvalidArgumentException If the action update failed.
638 */
639 public function cancel_action( $action_id ) {
640 /** @var \wpdb $wpdb */
641 global $wpdb;
642
643 $updated = $wpdb->update(
644 $wpdb->actionscheduler_actions,
645 array( 'status' => self::STATUS_CANCELED ),
646 array( 'action_id' => $action_id ),
647 array( '%s' ),
648 array( '%d' )
649 );
650 if ( false === $updated ) {
651 /* translators: %s: action ID */
652 throw new \InvalidArgumentException( sprintf( __( 'Unidentified action %s', 'action-scheduler' ), $action_id ) );
653 }
654 do_action( 'action_scheduler_canceled_action', $action_id );
655 }
656
657 /**
658 * Cancel pending actions by hook.
659 *
660 * @since 3.0.0
661 *
662 * @param string $hook Hook name.
663 *
664 * @return void
665 */
666 public function cancel_actions_by_hook( $hook ) {
667 $this->bulk_cancel_actions( array( 'hook' => $hook ) );
668 }
669
670 /**
671 * Cancel pending actions by group.
672 *
673 * @param string $group Group slug.
674 *
675 * @return void
676 */
677 public function cancel_actions_by_group( $group ) {
678 $this->bulk_cancel_actions( array( 'group' => $group ) );
679 }
680
681 /**
682 * Bulk cancel actions.
683 *
684 * @since 3.0.0
685 *
686 * @param array $query_args Query parameters.
687 */
688 protected function bulk_cancel_actions( $query_args ) {
689 /** @var \wpdb $wpdb */
690 global $wpdb;
691
692 if ( ! is_array( $query_args ) ) {
693 return;
694 }
695
696 // Don't cancel actions that are already canceled.
697 if ( isset( $query_args['status'] ) && self::STATUS_CANCELED === $query_args['status'] ) {
698 return;
699 }
700
701 $action_ids = true;
702 $query_args = wp_parse_args(
703 $query_args,
704 array(
705 'per_page' => 1000,
706 'status' => self::STATUS_PENDING,
707 'orderby' => 'action_id',
708 )
709 );
710
711 while ( $action_ids ) {
712 $action_ids = $this->query_actions( $query_args );
713 if ( empty( $action_ids ) ) {
714 break;
715 }
716
717 $format = array_fill( 0, count( $action_ids ), '%d' );
718 $query_in = '(' . implode( ',', $format ) . ')';
719 $parameters = $action_ids;
720 array_unshift( $parameters, self::STATUS_CANCELED );
721
722 $wpdb->query(
723 $wpdb->prepare(
724 "UPDATE {$wpdb->actionscheduler_actions} SET status = %s WHERE action_id IN {$query_in}", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
725 $parameters
726 )
727 );
728
729 do_action( 'action_scheduler_bulk_cancel_actions', $action_ids );
730 }
731 }
732
733 /**
734 * Delete an action.
735 *
736 * @param int $action_id Action ID.
737 * @throws \InvalidArgumentException If the action deletion failed.
738 */
739 public function delete_action( $action_id ) {
740 /** @var \wpdb $wpdb */
741 global $wpdb;
742 $deleted = $wpdb->delete( $wpdb->actionscheduler_actions, array( 'action_id' => $action_id ), array( '%d' ) );
743 if ( empty( $deleted ) ) {
744 throw new \InvalidArgumentException( sprintf( __( 'Unidentified action %s', 'action-scheduler' ), $action_id ) ); //phpcs:ignore WordPress.WP.I18n.MissingTranslatorsComment
745 }
746 do_action( 'action_scheduler_deleted_action', $action_id );
747 }
748
749 /**
750 * Get the schedule date for an action.
751 *
752 * @param string $action_id Action ID.
753 *
754 * @return \DateTime The local date the action is scheduled to run, or the date that it ran.
755 */
756 public function get_date( $action_id ) {
757 $date = $this->get_date_gmt( $action_id );
758 ActionScheduler_TimezoneHelper::set_local_timezone( $date );
759 return $date;
760 }
761
762 /**
763 * Get the GMT schedule date for an action.
764 *
765 * @param int $action_id Action ID.
766 *
767 * @throws \InvalidArgumentException If action cannot be identified.
768 * @return \DateTime The GMT date the action is scheduled to run, or the date that it ran.
769 */
770 protected function get_date_gmt( $action_id ) {
771 /** @var \wpdb $wpdb */
772 global $wpdb;
773 $record = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->actionscheduler_actions} WHERE action_id=%d", $action_id ) );
774 if ( empty( $record ) ) {
775 throw new \InvalidArgumentException( sprintf( __( 'Unidentified action %s', 'action-scheduler' ), $action_id ) ); //phpcs:ignore WordPress.WP.I18n.MissingTranslatorsComment
776 }
777 if ( self::STATUS_PENDING === $record->status ) {
778 return as_get_datetime_object( $record->scheduled_date_gmt );
779 } else {
780 return as_get_datetime_object( $record->last_attempt_gmt );
781 }
782 }
783
784 /**
785 * Stake a claim on actions.
786 *
787 * @param int $max_actions Maximum number of action to include in claim.
788 * @param \DateTime $before_date Jobs must be schedule before this date. Defaults to now.
789 * @param array $hooks Hooks to filter for.
790 * @param string $group Group to filter for.
791 *
792 * @return ActionScheduler_ActionClaim
793 */
794 public function stake_claim( $max_actions = 10, \DateTime $before_date = null, $hooks = array(), $group = '' ) {
795 $claim_id = $this->generate_claim_id();
796
797 $this->claim_before_date = $before_date;
798 $this->claim_actions( $claim_id, $max_actions, $before_date, $hooks, $group );
799 $action_ids = $this->find_actions_by_claim_id( $claim_id );
800 $this->claim_before_date = null;
801
802 return new ActionScheduler_ActionClaim( $claim_id, $action_ids );
803 }
804
805 /**
806 * Generate a new action claim.
807 *
808 * @return int Claim ID.
809 */
810 protected function generate_claim_id() {
811 /** @var \wpdb $wpdb */
812 global $wpdb;
813 $now = as_get_datetime_object();
814 $wpdb->insert( $wpdb->actionscheduler_claims, array( 'date_created_gmt' => $now->format( 'Y-m-d H:i:s' ) ) );
815
816 return $wpdb->insert_id;
817 }
818
819 /**
820 * Set a claim filter.
821 *
822 * @param string $filter_name Claim filter name.
823 * @param mixed $filter_values Values to filter.
824 * @return void
825 */
826 public function set_claim_filter( $filter_name, $filter_values ) {
827 if ( isset( $this->claim_filters[ $filter_name ] ) ) {
828 $this->claim_filters[ $filter_name ] = $filter_values;
829 }
830 }
831
832 /**
833 * Get the claim filter value.
834 *
835 * @param string $filter_name Claim filter name.
836 * @return mixed
837 */
838 public function get_claim_filter( $filter_name ) {
839 if ( isset( $this->claim_filters[ $filter_name ] ) ) {
840 return $this->claim_filters[ $filter_name ];
841 }
842
843 return '';
844 }
845
846 /**
847 * Mark actions claimed.
848 *
849 * @param string $claim_id Claim Id.
850 * @param int $limit Number of action to include in claim.
851 * @param \DateTime $before_date Should use UTC timezone.
852 * @param array $hooks Hooks to filter for.
853 * @param string $group Group to filter for.
854 *
855 * @return int The number of actions that were claimed.
856 * @throws \InvalidArgumentException Throws InvalidArgumentException if group doesn't exist.
857 * @throws \RuntimeException Throws RuntimeException if unable to claim action.
858 */
859 protected function claim_actions( $claim_id, $limit, \DateTime $before_date = null, $hooks = array(), $group = '' ) {
860 /** @var \wpdb $wpdb */
861 global $wpdb;
862
863 $now = as_get_datetime_object();
864 $date = is_null( $before_date ) ? $now : clone $before_date;
865 // can't use $wpdb->update() because of the <= condition.
866 $update = "UPDATE {$wpdb->actionscheduler_actions} SET claim_id=%d, last_attempt_gmt=%s, last_attempt_local=%s";
867 $params = array(
868 $claim_id,
869 $now->format( 'Y-m-d H:i:s' ),
870 current_time( 'mysql' ),
871 );
872
873 // Set claim filters.
874 if ( ! empty( $hooks ) ) {
875 $this->set_claim_filter( 'hooks', $hooks );
876 } else {
877 $hooks = $this->get_claim_filter( 'hooks' );
878 }
879 if ( ! empty( $group ) ) {
880 $this->set_claim_filter( 'group', $group );
881 } else {
882 $group = $this->get_claim_filter( 'group' );
883 }
884
885 $where = 'WHERE claim_id = 0 AND scheduled_date_gmt <= %s AND status=%s';
886 $params[] = $date->format( 'Y-m-d H:i:s' );
887 $params[] = self::STATUS_PENDING;
888
889 if ( ! empty( $hooks ) ) {
890 $placeholders = array_fill( 0, count( $hooks ), '%s' );
891 $where .= ' AND hook IN (' . join( ', ', $placeholders ) . ')';
892 $params = array_merge( $params, array_values( $hooks ) );
893 }
894
895 $group_operator = 'IN';
896 if ( empty( $group ) ) {
897 $group = $this->get_claim_filter( 'exclude-groups' );
898 $group_operator = 'NOT IN';
899 }
900
901 if ( ! empty( $group ) ) {
902 $group_ids = $this->get_group_ids( $group, false );
903
904 // throw exception if no matching group(s) found, this matches ActionScheduler_wpPostStore's behaviour.
905 if ( empty( $group_ids ) ) {
906 throw new InvalidArgumentException(
907 sprintf(
908 /* translators: %s: group name(s) */
909 _n(
910 'The group "%s" does not exist.',
911 'The groups "%s" do not exist.',
912 is_array( $group ) ? count( $group ) : 1,
913 'action-scheduler'
914 ),
915 $group
916 )
917 );
918 }
919
920 $id_list = implode( ',', array_map( 'intval', $group_ids ) );
921 $where .= " AND group_id {$group_operator} ( $id_list )";
922 }
923
924 /**
925 * Sets the order-by clause used in the action claim query.
926 *
927 * @since 3.4.0
928 *
929 * @param string $order_by_sql
930 */
931 $order = apply_filters( 'action_scheduler_claim_actions_order_by', 'ORDER BY attempts ASC, scheduled_date_gmt ASC, action_id ASC' );
932 $params[] = $limit;
933
934 $sql = $wpdb->prepare( "{$update} {$where} {$order} LIMIT %d", $params ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders
935 $rows_affected = $wpdb->query( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
936 if ( false === $rows_affected ) {
937 throw new \RuntimeException( __( 'Unable to claim actions. Database error.', 'action-scheduler' ) );
938 }
939
940 return (int) $rows_affected;
941 }
942
943 /**
944 * Get the number of active claims.
945 *
946 * @return int
947 */
948 public function get_claim_count() {
949 global $wpdb;
950
951 $sql = "SELECT COUNT(DISTINCT claim_id) FROM {$wpdb->actionscheduler_actions} WHERE claim_id != 0 AND status IN ( %s, %s)";
952 $sql = $wpdb->prepare( $sql, array( self::STATUS_PENDING, self::STATUS_RUNNING ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
953
954 return (int) $wpdb->get_var( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
955 }
956
957 /**
958 * Return an action's claim ID, as stored in the claim_id column.
959 *
960 * @param string $action_id Action ID.
961 * @return mixed
962 */
963 public function get_claim_id( $action_id ) {
964 /** @var \wpdb $wpdb */
965 global $wpdb;
966
967 $sql = "SELECT claim_id FROM {$wpdb->actionscheduler_actions} WHERE action_id=%d";
968 $sql = $wpdb->prepare( $sql, $action_id ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
969
970 return (int) $wpdb->get_var( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
971 }
972
973 /**
974 * Retrieve the action IDs of action in a claim.
975 *
976 * @param int $claim_id Claim ID.
977 * @return int[]
978 */
979 public function find_actions_by_claim_id( $claim_id ) {
980 /** @var \wpdb $wpdb */
981 global $wpdb;
982
983 $action_ids = array();
984 $before_date = isset( $this->claim_before_date ) ? $this->claim_before_date : as_get_datetime_object();
985 $cut_off = $before_date->format( 'Y-m-d H:i:s' );
986
987 $sql = $wpdb->prepare(
988 "SELECT action_id, scheduled_date_gmt FROM {$wpdb->actionscheduler_actions} WHERE claim_id = %d",
989 $claim_id
990 );
991
992 // Verify that the scheduled date for each action is within the expected bounds (in some unusual
993 // cases, we cannot depend on MySQL to honor all of the WHERE conditions we specify).
994 foreach ( $wpdb->get_results( $sql ) as $claimed_action ) { // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
995 if ( $claimed_action->scheduled_date_gmt <= $cut_off ) {
996 $action_ids[] = absint( $claimed_action->action_id );
997 }
998 }
999
1000 return $action_ids;
1001 }
1002
1003 /**
1004 * Release actions from a claim and delete the claim.
1005 *
1006 * @param ActionScheduler_ActionClaim $claim Claim object.
1007 */
1008 public function release_claim( ActionScheduler_ActionClaim $claim ) {
1009 /** @var \wpdb $wpdb */
1010 global $wpdb;
1011 /**
1012 * Deadlock warning: This function modifies actions to release them from claims that have been processed. Earlier, we used to it in a atomic query, i.e. we would update all actions belonging to a particular claim_id with claim_id = 0.
1013 * While this was functionally correct, it would cause deadlock, since this update query will hold a lock on the claim_id_.. index on the action table.
1014 * This allowed the possibility of a race condition, where the claimer query is also running at the same time, then the claimer query will also try to acquire a lock on the claim_id_.. index, and in this case if claim release query has already progressed to the point of acquiring the lock, but have not updated yet, it would cause a deadlock.
1015 *
1016 * We resolve this by getting all the actions_id that we want to release claim from in a separate query, and then releasing the claim on each of them. This way, our lock is acquired on the action_id index instead of the claim_id index. Note that the lock on claim_id will still be acquired, but it will only when we actually make the update, rather than when we select the actions.
1017 */
1018 $action_ids = $wpdb->get_col( $wpdb->prepare( "SELECT action_id FROM {$wpdb->actionscheduler_actions} WHERE claim_id = %d", $claim->get_id() ) );
1019
1020 $row_updates = 0;
1021 if ( count( $action_ids ) > 0 ) {
1022 $action_id_string = implode( ',', array_map( 'absint', $action_ids ) );
1023 $row_updates = $wpdb->query( "UPDATE {$wpdb->actionscheduler_actions} SET claim_id = 0 WHERE action_id IN ({$action_id_string})" ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
1024 }
1025
1026 $wpdb->delete( $wpdb->actionscheduler_claims, array( 'claim_id' => $claim->get_id() ), array( '%d' ) );
1027
1028 if ( $row_updates < count( $action_ids ) ) {
1029 throw new RuntimeException(
1030 sprintf(
1031 __( 'Unable to release actions from claim id %d.', 'woocommerce' ),
1032 $claim->get_id()
1033 )
1034 );
1035 }
1036 }
1037
1038 /**
1039 * Remove the claim from an action.
1040 *
1041 * @param int $action_id Action ID.
1042 *
1043 * @return void
1044 */
1045 public function unclaim_action( $action_id ) {
1046 /** @var \wpdb $wpdb */
1047 global $wpdb;
1048 $wpdb->update(
1049 $wpdb->actionscheduler_actions,
1050 array( 'claim_id' => 0 ),
1051 array( 'action_id' => $action_id ),
1052 array( '%s' ),
1053 array( '%d' )
1054 );
1055 }
1056
1057 /**
1058 * Mark an action as failed.
1059 *
1060 * @param int $action_id Action ID.
1061 * @throws \InvalidArgumentException Throw an exception if action was not updated.
1062 */
1063 public function mark_failure( $action_id ) {
1064 /** @var \wpdb $wpdb */
1065 global $wpdb;
1066 $updated = $wpdb->update(
1067 $wpdb->actionscheduler_actions,
1068 array( 'status' => self::STATUS_FAILED ),
1069 array( 'action_id' => $action_id ),
1070 array( '%s' ),
1071 array( '%d' )
1072 );
1073 if ( empty( $updated ) ) {
1074 throw new \InvalidArgumentException( sprintf( __( 'Unidentified action %s', 'action-scheduler' ), $action_id ) ); //phpcs:ignore WordPress.WP.I18n.MissingTranslatorsComment
1075 }
1076 }
1077
1078 /**
1079 * Add execution message to action log.
1080 *
1081 * @throws Exception If the action status cannot be updated to self::STATUS_RUNNING ('in-progress').
1082 *
1083 * @param int $action_id Action ID.
1084 *
1085 * @return void
1086 */
1087 public function log_execution( $action_id ) {
1088 /** @var \wpdb $wpdb */
1089 global $wpdb;
1090
1091 $sql = "UPDATE {$wpdb->actionscheduler_actions} SET attempts = attempts+1, status=%s, last_attempt_gmt = %s, last_attempt_local = %s WHERE action_id = %d";
1092 $sql = $wpdb->prepare( $sql, self::STATUS_RUNNING, current_time( 'mysql', true ), current_time( 'mysql' ), $action_id ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
1093
1094 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
1095 $status_updated = $wpdb->query( $sql );
1096
1097 if ( ! $status_updated ) {
1098 throw new Exception(
1099 sprintf(
1100 /* translators: 1: action ID. 2: status slug. */
1101 __( 'Unable to update the status of action %1$d to %2$s.', 'action-scheduler' ),
1102 $action_id,
1103 self::STATUS_RUNNING
1104 )
1105 );
1106 }
1107 }
1108
1109 /**
1110 * Mark an action as complete.
1111 *
1112 * @param int $action_id Action ID.
1113 *
1114 * @return void
1115 * @throws \InvalidArgumentException Throw an exception if action was not updated.
1116 */
1117 public function mark_complete( $action_id ) {
1118 /** @var \wpdb $wpdb */
1119 global $wpdb;
1120 $updated = $wpdb->update(
1121 $wpdb->actionscheduler_actions,
1122 array(
1123 'status' => self::STATUS_COMPLETE,
1124 'last_attempt_gmt' => current_time( 'mysql', true ),
1125 'last_attempt_local' => current_time( 'mysql' ),
1126 ),
1127 array( 'action_id' => $action_id ),
1128 array( '%s' ),
1129 array( '%d' )
1130 );
1131 if ( empty( $updated ) ) {
1132 throw new \InvalidArgumentException( sprintf( __( 'Unidentified action %s', 'action-scheduler' ), $action_id ) ); //phpcs:ignore WordPress.WP.I18n.MissingTranslatorsComment
1133 }
1134
1135 /**
1136 * Fires after a scheduled action has been completed.
1137 *
1138 * @since 3.4.2
1139 *
1140 * @param int $action_id Action ID.
1141 */
1142 do_action( 'action_scheduler_completed_action', $action_id );
1143 }
1144
1145 /**
1146 * Get an action's status.
1147 *
1148 * @param int $action_id Action ID.
1149 *
1150 * @return string
1151 * @throws \InvalidArgumentException Throw an exception if not status was found for action_id.
1152 * @throws \RuntimeException Throw an exception if action status could not be retrieved.
1153 */
1154 public function get_status( $action_id ) {
1155 /** @var \wpdb $wpdb */
1156 global $wpdb;
1157 $sql = "SELECT status FROM {$wpdb->actionscheduler_actions} WHERE action_id=%d";
1158 $sql = $wpdb->prepare( $sql, $action_id ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
1159 $status = $wpdb->get_var( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
1160
1161 if ( null === $status ) {
1162 throw new \InvalidArgumentException( __( 'Invalid action ID. No status found.', 'action-scheduler' ) );
1163 } elseif ( empty( $status ) ) {
1164 throw new \RuntimeException( __( 'Unknown status found for action.', 'action-scheduler' ) );
1165 } else {
1166 return $status;
1167 }
1168 }
1169 }
1170