PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.5.3
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.5.3
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.5.3, at database/DBMigrator.php

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