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

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