PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.2
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.2
4.9.2 4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 3.4.2 3.5.0 3.5.1 All 200 releases
← All changes | includes/Core/Migration.php +186 -85 3.5.04.9.2 View file →
@@ -1,7 +1,11 @@
1 1 <?php
2 +// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange -- one-shot data/schema migrations; caching unwanted.
3 +namespace WPDeveloper\BetterDocs\Core;
2 4
3 -namespace WPDeveloper\BetterDocs\Core;
5 +if ( ! defined( 'ABSPATH' ) ) {
6 + exit;
7 +}
4 8
5 9 use WP_Query;
6 10 use WPDeveloper\BetterDocs\Utils\Base;
7 11 use WPDeveloper\BetterDocs\Utils\Database;
@@ -7,103 +11,200 @@
7 11 use WPDeveloper\BetterDocs\Utils\Database;
8 12 use WPDeveloper\BetterDocs\Dependencies\DI\Container;
9 13
10 14 class Migration extends Base {
11 - /**
12 - * Database
13 - * @var Database
14 - */
15 - private $database;
16 - private $settings;
17 - private $container;
15 + /**
16 + * Database
17 + * @var Database
18 + */
19 + private $database;
20 + private $settings;
21 + private $container;
18 22
19 - public function __construct( Container $container ) {
20 - $this->container = $container;
21 - $this->database = $container->get( Database::class );
22 - $this->settings = $container->get( Settings::class );
23 - }
23 + public function __construct( Container $container ) {
24 + $this->container = $container;
25 + $this->database = $container->get( Database::class );
26 + $this->settings = $container->get( Settings::class );
27 + }
24 28
25 - public function init( $version ) {
26 - if( $version > 250 ) {
27 - for( $_version = 250; $_version <= $version; $_version++ ) {
28 - if( method_exists( $this, "v$_version") ) {
29 - call_user_func([$this, "v$_version"]);
30 - }
31 - }
32 - }
29 + public function init( $version ) {
30 + if ( $version > 250 ) {
31 + for ( $_version = 250; $_version <= $version; $_version++ ) {
32 + if ( method_exists( $this, "v$_version" ) ) {
33 + call_user_func( [ $this, "v$_version" ] );
34 + }
35 + }
36 + }
33 37
34 - $this->search_migration();
38 + $this->search_migration();
39 + $this->fix_search_table_collation();
40 + $this->backfill_search_keyword_hash();
35 41
36 - /**
37 - * Settings Migration
38 - */
39 - $this->settings->migration( $version );
40 - }
42 + /**
43 + * Settings Migration
44 + */
45 + $this->settings->migration( $version );
46 + }
41 47
42 - public function search_migration() {
43 - global $wpdb;
44 - if ( ! $this->database->get( 'betterdocs_search_data_migration', false ) ) {
45 - $search_data = $this->database->get( 'betterdocs_search_data' );
46 - if ( ! empty( $search_data ) ) {
47 - $search_data_arr = unserialize( $search_data );
48 - foreach ( $search_data_arr as $key => $value ) {
49 - $args = [
50 - 'post_type' => 'docs',
51 - 'post_status' => 'publish',
52 - 'posts_per_page' => -1,
53 - 'suppress_filters' => true,
54 - 's' => $key
55 - ];
48 + public function search_migration() {
49 + global $wpdb;
50 + if ( ! $this->database->get( 'betterdocs_search_data_migration', false ) ) {
51 + $search_data = $this->database->get( 'betterdocs_search_data' );
52 + if ( ! empty( $search_data ) ) {
53 + $search_data_arr = unserialize( $search_data );
54 + foreach ( $search_data_arr as $key => $value ) {
55 + $args = [
56 + 'post_type' => 'docs',
57 + 'post_status' => 'publish',
58 + 'posts_per_page' => -1,
59 + 'suppress_filters' => true, // phpcs:ignore WordPressVIPMinimum.Hooks.PreGetPosts.PreGetPosts,WordPressVIPMinimum.Performance.WPQueryParams.SuppressFilters_suppress_filters -- migration must run on raw posts without language filters.
60 + 's' => $key
61 + ];
56 62
57 - $loop = new WP_Query( $args );
58 - if ( $loop->have_posts() ) {
59 - $count = $value;
60 - $not_found_count = 0;
61 - } else {
62 - $count = 0;
63 - $not_found_count = $value;
64 - }
63 + $loop = new WP_Query( $args );
64 + if ( $loop->have_posts() ) {
65 + $count = $value;
66 + $not_found_count = 0;
67 + } else {
68 + $count = 0;
69 + $not_found_count = $value;
70 + }
65 71
66 - $keyword = $wpdb->get_var(
67 - $wpdb->prepare( "
72 + // Use BINARY comparison to avoid collation mismatch errors
73 + $keyword = $wpdb->get_var(
74 + $wpdb->prepare(
75 + "
68 76 SELECT keyword
69 77 FROM {$wpdb->prefix}betterdocs_search_keyword
70 - WHERE keyword = %s",
71 - $key
72 - )
73 - );
78 + WHERE BINARY keyword = %s",
79 + $key
80 + )
81 + );
74 82
75 - if ( $keyword == NUll ) {
76 - $insert = $wpdb->query(
77 - $wpdb->prepare(
78 - "INSERT INTO {$wpdb->prefix}betterdocs_search_keyword
79 - ( keyword )
80 - VALUES ( %s )",
81 - [
82 - $key
83 - ]
84 - )
85 - );
83 + if ( $keyword == null ) {
84 + $insert = $wpdb->query(
85 + $wpdb->prepare(
86 + "INSERT INTO {$wpdb->prefix}betterdocs_search_keyword
87 + ( keyword, keyword_hash )
88 + VALUES ( %s, %s )",
89 + [
90 + $key,
91 + md5( $key )
92 + ]
93 + )
94 + );
86 95
87 - if ( $insert ) {
88 - $wpdb->query(
89 - $wpdb->prepare(
90 - "INSERT INTO {$wpdb->prefix}betterdocs_search_log
96 + if ( $insert ) {
97 + $wpdb->query(
98 + $wpdb->prepare(
99 + "INSERT INTO {$wpdb->prefix}betterdocs_search_log
91 100 (keyword_id, count, not_found_count, created_at)
92 101 VALUES (%d, %d, %d, %s)",
93 - [
94 - $wpdb->insert_id,
95 - $count,
96 - $not_found_count,
97 - date( 'Y-m-d' )
98 - ]
99 - )
100 - );
101 - }
102 - }
103 - }
102 + [
103 + $wpdb->insert_id,
104 + $count,
105 + $not_found_count,
106 + gmdate( 'Y-m-d' )
107 + ]
108 + )
109 + );
110 + }
111 + }
112 + }
104 113
105 - $this->database->save( 'betterdocs_search_data_migration', '1.0' );
106 - }
107 - }
108 - }
114 + $this->database->save( 'betterdocs_search_data_migration', '1.0' );
115 + }
116 + }
117 + }
118 +
119 + /**
120 + * Fix collation issues in search tables
121 + * Converts tables to proper UTF-8 charset and collation to prevent
122 + * "Illegal mix of collations" errors when searching with non-Latin characters
123 + *
124 + * @since 1.0.2
125 + * @return void
126 + */
127 + public function fix_search_table_collation() {
128 + global $wpdb;
129 +
130 + // Check if migration already ran
131 + if ( $this->database->get( 'betterdocs_search_collation_fixed', false ) ) {
132 + return;
133 + }
134 +
135 + // Get the WordPress default charset and collation; restrict to a safe identifier
136 + // alphabet because they're interpolated into ALTER TABLE statements below.
137 + $charset = preg_replace( '/[^A-Za-z0-9_]/', '', $wpdb->charset ? $wpdb->charset : 'utf8mb4' );
138 + $collate = preg_replace( '/[^A-Za-z0-9_]/', '', $wpdb->collate ? $wpdb->collate : 'utf8mb4_unicode_520_ci' );
139 +
140 + // Fix search_keyword table
141 + $search_keyword_table = $wpdb->prefix . 'betterdocs_search_keyword';
142 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange -- one-shot collation migration; identifiers come from $wpdb only.
143 + if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $search_keyword_table ) ) === $search_keyword_table ) {
144 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange,PluginCheck.Security.DirectDB.UnescapedDBParameter
145 + $wpdb->query( "ALTER TABLE `{$search_keyword_table}` CONVERT TO CHARACTER SET {$charset} COLLATE {$collate}" );
146 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange,PluginCheck.Security.DirectDB.UnescapedDBParameter
147 + $wpdb->query( "ALTER TABLE `{$search_keyword_table}` MODIFY keyword TEXT CHARACTER SET {$charset} COLLATE {$collate} NOT NULL" );
148 + }
149 +
150 + // Fix search_log table
151 + $search_log_table = $wpdb->prefix . 'betterdocs_search_log';
152 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange
153 + if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $search_log_table ) ) === $search_log_table ) {
154 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange,PluginCheck.Security.DirectDB.UnescapedDBParameter
155 + $wpdb->query( "ALTER TABLE `{$search_log_table}` CONVERT TO CHARACTER SET {$charset} COLLATE {$collate}" );
156 + }
157 +
158 + // Mark migration as complete
159 + $this->database->save( 'betterdocs_search_collation_fixed', '1.0' );
160 + }
161 +
162 + /**
163 + * Populate keyword_hash for rows that predate the column.
164 + *
165 + * The column is added by dbDelta with an empty default; until it holds the
166 + * hash, insert_search_keyword() cannot find those rows by index and would
167 + * insert duplicates. Runs in bounded batches so a large keyword table does
168 + * not stall the request that triggers the upgrade — anything left over is
169 + * picked up on the next admin load.
170 + *
171 + * @since 1.0.3
172 + * @return void
173 + */
174 + public function backfill_search_keyword_hash() {
175 + global $wpdb;
176 +
177 + if ( $this->database->get( 'betterdocs_search_keyword_hash_filled', false ) ) {
178 + return;
179 + }
180 +
181 + $table = $wpdb->prefix . 'betterdocs_search_keyword';
182 +
183 + // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- one-shot schema backfill; identifier comes from $wpdb.
184 + if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) !== $table ) {
185 + return;
186 + }
187 +
188 + // Bail if the column never made it (dbDelta failed) — retry next load.
189 + if ( ! $wpdb->get_results( "SHOW COLUMNS FROM `{$table}` LIKE 'keyword_hash'" ) ) {
190 + return;
191 + }
192 +
193 + $batches = 0;
194 + do {
195 + $updated = $wpdb->query(
196 + "UPDATE `{$table}` SET keyword_hash = MD5( keyword ) WHERE keyword_hash = '' LIMIT 2000"
197 + );
198 + $batches++;
199 + } while ( $updated > 0 && $batches < 25 );
200 +
201 + // Only finish once nothing is left, so a table larger than 50k keywords
202 + // resumes instead of being marked done half-filled.
203 + $remaining = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}` WHERE keyword_hash = ''" );
204 + // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
205 +
206 + if ( 0 === $remaining ) {
207 + $this->database->save( 'betterdocs_search_keyword_hash_filled', '1.0.3' );
208 + }
209 + }
109 210 }