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

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