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

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