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