| 1 |
<?php |
| 2 |
|
| 3 |
declare (strict_types=1); |
| 4 |
namespace WindPress\WindPress\Core\Scanner; |
| 5 |
|
| 6 |
use InvalidArgumentException; |
| 7 |
use WP_Query; |
| 8 |
/** |
| 9 |
* A bounded, ID-ordered scan of posts. Numeric pages remain accepted for older callers. |
| 10 |
*/ |
| 11 |
class PostQuery |
| 12 |
{ |
| 13 |
public WP_Query $query; |
| 14 |
private int $maximum_id; |
| 15 |
private int $batch_size; |
| 16 |
private int $after_id = 0; |
| 17 |
private int $page = 1; |
| 18 |
private string $signature; |
| 19 |
public function __construct(array $arguments, array $metadata = []) |
| 20 |
{ |
| 21 |
global $wpdb; |
| 22 |
$this->batch_size = self::batch_size($arguments['posts_per_page'] ?? null); |
| 23 |
$this->signature = hash('sha256', wp_json_encode($arguments)); |
| 24 |
$cursor = $metadata['next_batch'] ?? \false; |
| 25 |
if (is_string($cursor) && strpos($cursor, 'posts:') === 0) { |
| 26 |
$state = json_decode((string) base64_decode(substr($cursor, 6), \true), \true); |
| 27 |
if (!is_array($state) || ($state['query'] ?? null) !== $this->signature || !isset($state['after'], $state['max'], $state['size']) || !is_int($state['after']) || !is_int($state['max']) || !is_int($state['size']) || $state['after'] < 0 || $state['max'] < $state['after'] || $state['size'] < 1 || $state['size'] > 200) { |
| 28 |
throw new InvalidArgumentException(__('The post scan cursor is invalid. Restart the scan.', 'windpress')); |
| 29 |
} |
| 30 |
$this->after_id = $state['after']; |
| 31 |
$this->maximum_id = $state['max']; |
| 32 |
$this->batch_size = $state['size']; |
| 33 |
} else { |
| 34 |
if ($cursor !== \false && $cursor !== null) { |
| 35 |
if (!is_int($cursor) && !is_string($cursor) || !ctype_digit((string) $cursor) || (int) $cursor < 1) { |
| 36 |
throw new InvalidArgumentException(__('The post scan page is invalid.', 'windpress')); |
| 37 |
} |
| 38 |
$this->page = (int) $cursor; |
| 39 |
} |
| 40 |
$this->maximum_id = (int) $wpdb->get_var("SELECT MAX(ID) FROM {$wpdb->posts}"); |
| 41 |
if ($wpdb->last_error !== '') { |
| 42 |
throw new \RuntimeException(__('Unable to initialize the post scan.', 'windpress')); |
| 43 |
} |
| 44 |
} |
| 45 |
$arguments = array_merge($arguments, ['windpress_scan' => \true, 'posts_per_page' => $this->batch_size, 'paged' => $this->page, 'orderby' => 'ID', 'order' => 'ASC', 'no_found_rows' => \true, 'ignore_sticky_posts' => \true, 'suppress_filters' => \false]); |
| 46 |
$this->query = new WP_Query(); |
| 47 |
$where_filter = function (string $where, WP_Query $query) use ($wpdb): string { |
| 48 |
if ($query !== $this->query) { |
| 49 |
return $where; |
| 50 |
} |
| 51 |
return $where . $wpdb->prepare(" AND {$wpdb->posts}.ID > %d AND {$wpdb->posts}.ID <= %d", $this->after_id, $this->maximum_id); |
| 52 |
}; |
| 53 |
add_filter('posts_where', $where_filter, 10, 2); |
| 54 |
try { |
| 55 |
$wpdb->last_error = ''; |
| 56 |
$this->query->query($arguments); |
| 57 |
if ($wpdb->last_error !== '') { |
| 58 |
throw new \RuntimeException(__('Unable to read a post scan batch.', 'windpress')); |
| 59 |
} |
| 60 |
if ((int) $this->query->query_vars['posts_per_page'] !== $this->batch_size || (int) ($this->query->query_vars['offset'] ?? 0) !== 0 || count($this->query->posts) > $this->batch_size) { |
| 61 |
throw new \RuntimeException(__('A query filter changed the scan batch limits. Exclude WindPress scans from that filter.', 'windpress')); |
| 62 |
} |
| 63 |
$previous_id = $this->after_id; |
| 64 |
foreach ($this->query->posts as $post) { |
| 65 |
$post_id = is_object($post) ? (int) $post->ID : (int) $post; |
| 66 |
if ($post_id <= $previous_id || $post_id > $this->maximum_id) { |
| 67 |
throw new \RuntimeException(__('A query filter changed the scan ordering. Exclude WindPress scans from that filter.', 'windpress')); |
| 68 |
} |
| 69 |
$previous_id = $post_id; |
| 70 |
} |
| 71 |
} finally { |
| 72 |
remove_filter('posts_where', $where_filter, 10); |
| 73 |
} |
| 74 |
} |
| 75 |
public static function batch_size($value = null): int |
| 76 |
{ |
| 77 |
$default = (int) apply_filters('f!windpress/core/scanner:batch_size', 50); |
| 78 |
$value = is_numeric($value) && (int) $value > 0 ? (int) $value : $default; |
| 79 |
return max(1, min(200, $value)); |
| 80 |
} |
| 81 |
public function is_first_batch(): bool |
| 82 |
{ |
| 83 |
return $this->after_id === 0 && $this->page === 1; |
| 84 |
} |
| 85 |
public function metadata(): array |
| 86 |
{ |
| 87 |
$posts = $this->query->posts; |
| 88 |
$last_post = $posts === [] ? null : end($posts); |
| 89 |
$last_id = is_object($last_post) ? (int) $last_post->ID : (int) $last_post; |
| 90 |
$has_more = count($posts) === $this->batch_size && $last_id > $this->after_id && $last_id < $this->maximum_id; |
| 91 |
return ['next_batch' => $has_more ? 'posts:' . base64_encode(wp_json_encode(['after' => $last_id, 'max' => $this->maximum_id, 'size' => $this->batch_size, 'query' => $this->signature])) : \false, 'total_batches' => \false]; |
| 92 |
} |
| 93 |
} |
| 94 |
|