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

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