PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.4.1
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.4.1
1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 trunk All 48 releases
fluent-cart / database / DBMigrator.php

DBMigrator.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.4.1, at database/DBMigrator.php

629 lines 32.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\Database;
4
5 use FluentCart\App\CPT\FluentProducts;
6 use FluentCart\App\Models\Product;
7
8 require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
9
10 use FluentCart\Database\Migrations\AttributeGroupsMigrator;
11 use FluentCart\Database\Migrations\AttributeObjectRelationsMigrator;
12 use FluentCart\Database\Migrations\AttributeTermsMigrator;
13 use FluentCart\Database\Migrations\CartMigrator;
14 use FluentCart\Database\Migrations\CustomersMigrator;
15 use FluentCart\Database\Migrations\MetaMigrator;
16 use FluentCart\Database\Migrations\Migrator;
17 use FluentCart\Database\Migrations\OrderMetaMigrator;
18 use FluentCart\Database\Migrations\OrdersMigrator;
19 use FluentCart\Database\Migrations\OrdersItemsMigrator;
20 use FluentCart\Database\Migrations\OrderTransactionsMigrator;
21 use FluentCart\Database\Migrations\ProductDetailsMigrator;
22 use FluentCart\Database\Migrations\ProductDownloadsMigrator;
23 use FluentCart\Database\Migrations\ProductMetaMigrator;
24 use FluentCart\Database\Migrations\ProductVariationMigrator;
25 use FluentCart\Database\Migrations\ScheduledActionsMigrator;
26 use FluentCart\Database\Migrations\ShippingClassesMigrator;
27 use FluentCart\Database\Migrations\SubscriptionMetaMigrator;
28 use FluentCart\Database\Migrations\SubscriptionsMigrator;
29 use FluentCart\Database\Migrations\TaxClassesMigrator;
30 use FluentCart\Database\Migrations\TaxRatesMigrator;
31 use FluentCart\Database\Migrations\OrderTaxRateMigrator;
32 use FluentCart\Database\Migrations\CouponsMigrator;
33 use FluentCart\Database\Migrations\CustomerAddressesMigrator;
34 use FluentCart\Database\Migrations\CustomerMetaMigrator;
35 use FluentCart\Database\Migrations\OrderAddressesMigrator;
36 use FluentCart\Database\Migrations\OrderDownloadPermissionsMigrator;
37 use FluentCart\Database\Migrations\OrderOperationsMigrator;
38 use FluentCart\Database\Migrations\AppliedCouponsMigrator;
39 use FluentCart\Database\Migrations\LabelMigrator;
40 use FluentCart\Database\Migrations\LabelRelationshipsMigrator;
41 use FluentCart\Database\Migrations\ActivityMigrator;
42 use FluentCart\Database\Migrations\WebhookLogger;
43 use FluentCart\Framework\Database\Schema;
44 use FluentCartPro\App\Modules\Licensing\Models\License;
45 use FluentCartPro\App\Modules\Licensing\Models\LicenseActivation;
46 use FluentCartPro\App\Modules\Licensing\Models\LicenseMeta;
47 use FluentCartPro\App\Modules\Licensing\Models\LicenseSite;
48 use FluentCart\Database\Migrations\ShippingZonesMigrator;
49 use FluentCart\Database\Migrations\ShippingMethodsMigrator;
50 use FluentCart\Database\Migrations\RetentionSnapshotsMigrator;
51
52 class DBMigrator
53 {
54 private static array $migrators = [
55 MetaMigrator::class,
56 AttributeGroupsMigrator::class,
57 AttributeObjectRelationsMigrator::class,
58 AttributeTermsMigrator::class,
59 CartMigrator::class,
60 CouponsMigrator::class,
61 CustomerAddressesMigrator::class,
62 CustomerMetaMigrator::class,
63 CustomersMigrator::class,
64 OrderAddressesMigrator::class,
65 OrderDownloadPermissionsMigrator::class,
66 OrderOperationsMigrator::class,
67 OrdersItemsMigrator::class,
68 OrdersMigrator::class,
69 OrderTaxRateMigrator::class,
70 OrderMetaMigrator::class,
71 OrderTransactionsMigrator::class,
72 ProductDetailsMigrator::class,
73 ProductDownloadsMigrator::class,
74 ProductMetaMigrator::class,
75 SubscriptionMetaMigrator::class,
76 SubscriptionsMigrator::class,
77 TaxClassesMigrator::class,
78 TaxRatesMigrator::class,
79 ProductVariationMigrator::class,
80 AppliedCouponsMigrator::class,
81 LabelMigrator::class,
82 LabelRelationshipsMigrator::class,
83 ActivityMigrator::class,
84 WebhookLogger::class,
85 ShippingZonesMigrator::class,
86 ShippingMethodsMigrator::class,
87 ShippingClassesMigrator::class,
88 ScheduledActionsMigrator::class,
89 RetentionSnapshotsMigrator::class
90 ];
91
92 public static function migrateUp($network_wide = false)
93 {
94 global $wpdb;
95 if ($network_wide) {
96 // Retrieve all site IDs from this network (WordPress >= 4.6 provides easy to use functions for that).
97 if (function_exists('get_sites') && function_exists('get_current_network_id')) {
98 $site_ids = get_sites(array('fields' => 'ids', 'network_id' => get_current_network_id()));
99 } else {
100 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
101 $site_ids = $wpdb->get_col("SELECT blog_id FROM $wpdb->blogs WHERE site_id = $wpdb->siteid;");
102 }
103 // Install the plugin for all these sites.
104 foreach ($site_ids as $site_id) {
105 switch_to_blog($site_id);
106 self::run_migrate();
107 restore_current_blog();
108 }
109 } else {
110 self::run_migrate();
111 }
112 }
113
114 public static function run_migrate()
115 {
116 self::migrate();
117 self::maybeMigrateDBChanges();
118 update_option('_fluent_cart_db_version', FLUENTCART_DB_VERSION, 'no');
119 }
120
121 public static function migrate()
122 {
123 /**
124 * @var $migrator Migrator
125 */
126 foreach (self::$migrators as $migrator) {
127 $migrator::migrate();
128 }
129 }
130
131 public static function maybeMigrateDBChanges()
132 {
133 /*
134 * TODO We will remove this after final release
135 */
136 $currentDBVersion = get_option('_fluent_cart_db_version');
137
138 if (!$currentDBVersion || version_compare($currentDBVersion, FLUENTCART_DB_VERSION, '<')) {
139
140 // Right after a plugin update, multiple requests (admin page +
141 // heartbeat/ajax) hit init before the version option is written and
142 // would all run the schema changes in parallel, producing
143 // duplicate-index / duplicate-entry errors. Only the request that
144 // wins the advisory lock proceeds; the others skip — the winner
145 // updates the version option for everyone.
146 if (!self::acquireMigrationLock()) {
147 return;
148 }
149
150 update_option('_fluent_cart_db_version', FLUENTCART_DB_VERSION, 'no');
151
152 // 2026-04-25
153 TaxRatesMigrator::upgradeShippingOverridePrecision();
154
155 // 2026-05-27
156 TaxRatesMigrator::fixPostcodeRangeSeparator();
157 OrdersMigrator::addFeeTotalColumn();
158
159 // 2026-05-10
160 AttributeGroupsMigrator::dropLegacyTitleUniqueIndexes();
161
162 // let's check the orders table sequence number
163 global $wpdb;
164
165 // Product Meta unique index removal
166 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
167 $hasProductMetaUnqIndex = $wpdb->get_col($wpdb->prepare("SELECT * FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND INDEX_NAME='" . $wpdb->prefix . "fct_pm__comp_unq' AND TABLE_NAME=%s", $wpdb->prefix . 'fct_product_meta'));
168 if ($hasProductMetaUnqIndex) {
169
170 $table_name = $wpdb->prefix . 'fct_product_meta';
171 $index_name = 'fct_pm__comp_unq';
172
173 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
174 $wpdb->query(
175 $wpdb->prepare(
176 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
177 "ALTER TABLE %i DROP INDEX %i",
178 $table_name,
179 $index_name
180 ));
181
182 }
183
184 if (!Schema::hasColumn('tax_behavior', 'fct_orders')) {
185 $table_name = $wpdb->prefix . 'fct_orders';
186 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
187 $wpdb->query($wpdb->prepare(
188 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
189 "ALTER TABLE %i ADD COLUMN `tax_behavior` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '0 => no_tax, 1 => exclusive, 2 => inclusive' AFTER `rate`",
190 $table_name
191 ));
192 }
193
194 if (!Schema::hasColumn('slug', 'fct_tax_classes')) {
195 $table_name = $wpdb->prefix . 'fct_tax_classes';
196 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
197 $wpdb->query($wpdb->prepare(
198 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
199 "ALTER TABLE %i ADD COLUMN `slug` VARCHAR(100) NULL AFTER `title`",
200 $table_name
201 ));
202 }
203
204 $ordersTable = $wpdb->prefix . 'fct_orders';
205
206 if (!Schema::hasColumn('fee_total', 'fct_orders')) {
207 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
208 $wpdb->query($wpdb->prepare(
209 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
210 "ALTER TABLE %i ADD COLUMN `fee_total` BIGINT NOT NULL DEFAULT '0' AFTER `shipping_total`",
211 $ordersTable
212 ));
213 }
214
215 // check if scheduled_at is exist or not
216 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
217 $isReceiptNumberMigrated = $wpdb->get_col($wpdb->prepare("SELECT * FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND COLUMN_NAME='receipt_number' AND TABLE_NAME=%s", $ordersTable));
218 if (!$isReceiptNumberMigrated) {
219 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
220 $wpdb->query($wpdb->prepare(
221 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
222 "ALTER TABLE %i ADD COLUMN `receipt_number` BIGINT NULL AFTER `parent_id`",
223 $ordersTable
224 ));
225 }
226
227 /**
228 * Changing fct_meta.key to fct_meta.meta_key
229 */
230 if (Schema::hasColumn('discount_total', 'fct_orders')) {
231 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
232 $wpdb->query($wpdb->prepare(
233 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
234 "ALTER TABLE %i CHANGE `discount_total` `manual_discount_total` BIGINT NOT NULL DEFAULT '0'",
235 $ordersTable
236 ));
237 }
238
239 /**
240 * Changing fct_meta.key to fct_meta.meta_key
241 */
242 if (Schema::hasColumn('key', 'fct_meta')) {
243 $table_name = $wpdb->prefix . 'fct_meta';
244 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
245 $wpdb->query($wpdb->prepare(
246 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
247 "ALTER TABLE %i CHANGE `key` `meta_key` VARCHAR(192)",
248 $table_name
249 ));
250 }
251
252 /**
253 * Changing fct_meta.value to fct_meta.meta_value
254 */
255 if (Schema::hasColumn('value', 'fct_meta')) {
256 $table_name = $wpdb->prefix . 'fct_meta';
257 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
258 $wpdb->query($wpdb->prepare(
259 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
260 "ALTER TABLE %i CHANGE `value` `meta_value` LONGTEXT",
261 $table_name
262 ));
263 }
264
265 /**
266 * Changing fct_order_meta.key to fct_order_meta.meta_key and fct_order_meta.value to fct_order_meta.meta_value
267 */
268 if (Schema::hasColumn('key', 'fct_order_meta')) {
269 $table_name = $wpdb->prefix . 'fct_order_meta';
270 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
271 $wpdb->query($wpdb->prepare(
272 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
273 "ALTER TABLE %i CHANGE `key` `meta_key` VARCHAR(192)",
274 $table_name
275 ));
276 }
277 /**
278 * Changing fct_meta.key to fct_meta.meta_key and fct_meta.value to fct_meta.meta_value
279 */
280 if (Schema::hasColumn('value', 'fct_order_meta')) {
281 $table_name = $wpdb->prefix . 'fct_order_meta';
282 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
283 $wpdb->query($wpdb->prepare(
284 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
285 "ALTER TABLE %i CHANGE `value` `meta_value` LONGTEXT",
286 $table_name
287 ));
288 }
289
290 /**
291 * adding ltv column to fct_customers table
292 */
293 if (!Schema::hasColumn('ltv', 'fct_customers')) {
294 $table_name = $wpdb->prefix . 'fct_customers';
295 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
296 $wpdb->query($wpdb->prepare(
297 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
298 "ALTER TABLE %i ADD COLUMN `ltv` BIGINT NOT NULL DEFAULT '0' AFTER `purchase_count`",
299 $table_name
300 ));
301 }
302
303 /**
304 * adding states column to fct_shipping_methods table
305 */
306 if (!Schema::hasColumn('states', 'fct_shipping_methods')) {
307 $table_name = $wpdb->prefix . 'fct_shipping_methods';
308 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
309 $wpdb->query($wpdb->prepare(
310 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
311 "ALTER TABLE %i ADD COLUMN `states` LONGTEXT NULL AFTER `is_enabled`",
312 $table_name
313 ));
314 }
315
316 /**
317 * modify states column to json in fct_shipping_methods table
318 */
319 if (Schema::hasColumn('states', 'fct_shipping_methods')) {
320 $table_name = $wpdb->prefix . 'fct_shipping_methods';
321 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
322 $wpdb->query($wpdb->prepare(
323 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
324 "ALTER TABLE %i MODIFY COLUMN `states` JSON NULL",
325 $table_name
326 ));
327 }
328
329 /**
330 * Changing fct_shipping_zones.regions to fct_shipping_zones.region
331 */
332 if (Schema::hasColumn('regions', 'fct_shipping_zones')) {
333 $table_name = $wpdb->prefix . 'fct_shipping_zones';
334 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
335 $wpdb->query($wpdb->prepare(
336 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
337 "ALTER TABLE %i CHANGE `regions` `region` VARCHAR(192) NOT NULL",
338 $table_name
339 ));
340 }
341
342 /**
343 * adding uuid column to fct_subscriptions table
344 */
345
346 if (!Schema::hasColumn('uuid', 'fct_subscriptions')) {
347 $table_name = $wpdb->prefix . 'fct_subscriptions';
348 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
349 $wpdb->query($wpdb->prepare(
350 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
351 "ALTER TABLE %i ADD COLUMN `uuid` VARCHAR(100) NOT NULL AFTER `id`",
352 $table_name
353 ));
354 }
355
356 if (Schema::hasColumn('initial_amount', 'fct_subscriptions')) {
357 $table_name = $wpdb->prefix . 'fct_subscriptions';
358 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
359 $wpdb->query($wpdb->prepare(
360 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
361 "ALTER TABLE %i CHANGE `initial_amount` `signup_fee` BIGINT UNSIGNED NOT NULL DEFAULT 0",
362 $table_name
363 ));
364 }
365
366 SubscriptionsMigrator::backfillEmptyUuids();
367
368 if (!Schema::hasColumn('meta', 'fct_order_addresses')) {
369 $table_name = $wpdb->prefix . 'fct_order_addresses';
370 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
371 $wpdb->query($wpdb->prepare(
372 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
373 "ALTER TABLE %i ADD COLUMN `meta` JSON DEFAULT NULL AFTER `country`",
374 $table_name
375 ));
376 }
377
378 if (!Schema::hasColumn('meta', 'fct_customer_addresses')) {
379 $table_name = $wpdb->prefix . 'fct_customer_addresses';
380 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
381 $wpdb->query($wpdb->prepare(
382 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
383 "ALTER TABLE %i ADD COLUMN `meta` JSON DEFAULT NULL AFTER `country`",
384 $table_name
385 ));
386 }
387
388 if (!Schema::hasColumn('meta', 'fct_order_tax_rate')) {
389 $table_name = $wpdb->prefix . 'fct_order_tax_rate';
390 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
391 $wpdb->query($wpdb->prepare(
392 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
393 "ALTER TABLE %i ADD COLUMN `meta` JSON",
394 $table_name
395 ));
396 }
397
398 if (!Schema::hasColumn('filed_at', 'fct_order_tax_rate')) {
399 $table_name = $wpdb->prefix . 'fct_order_tax_rate';
400 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
401 $wpdb->query($wpdb->prepare(
402 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
403 "ALTER TABLE %i ADD COLUMN `filed_at` DATETIME NULL AFTER `meta`",
404 $table_name
405 ));
406 }
407
408 if (Schema::hasColumn('categories', 'fct_tax_classes') && !Schema::hasColumn('meta', 'fct_tax_classes')) {
409 $table_name = $wpdb->prefix . 'fct_tax_classes';
410 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
411 $wpdb->query($wpdb->prepare(
412 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
413 "ALTER TABLE %i CHANGE `categories` `meta` JSON",
414 $table_name
415 ));
416 }
417
418 if (!Schema::hasColumn('meta', 'fct_tax_classes')) {
419 $table_name = $wpdb->prefix . 'fct_tax_classes';
420 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
421 $wpdb->query($wpdb->prepare(
422 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
423 "ALTER TABLE %i ADD COLUMN `meta` JSON",
424 $table_name
425 ));
426 }
427
428 if (!Schema::hasColumn('description', 'fct_tax_classes')) {
429 $table_name = $wpdb->prefix . 'fct_tax_classes';
430 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
431 $wpdb->query($wpdb->prepare(
432 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
433 "ALTER TABLE %i ADD COLUMN `description` LONGTEXT NULL AFTER `title`",
434 $table_name
435 ));
436 }
437
438 if (!Schema::hasColumn('group', 'fct_tax_rates')) {
439 $table_name = $wpdb->prefix . 'fct_tax_rates';
440 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
441 $wpdb->query($wpdb->prepare(
442 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
443 "ALTER TABLE %i ADD COLUMN `group` VARCHAR(45) NULL AFTER `name`",
444 $table_name
445 ));
446 }
447
448 if (!Schema::hasColumn('meta', 'fct_shipping_methods')) {
449 $table_name = $wpdb->prefix . 'fct_shipping_methods';
450 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
451 $wpdb->query($wpdb->prepare(
452 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
453 "ALTER TABLE %i ADD COLUMN `meta` JSON NULL AFTER `order`",
454 $table_name
455 ));
456
457 }
458
459 // Shipping schema upgrades — also run here so plugin updates
460 // without deactivation/reactivation still apply new columns
461 \FluentCart\Database\Migrations\ShippingZonesMigrator::migrated();
462 \FluentCart\Database\Migrations\ShippingClassesMigrator::migrated();
463 \FluentCart\Database\Migrations\SubscriptionsMigrator::migrated();
464
465
466 // 2026-05-06
467 // Backfill, dedup, then add unique index — must run in this order
468 // so the constraint can be applied without "Duplicate entry" errors.
469 TaxClassesMigrator::backfillNullSlugs();
470 TaxClassesMigrator::deduplicateSlugs();
471 TaxClassesMigrator::addSlugUniqueIndex();
472
473 // Ensure Standard tax class exists for all installs
474 TaxClassesMigrator::seedDefaultTaxClass();
475
476 // 2026-05-15
477 // Move EU VAT country registrations from wp_options blob to fct_meta rows.
478 \FluentCart\Database\Migrations\EuVatRegistrationMigrator::migrate();
479
480 // 2026-06-10
481 // One-time migration: move legacy price_suffix into the correct split field
482 // based on the store's tax_inclusion setting at the time of migration.
483 if (!get_option('_fluent_cart_price_suffix_migrated')) {
484 $taxSettings = get_option('fluent_cart_tax_configuration_settings', []);
485 $legacySuffix = isset($taxSettings['price_suffix']) ? $taxSettings['price_suffix'] : '';
486 $hasIncluded = isset($taxSettings['price_suffix_included']) && $taxSettings['price_suffix_included'] !== '';
487 $hasExcluded = isset($taxSettings['price_suffix_excluded']) && $taxSettings['price_suffix_excluded'] !== '';
488
489 if ($legacySuffix !== '' && !$hasIncluded && !$hasExcluded) {
490 $taxInclusion = isset($taxSettings['tax_inclusion']) ? $taxSettings['tax_inclusion'] : 'excluded';
491 if ($taxInclusion === 'excluded') {
492 $taxSettings['price_suffix_excluded'] = $legacySuffix;
493 } else {
494 $taxSettings['price_suffix_included'] = $legacySuffix;
495 }
496 update_option('fluent_cart_tax_configuration_settings', $taxSettings, true);
497 }
498 update_option('_fluent_cart_price_suffix_migrated', '1', 'no');
499 }
500
501 // 2026-05-18
502 // One-time migration: copy seller identity fields from the Pro seller_details fct_meta
503 // entry into fluent_cart_store_settings. Guarded by a sentinel so it runs exactly once.
504 if (!get_option('_fluent_cart_seller_details_migrated')) {
505 $sellerDetails = fluent_cart_get_option('seller_details', []);
506 if (is_array($sellerDetails) && !empty($sellerDetails)) {
507 $storeSettingsOption = get_option('fluent_cart_store_settings', []);
508 if (!is_array($storeSettingsOption)) {
509 $storeSettingsOption = [];
510 }
511 $fieldMap = [
512 'seller_vat_id' => 'seller_vat_id',
513 'seller_tax_id' => 'seller_tax_id',
514 'seller_legal_name' => 'company_name',
515 'seller_legal_registration_id' => 'legal_registration_id',
516 ];
517 $changed = false;
518 foreach ($fieldMap as $fromKey => $toKey) {
519 if (!empty($sellerDetails[$fromKey]) && empty($storeSettingsOption[$toKey])) {
520 $storeSettingsOption[$toKey] = sanitize_text_field($sellerDetails[$fromKey]);
521 $changed = true;
522 }
523 }
524 if ($changed) {
525 update_option('fluent_cart_store_settings', $storeSettingsOption, true);
526 }
527 }
528 update_option('_fluent_cart_seller_details_migrated', '1', 'no');
529 }
530
531 // 2026-06-03
532 // Fix: convert zero-date datetime columns to NULL (Issue #1911).
533 // Zero-dates arise on MySQL servers without STRICT_TRANS_TABLES when an
534 // empty string is written to a DATETIME column.
535 $table_name = $wpdb->prefix . 'fct_subscriptions';
536 foreach (['next_billing_date', 'canceled_at', 'expire_at'] as $col) {
537 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
538 $wpdb->query($wpdb->prepare(
539 "UPDATE %i SET `{$col}` = NULL WHERE `{$col}` IN ('0000-00-00 00:00:00', '0000-00-00')",
540 $table_name
541 ));
542 }
543
544 self::releaseMigrationLock();
545 }
546 }
547
548 /**
549 * Try to acquire the cross-request migration lock.
550 *
551 * Uses a MySQL advisory lock (GET_LOCK with zero wait) so only one request
552 * runs the schema upgrade at a time. If the connection dies mid-migration,
553 * MySQL releases the lock automatically when the connection closes.
554 *
555 * @return bool True when this request may run the migration.
556 */
557 private static function acquireMigrationLock()
558 {
559 global $wpdb;
560
561 if (Schema::isSqlite()) {
562 // GET_LOCK is MySQL-only; SQLite has no concurrent writers.
563 return true;
564 }
565
566 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
567 $acquired = $wpdb->get_var($wpdb->prepare(
568 "SELECT GET_LOCK(%s, 0)",
569 self::getMigrationLockName()
570 ));
571
572 // NULL means the server could not create the lock — fail open so a
573 // locking hiccup can never block schema upgrades entirely.
574 return $acquired === null || (string)$acquired === '1';
575 }
576
577 private static function releaseMigrationLock()
578 {
579 global $wpdb;
580
581 if (Schema::isSqlite()) {
582 return;
583 }
584
585 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
586 $wpdb->query($wpdb->prepare(
587 "SELECT RELEASE_LOCK(%s)",
588 self::getMigrationLockName()
589 ));
590 }
591
592 private static function getMigrationLockName()
593 {
594 global $wpdb;
595
596 // GET_LOCK names are server-wide; scope to this site's DB and prefix
597 // so two WordPress installs on one MySQL server can't block each other.
598 $dbName = defined('DB_NAME') ? DB_NAME : '';
599
600 return 'fct_db_migration_' . md5($dbName . '|' . $wpdb->prefix);
601 }
602
603 public static function migrateDown($network_wide = false)
604 {
605 /**
606 * @var $migrator Migrator
607 */
608 foreach (self::$migrators as $migrator) {
609 $migrator::dropTable();
610 }
611
612 Product::query()->where('post_type', '=', FluentProducts::CPT_NAME)->delete();
613
614 //Migrate Down The Licenses
615 if (class_exists(License::class)) {
616 License::query()->truncate();
617 LicenseActivation::query()->truncate();
618 LicenseSite::query()->truncate();
619 LicenseMeta::query()->truncate();
620 }
621 }
622
623 public static function refresh($network_wide = false)
624 {
625 static::migrateDown($network_wide);
626 static::migrateUp($network_wide);
627 }
628 }
629