PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.28.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.28.0
2.7.0 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 All 48 releases
thinkrank / includes / database / class-database-schema.php

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

1,579 lines 61.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Database Schema Manager Class
5 *
6 * Comprehensive database schema implementation for ThinkRank SEO plugin.
7 * Creates and manages all 11 ThinkRank tables with proper indexes, constraints,
8 * and WordPress-compliant database operations following 2025 best practices.
9 *
10 * Tables managed:
11 * - SEO Tables (7): Settings, Analysis, Keywords, Schema, Social, Performance, Local
12 * - AI/Core Tables (4): AI Cache, AI Usage, Content Briefs, SEO Scores
13 *
14 * @package ThinkRank
15 * @subpackage Database
16 * @since 1.0.0
17 */
18
19 declare(strict_types=1);
20
21 namespace ThinkRank\Database;
22
23 /**
24 * Database Schema Manager Class
25 *
26 * Handles creation, management, and optimization of all SEO database tables.
27 * Implements WordPress database standards with proper indexing and constraints.
28 *
29 * @since 1.0.0
30 */
31 class Database_Schema {
32
33 /**
34 * WordPress database instance
35 *
36 * @since 1.0.0
37 * @var \wpdb
38 */
39 private \wpdb $wpdb;
40
41 /**
42 * Database version for schema tracking
43 *
44 * @since 1.0.0
45 * @var string
46 */
47 private string $db_version = '1.6.0';
48
49 /**
50 * Transient caching a verified-complete schema, so the missing-table probe
51 * costs one query per hour on a healthy site rather than one per request.
52 *
53 * @since 1.28.0
54 * @var string
55 */
56 private const TABLES_VERIFIED_TRANSIENT = 'thinkrank_schema_verified';
57
58 /**
59 * Database table definitions with specifications
60 *
61 * Consolidated table definitions for all ThinkRank tables (11 total):
62 * - SEO Tables (7): Core SEO functionality with context-aware structure
63 * - AI/Core Tables (4): AI caching, usage tracking, content briefs, and scoring
64 *
65 * @since 1.0.0
66 * @var array
67 */
68 private array $table_definitions = [
69 // === SEO TABLES (7) ===
70 'seo_settings' => [
71 'description' => 'Universal SEO settings storage with context-aware structure',
72 'primary_key' => 'setting_id',
73 'indexes' => ['context_type', 'context_id', 'setting_category', 'is_active'],
74 'foreign_keys' => []
75 ],
76 'seo_analysis' => [
77 'description' => 'SEO analysis results and scoring data',
78 'primary_key' => 'analysis_id',
79 'indexes' => ['context_type', 'context_id', 'analysis_type', 'created_at'],
80 'composite_indexes' => [
81 'context_analysis_date' => ['context_type', 'analysis_type', 'created_at'],
82 'context_recent' => ['context_type', 'context_id', 'created_at']
83 ],
84 'foreign_keys' => []
85 ],
86 'seo_keywords' => [
87 'description' => 'Keyword tracking and optimization data',
88 'primary_key' => 'keyword_id',
89 'indexes' => ['context_type', 'context_id', 'keyword_type', 'keyword_hash'],
90 'foreign_keys' => []
91 ],
92 'seo_schema' => [
93 'description' => 'Schema markup storage and validation',
94 'primary_key' => 'schema_id',
95 'indexes' => ['context_type', 'context_id', 'schema_type', 'is_active'],
96 'foreign_keys' => []
97 ],
98 'seo_social' => [
99 'description' => 'Social media meta and optimization data',
100 'primary_key' => 'social_id',
101 'indexes' => ['context_type', 'context_id', 'platform', 'is_active'],
102 'foreign_keys' => []
103 ],
104 'seo_performance' => [
105 'description' => 'Performance metrics and Core Web Vitals data',
106 'primary_key' => 'performance_id',
107 'indexes' => ['context_type', 'context_id', 'metric_type', 'measured_at'],
108 'foreign_keys' => []
109 ],
110 'seo_local' => [
111 'description' => 'Local SEO and business data storage',
112 'primary_key' => 'local_id',
113 'indexes' => ['context_type', 'context_id', 'business_type', 'is_active'],
114 'foreign_keys' => []
115 ],
116
117 // === AI/CORE TABLES (4) ===
118 'ai_cache' => [
119 'description' => 'AI response caching for performance optimization',
120 'primary_key' => 'id',
121 'indexes' => ['cache_key', 'expires_at', 'created_at'],
122 'composite_indexes' => [
123 'cache_lookup' => ['cache_key', 'expires_at'],
124 'cleanup_expired' => ['expires_at', 'created_at']
125 ],
126 'foreign_keys' => []
127 ],
128 'ai_usage' => [
129 'description' => 'AI usage tracking and token consumption monitoring',
130 'primary_key' => 'id',
131 'indexes' => ['user_id', 'action', 'provider', 'created_at'],
132 'composite_indexes' => [
133 'user_analytics' => ['user_id', 'created_at', 'provider'],
134 'provider_action' => ['provider', 'action', 'created_at'],
135 'user_provider_date' => ['user_id', 'provider', 'created_at']
136 ],
137 'foreign_keys' => []
138 ],
139 'content_briefs' => [
140 'description' => 'Generated content briefs storage and management',
141 'primary_key' => 'id',
142 'indexes' => ['user_id', 'content_type', 'created_at'],
143 'composite_indexes' => [
144 'user_content_date' => ['user_id', 'content_type', 'created_at'],
145 'user_recent' => ['user_id', 'created_at']
146 ],
147 'foreign_keys' => []
148 ],
149 'seo_scores' => [
150 'description' => 'SEO score calculations and historical tracking',
151 'primary_key' => 'id',
152 'indexes' => ['post_id', 'user_id', 'overall_score', 'grade', 'calculated_at', 'created_at'],
153 'composite_indexes' => [
154 'post_user_date' => ['post_id', 'user_id', 'created_at'],
155 'user_score_date' => ['user_id', 'overall_score', 'created_at'],
156 'post_latest' => ['post_id', 'calculated_at']
157 ],
158 'foreign_keys' => []
159 ],
160 'instant_indexing_logs' => [
161 'description' => 'Log of IndexNow URL submissions',
162 'primary_key' => 'id',
163 'indexes' => ['status', 'response_code', 'created_at'],
164 'foreign_keys' => []
165 ],
166 'email_report_logs' => [
167 'description' => 'Audit + dedupe log for scheduled SEO email reports',
168 'primary_key' => 'id',
169 'indexes' => ['site_id', 'status', 'sent_at', 'period_start'],
170 'composite_indexes' => [
171 'dedupe_key' => ['site_id', 'period_start', 'recipient_hash'],
172 'site_recent' => ['site_id', 'sent_at']
173 ],
174 'foreign_keys' => []
175 ],
176
177 // === AI VISIBILITY TABLES (2) ===
178 'ai_traffic' => [
179 'description' => 'Daily aggregate counters for AI referral traffic, AI crawler hits, and the all-traffic baseline',
180 'primary_key' => 'id',
181 'indexes' => ['day', 'kind'],
182 'foreign_keys' => []
183 ],
184 'brand_visibility_checks' => [
185 'description' => 'History of AI brand-visibility checks run through the configured AI provider',
186 'primary_key' => 'id',
187 'indexes' => ['checked_at', 'query_text'],
188 'foreign_keys' => []
189 ],
190 'bv_runs' => [
191 'description' => 'Brand Visibility v2 analysis runs: one row per run, with its config snapshot, progress counters and computed aggregates',
192 'primary_key' => 'id',
193 'indexes' => ['status', 'started_at'],
194 'foreign_keys' => []
195 ],
196 'bv_tasks' => [
197 'description' => 'Brand Visibility v2 units of work: one row per query x platform x sample, processed off-request by cron ticks',
198 'primary_key' => 'id',
199 'indexes' => ['run_id', 'status'],
200 'composite_indexes' => [
201 'run_status' => ['run_id', 'status'],
202 ],
203 'foreign_keys' => []
204 ]
205 ];
206
207 /**
208 * WordPress database charset and collation
209 *
210 * @since 1.0.0
211 * @var array
212 */
213 private array $db_config;
214
215 /**
216 * Table categories for better organization and maintenance
217 *
218 * @since 1.0.0
219 * @var array
220 */
221 private array $table_categories = [
222 'seo' => ['seo_settings', 'seo_analysis', 'seo_keywords', 'seo_schema', 'seo_social', 'seo_performance', 'seo_local', 'instant_indexing_logs'],
223 'ai' => ['ai_cache', 'ai_usage'],
224 'content' => ['content_briefs'],
225 'scoring' => ['seo_scores'],
226 'reporting' => ['email_report_logs'],
227 'ai_visibility' => ['ai_traffic', 'brand_visibility_checks', 'bv_runs', 'bv_tasks']
228 ];
229
230 /**
231 * Constructor
232 *
233 * @since 1.0.0
234 */
235 public function __construct() {
236 global $wpdb;
237 $this->wpdb = $wpdb;
238
239 // Set database configuration
240 $this->db_config = [
241 'charset' => $wpdb->charset ?: 'utf8mb4',
242 'collate' => $wpdb->collate ?: 'utf8mb4_unicode_ci'
243 ];
244 }
245
246 /**
247 * Create all database tables
248 *
249 * @since 1.0.0
250 *
251 * @return array Creation results with success/failure status
252 */
253 public function create_tables(): array {
254 $results = [
255 'success' => true,
256 'tables_created' => [],
257 'tables_failed' => [],
258 'errors' => [],
259 'total_tables' => count($this->table_definitions)
260 ];
261
262 // Require WordPress upgrade functions
263 if (!function_exists('dbDelta')) {
264 require_once ABSPATH . 'wp-admin/includes/upgrade.php';
265 }
266
267 foreach ($this->table_definitions as $table_name => $definition) {
268 try {
269 $full_table_name = $this->get_table_name($table_name);
270 $sql = $this->get_table_sql($table_name);
271
272 // Create table using dbDelta for WordPress compatibility
273 $result = dbDelta($sql);
274
275 // Verify table creation
276 if ($this->table_exists($full_table_name)) {
277 $results['tables_created'][] = $full_table_name;
278
279 // Create indexes
280 $this->create_table_indexes($table_name);
281
282 // Add constraints if needed
283 $this->add_table_constraints($table_name);
284 } else {
285 $results['tables_failed'][] = $full_table_name;
286 $results['errors'][] = "Failed to create table: {$full_table_name}";
287 $results['success'] = false;
288 }
289 } catch (\Exception $e) {
290 $results['tables_failed'][] = $this->get_table_name($table_name);
291 $results['errors'][] = "Error creating {$table_name}: " . $e->getMessage();
292 $results['success'] = false;
293 }
294 }
295
296 // Update database version
297 if ($results['success']) {
298 update_option('thinkrank_seo_db_version', $this->db_version);
299 update_option('thinkrank_seo_db_created', current_time('mysql'));
300 }
301
302 // The schema just changed, so any cached "verified complete" answer is
303 // stale either way — drop it and let the next probe re-check.
304 delete_transient(self::TABLES_VERIFIED_TRANSIENT);
305
306 return $results;
307 }
308
309 /**
310 * Drop all database tables
311 *
312 * @since 1.0.0
313 *
314 * @return array Deletion results
315 */
316 public function drop_tables(): array {
317 $results = [
318 'success' => true,
319 'tables_dropped' => [],
320 'tables_failed' => [],
321 'errors' => []
322 ];
323
324 foreach (array_keys($this->table_definitions) as $table_name) {
325 try {
326 $full_table_name = $this->get_table_name($table_name);
327
328 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange,WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Plugin deactivation requires direct schema changes, DDL cannot be prepared, table name is validated
329 $result = $this->wpdb->query("DROP TABLE IF EXISTS `{$full_table_name}`");
330
331 if ($result !== false) {
332 $results['tables_dropped'][] = $full_table_name;
333 } else {
334 $results['tables_failed'][] = $full_table_name;
335 $results['errors'][] = "Failed to drop table: {$full_table_name}";
336 $results['success'] = false;
337 }
338 } catch (\Exception $e) {
339 $results['tables_failed'][] = $this->get_table_name($table_name);
340 $results['errors'][] = "Error dropping {$table_name}: " . $e->getMessage();
341 $results['success'] = false;
342 }
343 }
344
345 // Clean up options
346 if ($results['success']) {
347 delete_option('thinkrank_seo_db_version');
348 delete_option('thinkrank_seo_db_created');
349 }
350
351 return $results;
352 }
353
354 /**
355 * Check if database schema needs updates
356 *
357 * @since 1.0.0
358 *
359 * @return bool True if update needed
360 */
361 public function needs_update(): bool {
362 $current_version = get_option('thinkrank_seo_db_version', '0.0.0');
363
364 if (version_compare($current_version, $this->db_version, '<')) {
365 return true;
366 }
367
368 // Version-only gating has now failed twice (#252, #270): if tables are
369 // added but the version isn't moved — or a table is dropped, or an
370 // upgrade half-completes — the stored version matches, the gate says
371 // "nothing to do", and the feature is dead with no way back except
372 // deactivate/reactivate. So also heal when a registered table is
373 // actually missing. dbDelta only creates what's absent, making this
374 // safe to re-run.
375 return !empty($this->missing_tables());
376 }
377
378 /**
379 * Registered tables that don't exist in the database.
380 *
381 * One `SHOW TABLES LIKE` for all of them, and the healthy answer is cached
382 * so a correct install pays at most one extra query per hour rather than
383 * one per request. The cache is cleared whenever tables are created.
384 *
385 * @since 1.28.0
386 *
387 * @return string[] Missing table names (full, prefixed).
388 */
389 public function missing_tables(): array {
390 $cached = get_transient(self::TABLES_VERIFIED_TRANSIENT);
391 if ($cached === $this->db_version) {
392 return [];
393 }
394
395 $expected = [];
396 foreach (array_keys($this->table_definitions) as $table) {
397 $expected[] = $this->get_table_name($table);
398 }
399
400 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- schema probe; result cached below.
401 $existing = (array) $this->wpdb->get_col(
402 $this->wpdb->prepare(
403 'SHOW TABLES LIKE %s',
404 $this->wpdb->esc_like($this->wpdb->prefix . 'thinkrank_') . '%'
405 )
406 );
407
408 $missing = array_values(array_diff($expected, $existing));
409
410 if (empty($missing)) {
411 set_transient(self::TABLES_VERIFIED_TRANSIENT, $this->db_version, HOUR_IN_SECONDS);
412 }
413
414 return $missing;
415 }
416
417 /**
418 * The schema version this plugin build expects (the target of needs_update).
419 *
420 * @since 1.23.0
421 *
422 * @return string Expected schema version, e.g. "1.2.0".
423 */
424 public function get_schema_version(): string {
425 return $this->db_version;
426 }
427
428 /**
429 * Get database status and information
430 *
431 * @since 1.0.0
432 *
433 * @return array Database status information
434 */
435 public function get_database_status(): array {
436 $status = [
437 'version' => get_option('thinkrank_seo_db_version', 'Not installed'),
438 'created_at' => get_option('thinkrank_seo_db_created', 'Unknown'),
439 'tables' => [],
440 'total_records' => 0,
441 'database_size' => 0,
442 'needs_update' => $this->needs_update()
443 ];
444
445 foreach (array_keys($this->table_definitions) as $table_name) {
446 $full_table_name = $this->get_table_name($table_name);
447 $table_info = $this->get_table_info($full_table_name);
448
449 $status['tables'][$table_name] = $table_info;
450 $status['total_records'] += $table_info['row_count'];
451 $status['database_size'] += $table_info['data_size'];
452 }
453
454 return $status;
455 }
456
457 /**
458 * Optimize all database tables
459 *
460 * @since 1.0.0
461 *
462 * @return array Optimization results
463 */
464 public function optimize_tables(): array {
465 $results = [
466 'success' => true,
467 'tables_optimized' => [],
468 'tables_failed' => [],
469 'space_saved' => 0,
470 'errors' => []
471 ];
472
473 foreach (array_keys($this->table_definitions) as $table_name) {
474 try {
475 $full_table_name = $this->get_table_name($table_name);
476
477 // Get table size before optimization
478 $size_before = $this->get_table_size($full_table_name);
479
480 // Optimize table
481 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table optimization requires direct database access, DDL cannot be prepared, table name is validated
482 $result = $this->wpdb->query("OPTIMIZE TABLE `{$full_table_name}`");
483
484 if ($result !== false) {
485 $size_after = $this->get_table_size($full_table_name);
486 $space_saved = $size_before - $size_after;
487
488 $results['tables_optimized'][] = [
489 'table' => $full_table_name,
490 'space_saved' => $space_saved
491 ];
492 $results['space_saved'] += $space_saved;
493 } else {
494 $results['tables_failed'][] = $full_table_name;
495 $results['errors'][] = "Failed to optimize table: {$full_table_name}";
496 $results['success'] = false;
497 }
498 } catch (\Exception $e) {
499 $results['tables_failed'][] = $this->get_table_name($table_name);
500 $results['errors'][] = "Error optimizing {$table_name}: " . $e->getMessage();
501 $results['success'] = false;
502 }
503 }
504
505 return $results;
506 }
507
508 /**
509 * Get full table name with WordPress prefix
510 *
511 * @since 1.0.0
512 *
513 * @param string $table_name Base table name
514 * @return string Full table name with prefix
515 */
516 private function get_table_name(string $table_name): string {
517 return $this->wpdb->prefix . 'thinkrank_' . $table_name;
518 }
519
520 /**
521 * Check if table exists
522 *
523 * @since 1.0.0
524 *
525 * @param string $table_name Full table name
526 * @return bool True if table exists
527 */
528 private function table_exists(string $table_name): bool {
529 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema validation requires direct database access
530 $result = $this->wpdb->get_var(
531 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
532 $this->wpdb->prepare("SHOW TABLES LIKE %s", $table_name)
533 );
534
535 return $result === $table_name;
536 }
537
538 /**
539 * Get SQL for creating a specific table
540 *
541 * @since 1.0.0
542 *
543 * @param string $table_name Table name
544 * @return string SQL for table creation
545 */
546 private function get_table_sql(string $table_name): string {
547 $full_table_name = $this->get_table_name($table_name);
548 $charset_collate = "DEFAULT CHARACTER SET {$this->db_config['charset']} COLLATE {$this->db_config['collate']}";
549
550 switch ($table_name) {
551 // SEO Tables
552 case 'seo_settings':
553 return $this->get_seo_settings_table_sql($full_table_name, $charset_collate);
554 case 'seo_analysis':
555 return $this->get_seo_analysis_table_sql($full_table_name, $charset_collate);
556 case 'seo_keywords':
557 return $this->get_seo_keywords_table_sql($full_table_name, $charset_collate);
558 case 'seo_schema':
559 return $this->get_seo_schema_table_sql($full_table_name, $charset_collate);
560 case 'seo_social':
561 return $this->get_seo_social_table_sql($full_table_name, $charset_collate);
562 case 'seo_performance':
563 return $this->get_seo_performance_table_sql($full_table_name, $charset_collate);
564 case 'seo_local':
565 return $this->get_seo_local_table_sql($full_table_name, $charset_collate);
566
567 // AI/Core Tables
568 case 'ai_cache':
569 return $this->get_ai_cache_table_sql($full_table_name, $charset_collate);
570 case 'ai_usage':
571 return $this->get_ai_usage_table_sql($full_table_name, $charset_collate);
572 case 'content_briefs':
573 return $this->get_content_briefs_table_sql($full_table_name, $charset_collate);
574 case 'seo_scores':
575 return $this->get_seo_scores_table_sql($full_table_name, $charset_collate);
576 case 'instant_indexing_logs':
577 return $this->get_instant_indexing_logs_table_sql($full_table_name, $charset_collate);
578 case 'email_report_logs':
579 return $this->get_email_report_logs_table_sql($full_table_name, $charset_collate);
580
581 // AI Visibility Tables
582 case 'ai_traffic':
583 return $this->get_ai_traffic_table_sql($full_table_name, $charset_collate);
584 case 'bv_runs':
585 return $this->get_bv_runs_table_sql($full_table_name, $charset_collate);
586 case 'bv_tasks':
587 return $this->get_bv_tasks_table_sql($full_table_name, $charset_collate);
588 case 'brand_visibility_checks':
589 return $this->get_brand_visibility_checks_table_sql($full_table_name, $charset_collate);
590
591 default:
592 throw new \InvalidArgumentException('Unknown table: ' . esc_html($table_name));
593 }
594 }
595
596 /**
597 * Get SQL for SEO Settings table
598 *
599 * @since 1.0.0
600 *
601 * @param string $table_name Full table name
602 * @param string $charset_collate Charset and collation
603 * @return string SQL for table creation
604 */
605 private function get_seo_settings_table_sql(string $table_name, string $charset_collate): string {
606 return "CREATE TABLE `{$table_name}` (
607 setting_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
608 context_type varchar(50) NOT NULL DEFAULT 'site',
609 context_id bigint(20) unsigned NULL,
610 setting_category varchar(100) NOT NULL DEFAULT 'general',
611 setting_key varchar(255) NOT NULL,
612 setting_value longtext NULL,
613 setting_type varchar(50) NOT NULL DEFAULT 'string',
614 is_active tinyint(1) NOT NULL DEFAULT 1,
615 priority int(11) NOT NULL DEFAULT 0,
616 created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
617 updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
618 created_by bigint(20) unsigned NULL,
619 updated_by bigint(20) unsigned NULL,
620 PRIMARY KEY (setting_id),
621 UNIQUE KEY unique_setting (context_type, context_id, setting_category, setting_key),
622 KEY idx_context (context_type, context_id),
623 KEY idx_category (setting_category),
624 KEY idx_active (is_active),
625 KEY idx_created (created_at),
626 KEY idx_updated (updated_at),
627 KEY idx_context_cat_active (context_type, context_id, setting_category, is_active)
628 ) {$charset_collate};";
629 }
630
631 /**
632 * Get SQL for SEO Analysis table
633 *
634 * @since 1.0.0
635 *
636 * @param string $table_name Full table name
637 * @param string $charset_collate Charset and collation
638 * @return string SQL for table creation
639 */
640 private function get_seo_analysis_table_sql(string $table_name, string $charset_collate): string {
641 return "CREATE TABLE `{$table_name}` (
642 analysis_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
643 context_type varchar(50) NOT NULL DEFAULT 'site',
644 context_id bigint(20) unsigned NULL,
645 analysis_type varchar(100) NOT NULL,
646 analysis_data longtext NULL,
647 score int(11) NOT NULL DEFAULT 0,
648 status varchar(50) NOT NULL DEFAULT 'pending',
649 ai_confidence decimal(3,2) NULL,
650 recommendations longtext NULL,
651 validation_errors longtext NULL,
652 created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
653 updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
654 analyzed_by bigint(20) unsigned NULL,
655 PRIMARY KEY (analysis_id),
656 KEY idx_context (context_type, context_id),
657 KEY idx_type (analysis_type),
658 KEY idx_status (status),
659 KEY idx_score (score),
660 KEY idx_created (created_at),
661 KEY idx_confidence (ai_confidence)
662 ) {$charset_collate};";
663 }
664
665 /**
666 * Get SQL for SEO Keywords table
667 *
668 * @since 1.0.0
669 *
670 * @param string $table_name Full table name
671 * @param string $charset_collate Charset and collation
672 * @return string SQL for table creation
673 */
674 private function get_seo_keywords_table_sql(string $table_name, string $charset_collate): string {
675 return "CREATE TABLE `{$table_name}` (
676 keyword_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
677 context_type varchar(50) NOT NULL DEFAULT 'site',
678 context_id bigint(20) unsigned NULL,
679 keyword_text varchar(500) NOT NULL,
680 keyword_hash varchar(64) NOT NULL,
681 keyword_type varchar(50) NOT NULL DEFAULT 'primary',
682 search_volume int(11) NULL,
683 competition_score decimal(3,2) NULL,
684 difficulty_score decimal(3,2) NULL,
685 density decimal(5,2) NULL,
686 position int(11) NULL,
687 ranking_url varchar(2048) NULL,
688 is_tracking tinyint(1) NOT NULL DEFAULT 0,
689 is_active tinyint(1) NOT NULL DEFAULT 1,
690 created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
691 updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
692 tracked_by bigint(20) unsigned NULL,
693 PRIMARY KEY (keyword_id),
694 UNIQUE KEY unique_keyword (context_type, context_id, keyword_hash),
695 KEY idx_context (context_type, context_id),
696 KEY idx_type (keyword_type),
697 KEY idx_hash (keyword_hash),
698 KEY idx_tracking (is_tracking),
699 KEY idx_active (is_active),
700 KEY idx_position (position),
701 KEY idx_created (created_at),
702 FULLTEXT KEY ft_keyword (keyword_text)
703 ) {$charset_collate};";
704 }
705
706 /**
707 * Get SQL for SEO Schema table (Optimized Version 2.0)
708 *
709 * @since 1.0.0
710 * @updated 2.0.0 - Optimized structure with fewer columns and better indexes
711 *
712 * @param string $table_name Full table name
713 * @param string $charset_collate Charset and collation
714 * @return string SQL for table creation
715 */
716 private function get_seo_schema_table_sql(string $table_name, string $charset_collate): string {
717 // Check MySQL version for JSON column support with caching
718 $schema_data_type = $this->get_mysql_json_support() ? 'JSON' : 'longtext';
719
720 return "CREATE TABLE `{$table_name}` (
721 schema_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
722 context_type varchar(50) NOT NULL DEFAULT 'site',
723 context_id bigint(20) unsigned NULL,
724 schema_type varchar(100) NOT NULL,
725 schema_data {$schema_data_type} NOT NULL,
726 validation_status varchar(50) NOT NULL DEFAULT 'pending',
727 is_active tinyint(1) NOT NULL DEFAULT 1,
728 created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
729 updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
730 PRIMARY KEY (schema_id),
731 KEY idx_context_active (context_type, context_id, is_active),
732 KEY idx_type_active (schema_type, is_active),
733 KEY idx_created (created_at),
734 KEY idx_context_schema_active (context_type, schema_type, is_active, created_at DESC),
735 KEY idx_validation_active (validation_status, is_active)
736 ) {$charset_collate};";
737 }
738
739 /**
740 * Get SQL for SEO Social table
741 *
742 * @since 1.0.0
743 *
744 * @param string $table_name Full table name
745 * @param string $charset_collate Charset and collation
746 * @return string SQL for table creation
747 */
748 private function get_seo_social_table_sql(string $table_name, string $charset_collate): string {
749 return "CREATE TABLE `{$table_name}` (
750 social_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
751 context_type varchar(50) NOT NULL DEFAULT 'site',
752 context_id bigint(20) unsigned NULL,
753 platform varchar(50) NOT NULL,
754 meta_type varchar(100) NOT NULL,
755 meta_key varchar(255) NOT NULL,
756 meta_value longtext NULL,
757 image_url varchar(2048) NULL,
758 image_width int(11) NULL,
759 image_height int(11) NULL,
760 is_optimized tinyint(1) NOT NULL DEFAULT 0,
761 is_active tinyint(1) NOT NULL DEFAULT 1,
762 created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
763 updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
764 created_by bigint(20) unsigned NULL,
765 PRIMARY KEY (social_id),
766 UNIQUE KEY unique_social_meta (context_type, context_id, platform, meta_key),
767 KEY idx_context (context_type, context_id),
768 KEY idx_platform (platform),
769 KEY idx_type (meta_type),
770 KEY idx_optimized (is_optimized),
771 KEY idx_active (is_active),
772 KEY idx_created (created_at)
773 ) {$charset_collate};";
774 }
775
776 /**
777 * Get SQL for SEO Performance table
778 *
779 * @since 1.0.0
780 *
781 * @param string $table_name Full table name
782 * @param string $charset_collate Charset and collation
783 * @return string SQL for table creation
784 */
785 private function get_seo_performance_table_sql(string $table_name, string $charset_collate): string {
786 return "CREATE TABLE `{$table_name}` (
787 performance_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
788 context_type varchar(50) NOT NULL DEFAULT 'site',
789 context_id bigint(20) unsigned NULL,
790 metric_type varchar(100) NOT NULL,
791 metric_value decimal(10,4) NOT NULL,
792 metric_unit varchar(50) NOT NULL DEFAULT 'score',
793 threshold_good decimal(10,4) NULL,
794 threshold_poor decimal(10,4) NULL,
795 status varchar(50) NOT NULL DEFAULT 'unknown',
796 device_type varchar(20) NOT NULL DEFAULT 'desktop',
797 connection_type varchar(50) NULL,
798 measured_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
799 created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
800 measured_by varchar(100) NULL,
801 PRIMARY KEY (performance_id),
802 KEY idx_context (context_type, context_id),
803 KEY idx_metric (metric_type),
804 KEY idx_status (status),
805 KEY idx_device (device_type),
806 KEY idx_measured (measured_at),
807 KEY idx_created (created_at),
808 KEY idx_value (metric_value)
809 ) {$charset_collate};";
810 }
811
812 /**
813 * Get SQL for SEO Local table
814 *
815 * @since 1.0.0
816 *
817 * @param string $table_name Full table name
818 * @param string $charset_collate Charset and collation
819 * @return string SQL for table creation
820 */
821 private function get_seo_local_table_sql(string $table_name, string $charset_collate): string {
822 return "CREATE TABLE `{$table_name}` (
823 local_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
824 context_type varchar(50) NOT NULL DEFAULT 'site',
825 context_id bigint(20) unsigned NULL,
826 business_type varchar(100) NOT NULL DEFAULT 'LocalBusiness',
827 business_name varchar(255) NOT NULL,
828 business_address longtext NULL,
829 business_phone varchar(50) NULL,
830 business_email varchar(255) NULL,
831 business_website varchar(2048) NULL,
832 latitude decimal(10,8) NULL,
833 longitude decimal(11,8) NULL,
834 google_place_id varchar(255) NULL,
835 google_my_business_url varchar(2048) NULL,
836 business_hours longtext NULL,
837 nap_consistency_score int(11) NOT NULL DEFAULT 0,
838 local_seo_score int(11) NOT NULL DEFAULT 0,
839 is_verified tinyint(1) NOT NULL DEFAULT 0,
840 is_active tinyint(1) NOT NULL DEFAULT 1,
841 created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
842 updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
843 created_by bigint(20) unsigned NULL,
844 PRIMARY KEY (local_id),
845 UNIQUE KEY unique_business (context_type, context_id),
846 KEY idx_context (context_type, context_id),
847 KEY idx_type (business_type),
848 KEY idx_location (latitude, longitude),
849 KEY idx_verified (is_verified),
850 KEY idx_active (is_active),
851 KEY idx_nap_score (nap_consistency_score),
852 KEY idx_local_score (local_seo_score),
853 KEY idx_created (created_at)
854 ) {$charset_collate};";
855 }
856
857 /**
858 * Get SQL for AI Cache table
859 *
860 * @since 1.0.0
861 *
862 * @param string $table_name Full table name
863 * @param string $charset_collate Charset and collation
864 * @return string SQL for table creation
865 */
866 private function get_ai_cache_table_sql(string $table_name, string $charset_collate): string {
867 return "CREATE TABLE `{$table_name}` (
868 id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
869 cache_key varchar(255) NOT NULL,
870 cache_data longtext NOT NULL,
871 expires_at bigint(20) unsigned NOT NULL,
872 created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
873 PRIMARY KEY (id),
874 UNIQUE KEY cache_key (cache_key),
875 KEY expires_at_idx (expires_at),
876 KEY created_at_idx (created_at)
877 ) {$charset_collate};";
878 }
879
880 /**
881 * Get SQL for AI Usage table
882 *
883 * @since 1.0.0
884 *
885 * @param string $table_name Full table name
886 * @param string $charset_collate Charset and collation
887 * @return string SQL for table creation
888 */
889 private function get_ai_usage_table_sql(string $table_name, string $charset_collate): string {
890 return "CREATE TABLE `{$table_name}` (
891 id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
892 user_id bigint(20) unsigned NOT NULL,
893 action varchar(100) NOT NULL,
894 tokens_used int(11) NOT NULL DEFAULT 0,
895 provider varchar(50) NOT NULL,
896 post_id bigint(20) unsigned NULL,
897 metadata longtext NULL,
898 created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
899 PRIMARY KEY (id),
900 KEY user_id_idx (user_id),
901 KEY action_idx (action),
902 KEY created_at_idx (created_at),
903 KEY provider_idx (provider)
904 ) {$charset_collate};";
905 }
906
907 /**
908 * Get SQL for Content Briefs table
909 *
910 * @since 1.0.0
911 *
912 * @param string $table_name Full table name
913 * @param string $charset_collate Charset and collation
914 * @return string SQL for table creation
915 */
916 private function get_content_briefs_table_sql(string $table_name, string $charset_collate): string {
917 return "CREATE TABLE `{$table_name}` (
918 id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
919 user_id bigint(20) unsigned NOT NULL,
920 title varchar(255) NOT NULL,
921 target_keywords text NOT NULL,
922 content_type varchar(50) NOT NULL DEFAULT 'blog_post',
923 brief_data longtext NOT NULL,
924 parsing_status varchar(50) NOT NULL DEFAULT 'success',
925 created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
926 updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
927 PRIMARY KEY (id),
928 KEY user_id_idx (user_id),
929 KEY created_at_idx (created_at),
930 KEY content_type_idx (content_type),
931 KEY parsing_status_idx (parsing_status)
932 ) {$charset_collate};";
933 }
934
935 /**
936 * Get SQL for Instant Indexing Logs table
937 *
938 * @since 1.0.0
939 *
940 * @param string $table_name Full table name
941 * @param string $charset_collate Charset and collation
942 * @return string SQL for table creation
943 */
944 private function get_instant_indexing_logs_table_sql(string $table_name, string $charset_collate): string {
945 return "CREATE TABLE `{$table_name}` (
946 id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
947 url varchar(2048) NOT NULL,
948 status varchar(50) NOT NULL,
949 response_code int(11) NULL,
950 response_message text NULL,
951 created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
952 PRIMARY KEY (id),
953 KEY idx_status (status),
954 KEY idx_response_code (response_code),
955 KEY idx_created (created_at)
956 ) {$charset_collate};";
957 }
958
959 /**
960 * Get SQL for SEO Scores table
961 *
962 * @since 1.0.0
963 *
964 * @param string $table_name Full table name
965 * @param string $charset_collate Charset and collation
966 * @return string SQL for table creation
967 */
968 private function get_seo_scores_table_sql(string $table_name, string $charset_collate): string {
969 return "CREATE TABLE `{$table_name}` (
970 id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
971 post_id bigint(20) unsigned NOT NULL,
972 user_id bigint(20) unsigned NOT NULL,
973 overall_score int(11) NOT NULL,
974 score_breakdown longtext NOT NULL,
975 suggestions longtext NOT NULL,
976 grade varchar(2) NOT NULL,
977 readability_score varchar(100) DEFAULT NULL,
978 content_quality varchar(100) DEFAULT NULL,
979 algorithm_version varchar(20) NOT NULL DEFAULT '2024.1',
980 calculated_at datetime NOT NULL,
981 created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
982 PRIMARY KEY (id),
983 KEY post_id_idx (post_id),
984 KEY user_id_idx (user_id),
985 KEY created_at_idx (created_at),
986 KEY overall_score_idx (overall_score)
987 ) {$charset_collate};";
988 }
989
990 /**
991 * Get SQL for Email Report Logs table.
992 *
993 * Audit + dedupe log for scheduled SEO email reports. The
994 * `unique_send` constraint on (site_id, period_start, recipient_hash)
995 * is what prevents a given site from being sent the same period twice
996 * to the same recipient — required by the PRD.
997 *
998 * `recipient_hash` is a sha256 of the lowercased, sorted recipient list
999 * (so [a@x, b@x] and [b@x, a@x] dedupe to the same row).
1000 *
1001 * @since 1.9.0
1002 *
1003 * @param string $table_name Full table name.
1004 * @param string $charset_collate Charset and collation.
1005 * @return string SQL for table creation.
1006 */
1007 private function get_email_report_logs_table_sql(string $table_name, string $charset_collate): string {
1008 return "CREATE TABLE `{$table_name}` (
1009 id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
1010 site_id bigint(20) unsigned NOT NULL DEFAULT 0,
1011 period_start datetime NOT NULL,
1012 period_end datetime NOT NULL,
1013 recipient_hash char(64) NOT NULL,
1014 recipient_count smallint(5) unsigned NOT NULL DEFAULT 1,
1015 frequency_days smallint(5) unsigned NOT NULL DEFAULT 30,
1016 status varchar(20) NOT NULL DEFAULT 'pending',
1017 error_message text NULL,
1018 sent_at datetime NULL,
1019 created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
1020 PRIMARY KEY (id),
1021 UNIQUE KEY unique_send (site_id, period_start, recipient_hash),
1022 KEY idx_site (site_id),
1023 KEY idx_status (status),
1024 KEY idx_sent (sent_at),
1025 KEY idx_period (period_start)
1026 ) {$charset_collate};";
1027 }
1028
1029 /**
1030 * Create indexes for a specific table
1031 *
1032 * @since 1.0.0
1033 *
1034 * @param string $table_name Table name
1035 * @return bool Success status
1036 */
1037 private function create_table_indexes(string $table_name): bool {
1038 $full_table_name = $this->get_table_name($table_name);
1039 $definition = $this->table_definitions[$table_name] ?? [];
1040
1041 $success = true;
1042
1043 // Create single column indexes
1044 if (!empty($definition['indexes'])) {
1045 foreach ($definition['indexes'] as $index_name) {
1046 try {
1047 // Check if index already exists
1048 if ($this->index_exists($full_table_name, $index_name)) {
1049 continue;
1050 }
1051
1052 $index_sql = $this->get_index_sql($full_table_name, $index_name);
1053 if ($index_sql) {
1054 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange,WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Index creation requires direct schema changes, DDL cannot be prepared
1055 $result = $this->wpdb->query($index_sql);
1056 if (false === $result) {
1057 $success = false;
1058 }
1059 }
1060 } catch (\Exception $e) {
1061 $success = false;
1062 }
1063 }
1064 }
1065
1066 // Create composite indexes for performance optimization
1067 if (!empty($definition['composite_indexes'])) {
1068 foreach ($definition['composite_indexes'] as $index_name => $columns) {
1069 try {
1070 // Check if index already exists
1071 if ($this->index_exists($full_table_name, "idx_{$index_name}")) {
1072 continue;
1073 }
1074
1075 $index_sql = $this->get_composite_index_sql($full_table_name, $index_name, $columns);
1076 if ($index_sql) {
1077 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange,WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Composite index creation requires direct schema changes, DDL cannot be prepared
1078 $result = $this->wpdb->query($index_sql);
1079 if (false === $result) {
1080 $success = false;
1081 }
1082 }
1083 } catch (\Exception $e) {
1084 $success = false;
1085 }
1086 }
1087 }
1088
1089 return $success;
1090 }
1091
1092 /**
1093 * Add constraints for a specific table
1094 *
1095 * @since 1.0.0
1096 *
1097 * @param string $table_name Table name
1098 * @return bool Success status
1099 */
1100 private function add_table_constraints(string $table_name): bool {
1101 $full_table_name = $this->get_table_name($table_name);
1102 $definition = $this->table_definitions[$table_name] ?? [];
1103
1104 if (empty($definition['foreign_keys'])) {
1105 return true;
1106 }
1107
1108 $success = true;
1109 foreach ($definition['foreign_keys'] as $constraint) {
1110 try {
1111 $constraint_sql = $this->get_constraint_sql($full_table_name, $constraint);
1112 if ($constraint_sql) {
1113 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange,WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Constraint creation requires direct schema changes, DDL cannot be prepared
1114 $result = $this->wpdb->query($constraint_sql);
1115 if (false === $result) {
1116 $success = false;
1117 }
1118 }
1119 } catch (\Exception $e) {
1120 $success = false;
1121 }
1122 }
1123
1124 return $success;
1125 }
1126
1127 /**
1128 * Get index SQL for a table
1129 *
1130 * @since 1.0.0
1131 *
1132 * @param string $table_name Full table name
1133 * @param string $index_name Index name
1134 * @return string Index SQL
1135 */
1136 private function get_index_sql(string $table_name, string $index_name): string {
1137 // Most indexes are already created in the table definition
1138 // This method is for additional indexes if needed
1139 return '';
1140 }
1141
1142 /**
1143 * Get composite index SQL for performance optimization
1144 *
1145 * @since 1.0.0
1146 *
1147 * @param string $table_name Full table name
1148 * @param string $index_name Index name
1149 * @param array $columns Column names for composite index
1150 * @return string Composite index SQL
1151 */
1152 private function get_composite_index_sql(string $table_name, string $index_name, array $columns): string {
1153 if (empty($columns)) {
1154 return '';
1155 }
1156
1157 // Escape column names
1158 $escaped_columns = array_map(function ($column) {
1159 return "`{$column}`";
1160 }, $columns);
1161
1162 $columns_sql = implode(', ', $escaped_columns);
1163 $index_name_escaped = esc_sql($index_name);
1164
1165 return "CREATE INDEX `idx_{$index_name_escaped}` ON `{$table_name}` ({$columns_sql})";
1166 }
1167
1168 /**
1169 * Get constraint SQL for a table
1170 *
1171 * @since 1.0.0
1172 *
1173 * @param string $table_name Full table name
1174 * @param array $constraint Constraint definition
1175 * @return string Constraint SQL
1176 */
1177 private function get_constraint_sql(string $table_name, array $constraint): string {
1178 // Foreign key constraints would be defined here
1179 // Currently not implemented as tables are designed to be independent
1180 return '';
1181 }
1182
1183 /**
1184 * Get table information
1185 *
1186 * @since 1.0.0
1187 *
1188 * @param string $table_name Full table name
1189 * @return array Table information
1190 */
1191 private function get_table_info(string $table_name): array {
1192 $info = [
1193 'exists' => false,
1194 'row_count' => 0,
1195 'data_size' => 0,
1196 'index_size' => 0,
1197 'total_size' => 0,
1198 'created' => null,
1199 'updated' => null
1200 ];
1201
1202 if (!$this->table_exists($table_name)) {
1203 return $info;
1204 }
1205
1206 $info['exists'] = true;
1207
1208 // Get row count
1209 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table statistics require direct database access, table name cannot be prepared, table name is validated
1210 $row_count = $this->wpdb->get_var("SELECT COUNT(*) FROM `{$table_name}`");
1211 $info['row_count'] = (int) $row_count;
1212
1213 // Get table size information
1214 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table size information requires direct database access
1215 $size_info = $this->wpdb->get_row(
1216 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
1217 $this->wpdb->prepare(
1218 "SELECT
1219 data_length as data_size,
1220 index_length as index_size,
1221 (data_length + index_length) as total_size,
1222 create_time as created,
1223 update_time as updated
1224 FROM information_schema.TABLES
1225 WHERE table_schema = %s AND table_name = %s",
1226 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- DB_NAME is a WordPress constant, safe to use
1227 DB_NAME,
1228 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table_name is validated and used as parameter
1229 $table_name
1230 ),
1231 ARRAY_A
1232 );
1233
1234 if ($size_info) {
1235 $info['data_size'] = (int) $size_info['data_size'];
1236 $info['index_size'] = (int) $size_info['index_size'];
1237 $info['total_size'] = (int) $size_info['total_size'];
1238 $info['created'] = $size_info['created'];
1239 $info['updated'] = $size_info['updated'];
1240 }
1241
1242 return $info;
1243 }
1244
1245 /**
1246 * Get table size in bytes
1247 *
1248 * @since 1.0.0
1249 *
1250 * @param string $table_name Full table name
1251 * @return int Table size in bytes
1252 */
1253 private function get_table_size(string $table_name): int {
1254 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table size calculation requires direct database access
1255 $size = $this->wpdb->get_var(
1256 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
1257 $this->wpdb->prepare(
1258 "SELECT (data_length + index_length) as total_size
1259 FROM information_schema.TABLES
1260 WHERE table_schema = %s AND table_name = %s",
1261 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- DB_NAME is a WordPress constant, safe to use
1262 DB_NAME,
1263 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table_name is validated and used as parameter
1264 $table_name
1265 )
1266 );
1267
1268 return (int) $size;
1269 }
1270
1271 /**
1272 * Get tables by category for better organization
1273 *
1274 * @since 1.0.0
1275 *
1276 * @param string $category Category name (seo, ai, content, scoring)
1277 * @return array Table names in the category
1278 */
1279 public function get_tables_by_category(string $category): array {
1280 return $this->table_categories[$category] ?? [];
1281 }
1282
1283 /**
1284 * Get all table categories
1285 *
1286 * @since 1.0.0
1287 *
1288 * @return array All table categories with their tables
1289 */
1290 public function get_table_categories(): array {
1291 return $this->table_categories;
1292 }
1293
1294 /**
1295 * Get table count by category
1296 *
1297 * @since 1.0.0
1298 *
1299 * @return array Table counts per category
1300 */
1301 public function get_table_count_by_category(): array {
1302 $counts = [];
1303 foreach ($this->table_categories as $category => $tables) {
1304 $counts[$category] = count($tables);
1305 }
1306 $counts['total'] = count($this->table_definitions);
1307 return $counts;
1308 }
1309
1310 /**
1311 * Validate table definition structure
1312 *
1313 * @since 1.0.0
1314 *
1315 * @param string $table_name Table name to validate
1316 * @return array Validation results
1317 */
1318 public function validate_table_definition(string $table_name): array {
1319 $definition = $this->table_definitions[$table_name] ?? null;
1320
1321 if (!$definition) {
1322 return [
1323 'valid' => false,
1324 'errors' => ["Table definition not found: {$table_name}"]
1325 ];
1326 }
1327
1328 $errors = [];
1329 $required_keys = ['description', 'primary_key', 'indexes', 'foreign_keys'];
1330
1331 foreach ($required_keys as $key) {
1332 if (!isset($definition[$key])) {
1333 $errors[] = "Missing required key '{$key}' in table definition for {$table_name}";
1334 }
1335 }
1336
1337 return [
1338 'valid' => empty($errors),
1339 'errors' => $errors
1340 ];
1341 }
1342
1343 /**
1344 * Add composite indexes to existing tables for performance optimization
1345 *
1346 * @since 1.0.0
1347 *
1348 * @return bool Success status
1349 */
1350 public function add_performance_indexes(): bool {
1351 $success = true;
1352
1353 foreach ($this->table_definitions as $table_name => $definition) {
1354 if (!empty($definition['composite_indexes'])) {
1355 $full_table_name = $this->get_table_name($table_name);
1356
1357 // Check if table exists before adding indexes
1358 if (!$this->table_exists($full_table_name)) {
1359 continue;
1360 }
1361
1362 foreach ($definition['composite_indexes'] as $index_name => $columns) {
1363 try {
1364 // Check if index already exists
1365 if ($this->index_exists($full_table_name, "idx_{$index_name}")) {
1366 continue;
1367 }
1368
1369 $index_sql = $this->get_composite_index_sql($full_table_name, $index_name, $columns);
1370 if ($index_sql) {
1371 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange,WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Performance index creation requires direct schema changes, DDL cannot be prepared
1372 $result = $this->wpdb->query($index_sql);
1373 if (false === $result) {
1374 $success = false;
1375 // Index creation failed - logged in database operations
1376 }
1377 }
1378 } catch (\Exception $e) {
1379 $success = false;
1380 // Exception during index creation - logged in database operations
1381 }
1382 }
1383 }
1384 }
1385
1386 return $success;
1387 }
1388
1389 /**
1390 * Check if an index exists on a table
1391 *
1392 * @since 1.0.0
1393 *
1394 * @param string $table_name Full table name
1395 * @param string $index_name Index name
1396 * @return bool Whether index exists
1397 */
1398 private function index_exists(string $table_name, string $index_name): bool {
1399 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Index existence check requires direct database access
1400 $result = $this->wpdb->get_var(
1401 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
1402 $this->wpdb->prepare(
1403 "SELECT COUNT(*) FROM information_schema.statistics
1404 WHERE table_schema = %s AND table_name = %s AND index_name = %s",
1405 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- DB_NAME is a WordPress constant, safe to use
1406 DB_NAME,
1407 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table_name is validated and used as parameter
1408 $table_name,
1409 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $index_name is validated and used as parameter
1410 $index_name
1411 )
1412 );
1413
1414 return (int) $result > 0;
1415 }
1416
1417 /**
1418 * Check if MySQL supports JSON column type with caching
1419 *
1420 * Uses WordPress's built-in database version detection and caches the result
1421 * to avoid repeated database queries during schema creation.
1422 *
1423 * @since 1.0.0
1424 * @return bool True if MySQL 5.7+ supports JSON columns
1425 */
1426 private function get_mysql_json_support(): bool {
1427 // Check if we have cached result
1428 static $json_support = null;
1429
1430 if ($json_support !== null) {
1431 return $json_support;
1432 }
1433
1434 // Use WordPress's built-in database version method
1435 global $wpdb;
1436
1437 // Get MySQL version using WordPress method (safer than direct query)
1438 $mysql_version = $wpdb->db_version();
1439
1440 // Cache the result for subsequent calls
1441 $json_support = version_compare($mysql_version, '5.7.0', '>=');
1442
1443 return $json_support;
1444 }
1445
1446 /**
1447 * Get SQL for the AI traffic table.
1448 *
1449 * Daily aggregate counters only — one row per (day, kind, source, path).
1450 * `kind` is 'referral' (human visit from an AI platform), 'crawler' (AI
1451 * bot user-agent), or 'baseline' (all human pageviews, for the share-of-
1452 * traffic figure). No IPs, no user agents, no per-visit rows: aggregates
1453 * keep the table small and the feature privacy-clean.
1454 *
1455 * @since 1.27.0
1456 *
1457 * @param string $table_name Full table name
1458 * @param string $charset_collate Charset and collation
1459 * @return string SQL for table creation
1460 */
1461 private function get_ai_traffic_table_sql(string $table_name, string $charset_collate): string {
1462 return "CREATE TABLE `{$table_name}` (
1463 id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
1464 day date NOT NULL,
1465 kind varchar(12) NOT NULL,
1466 source varchar(40) NOT NULL DEFAULT '',
1467 path varchar(191) NOT NULL DEFAULT '',
1468 hits bigint(20) unsigned NOT NULL DEFAULT 1,
1469 PRIMARY KEY (id),
1470 UNIQUE KEY uniq_bucket (day, kind, source, path),
1471 KEY idx_day (day),
1472 KEY idx_kind (kind)
1473 ) {$charset_collate};";
1474 }
1475
1476 /**
1477 * Get SQL for the brand visibility checks table.
1478 *
1479 * One row per (query, check run): whether the AI provider's answer
1480 * mentioned the brand and/or cited the site's domain, plus a short
1481 * excerpt for context.
1482 *
1483 * @since 1.27.0
1484 *
1485 * @param string $table_name Full table name
1486 * @param string $charset_collate Charset and collation
1487 * @return string SQL for table creation
1488 */
1489 private function get_brand_visibility_checks_table_sql(string $table_name, string $charset_collate): string {
1490 return "CREATE TABLE `{$table_name}` (
1491 id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
1492 checked_at datetime NOT NULL,
1493 query_text varchar(191) NOT NULL,
1494 provider varchar(20) NOT NULL DEFAULT '',
1495 model varchar(80) NOT NULL DEFAULT '',
1496 mentioned tinyint(1) NOT NULL DEFAULT 0,
1497 cited tinyint(1) NOT NULL DEFAULT 0,
1498 excerpt text NULL,
1499 answer longtext NULL,
1500 PRIMARY KEY (id),
1501 KEY idx_checked (checked_at),
1502 KEY idx_query (query_text)
1503 ) {$charset_collate};";
1504 }
1505
1506 /**
1507 * Brand Visibility v2 — analysis runs.
1508 *
1509 * One row per "Run analysis". `config` snapshots the brand profile,
1510 * competitors, queries and platforms the run was started with, so a run's
1511 * results stay interpretable after the user edits their setup. `results`
1512 * holds the computed aggregates (index, mention rate, share of voice,
1513 * per-platform and per-query breakdowns) written once by the finalizer.
1514 *
1515 * @since 1.28.0
1516 *
1517 * @param string $table_name Full table name.
1518 * @param string $charset_collate Charset/collation clause.
1519 * @return string CREATE TABLE statement.
1520 */
1521 private function get_bv_runs_table_sql(string $table_name, string $charset_collate): string {
1522 return "CREATE TABLE `{$table_name}` (
1523 id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
1524 status varchar(20) NOT NULL DEFAULT 'queued',
1525 started_at datetime NOT NULL,
1526 finished_at datetime NULL,
1527 tasks_total int(11) NOT NULL DEFAULT 0,
1528 tasks_done int(11) NOT NULL DEFAULT 0,
1529 tasks_failed int(11) NOT NULL DEFAULT 0,
1530 config longtext NULL,
1531 results longtext NULL,
1532 error text NULL,
1533 PRIMARY KEY (id),
1534 KEY idx_status (status),
1535 KEY idx_started (started_at)
1536 ) {$charset_collate};";
1537 }
1538
1539 /**
1540 * Brand Visibility v2 — individual probe tasks.
1541 *
1542 * One row per (query x platform x sample). Sampling is the whole point:
1543 * a single LLM answer is noise, so a mention rate is only meaningful as
1544 * mentions/samples. Rows are processed off-request by cron ticks, which is
1545 * what keeps a 100+ call run from timing out a REST request, and what lets
1546 * an interrupted run resume instead of restarting.
1547 *
1548 * @since 1.28.0
1549 *
1550 * @param string $table_name Full table name.
1551 * @param string $charset_collate Charset/collation clause.
1552 * @return string CREATE TABLE statement.
1553 */
1554 private function get_bv_tasks_table_sql(string $table_name, string $charset_collate): string {
1555 return "CREATE TABLE `{$table_name}` (
1556 id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
1557 run_id bigint(20) unsigned NOT NULL,
1558 query_text varchar(500) NOT NULL,
1559 query_type varchar(20) NOT NULL DEFAULT 'branded',
1560 platform varchar(20) NOT NULL DEFAULT '',
1561 sample_index tinyint(3) unsigned NOT NULL DEFAULT 0,
1562 status varchar(20) NOT NULL DEFAULT 'pending',
1563 attempts tinyint(3) unsigned NOT NULL DEFAULT 0,
1564 mentioned tinyint(1) NOT NULL DEFAULT 0,
1565 cited tinyint(1) NOT NULL DEFAULT 0,
1566 sentiment varchar(10) NOT NULL DEFAULT '',
1567 competitors text NULL,
1568 excerpt text NULL,
1569 answer longtext NULL,
1570 error text NULL,
1571 updated_at datetime NULL,
1572 PRIMARY KEY (id),
1573 KEY idx_run (run_id),
1574 KEY idx_status (status),
1575 KEY run_status (run_id, status)
1576 ) {$charset_collate};";
1577 }
1578 }
1579