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

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