PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / trunk
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO vtrunk
2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 1.11.0 All 47 releases
thinkrank / includes / core / class-database.php

class-database.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO trunk, at includes/core/class-database.php

618 lines 22.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Database Manager Class
5 *
6 * Handles database operations and provides repository pattern
7 *
8 * @package ThinkRank\Core
9 * @since 1.0.0
10 */
11
12 declare(strict_types=1);
13
14 namespace ThinkRank\Core;
15
16 // Prevent direct access
17 if (!defined('ABSPATH')) {
18 exit;
19 }
20
21 /**
22 * Database Class
23 *
24 * Single Responsibility: Database operations and query management
25 * Repository Pattern: Abstraction layer for data access
26 *
27 * @since 1.0.0
28 */
29 class Database {
30
31 /**
32 * WordPress database instance
33 *
34 * @var \wpdb
35 */
36 private \wpdb $wpdb;
37
38 /**
39 * Table names
40 *
41 * @var array
42 */
43 private array $tables;
44
45 /**
46 * Constructor
47 */
48 public function __construct() {
49 global $wpdb;
50 $this->wpdb = $wpdb;
51
52 $this->tables = [
53 'ai_cache' => $wpdb->prefix . 'thinkrank_ai_cache',
54 'ai_usage' => $wpdb->prefix . 'thinkrank_ai_usage',
55 'content_briefs' => $wpdb->prefix . 'thinkrank_content_briefs',
56 'seo_scores' => $wpdb->prefix . 'thinkrank_seo_scores',
57 'seo_performance' => $wpdb->prefix . 'thinkrank_seo_performance',
58 'instant_indexing_logs' => $wpdb->prefix . 'thinkrank_instant_indexing_logs',
59 ];
60 }
61
62 /**
63 * Initialize database operations
64 *
65 * @return void
66 */
67 public function init(): void {
68 // Add any initialization hooks here
69 add_action('thinkrank_cache_cleanup', [$this, 'cleanup_expired_cache']);
70
71 // Hook the missing usage analytics cron handler
72 add_action('thinkrank_usage_analytics', [$this, 'process_weekly_analytics']);
73 }
74
75 /**
76 * Get table name
77 *
78 * @param string $table Table identifier
79 * @return string Full table name
80 * @throws \InvalidArgumentException If table doesn't exist
81 */
82 public function get_table(string $table): string {
83 if (!isset($this->tables[$table])) {
84 throw new \InvalidArgumentException(sprintf("Table '%s' not found", esc_html($table)));
85 }
86
87 return $this->tables[$table];
88 }
89
90 /**
91 * Execute prepared query safely
92 *
93 * @param string $query SQL query with placeholders
94 * @param array $args Query arguments
95 * @return mixed Query result
96 */
97 public function query(string $query, array $args = []) {
98 if (!empty($args)) {
99 // Prepare the query first, then execute
100 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Query is prepared in the line below
101 $prepared_query = $this->wpdb->prepare($query, $args);
102 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Using prepared query from above
103 return $this->wpdb->query($prepared_query);
104 }
105
106 // For queries without parameters, execute directly (safe for static queries)
107 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- No user input in static queries
108 return $this->wpdb->query($query);
109 }
110
111 /**
112 * Get single row
113 *
114 * @param string $query SQL query with placeholders
115 * @param array $args Query arguments
116 * @param string $output Output type (OBJECT, ARRAY_A, ARRAY_N)
117 * @return mixed Single row result
118 */
119 public function get_row(string $query, array $args = [], string $output = OBJECT) {
120 if (!empty($args)) {
121 // Prepare the query first, then execute
122 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Query is prepared in the line below
123 $prepared_query = $this->wpdb->prepare($query, $args);
124 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Using prepared query from above
125 return $this->wpdb->get_row($prepared_query, $output);
126 }
127
128 // For queries without parameters, execute directly (safe for static queries)
129 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- No user input in static queries
130 return $this->wpdb->get_row($query, $output);
131 }
132
133 /**
134 * Get multiple rows
135 *
136 * @param string $query SQL query with placeholders
137 * @param array $args Query arguments
138 * @param string $output Output type (OBJECT, ARRAY_A, ARRAY_N)
139 * @return array Multiple rows result
140 */
141 public function get_results(string $query, array $args = [], string $output = OBJECT): array {
142 if (!empty($args)) {
143 // Prepare the query first, then execute
144 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Query is prepared in the line below
145 $prepared_query = $this->wpdb->prepare($query, $args);
146 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Using prepared query from above
147 $results = $this->wpdb->get_results($prepared_query, $output);
148 } else {
149 // For queries without parameters, execute directly (safe for static queries)
150 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- No user input in static queries
151 $results = $this->wpdb->get_results($query, $output);
152 }
153
154 return is_array($results) ? $results : [];
155 }
156
157 /**
158 * Get single variable
159 *
160 * @param string $query SQL query with placeholders
161 * @param array $args Query arguments
162 * @return mixed Single variable result
163 */
164 public function get_var(string $query, array $args = []) {
165 if (!empty($args)) {
166 // Prepare the query first, then execute
167 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Query is prepared in the line below
168 $prepared_query = $this->wpdb->prepare($query, $args);
169 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Using prepared query from above
170 return $this->wpdb->get_var($prepared_query);
171 }
172
173 // For queries without parameters, execute directly (safe for static queries)
174 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- No user input in static queries
175 return $this->wpdb->get_var($query);
176 }
177
178 /**
179 * Insert data into table
180 *
181 * @param string $table Table identifier
182 * @param array $data Data to insert
183 * @param array $format Data format (optional)
184 * @return int|false Insert ID or false on failure
185 */
186 public function insert(string $table, array $data, array $format = []) {
187 $table_name = $this->get_table($table);
188
189 $result = $this->wpdb->insert($table_name, $data, $format);
190
191 if (false === $result) {
192 $this->log_error('Insert failed', [
193 'table' => $table,
194 'data' => $data,
195 'error' => $this->wpdb->last_error
196 ]);
197 return false;
198 }
199
200 return $this->wpdb->insert_id;
201 }
202
203 /**
204 * Update data in table
205 *
206 * @param string $table Table identifier
207 * @param array $data Data to update
208 * @param array $where Where conditions
209 * @param array $format Data format (optional)
210 * @param array $where_format Where format (optional)
211 * @return int|false Number of rows updated or false on failure
212 */
213 public function update(string $table, array $data, array $where, array $format = [], array $where_format = []) {
214 $table_name = $this->get_table($table);
215
216 $result = $this->wpdb->update($table_name, $data, $where, $format, $where_format);
217
218 if (false === $result) {
219 $this->log_error('Update failed', [
220 'table' => $table,
221 'data' => $data,
222 'where' => $where,
223 'error' => $this->wpdb->last_error
224 ]);
225 }
226
227 return $result;
228 }
229
230 /**
231 * Delete data from table
232 *
233 * @param string $table Table identifier
234 * @param array $where Where conditions
235 * @param array $where_format Where format (optional)
236 * @return int|false Number of rows deleted or false on failure
237 */
238 public function delete(string $table, array $where, array $where_format = []) {
239 $table_name = $this->get_table($table);
240
241 $result = $this->wpdb->delete($table_name, $where, $where_format);
242
243 if (false === $result) {
244 $this->log_error('Delete failed', [
245 'table' => $table,
246 'where' => $where,
247 'error' => $this->wpdb->last_error
248 ]);
249 }
250
251 return $result;
252 }
253
254 /**
255 * Start database transaction
256 *
257 * @return void
258 */
259 public function start_transaction(): void {
260 // Transaction commands don't need preparation as they contain no user input
261 $this->wpdb->query('START TRANSACTION');
262 }
263
264 /**
265 * Commit database transaction
266 *
267 * @return void
268 */
269 public function commit(): void {
270 // Transaction commands don't need preparation as they contain no user input
271 $this->wpdb->query('COMMIT');
272 }
273
274 /**
275 * Rollback database transaction
276 *
277 * @return void
278 */
279 public function rollback(): void {
280 // Transaction commands don't need preparation as they contain no user input
281 $this->wpdb->query('ROLLBACK');
282 }
283
284 /**
285 * Get last database error
286 *
287 * @return string Last error message
288 */
289 public function get_last_error(): string {
290 return $this->wpdb->last_error;
291 }
292
293 /**
294 * Clean up expired cache entries
295 *
296 * @return void
297 */
298 public function cleanup_expired_cache(): void {
299 $cache_table = $this->get_table('ai_cache');
300
301 // Check WordPress version for %i support (introduced in 6.2)
302 if (version_compare($GLOBALS['wp_version'], '6.2', '>=')) {
303 // Use %i placeholder for table identifier (WordPress 6.2+)
304 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnsupportedIdentifierPlaceholder
305 $deleted = $this->wpdb->query(
306 $this->wpdb->prepare(
307 // expires_at is a bigint unix timestamp (see Cache_Manager),
308 // so compare against time(), not a MySQL datetime string.
309 'DELETE FROM %i WHERE expires_at < %d',
310 $cache_table,
311 time()
312 )
313 );
314 // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnsupportedIdentifierPlaceholder
315 } else {
316 // Fallback for older WordPress versions - table name is escaped and safe
317 $escaped_table = esc_sql($cache_table);
318 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
319 $deleted = $this->wpdb->query(
320 $this->wpdb->prepare(
321 "DELETE FROM `{$escaped_table}` WHERE expires_at < %d",
322 time()
323 )
324 );
325 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
326 }
327
328 if ($deleted > 0) {
329 $this->log_error(
330 sprintf('Cleaned up %d expired cache entries', $deleted),
331 ['type' => 'info']
332 );
333 }
334 }
335
336 /**
337 * Get database statistics
338 *
339 * @return array Database statistics
340 */
341 public function get_stats(): array {
342 $stats = [];
343
344 foreach ($this->tables as $key => $table) {
345 // Check WordPress version for %i support (introduced in 6.2)
346 if (version_compare($GLOBALS['wp_version'], '6.2', '>=')) {
347 // Use %i placeholder for table identifier (WordPress 6.2+)
348 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnsupportedIdentifierPlaceholder
349 $count = $this->wpdb->get_var(
350 $this->wpdb->prepare('SELECT COUNT(*) FROM %i', $table)
351 );
352 // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnsupportedIdentifierPlaceholder
353 } else {
354 // Fallback for older WordPress versions - table name is from our controlled list
355 $escaped_table = esc_sql($table);
356 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is properly escaped and from controlled source
357 $count = $this->wpdb->get_var("SELECT COUNT(*) FROM `{$escaped_table}`");
358 }
359 $stats[$key] = (int) $count;
360 }
361
362 return $stats;
363 }
364
365 /**
366 * Log database errors
367 *
368 * @param string $message Error message
369 * @param array $context Error context
370 * @return void
371 */
372 private function log_error(string $message, array $context = []): void {
373 // Only log errors if both WP_DEBUG and custom debug flag are enabled
374 if (!defined('WP_DEBUG') || !WP_DEBUG) {
375 return;
376 }
377
378 if (!defined('THINKRANK_DEBUG_LOGGING') || !THINKRANK_DEBUG_LOGGING) {
379 return;
380 }
381
382 // Use WordPress error logging function only in debug mode
383 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
384 error_log('ThinkRank Database Error: ' . $message . ' - ' . wp_json_encode($context));
385 }
386
387 /**
388 * Log debug information
389 *
390 * @param string $message Debug message
391 * @param array $context Debug context
392 * @return void
393 */
394 private function log_debug(string $message, array $context = []): void {
395 // Only log debug info if both WP_DEBUG and custom debug flag are enabled
396 if (!defined('WP_DEBUG') || !WP_DEBUG) {
397 return;
398 }
399
400 if (!defined('THINKRANK_DEBUG_LOGGING') || !THINKRANK_DEBUG_LOGGING) {
401 return;
402 }
403
404 // Use WordPress error logging function only in debug mode
405 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
406 error_log('ThinkRank Database Debug: ' . $message . ' - ' . wp_json_encode($context));
407 }
408
409 /**
410 * Process weekly analytics (handles thinkrank_usage_analytics cron job)
411 *
412 * @return void
413 */
414 public function process_weekly_analytics(): void {
415 try {
416 // 1. Clean old data from analytics tables
417 $cleanup_results = $this->cleanup_old_analytics_data();
418
419 // 2. Clear analytics cache to force fresh calculations
420 $cache_results = $this->clear_analytics_cache();
421
422 // Log successful processing
423 $this->log_debug('Weekly analytics processing completed', [
424 'cleanup_results' => $cleanup_results,
425 'cache_results' => $cache_results,
426 'processed_at' => current_time('mysql')
427 ]);
428 } catch (\Exception $e) {
429 // Log error but don't throw to prevent cron job failures
430 $this->log_debug('Weekly analytics processing failed', [
431 'error' => $e->getMessage(),
432 'trace' => $e->getTraceAsString()
433 ]);
434 }
435 }
436
437 /**
438 * Clean old data from analytics tables
439 *
440 * @return array Cleanup results
441 */
442 private function cleanup_old_analytics_data(): array {
443 $results = [];
444
445 // Define retention periods (in days)
446 $retention_config = [
447 'ai_usage' => 365, // 1 year
448 'seo_scores' => 180, // 6 months
449 'content_briefs' => 90, // 3 months
450 'seo_performance' => 90, // 3 months (performance data grows fast)
451 'instant_indexing_logs' => 90 // 3 months (one row per URL per submit)
452 ];
453
454 foreach ($retention_config as $table_key => $retention_days) {
455 try {
456 $deleted_count = $this->cleanup_table_data($table_key, $retention_days);
457 $results[$table_key] = [
458 'deleted_records' => $deleted_count,
459 'retention_days' => $retention_days,
460 'success' => true
461 ];
462 } catch (\Exception $e) {
463 $results[$table_key] = [
464 'deleted_records' => 0,
465 'retention_days' => $retention_days,
466 'success' => false,
467 'error' => $e->getMessage()
468 ];
469 }
470 }
471
472 // Also call the existing performance data cleanup method
473 try {
474 $performance_collector = new \ThinkRank\SEO\Performance_Data_Collector();
475 $performance_deleted = $performance_collector->cleanup_old_data(30); // Keep 30 days of detailed performance data
476 $results['performance_detailed'] = [
477 'deleted_records' => $performance_deleted,
478 'retention_days' => 30,
479 'success' => true
480 ];
481 } catch (\Exception $e) {
482 $results['performance_detailed'] = [
483 'deleted_records' => 0,
484 'retention_days' => 30,
485 'success' => false,
486 'error' => $e->getMessage()
487 ];
488 }
489
490 return $results;
491 }
492
493 /**
494 * Clean data from specific table
495 *
496 * @param string $table_key Table identifier
497 * @param int $retention_days Number of days to keep
498 * @return int Number of deleted records
499 */
500 private function cleanup_table_data(string $table_key, int $retention_days): int {
501 $table_name = $this->get_table($table_key);
502 $cutoff_date = gmdate('Y-m-d H:i:s', strtotime("-{$retention_days} days"));
503
504 // Get the appropriate date column for each table
505 $date_columns = [
506 'ai_usage' => 'created_at',
507 'seo_scores' => 'calculated_at',
508 'content_briefs' => 'created_at',
509 'seo_performance' => 'measured_at'
510 ];
511
512 $date_column = $date_columns[$table_key] ?? 'created_at';
513
514 // Delete old records
515 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics cleanup requires direct database access, table and column names are validated internally
516 $deleted = $this->wpdb->query(
517 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
518 $this->wpdb->prepare(
519 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table and column names are validated internally
520 "DELETE FROM `{$table_name}` WHERE `{$date_column}` < %s",
521 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $cutoff_date is validated and used as parameter
522 $cutoff_date
523 )
524 );
525
526 return $deleted !== false ? (int) $deleted : 0;
527 }
528
529 /**
530 * Clear analytics-related cache
531 *
532 * @return array Cache clearing results
533 */
534 private function clear_analytics_cache(): array {
535 $cache_keys = [
536 'analytics_overview_7d',
537 'analytics_overview_30d',
538 'analytics_overview_90d',
539 'usage_breakdown_weekly',
540 'usage_breakdown_monthly',
541 'cost_analysis_7d',
542 'cost_analysis_30d',
543 'cost_analysis_90d'
544 ];
545
546 $cleared_count = 0;
547
548 foreach ($cache_keys as $key) {
549 if (delete_transient($key)) {
550 $cleared_count++;
551 }
552 }
553
554 // Also clear any user-specific analytics cache
555 $analytics_like = $this->wpdb->esc_like('_transient_thinkrank_analytics_') . '%';
556 $timeout_like = $this->wpdb->esc_like('_transient_timeout_thinkrank_analytics_') . '%';
557 $options_table = $this->wpdb->options;
558 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $options_table is from $wpdb->options, a WordPress core table name.
559 $prepared_sql = $this->wpdb->prepare(
560 "DELETE FROM {$options_table} WHERE option_name LIKE %s OR option_name LIKE %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
561 $analytics_like,
562 $timeout_like
563 );
564 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Query is prepared above.
565 $user_cache_deleted = $this->wpdb->query( $prepared_sql );
566
567 return [
568 'transients_cleared' => $cleared_count,
569 'user_cache_cleared' => $user_cache_deleted !== false ? (int) $user_cache_deleted : 0,
570 'total_cleared' => $cleared_count + ($user_cache_deleted !== false ? (int) $user_cache_deleted : 0)
571 ];
572 }
573
574 /**
575 * Get table sizes for monitoring
576 *
577 * @since 1.0.0
578 *
579 * @return array Table sizes in MB
580 */
581 public function get_table_sizes(): array {
582 $sizes = [];
583 $tables = [
584 'ai_usage',
585 'seo_scores',
586 'content_briefs',
587 'seo_performance'
588 ];
589
590 foreach ($tables as $table_key) {
591 $table_name = $this->get_table($table_key);
592
593 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Query is prepared with placeholders below.
594 $prepared_sql = $this->wpdb->prepare(
595 "SELECT
596 table_name,
597 ROUND(((data_length + index_length) / 1024 / 1024), 2) AS size_mb,
598 table_rows
599 FROM information_schema.TABLES
600 WHERE table_schema = %s AND table_name = %s",
601 DB_NAME,
602 $table_name
603 );
604 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Query is prepared above.
605 $size_result = $this->wpdb->get_row( $prepared_sql );
606
607 if ($size_result) {
608 $sizes[$table_key] = [
609 'size_mb' => (float) $size_result->size_mb,
610 'rows' => (int) $size_result->table_rows
611 ];
612 }
613 }
614
615 return $sizes;
616 }
617 }
618