PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
← All changes | redirections/class-metasync-redirection-database.php +148 -105 2.6.12 → 2.7.1 View file →
@@ -7,8 +7,13 @@
7 7 * @package Metasync
8 8 * @subpackage Metasync/redirections
9 9 * @author Engineering Team <[email protected]>
10 10 */
11 +
12 +if (!defined('ABSPATH')) {
13 + exit;
14 +}
15 +
11 16 if (!class_exists('Metasync_Redirection_Database')) {
12 17 class Metasync_Redirection_Database
13 18 {
14 19 public static $table_name = "metasync_redirections";
@@ -32,9 +37,9 @@
32 37 global $wpdb;
33 38 $table_name = $this->get_table_name();
34 39
35 40 // Check if table exists
36 - if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $table_name)) != $table_name) {
41 + if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $table_name)) != $table_name) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- self-healing schema backstop for installs that missed a migration; admin write paths only, once per request via the $structure_verified gate
37 42 // Table doesn't exist, run full migration
38 43 require_once dirname(__FILE__, 2) . '/database/class-db-migrations.php';
39 44 MetaSync_DBMigration::activation();
40 45 return;
@@ -40,43 +45,55 @@
40 45 return;
41 46 }
42 47
43 48 // Check if required columns exist
44 - $columns = $wpdb->get_col("DESCRIBE {$table_name}");
49 + $columns = $wpdb->get_col("DESCRIBE {$wpdb->prefix}metasync_redirections"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- self-healing schema backstop — column introspection, admin write paths only, once per request
45 50
46 - $required_columns = [
47 - 'pattern_type' => "ALTER TABLE {$table_name} ADD COLUMN pattern_type ENUM('exact', 'contain', 'start', 'end', 'regex') NOT NULL DEFAULT 'exact' AFTER status",
48 - 'regex_pattern' => "ALTER TABLE {$table_name} ADD COLUMN regex_pattern TEXT NULL AFTER pattern_type",
49 - 'description' => "ALTER TABLE {$table_name} ADD COLUMN description TEXT NULL AFTER regex_pattern",
50 - 'created_at' => "ALTER TABLE {$table_name} ADD COLUMN created_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00' AFTER description",
51 - 'updated_at' => "ALTER TABLE {$table_name} ADD COLUMN updated_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00' AFTER created_at",
52 - 'last_accessed_at' => "ALTER TABLE {$table_name} ADD COLUMN last_accessed_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00' AFTER updated_at"
53 - ];
54 -
55 - foreach ($required_columns as $column => $sql) {
56 - if (!in_array($column, $columns)) {
57 - $wpdb->query($sql);
51 + if (!in_array('pattern_type', $columns)) {
52 + $wpdb->query("ALTER TABLE {$wpdb->prefix}metasync_redirections ADD COLUMN pattern_type ENUM('exact', 'contain', 'start', 'end', 'regex', 'wildcard') NOT NULL DEFAULT 'exact' AFTER status"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- self-healing schema backstop for installs that missed a migration; admin write paths only, once per request via the $structure_verified gate
53 + }
54 + if (!in_array('regex_pattern', $columns)) {
55 + $wpdb->query("ALTER TABLE {$wpdb->prefix}metasync_redirections ADD COLUMN regex_pattern TEXT NULL AFTER pattern_type"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- self-healing schema backstop for installs that missed a migration; admin write paths only, once per request via the $structure_verified gate
56 + }
57 + if (!in_array('description', $columns)) {
58 + $wpdb->query("ALTER TABLE {$wpdb->prefix}metasync_redirections ADD COLUMN description TEXT NULL AFTER regex_pattern"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- self-healing schema backstop for installs that missed a migration; admin write paths only, once per request via the $structure_verified gate
59 + }
60 + if (!in_array('created_at', $columns)) {
61 + $wpdb->query("ALTER TABLE {$wpdb->prefix}metasync_redirections ADD COLUMN created_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00' AFTER description"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- self-healing schema backstop for installs that missed a migration; admin write paths only, once per request via the $structure_verified gate
62 + }
63 + if (!in_array('updated_at', $columns)) {
64 + $wpdb->query("ALTER TABLE {$wpdb->prefix}metasync_redirections ADD COLUMN updated_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00' AFTER created_at"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- self-healing schema backstop for installs that missed a migration; admin write paths only, once per request via the $structure_verified gate
65 + }
66 + if (!in_array('last_accessed_at', $columns)) {
67 + $wpdb->query("ALTER TABLE {$wpdb->prefix}metasync_redirections ADD COLUMN last_accessed_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00' AFTER updated_at"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- self-healing schema backstop for installs that missed a migration; admin write paths only, once per request via the $structure_verified gate
68 + }
69 +
70 + // Widen pattern_type for installs whose column predates wildcard
71 + // matching — column-exists checks never re-ALTER, so the enum would
72 + // otherwise keep silently rejecting wildcard rows.
73 + if (in_array('pattern_type', $columns)) {
74 + $column_info = $wpdb->get_row($wpdb->prepare("SHOW COLUMNS FROM {$wpdb->prefix}metasync_redirections LIKE %s", 'pattern_type')); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- self-healing schema backstop — enum widening for pre-wildcard installs, admin write paths only
75 + if ($column_info && strpos((string) $column_info->Type, 'wildcard') === false) {
76 + $wpdb->query("ALTER TABLE {$wpdb->prefix}metasync_redirections MODIFY COLUMN pattern_type ENUM('exact', 'contain', 'start', 'end', 'regex', 'wildcard') NOT NULL DEFAULT 'exact'"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- self-healing schema backstop — enum widening for pre-wildcard installs, admin write paths only
58 77 }
59 78 }
60 -
79 +
61 80 // Check and add indexes
62 - $indexes = $wpdb->get_results("SHOW INDEX FROM {$table_name}");
81 + $indexes = $wpdb->get_results("SHOW INDEX FROM {$wpdb->prefix}metasync_redirections"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- self-healing schema backstop — index introspection, admin write paths only, once per request
63 82 $index_names = array_column($indexes, 'Key_name');
64 83
65 - $required_indexes = [
66 - 'pattern_type' => "ALTER TABLE {$table_name} ADD KEY pattern_type (pattern_type)",
67 - 'created_at' => "ALTER TABLE {$table_name} ADD KEY created_at (created_at)",
68 - 'status_created' => "ALTER TABLE {$table_name} ADD KEY status_created (status, created_at)"
69 - ];
70 -
71 - foreach ($required_indexes as $index => $sql) {
72 - if (!in_array($index, $index_names)) {
73 - $wpdb->query($sql);
74 - }
84 + if (!in_array('pattern_type', $index_names)) {
85 + $wpdb->query("ALTER TABLE {$wpdb->prefix}metasync_redirections ADD KEY pattern_type (pattern_type)"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- self-healing schema backstop for installs that missed a migration; admin write paths only, once per request via the $structure_verified gate
75 86 }
87 + if (!in_array('created_at', $index_names)) {
88 + $wpdb->query("ALTER TABLE {$wpdb->prefix}metasync_redirections ADD KEY created_at (created_at)"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- self-healing schema backstop for installs that missed a migration; admin write paths only, once per request via the $structure_verified gate
89 + }
90 + if (!in_array('status_created', $index_names)) {
91 + $wpdb->query("ALTER TABLE {$wpdb->prefix}metasync_redirections ADD KEY status_created (status, created_at)"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- self-healing schema backstop for installs that missed a migration; admin write paths only, once per request via the $structure_verified gate
92 + }
76 93
77 94 // Set default pattern_type for existing records
78 - $wpdb->query("UPDATE {$table_name} SET pattern_type = 'exact' WHERE pattern_type IS NULL OR pattern_type = ''");
95 + $wpdb->query("UPDATE {$wpdb->prefix}metasync_redirections SET pattern_type = 'exact' WHERE pattern_type IS NULL OR pattern_type = ''"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- self-healing schema backstop — one-time backfill bound to the column additions above
79 96 }
80 97
81 98 /**
82 99 * Manually trigger table structure update
@@ -91,16 +108,14 @@
91 108
92 109 public function getAllRecords()
93 110 {
94 111 global $wpdb;
95 - $tableName = $this->get_table_name();
96 - return $wpdb->get_results(" SELECT * FROM `$tableName` ");
112 + return $wpdb->get_results("SELECT * FROM `{$wpdb->prefix}metasync_redirections`"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom-table repository — no WordPress API exists for plugin tables; the hot front-end read (getAllActiveRecords) is object-cached with invalidation on every write
97 113 }
98 114
99 115 public function getAllActiveRecords()
100 116 {
101 117 global $wpdb;
102 - $tableName = $this->get_table_name();
103 118
104 119 // Check cache first
105 120 $cache_key = 'metasync_active_redirections';
106 121 $cached_redirections = wp_cache_get($cache_key, 'metasync');
@@ -108,9 +123,13 @@
108 123 if ($cached_redirections !== false) {
109 124 return $cached_redirections;
110 125 }
111 126
112 - $redirections = $wpdb->get_results($wpdb->prepare("SELECT * FROM `$tableName` WHERE status = %s ORDER BY created_at DESC", 'active'));
127 + // id tiebreaker: created_at has one-second resolution, so rows created in
128 + // the same second (bulk import, migrations) would come back in arbitrary
129 + // order — and the engine's first-match-wins scan would then be
130 + // nondeterministic between requests.
131 + $redirections = $wpdb->get_results($wpdb->prepare("SELECT * FROM `{$wpdb->prefix}metasync_redirections` WHERE status = %s ORDER BY created_at DESC, id DESC", 'active')); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom-table repository — this read is the cache SOURCE for the object-cached active set (invalidated on every write)
113 132
114 133 // Cache for 1 hour
115 134 wp_cache_set($cache_key, $redirections, 'metasync', HOUR_IN_SECONDS);
116 135
@@ -124,10 +143,9 @@
124 143 */
125 144 public function find($id)
126 145 {
127 146 global $wpdb;
128 - $tableName = $this->get_table_name();
129 - return $wpdb->get_row($wpdb->prepare("SELECT * FROM `$tableName` WHERE id = %d", intval($id)));
147 + return $wpdb->get_row($wpdb->prepare("SELECT * FROM `{$wpdb->prefix}metasync_redirections` WHERE id = %d", intval($id))); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom-table repository — no WordPress API exists for plugin tables; the hot front-end read (getAllActiveRecords) is object-cached with invalidation on every write
130 148 }
131 149
132 150 /**
133 151 * Add a record.
@@ -161,9 +179,9 @@
161 179 $sources = $args['sources_from'];
162 180 $args['sources_from'] = serialize( array_combine( array_values( $sources ), array_fill( 0, count( $sources ), 'exact' ) ) ?: [] );
163 181 }
164 182
165 - $result = $wpdb->insert( $this->get_table_name(), $args );
183 + $result = $wpdb->insert( $this->get_table_name(), $args ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom-table repository — no WordPress API exists for plugin tables; the hot front-end read (getAllActiveRecords) is object-cached with invalidation on every write
166 184
167 185 // Clear cache after adding
168 186 $this->clear_cache();
169 187
@@ -181,17 +199,20 @@
181 199
182 200 // Ensure table structure is up to date
183 201 $this->ensure_table_structure();
184 202
185 - $tableName = $this->get_table_name();
186 - $row = $wpdb->get_row($wpdb->prepare("SELECT * FROM `$tableName` WHERE `id` = %s ", $id));
187 - if (!$row) return;
203 + $row = $wpdb->get_row($wpdb->prepare("SELECT * FROM `{$wpdb->prefix}metasync_redirections` WHERE `id` = %s ", $id)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom-table repository — no WordPress API exists for plugin tables; the hot front-end read (getAllActiveRecords) is object-cached with invalidation on every write
204 + if (!$row) return false;
188 205
189 206 $args['updated_at'] = current_time('mysql');
190 - $wpdb->update($tableName, $args, ['id' => $id]);
207 + $result = $wpdb->update($wpdb->prefix . 'metasync_redirections', $args, ['id' => $id]); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom-table repository — no WordPress API exists for plugin tables; the hot front-end read (getAllActiveRecords) is object-cached with invalidation on every write
191 208
192 209 // Clear cache after updating
193 210 $this->clear_cache();
211 +
212 + // Row count on success (0 = no-op write), false on query failure —
213 + // callers checking `=== false` could never fire against void.
214 + return $result;
194 215 }
195 216
196 217 /**
197 218 * Get total number of rows in the DB table).
@@ -198,10 +219,9 @@
198 219 */
199 220 public function get_count()
200 221 {
201 222 global $wpdb;
202 - $tableName = $this->get_table_name();
203 - return (int) $wpdb->get_var("SELECT COUNT(*) FROM `$tableName`");
223 + return (int) $wpdb->get_var("SELECT COUNT(*) FROM `{$wpdb->prefix}metasync_redirections`"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom-table repository — no WordPress API exists for plugin tables; the hot front-end read (getAllActiveRecords) is object-cached with invalidation on every write
204 224 }
205 225
206 226 /**
207 227 * Delete a redirection record.
@@ -208,15 +228,11 @@
208 228 */
209 229 public function delete($items)
210 230 {
211 231 global $wpdb;
212 - $tableName = $this->get_table_name();
213 232 if (!is_array($items) || empty($items)) return;
214 - $ids = implode(',', array_fill(0, count($items), '%d'));
215 - $wpdb->query($wpdb->prepare(
216 - "
217 - DELETE FROM `$tableName`
218 - WHERE `id` IN ($ids) ",
233 + $wpdb->query($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom-table repository — no WordPress API exists for plugin tables; the hot front-end read (getAllActiveRecords) is object-cached with invalidation on every write
234 + 'DELETE FROM `' . $wpdb->prefix . 'metasync_redirections` WHERE `id` IN (' . implode(',', array_fill(0, count($items), '%d')) . ')',
219 235 $items
220 236 ));
221 237
222 238 // Clear cache after deleting
@@ -228,25 +244,17 @@
228 244 */
229 245 public function update_status($items, $status)
230 246 {
231 247 global $wpdb;
232 - $tableName = $this->get_table_name();
233 248 if (!is_array($items) || empty($items)) return;
234 - $ids = implode(', ', array_fill(0, count($items), '%d'));
235 - $set_status = $wpdb->prepare(
236 - "
237 - UPDATE `$tableName`
238 - SET `status` = %s, `updated_at` = %s",
239 - $status,
240 - current_time('mysql')
249 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom-table repository — no WordPress API exists for plugin tables; the hot front-end read (getAllActiveRecords) is object-cached with invalidation on every write
250 + $wpdb->prepare(
251 + 'UPDATE `' . $wpdb->prefix . 'metasync_redirections`
252 + SET `status` = %s, `updated_at` = %s
253 + WHERE `id` IN (' . implode(', ', array_fill(0, count($items), '%d')) . ')',
254 + array_merge(array($status, current_time('mysql')), array_map('intval', $items))
255 + )
241 256 );
242 - $where = $wpdb->prepare(
243 - "
244 - WHERE `id` IN ( $ids )",
245 - $items
246 - );
247 - $query = "{$set_status}{$where}";
248 - $wpdb->query($query);
249 257
250 258 // Clear cache after updating status
251 259 $this->clear_cache();
252 260 }
@@ -257,13 +265,19 @@
257 265 */
258 266 public function update_counter($row)
259 267 {
260 268 global $wpdb;
261 - $update_data = [
262 - 'last_accessed_at' => current_time('mysql'),
263 - 'hits_count' => absint($row->hits_count) + 1,
264 - ];
265 - $wpdb->update($this->get_table_name(), $update_data, ['id' => $row->id]);
269 + // Atomic increment. The $row object comes from getAllActiveRecords(),
270 + // which can be an hour-stale cache hit, and concurrent requests that
271 + // both read the same hits_count would lose every hit but one. Bump in
272 + // SQL so the stored counter is correct regardless of what $row saw.
273 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom-table repository — no WordPress API exists for plugin tables; the hot front-end read (getAllActiveRecords) is object-cached with invalidation on every write
274 + $wpdb->prepare(
275 + "UPDATE `{$wpdb->prefix}metasync_redirections` SET `hits_count` = `hits_count` + 1, `last_accessed_at` = %s WHERE `id` = %d",
276 + current_time('mysql'),
277 + absint($row->id)
278 + )
279 + );
266 280 }
267 281
268 282 /**
269 283 * Clear redirection cache
@@ -270,11 +284,41 @@
270 284 */
271 285 public function clear_cache()
272 286 {
273 287 wp_cache_delete('metasync_active_redirections', 'metasync');
288 + // Redirect sources must leave the sitemap promptly: the sitemap
289 + // generator listens for this on every add/update/delete/status write
290 + // and busts its (30-day) caches so the next crawl regenerates.
291 + do_action('metasync_redirections_changed');
274 292 }
275 293
276 294 /**
295 + * Columns that may be used for ORDER BY. Every entry maps to the literal
296 + * column name, so only allowlisted identifiers can reach the query.
297 + */
298 + private function get_sortable_columns()
299 + {
300 + return [
301 + 'id', 'sources_from', 'url_redirect_to', 'http_code', 'hits_count',
302 + 'status', 'pattern_type', 'created_at', 'updated_at', 'last_accessed_at', 'description',
303 + ];
304 + }
305 +
306 + /**
307 + * Build the ORDER BY clause for search()/count() from the allowlist.
308 + * Falls back to the default ordering for unknown/invalid input.
309 + */
310 + private function get_order_by_sql($filters)
311 + {
312 + $direction = (!empty($filters['order']) && strtoupper($filters['order']) === 'ASC') ? 'ASC' : 'DESC';
313 + $requested = isset($filters['order_by']) ? (string) $filters['order_by'] : '';
314 + if (!in_array($requested, $this->get_sortable_columns(), true)) {
315 + $requested = 'created_at';
316 + }
317 + return sanitize_sql_orderby($requested . ' ' . $direction);
318 + }
319 +
320 + /**
277 321 * Search redirections with filters
278 322 * @param array $filters Search filters
279 323 */
280 324 public function search_redirections($filters = [])
@@ -279,13 +323,12 @@
279 323 */
280 324 public function search_redirections($filters = [])
281 325 {
282 326 global $wpdb;
283 - $tableName = $this->get_table_name();
284 -
327 +
285 328 $where_conditions = ['1=1'];
286 329 $where_values = [];
287 -
330 +
288 331 if (!empty($filters['search'])) {
289 332 $where_conditions[] = "(sources_from LIKE %s OR url_redirect_to LIKE %s OR description LIKE %s)";
290 333 $search_term = '%' . $wpdb->esc_like($filters['search']) . '%';
291 334 $where_values[] = $search_term;
@@ -291,43 +334,43 @@
291 334 $where_values[] = $search_term;
292 335 $where_values[] = $search_term;
293 336 $where_values[] = $search_term;
294 337 }
295 -
338 +
296 339 if (!empty($filters['status'])) {
297 340 $where_conditions[] = "status = %s";
298 341 $where_values[] = $filters['status'];
299 342 }
300 -
343 +
301 344 if (!empty($filters['pattern_type'])) {
302 345 $where_conditions[] = "pattern_type = %s";
303 346 $where_values[] = $filters['pattern_type'];
304 347 }
305 -
348 +
306 349 if (!empty($filters['http_code'])) {
307 350 $where_conditions[] = "http_code = %d";
308 351 $where_values[] = intval($filters['http_code']);
309 352 }
310 -
311 - $where_clause = implode(' AND ', $where_conditions);
312 - $order_by = !empty($filters['order_by']) ? sanitize_sql_orderby($filters['order_by']) : 'created_at';
313 - $order = !empty($filters['order']) && strtoupper($filters['order']) === 'ASC' ? 'ASC' : 'DESC';
314 -
315 - // Add pagination support
316 - $limit_clause = '';
317 - if (isset($filters['per_page']) && isset($filters['offset'])) {
318 - $limit_clause = " LIMIT %d OFFSET %d";
319 - $where_values[] = intval($filters['per_page']);
320 - $where_values[] = intval($filters['offset']);
353 +
354 + $order_by_sql = $this->get_order_by_sql($filters) ?: 'created_at DESC';
355 +
356 + // The query is assembled from literal placeholder fragments only; the
357 + // ORDER BY fragment comes from the sortable-column allowlist wrapped in
358 + // sanitize_sql_orderby(). All dynamic values bind via prepare() below.
359 + $query = 'SELECT * FROM `' . $wpdb->prefix . 'metasync_redirections` WHERE ' . implode(' AND ', $where_conditions)
360 + . ' ORDER BY ' . $order_by_sql
361 + . (isset($filters['per_page'], $filters['offset']) ? ' LIMIT %d OFFSET %d' : '');
362 + $values = isset($filters['per_page'], $filters['offset'])
363 + ? array_merge($where_values, array(intval($filters['per_page']), intval($filters['offset'])))
364 + : $where_values;
365 +
366 + // With no filters the assembled query is literal-only (no
367 + // placeholders); prepare() rejects placeholder-less queries, so only
368 + // route it through prepare() when there are values to bind.
369 + if (empty($values)) {
370 + return $wpdb->get_results($query); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery -- literal fragments only, nothing to bind
321 371 }
322 -
323 - $query = "SELECT * FROM `$tableName` WHERE $where_clause ORDER BY $order_by $order" . $limit_clause;
324 -
325 - if (!empty($where_values)) {
326 - return $wpdb->get_results($wpdb->prepare($query, $where_values));
327 - } else {
328 - return $wpdb->get_results($query);
329 - }
372 + return $wpdb->get_results($wpdb->prepare($query, $values)); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery -- literal fragments + allowlisted ORDER BY, values bound via prepare()
330 373 }
331 374
332 375 /**
333 376 * Count total redirections with filters (for pagination)
@@ -335,13 +378,12 @@
335 378 */
336 379 public function count_redirections($filters = [])
337 380 {
338 381 global $wpdb;
339 - $tableName = $this->get_table_name();
340 -
382 +
341 383 $where_conditions = ['1=1'];
342 384 $where_values = [];
343 -
385 +
344 386 if (!empty($filters['search'])) {
345 387 $where_conditions[] = "(sources_from LIKE %s OR url_redirect_to LIKE %s OR description LIKE %s)";
346 388 $search_term = '%' . $wpdb->esc_like($filters['search']) . '%';
347 389 $where_values[] = $search_term;
@@ -347,31 +389,32 @@
347 389 $where_values[] = $search_term;
348 390 $where_values[] = $search_term;
349 391 $where_values[] = $search_term;
350 392 }
351 -
393 +
352 394 if (!empty($filters['status'])) {
353 395 $where_conditions[] = "status = %s";
354 396 $where_values[] = $filters['status'];
355 397 }
356 -
398 +
357 399 if (!empty($filters['pattern_type'])) {
358 400 $where_conditions[] = "pattern_type = %s";
359 401 $where_values[] = $filters['pattern_type'];
360 402 }
361 -
403 +
362 404 if (!empty($filters['http_code'])) {
363 405 $where_conditions[] = "http_code = %d";
364 406 $where_values[] = intval($filters['http_code']);
365 407 }
366 -
367 - $where_clause = implode(' AND ', $where_conditions);
368 - $query = "SELECT COUNT(*) FROM `$tableName` WHERE $where_clause";
369 -
370 - if (!empty($where_values)) {
371 - return $wpdb->get_var($wpdb->prepare($query, $where_values));
372 - } else {
373 - return $wpdb->get_var($query);
408 +
409 + // The query is assembled from literal placeholder fragments only; all
410 + // dynamic values bind via prepare() below.
411 + $query = 'SELECT COUNT(*) FROM `' . $wpdb->prefix . 'metasync_redirections` WHERE ' . implode(' AND ', $where_conditions);
412 +
413 + // Placeholder-less queries must skip prepare() (see search above).
414 + if (empty($where_values)) {
415 + return $wpdb->get_var($query); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery -- literal fragments only, nothing to bind
374 416 }
417 + return $wpdb->get_var($wpdb->prepare($query, $where_values)); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery -- literal fragments only, values bound via prepare()
375 418 }
376 419 }
377 420 }