PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.29.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.29.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.29.0, at includes/database/class-database-schema.php

1,585 lines 62.3 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 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- table name is $wpdb->prefix plus a literal, and every value is passed as a placeholder replacement.
403 $this->wpdb->prepare(
404 'SHOW TABLES LIKE %s',
405 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- table name is $wpdb->prefix plus a literal, and every value is passed as a placeholder replacement.
406 $this->wpdb->esc_like($this->wpdb->prefix . 'thinkrank_') . '%'
407 )
408 );
409 // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
410 // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
411
412 $missing = array_values(array_diff($expected, $existing));
413
414 if (empty($missing)) {
415 set_transient(self::TABLES_VERIFIED_TRANSIENT, $this->db_version, HOUR_IN_SECONDS);
416 }
417
418 return $missing;
419 }
420
421 /**
422 * The schema version this plugin build expects (the target of needs_update).
423 *
424 * @since 1.23.0
425 *
426 * @return string Expected schema version, e.g. "1.2.0".
427 */
428 public function get_schema_version(): string {
429 return $this->db_version;
430 }
431
432 /**
433 * Get database status and information
434 *
435 * @since 1.0.0
436 *
437 * @return array Database status information
438 */
439 public function get_database_status(): array {
440 $status = [
441 'version' => get_option('thinkrank_seo_db_version', 'Not installed'),
442 'created_at' => get_option('thinkrank_seo_db_created', 'Unknown'),
443 'tables' => [],
444 'total_records' => 0,
445 'database_size' => 0,
446 'needs_update' => $this->needs_update()
447 ];
448
449 foreach (array_keys($this->table_definitions) as $table_name) {
450 $full_table_name = $this->get_table_name($table_name);
451 $table_info = $this->get_table_info($full_table_name);
452
453 $status['tables'][$table_name] = $table_info;
454 $status['total_records'] += $table_info['row_count'];
455 $status['database_size'] += $table_info['data_size'];
456 }
457
458 return $status;
459 }
460
461 /**
462 * Optimize all database tables
463 *
464 * @since 1.0.0
465 *
466 * @return array Optimization results
467 */
468 public function optimize_tables(): array {
469 $results = [
470 'success' => true,
471 'tables_optimized' => [],
472 'tables_failed' => [],
473 'space_saved' => 0,
474 'errors' => []
475 ];
476
477 foreach (array_keys($this->table_definitions) as $table_name) {
478 try {
479 $full_table_name = $this->get_table_name($table_name);
480
481 // Get table size before optimization
482 $size_before = $this->get_table_size($full_table_name);
483
484 // Optimize table
485 // 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
486 $result = $this->wpdb->query("OPTIMIZE TABLE `{$full_table_name}`");
487
488 if ($result !== false) {
489 $size_after = $this->get_table_size($full_table_name);
490 $space_saved = $size_before - $size_after;
491
492 $results['tables_optimized'][] = [
493 'table' => $full_table_name,
494 'space_saved' => $space_saved
495 ];
496 $results['space_saved'] += $space_saved;
497 } else {
498 $results['tables_failed'][] = $full_table_name;
499 $results['errors'][] = "Failed to optimize table: {$full_table_name}";
500 $results['success'] = false;
501 }
502 } catch (\Exception $e) {
503 $results['tables_failed'][] = $this->get_table_name($table_name);
504 $results['errors'][] = "Error optimizing {$table_name}: " . $e->getMessage();
505 $results['success'] = false;
506 }
507 }
508
509 return $results;
510 }
511
512 /**
513 * Get full table name with WordPress prefix
514 *
515 * @since 1.0.0
516 *
517 * @param string $table_name Base table name
518 * @return string Full table name with prefix
519 */
520 private function get_table_name(string $table_name): string {
521 return $this->wpdb->prefix . 'thinkrank_' . $table_name;
522 }
523
524 /**
525 * Check if table exists
526 *
527 * @since 1.0.0
528 *
529 * @param string $table_name Full table name
530 * @return bool True if table exists
531 */
532 private function table_exists(string $table_name): bool {
533 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema validation requires direct database access
534 $result = $this->wpdb->get_var(
535 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
536 $this->wpdb->prepare("SHOW TABLES LIKE %s", $table_name)
537 );
538
539 return $result === $table_name;
540 }
541
542 /**
543 * Get SQL for creating a specific table
544 *
545 * @since 1.0.0
546 *
547 * @param string $table_name Table name
548 * @return string SQL for table creation
549 *
550 * @throws \InvalidArgumentException On failure.
551 */
552 private function get_table_sql(string $table_name): string {
553 $full_table_name = $this->get_table_name($table_name);
554 $charset_collate = "DEFAULT CHARACTER SET {$this->db_config['charset']} COLLATE {$this->db_config['collate']}";
555
556 switch ($table_name) {
557 // SEO Tables
558 case 'seo_settings':
559 return $this->get_seo_settings_table_sql($full_table_name, $charset_collate);
560 case 'seo_analysis':
561 return $this->get_seo_analysis_table_sql($full_table_name, $charset_collate);
562 case 'seo_keywords':
563 return $this->get_seo_keywords_table_sql($full_table_name, $charset_collate);
564 case 'seo_schema':
565 return $this->get_seo_schema_table_sql($full_table_name, $charset_collate);
566 case 'seo_social':
567 return $this->get_seo_social_table_sql($full_table_name, $charset_collate);
568 case 'seo_performance':
569 return $this->get_seo_performance_table_sql($full_table_name, $charset_collate);
570 case 'seo_local':
571 return $this->get_seo_local_table_sql($full_table_name, $charset_collate);
572
573 // AI/Core Tables
574 case 'ai_cache':
575 return $this->get_ai_cache_table_sql($full_table_name, $charset_collate);
576 case 'ai_usage':
577 return $this->get_ai_usage_table_sql($full_table_name, $charset_collate);
578 case 'content_briefs':
579 return $this->get_content_briefs_table_sql($full_table_name, $charset_collate);
580 case 'seo_scores':
581 return $this->get_seo_scores_table_sql($full_table_name, $charset_collate);
582 case 'instant_indexing_logs':
583 return $this->get_instant_indexing_logs_table_sql($full_table_name, $charset_collate);
584 case 'email_report_logs':
585 return $this->get_email_report_logs_table_sql($full_table_name, $charset_collate);
586
587 // AI Visibility Tables
588 case 'ai_traffic':
589 return $this->get_ai_traffic_table_sql($full_table_name, $charset_collate);
590 case 'bv_runs':
591 return $this->get_bv_runs_table_sql($full_table_name, $charset_collate);
592 case 'bv_tasks':
593 return $this->get_bv_tasks_table_sql($full_table_name, $charset_collate);
594 case 'brand_visibility_checks':
595 return $this->get_brand_visibility_checks_table_sql($full_table_name, $charset_collate);
596
597 default:
598 throw new \InvalidArgumentException('Unknown table: ' . esc_html($table_name));
599 }
600 }
601
602 /**
603 * Get SQL for SEO Settings table
604 *
605 * @since 1.0.0
606 *
607 * @param string $table_name Full table name
608 * @param string $charset_collate Charset and collation
609 * @return string SQL for table creation
610 */
611 private function get_seo_settings_table_sql(string $table_name, string $charset_collate): string {
612 return "CREATE TABLE `{$table_name}` (
613 setting_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
614 context_type varchar(50) NOT NULL DEFAULT 'site',
615 context_id bigint(20) unsigned NULL,
616 setting_category varchar(100) NOT NULL DEFAULT 'general',
617 setting_key varchar(255) NOT NULL,
618 setting_value longtext NULL,
619 setting_type varchar(50) NOT NULL DEFAULT 'string',
620 is_active tinyint(1) NOT NULL DEFAULT 1,
621 priority int(11) NOT NULL DEFAULT 0,
622 created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
623 updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
624 created_by bigint(20) unsigned NULL,
625 updated_by bigint(20) unsigned NULL,
626 PRIMARY KEY (setting_id),
627 UNIQUE KEY unique_setting (context_type, context_id, setting_category, setting_key),
628 KEY idx_context (context_type, context_id),
629 KEY idx_category (setting_category),
630 KEY idx_active (is_active),
631 KEY idx_created (created_at),
632 KEY idx_updated (updated_at),
633 KEY idx_context_cat_active (context_type, context_id, setting_category, is_active)
634 ) {$charset_collate};";
635 }
636
637 /**
638 * Get SQL for SEO Analysis table
639 *
640 * @since 1.0.0
641 *
642 * @param string $table_name Full table name
643 * @param string $charset_collate Charset and collation
644 * @return string SQL for table creation
645 */
646 private function get_seo_analysis_table_sql(string $table_name, string $charset_collate): string {
647 return "CREATE TABLE `{$table_name}` (
648 analysis_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
649 context_type varchar(50) NOT NULL DEFAULT 'site',
650 context_id bigint(20) unsigned NULL,
651 analysis_type varchar(100) NOT NULL,
652 analysis_data longtext NULL,
653 score int(11) NOT NULL DEFAULT 0,
654 status varchar(50) NOT NULL DEFAULT 'pending',
655 ai_confidence decimal(3,2) NULL,
656 recommendations longtext NULL,
657 validation_errors longtext NULL,
658 created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
659 updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
660 analyzed_by bigint(20) unsigned NULL,
661 PRIMARY KEY (analysis_id),
662 KEY idx_context (context_type, context_id),
663 KEY idx_type (analysis_type),
664 KEY idx_status (status),
665 KEY idx_score (score),
666 KEY idx_created (created_at),
667 KEY idx_confidence (ai_confidence)
668 ) {$charset_collate};";
669 }
670
671 /**
672 * Get SQL for SEO Keywords table
673 *
674 * @since 1.0.0
675 *
676 * @param string $table_name Full table name
677 * @param string $charset_collate Charset and collation
678 * @return string SQL for table creation
679 */
680 private function get_seo_keywords_table_sql(string $table_name, string $charset_collate): string {
681 return "CREATE TABLE `{$table_name}` (
682 keyword_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
683 context_type varchar(50) NOT NULL DEFAULT 'site',
684 context_id bigint(20) unsigned NULL,
685 keyword_text varchar(500) NOT NULL,
686 keyword_hash varchar(64) NOT NULL,
687 keyword_type varchar(50) NOT NULL DEFAULT 'primary',
688 search_volume int(11) NULL,
689 competition_score decimal(3,2) NULL,
690 difficulty_score decimal(3,2) NULL,
691 density decimal(5,2) NULL,
692 position int(11) NULL,
693 ranking_url varchar(2048) NULL,
694 is_tracking tinyint(1) NOT NULL DEFAULT 0,
695 is_active tinyint(1) NOT NULL DEFAULT 1,
696 created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
697 updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
698 tracked_by bigint(20) unsigned NULL,
699 PRIMARY KEY (keyword_id),
700 UNIQUE KEY unique_keyword (context_type, context_id, keyword_hash),
701 KEY idx_context (context_type, context_id),
702 KEY idx_type (keyword_type),
703 KEY idx_hash (keyword_hash),
704 KEY idx_tracking (is_tracking),
705 KEY idx_active (is_active),
706 KEY idx_position (position),
707 KEY idx_created (created_at),
708 FULLTEXT KEY ft_keyword (keyword_text)
709 ) {$charset_collate};";
710 }
711
712 /**
713 * Get SQL for SEO Schema table (Optimized Version 2.0)
714 *
715 * @since 1.0.0
716 * @updated 2.0.0 - Optimized structure with fewer columns and better indexes
717 *
718 * @param string $table_name Full table name
719 * @param string $charset_collate Charset and collation
720 * @return string SQL for table creation
721 */
722 private function get_seo_schema_table_sql(string $table_name, string $charset_collate): string {
723 // Check MySQL version for JSON column support with caching
724 $schema_data_type = $this->get_mysql_json_support() ? 'JSON' : 'longtext';
725
726 return "CREATE TABLE `{$table_name}` (
727 schema_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
728 context_type varchar(50) NOT NULL DEFAULT 'site',
729 context_id bigint(20) unsigned NULL,
730 schema_type varchar(100) NOT NULL,
731 schema_data {$schema_data_type} NOT NULL,
732 validation_status varchar(50) NOT NULL DEFAULT 'pending',
733 is_active tinyint(1) NOT NULL DEFAULT 1,
734 created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
735 updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
736 PRIMARY KEY (schema_id),
737 KEY idx_context_active (context_type, context_id, is_active),
738 KEY idx_type_active (schema_type, is_active),
739 KEY idx_created (created_at),
740 KEY idx_context_schema_active (context_type, schema_type, is_active, created_at DESC),
741 KEY idx_validation_active (validation_status, is_active)
742 ) {$charset_collate};";
743 }
744
745 /**
746 * Get SQL for SEO Social table
747 *
748 * @since 1.0.0
749 *
750 * @param string $table_name Full table name
751 * @param string $charset_collate Charset and collation
752 * @return string SQL for table creation
753 */
754 private function get_seo_social_table_sql(string $table_name, string $charset_collate): string {
755 return "CREATE TABLE `{$table_name}` (
756 social_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
757 context_type varchar(50) NOT NULL DEFAULT 'site',
758 context_id bigint(20) unsigned NULL,
759 platform varchar(50) NOT NULL,
760 meta_type varchar(100) NOT NULL,
761 meta_key varchar(255) NOT NULL,
762 meta_value longtext NULL,
763 image_url varchar(2048) NULL,
764 image_width int(11) NULL,
765 image_height int(11) NULL,
766 is_optimized tinyint(1) NOT NULL DEFAULT 0,
767 is_active tinyint(1) NOT NULL DEFAULT 1,
768 created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
769 updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
770 created_by bigint(20) unsigned NULL,
771 PRIMARY KEY (social_id),
772 UNIQUE KEY unique_social_meta (context_type, context_id, platform, meta_key),
773 KEY idx_context (context_type, context_id),
774 KEY idx_platform (platform),
775 KEY idx_type (meta_type),
776 KEY idx_optimized (is_optimized),
777 KEY idx_active (is_active),
778 KEY idx_created (created_at)
779 ) {$charset_collate};";
780 }
781
782 /**
783 * Get SQL for SEO Performance table
784 *
785 * @since 1.0.0
786 *
787 * @param string $table_name Full table name
788 * @param string $charset_collate Charset and collation
789 * @return string SQL for table creation
790 */
791 private function get_seo_performance_table_sql(string $table_name, string $charset_collate): string {
792 return "CREATE TABLE `{$table_name}` (
793 performance_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
794 context_type varchar(50) NOT NULL DEFAULT 'site',
795 context_id bigint(20) unsigned NULL,
796 metric_type varchar(100) NOT NULL,
797 metric_value decimal(10,4) NOT NULL,
798 metric_unit varchar(50) NOT NULL DEFAULT 'score',
799 threshold_good decimal(10,4) NULL,
800 threshold_poor decimal(10,4) NULL,
801 status varchar(50) NOT NULL DEFAULT 'unknown',
802 device_type varchar(20) NOT NULL DEFAULT 'desktop',
803 connection_type varchar(50) NULL,
804 measured_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
805 created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
806 measured_by varchar(100) NULL,
807 PRIMARY KEY (performance_id),
808 KEY idx_context (context_type, context_id),
809 KEY idx_metric (metric_type),
810 KEY idx_status (status),
811 KEY idx_device (device_type),
812 KEY idx_measured (measured_at),
813 KEY idx_created (created_at),
814 KEY idx_value (metric_value)
815 ) {$charset_collate};";
816 }
817
818 /**
819 * Get SQL for SEO Local table
820 *
821 * @since 1.0.0
822 *
823 * @param string $table_name Full table name
824 * @param string $charset_collate Charset and collation
825 * @return string SQL for table creation
826 */
827 private function get_seo_local_table_sql(string $table_name, string $charset_collate): string {
828 return "CREATE TABLE `{$table_name}` (
829 local_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
830 context_type varchar(50) NOT NULL DEFAULT 'site',
831 context_id bigint(20) unsigned NULL,
832 business_type varchar(100) NOT NULL DEFAULT 'LocalBusiness',
833 business_name varchar(255) NOT NULL,
834 business_address longtext NULL,
835 business_phone varchar(50) NULL,
836 business_email varchar(255) NULL,
837 business_website varchar(2048) NULL,
838 latitude decimal(10,8) NULL,
839 longitude decimal(11,8) NULL,
840 google_place_id varchar(255) NULL,
841 google_my_business_url varchar(2048) NULL,
842 business_hours longtext NULL,
843 nap_consistency_score int(11) NOT NULL DEFAULT 0,
844 local_seo_score int(11) NOT NULL DEFAULT 0,
845 is_verified tinyint(1) NOT NULL DEFAULT 0,
846 is_active tinyint(1) NOT NULL DEFAULT 1,
847 created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
848 updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
849 created_by bigint(20) unsigned NULL,
850 PRIMARY KEY (local_id),
851 UNIQUE KEY unique_business (context_type, context_id),
852 KEY idx_context (context_type, context_id),
853 KEY idx_type (business_type),
854 KEY idx_location (latitude, longitude),
855 KEY idx_verified (is_verified),
856 KEY idx_active (is_active),
857 KEY idx_nap_score (nap_consistency_score),
858 KEY idx_local_score (local_seo_score),
859 KEY idx_created (created_at)
860 ) {$charset_collate};";
861 }
862
863 /**
864 * Get SQL for AI Cache table
865 *
866 * @since 1.0.0
867 *
868 * @param string $table_name Full table name
869 * @param string $charset_collate Charset and collation
870 * @return string SQL for table creation
871 */
872 private function get_ai_cache_table_sql(string $table_name, string $charset_collate): string {
873 return "CREATE TABLE `{$table_name}` (
874 id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
875 cache_key varchar(255) NOT NULL,
876 cache_data longtext NOT NULL,
877 expires_at bigint(20) unsigned NOT NULL,
878 created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
879 PRIMARY KEY (id),
880 UNIQUE KEY cache_key (cache_key),
881 KEY expires_at_idx (expires_at),
882 KEY created_at_idx (created_at)
883 ) {$charset_collate};";
884 }
885
886 /**
887 * Get SQL for AI Usage table
888 *
889 * @since 1.0.0
890 *
891 * @param string $table_name Full table name
892 * @param string $charset_collate Charset and collation
893 * @return string SQL for table creation
894 */
895 private function get_ai_usage_table_sql(string $table_name, string $charset_collate): string {
896 return "CREATE TABLE `{$table_name}` (
897 id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
898 user_id bigint(20) unsigned NOT NULL,
899 action varchar(100) NOT NULL,
900 tokens_used int(11) NOT NULL DEFAULT 0,
901 provider varchar(50) NOT NULL,
902 post_id bigint(20) unsigned NULL,
903 metadata longtext NULL,
904 created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
905 PRIMARY KEY (id),
906 KEY user_id_idx (user_id),
907 KEY action_idx (action),
908 KEY created_at_idx (created_at),
909 KEY provider_idx (provider)
910 ) {$charset_collate};";
911 }
912
913 /**
914 * Get SQL for Content Briefs table
915 *
916 * @since 1.0.0
917 *
918 * @param string $table_name Full table name
919 * @param string $charset_collate Charset and collation
920 * @return string SQL for table creation
921 */
922 private function get_content_briefs_table_sql(string $table_name, string $charset_collate): string {
923 return "CREATE TABLE `{$table_name}` (
924 id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
925 user_id bigint(20) unsigned NOT NULL,
926 title varchar(255) NOT NULL,
927 target_keywords text NOT NULL,
928 content_type varchar(50) NOT NULL DEFAULT 'blog_post',
929 brief_data longtext NOT NULL,
930 parsing_status varchar(50) NOT NULL DEFAULT 'success',
931 created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
932 updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
933 PRIMARY KEY (id),
934 KEY user_id_idx (user_id),
935 KEY created_at_idx (created_at),
936 KEY content_type_idx (content_type),
937 KEY parsing_status_idx (parsing_status)
938 ) {$charset_collate};";
939 }
940
941 /**
942 * Get SQL for Instant Indexing Logs table
943 *
944 * @since 1.0.0
945 *
946 * @param string $table_name Full table name
947 * @param string $charset_collate Charset and collation
948 * @return string SQL for table creation
949 */
950 private function get_instant_indexing_logs_table_sql(string $table_name, string $charset_collate): string {
951 return "CREATE TABLE `{$table_name}` (
952 id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
953 url varchar(2048) NOT NULL,
954 status varchar(50) NOT NULL,
955 response_code int(11) NULL,
956 response_message text NULL,
957 created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
958 PRIMARY KEY (id),
959 KEY idx_status (status),
960 KEY idx_response_code (response_code),
961 KEY idx_created (created_at)
962 ) {$charset_collate};";
963 }
964
965 /**
966 * Get SQL for SEO Scores table
967 *
968 * @since 1.0.0
969 *
970 * @param string $table_name Full table name
971 * @param string $charset_collate Charset and collation
972 * @return string SQL for table creation
973 */
974 private function get_seo_scores_table_sql(string $table_name, string $charset_collate): string {
975 return "CREATE TABLE `{$table_name}` (
976 id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
977 post_id bigint(20) unsigned NOT NULL,
978 user_id bigint(20) unsigned NOT NULL,
979 overall_score int(11) NOT NULL,
980 score_breakdown longtext NOT NULL,
981 suggestions longtext NOT NULL,
982 grade varchar(2) NOT NULL,
983 readability_score varchar(100) DEFAULT NULL,
984 content_quality varchar(100) DEFAULT NULL,
985 algorithm_version varchar(20) NOT NULL DEFAULT '2024.1',
986 calculated_at datetime NOT NULL,
987 created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
988 PRIMARY KEY (id),
989 KEY post_id_idx (post_id),
990 KEY user_id_idx (user_id),
991 KEY created_at_idx (created_at),
992 KEY overall_score_idx (overall_score)
993 ) {$charset_collate};";
994 }
995
996 /**
997 * Get SQL for Email Report Logs table.
998 *
999 * Audit + dedupe log for scheduled SEO email reports. The
1000 * `unique_send` constraint on (site_id, period_start, recipient_hash)
1001 * is what prevents a given site from being sent the same period twice
1002 * to the same recipient — required by the PRD.
1003 *
1004 * `recipient_hash` is a sha256 of the lowercased, sorted recipient list
1005 * (so [a@x, b@x] and [b@x, a@x] dedupe to the same row).
1006 *
1007 * @since 1.9.0
1008 *
1009 * @param string $table_name Full table name.
1010 * @param string $charset_collate Charset and collation.
1011 * @return string SQL for table creation.
1012 */
1013 private function get_email_report_logs_table_sql(string $table_name, string $charset_collate): string {
1014 return "CREATE TABLE `{$table_name}` (
1015 id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
1016 site_id bigint(20) unsigned NOT NULL DEFAULT 0,
1017 period_start datetime NOT NULL,
1018 period_end datetime NOT NULL,
1019 recipient_hash char(64) NOT NULL,
1020 recipient_count smallint(5) unsigned NOT NULL DEFAULT 1,
1021 frequency_days smallint(5) unsigned NOT NULL DEFAULT 30,
1022 status varchar(20) NOT NULL DEFAULT 'pending',
1023 error_message text NULL,
1024 sent_at datetime NULL,
1025 created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
1026 PRIMARY KEY (id),
1027 UNIQUE KEY unique_send (site_id, period_start, recipient_hash),
1028 KEY idx_site (site_id),
1029 KEY idx_status (status),
1030 KEY idx_sent (sent_at),
1031 KEY idx_period (period_start)
1032 ) {$charset_collate};";
1033 }
1034
1035 /**
1036 * Create indexes for a specific table
1037 *
1038 * @since 1.0.0
1039 *
1040 * @param string $table_name Table name
1041 * @return bool Success status
1042 */
1043 private function create_table_indexes(string $table_name): bool {
1044 $full_table_name = $this->get_table_name($table_name);
1045 $definition = $this->table_definitions[$table_name] ?? [];
1046
1047 $success = true;
1048
1049 // Create single column indexes
1050 if (!empty($definition['indexes'])) {
1051 foreach ($definition['indexes'] as $index_name) {
1052 try {
1053 // Check if index already exists
1054 if ($this->index_exists($full_table_name, $index_name)) {
1055 continue;
1056 }
1057
1058 $index_sql = $this->get_index_sql($full_table_name, $index_name);
1059 if ($index_sql) {
1060 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange,WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Index creation requires direct schema changes, DDL cannot be prepared
1061 $result = $this->wpdb->query($index_sql);
1062 if (false === $result) {
1063 $success = false;
1064 }
1065 }
1066 } catch (\Exception $e) {
1067 $success = false;
1068 }
1069 }
1070 }
1071
1072 // Create composite indexes for performance optimization
1073 if (!empty($definition['composite_indexes'])) {
1074 foreach ($definition['composite_indexes'] as $index_name => $columns) {
1075 try {
1076 // Check if index already exists
1077 if ($this->index_exists($full_table_name, "idx_{$index_name}")) {
1078 continue;
1079 }
1080
1081 $index_sql = $this->get_composite_index_sql($full_table_name, $index_name, $columns);
1082 if ($index_sql) {
1083 // 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
1084 $result = $this->wpdb->query($index_sql);
1085 if (false === $result) {
1086 $success = false;
1087 }
1088 }
1089 } catch (\Exception $e) {
1090 $success = false;
1091 }
1092 }
1093 }
1094
1095 return $success;
1096 }
1097
1098 /**
1099 * Add constraints for a specific table
1100 *
1101 * @since 1.0.0
1102 *
1103 * @param string $table_name Table name
1104 * @return bool Success status
1105 */
1106 private function add_table_constraints(string $table_name): bool {
1107 $full_table_name = $this->get_table_name($table_name);
1108 $definition = $this->table_definitions[$table_name] ?? [];
1109
1110 if (empty($definition['foreign_keys'])) {
1111 return true;
1112 }
1113
1114 $success = true;
1115 foreach ($definition['foreign_keys'] as $constraint) {
1116 try {
1117 $constraint_sql = $this->get_constraint_sql($full_table_name, $constraint);
1118 if ($constraint_sql) {
1119 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange,WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Constraint creation requires direct schema changes, DDL cannot be prepared
1120 $result = $this->wpdb->query($constraint_sql);
1121 if (false === $result) {
1122 $success = false;
1123 }
1124 }
1125 } catch (\Exception $e) {
1126 $success = false;
1127 }
1128 }
1129
1130 return $success;
1131 }
1132
1133 /**
1134 * Get index SQL for a table
1135 *
1136 * @since 1.0.0
1137 *
1138 * @param string $table_name Full table name
1139 * @param string $index_name Index name
1140 * @return string Index SQL
1141 */
1142 private function get_index_sql(string $table_name, string $index_name): string {
1143 // Most indexes are already created in the table definition
1144 // This method is for additional indexes if needed
1145 return '';
1146 }
1147
1148 /**
1149 * Get composite index SQL for performance optimization
1150 *
1151 * @since 1.0.0
1152 *
1153 * @param string $table_name Full table name
1154 * @param string $index_name Index name
1155 * @param array $columns Column names for composite index
1156 * @return string Composite index SQL
1157 */
1158 private function get_composite_index_sql(string $table_name, string $index_name, array $columns): string {
1159 if (empty($columns)) {
1160 return '';
1161 }
1162
1163 // Escape column names
1164 $escaped_columns = array_map(function ($column) {
1165 return "`{$column}`";
1166 }, $columns);
1167
1168 $columns_sql = implode(', ', $escaped_columns);
1169 $index_name_escaped = esc_sql($index_name);
1170
1171 return "CREATE INDEX `idx_{$index_name_escaped}` ON `{$table_name}` ({$columns_sql})";
1172 }
1173
1174 /**
1175 * Get constraint SQL for a table
1176 *
1177 * @since 1.0.0
1178 *
1179 * @param string $table_name Full table name
1180 * @param array $constraint Constraint definition
1181 * @return string Constraint SQL
1182 */
1183 private function get_constraint_sql(string $table_name, array $constraint): string {
1184 // Foreign key constraints would be defined here
1185 // Currently not implemented as tables are designed to be independent
1186 return '';
1187 }
1188
1189 /**
1190 * Get table information
1191 *
1192 * @since 1.0.0
1193 *
1194 * @param string $table_name Full table name
1195 * @return array Table information
1196 */
1197 private function get_table_info(string $table_name): array {
1198 $info = [
1199 'exists' => false,
1200 'row_count' => 0,
1201 'data_size' => 0,
1202 'index_size' => 0,
1203 'total_size' => 0,
1204 'created' => null,
1205 'updated' => null
1206 ];
1207
1208 if (!$this->table_exists($table_name)) {
1209 return $info;
1210 }
1211
1212 $info['exists'] = true;
1213
1214 // Get row count
1215 // 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
1216 $row_count = $this->wpdb->get_var("SELECT COUNT(*) FROM `{$table_name}`");
1217 $info['row_count'] = (int) $row_count;
1218
1219 // Get table size information
1220 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table size information requires direct database access
1221 $size_info = $this->wpdb->get_row(
1222 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
1223 $this->wpdb->prepare(
1224 "SELECT
1225 data_length as data_size,
1226 index_length as index_size,
1227 (data_length + index_length) as total_size,
1228 create_time as created,
1229 update_time as updated
1230 FROM information_schema.TABLES
1231 WHERE table_schema = %s AND table_name = %s",
1232 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- DB_NAME is a WordPress constant, safe to use
1233 DB_NAME,
1234 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table_name is validated and used as parameter
1235 $table_name
1236 ),
1237 ARRAY_A
1238 );
1239
1240 if ($size_info) {
1241 $info['data_size'] = (int) $size_info['data_size'];
1242 $info['index_size'] = (int) $size_info['index_size'];
1243 $info['total_size'] = (int) $size_info['total_size'];
1244 $info['created'] = $size_info['created'];
1245 $info['updated'] = $size_info['updated'];
1246 }
1247
1248 return $info;
1249 }
1250
1251 /**
1252 * Get table size in bytes
1253 *
1254 * @since 1.0.0
1255 *
1256 * @param string $table_name Full table name
1257 * @return int Table size in bytes
1258 */
1259 private function get_table_size(string $table_name): int {
1260 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table size calculation requires direct database access
1261 $size = $this->wpdb->get_var(
1262 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
1263 $this->wpdb->prepare(
1264 "SELECT (data_length + index_length) as total_size
1265 FROM information_schema.TABLES
1266 WHERE table_schema = %s AND table_name = %s",
1267 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- DB_NAME is a WordPress constant, safe to use
1268 DB_NAME,
1269 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table_name is validated and used as parameter
1270 $table_name
1271 )
1272 );
1273
1274 return (int) $size;
1275 }
1276
1277 /**
1278 * Get tables by category for better organization
1279 *
1280 * @since 1.0.0
1281 *
1282 * @param string $category Category name (seo, ai, content, scoring)
1283 * @return array Table names in the category
1284 */
1285 public function get_tables_by_category(string $category): array {
1286 return $this->table_categories[$category] ?? [];
1287 }
1288
1289 /**
1290 * Get all table categories
1291 *
1292 * @since 1.0.0
1293 *
1294 * @return array All table categories with their tables
1295 */
1296 public function get_table_categories(): array {
1297 return $this->table_categories;
1298 }
1299
1300 /**
1301 * Get table count by category
1302 *
1303 * @since 1.0.0
1304 *
1305 * @return array Table counts per category
1306 */
1307 public function get_table_count_by_category(): array {
1308 $counts = [];
1309 foreach ($this->table_categories as $category => $tables) {
1310 $counts[$category] = count($tables);
1311 }
1312 $counts['total'] = count($this->table_definitions);
1313 return $counts;
1314 }
1315
1316 /**
1317 * Validate table definition structure
1318 *
1319 * @since 1.0.0
1320 *
1321 * @param string $table_name Table name to validate
1322 * @return array Validation results
1323 */
1324 public function validate_table_definition(string $table_name): array {
1325 $definition = $this->table_definitions[$table_name] ?? null;
1326
1327 if (!$definition) {
1328 return [
1329 'valid' => false,
1330 'errors' => ["Table definition not found: {$table_name}"]
1331 ];
1332 }
1333
1334 $errors = [];
1335 $required_keys = ['description', 'primary_key', 'indexes', 'foreign_keys'];
1336
1337 foreach ($required_keys as $key) {
1338 if (!isset($definition[$key])) {
1339 $errors[] = "Missing required key '{$key}' in table definition for {$table_name}";
1340 }
1341 }
1342
1343 return [
1344 'valid' => empty($errors),
1345 'errors' => $errors
1346 ];
1347 }
1348
1349 /**
1350 * Add composite indexes to existing tables for performance optimization
1351 *
1352 * @since 1.0.0
1353 *
1354 * @return bool Success status
1355 */
1356 public function add_performance_indexes(): bool {
1357 $success = true;
1358
1359 foreach ($this->table_definitions as $table_name => $definition) {
1360 if (!empty($definition['composite_indexes'])) {
1361 $full_table_name = $this->get_table_name($table_name);
1362
1363 // Check if table exists before adding indexes
1364 if (!$this->table_exists($full_table_name)) {
1365 continue;
1366 }
1367
1368 foreach ($definition['composite_indexes'] as $index_name => $columns) {
1369 try {
1370 // Check if index already exists
1371 if ($this->index_exists($full_table_name, "idx_{$index_name}")) {
1372 continue;
1373 }
1374
1375 $index_sql = $this->get_composite_index_sql($full_table_name, $index_name, $columns);
1376 if ($index_sql) {
1377 // 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
1378 $result = $this->wpdb->query($index_sql);
1379 if (false === $result) {
1380 $success = false;
1381 // Index creation failed - logged in database operations
1382 }
1383 }
1384 } catch (\Exception $e) {
1385 $success = false;
1386 // Exception during index creation - logged in database operations
1387 }
1388 }
1389 }
1390 }
1391
1392 return $success;
1393 }
1394
1395 /**
1396 * Check if an index exists on a table
1397 *
1398 * @since 1.0.0
1399 *
1400 * @param string $table_name Full table name
1401 * @param string $index_name Index name
1402 * @return bool Whether index exists
1403 */
1404 private function index_exists(string $table_name, string $index_name): bool {
1405 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Index existence check requires direct database access
1406 $result = $this->wpdb->get_var(
1407 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
1408 $this->wpdb->prepare(
1409 "SELECT COUNT(*) FROM information_schema.statistics
1410 WHERE table_schema = %s AND table_name = %s AND index_name = %s",
1411 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- DB_NAME is a WordPress constant, safe to use
1412 DB_NAME,
1413 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table_name is validated and used as parameter
1414 $table_name,
1415 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $index_name is validated and used as parameter
1416 $index_name
1417 )
1418 );
1419
1420 return (int) $result > 0;
1421 }
1422
1423 /**
1424 * Check if MySQL supports JSON column type with caching
1425 *
1426 * Uses WordPress's built-in database version detection and caches the result
1427 * to avoid repeated database queries during schema creation.
1428 *
1429 * @since 1.0.0
1430 * @return bool True if MySQL 5.7+ supports JSON columns
1431 */
1432 private function get_mysql_json_support(): bool {
1433 // Check if we have cached result
1434 static $json_support = null;
1435
1436 if ($json_support !== null) {
1437 return $json_support;
1438 }
1439
1440 // Use WordPress's built-in database version method
1441 global $wpdb;
1442
1443 // Get MySQL version using WordPress method (safer than direct query)
1444 $mysql_version = $wpdb->db_version();
1445
1446 // Cache the result for subsequent calls
1447 $json_support = version_compare($mysql_version, '5.7.0', '>=');
1448
1449 return $json_support;
1450 }
1451
1452 /**
1453 * Get SQL for the AI traffic table.
1454 *
1455 * Daily aggregate counters only — one row per (day, kind, source, path).
1456 * `kind` is 'referral' (human visit from an AI platform), 'crawler' (AI
1457 * bot user-agent), or 'baseline' (all human pageviews, for the share-of-
1458 * traffic figure). No IPs, no user agents, no per-visit rows: aggregates
1459 * keep the table small and the feature privacy-clean.
1460 *
1461 * @since 1.27.0
1462 *
1463 * @param string $table_name Full table name
1464 * @param string $charset_collate Charset and collation
1465 * @return string SQL for table creation
1466 */
1467 private function get_ai_traffic_table_sql(string $table_name, string $charset_collate): string {
1468 return "CREATE TABLE `{$table_name}` (
1469 id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
1470 day date NOT NULL,
1471 kind varchar(12) NOT NULL,
1472 source varchar(40) NOT NULL DEFAULT '',
1473 path varchar(191) NOT NULL DEFAULT '',
1474 hits bigint(20) unsigned NOT NULL DEFAULT 1,
1475 PRIMARY KEY (id),
1476 UNIQUE KEY uniq_bucket (day, kind, source, path),
1477 KEY idx_day (day),
1478 KEY idx_kind (kind)
1479 ) {$charset_collate};";
1480 }
1481
1482 /**
1483 * Get SQL for the brand visibility checks table.
1484 *
1485 * One row per (query, check run): whether the AI provider's answer
1486 * mentioned the brand and/or cited the site's domain, plus a short
1487 * excerpt for context.
1488 *
1489 * @since 1.27.0
1490 *
1491 * @param string $table_name Full table name
1492 * @param string $charset_collate Charset and collation
1493 * @return string SQL for table creation
1494 */
1495 private function get_brand_visibility_checks_table_sql(string $table_name, string $charset_collate): string {
1496 return "CREATE TABLE `{$table_name}` (
1497 id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
1498 checked_at datetime NOT NULL,
1499 query_text varchar(191) NOT NULL,
1500 provider varchar(20) NOT NULL DEFAULT '',
1501 model varchar(80) NOT NULL DEFAULT '',
1502 mentioned tinyint(1) NOT NULL DEFAULT 0,
1503 cited tinyint(1) NOT NULL DEFAULT 0,
1504 excerpt text NULL,
1505 answer longtext NULL,
1506 PRIMARY KEY (id),
1507 KEY idx_checked (checked_at),
1508 KEY idx_query (query_text)
1509 ) {$charset_collate};";
1510 }
1511
1512 /**
1513 * Brand Visibility v2 — analysis runs.
1514 *
1515 * One row per "Run analysis". `config` snapshots the brand profile,
1516 * competitors, queries and platforms the run was started with, so a run's
1517 * results stay interpretable after the user edits their setup. `results`
1518 * holds the computed aggregates (index, mention rate, share of voice,
1519 * per-platform and per-query breakdowns) written once by the finalizer.
1520 *
1521 * @since 1.28.0
1522 *
1523 * @param string $table_name Full table name.
1524 * @param string $charset_collate Charset/collation clause.
1525 * @return string CREATE TABLE statement.
1526 */
1527 private function get_bv_runs_table_sql(string $table_name, string $charset_collate): string {
1528 return "CREATE TABLE `{$table_name}` (
1529 id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
1530 status varchar(20) NOT NULL DEFAULT 'queued',
1531 started_at datetime NOT NULL,
1532 finished_at datetime NULL,
1533 tasks_total int(11) NOT NULL DEFAULT 0,
1534 tasks_done int(11) NOT NULL DEFAULT 0,
1535 tasks_failed int(11) NOT NULL DEFAULT 0,
1536 config longtext NULL,
1537 results longtext NULL,
1538 error text NULL,
1539 PRIMARY KEY (id),
1540 KEY idx_status (status),
1541 KEY idx_started (started_at)
1542 ) {$charset_collate};";
1543 }
1544
1545 /**
1546 * Brand Visibility v2 — individual probe tasks.
1547 *
1548 * One row per (query x platform x sample). Sampling is the whole point:
1549 * a single LLM answer is noise, so a mention rate is only meaningful as
1550 * mentions/samples. Rows are processed off-request by cron ticks, which is
1551 * what keeps a 100+ call run from timing out a REST request, and what lets
1552 * an interrupted run resume instead of restarting.
1553 *
1554 * @since 1.28.0
1555 *
1556 * @param string $table_name Full table name.
1557 * @param string $charset_collate Charset/collation clause.
1558 * @return string CREATE TABLE statement.
1559 */
1560 private function get_bv_tasks_table_sql(string $table_name, string $charset_collate): string {
1561 return "CREATE TABLE `{$table_name}` (
1562 id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
1563 run_id bigint(20) unsigned NOT NULL,
1564 query_text varchar(500) NOT NULL,
1565 query_type varchar(20) NOT NULL DEFAULT 'branded',
1566 platform varchar(20) NOT NULL DEFAULT '',
1567 sample_index tinyint(3) unsigned NOT NULL DEFAULT 0,
1568 status varchar(20) NOT NULL DEFAULT 'pending',
1569 attempts tinyint(3) unsigned NOT NULL DEFAULT 0,
1570 mentioned tinyint(1) NOT NULL DEFAULT 0,
1571 cited tinyint(1) NOT NULL DEFAULT 0,
1572 sentiment varchar(10) NOT NULL DEFAULT '',
1573 competitors text NULL,
1574 excerpt text NULL,
1575 answer longtext NULL,
1576 error text NULL,
1577 updated_at datetime NULL,
1578 PRIMARY KEY (id),
1579 KEY idx_run (run_id),
1580 KEY idx_status (status),
1581 KEY run_status (run_id, status)
1582 ) {$charset_collate};";
1583 }
1584 }
1585