PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.6
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.6
1.6.6 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 All 49 releases
← All changes | database/DBMigrator.php +252 -10 1.3.28 → 1.6.6 View file →
@@ -6,11 +6,8 @@
6 6 use FluentCart\App\Models\Product;
7 7
8 8 require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
9 9
10 -use FluentCart\Database\Migrations\AttributeGroupsMigrator;
11 -use FluentCart\Database\Migrations\AttributeObjectRelationsMigrator;
12 -use FluentCart\Database\Migrations\AttributeTermsMigrator;
13 10 use FluentCart\Database\Migrations\CartMigrator;
14 11 use FluentCart\Database\Migrations\CustomersMigrator;
15 12 use FluentCart\Database\Migrations\MetaMigrator;
16 13 use FluentCart\Database\Migrations\Migrator;
@@ -47,16 +44,17 @@
47 44 use FluentCartPro\App\Modules\Licensing\Models\LicenseSite;
48 45 use FluentCart\Database\Migrations\ShippingZonesMigrator;
49 46 use FluentCart\Database\Migrations\ShippingMethodsMigrator;
50 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;
51 52
52 53 class DBMigrator
53 54 {
54 55 private static array $migrators = [
55 56 MetaMigrator::class,
56 - AttributeGroupsMigrator::class,
57 - AttributeObjectRelationsMigrator::class,
58 - AttributeTermsMigrator::class,
59 57 CartMigrator::class,
60 58 CouponsMigrator::class,
61 59 CustomerAddressesMigrator::class,
62 60 CustomerMetaMigrator::class,
@@ -62,13 +60,13 @@
62 60 CustomerMetaMigrator::class,
63 61 CustomersMigrator::class,
64 62 OrderAddressesMigrator::class,
65 63 OrderDownloadPermissionsMigrator::class,
66 - OrderMetaMigrator::class,
67 64 OrderOperationsMigrator::class,
68 65 OrdersItemsMigrator::class,
69 66 OrdersMigrator::class,
70 67 OrderTaxRateMigrator::class,
68 + OrderMetaMigrator::class,
71 69 OrderTransactionsMigrator::class,
72 70 ProductDetailsMigrator::class,
73 71 ProductDownloadsMigrator::class,
74 72 ProductMetaMigrator::class,
@@ -85,9 +83,15 @@
85 83 ShippingZonesMigrator::class,
86 84 ShippingMethodsMigrator::class,
87 85 ShippingClassesMigrator::class,
88 86 ScheduledActionsMigrator::class,
89 - RetentionSnapshotsMigrator::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
90 94 ];
91 95
92 96 public static function migrateUp($network_wide = false)
93 97 {
@@ -125,12 +129,16 @@
125 129 */
126 130 foreach (self::$migrators as $migrator) {
127 131 $migrator::migrate();
128 132 }
133 +
134 + do_action('fluent_cart/after_migrate');
129 135 }
130 136
131 137 public static function maybeMigrateDBChanges()
132 138 {
139 + self::maybeMigrateCustomerRecoveryIndexes();
140 +
133 141 /*
134 142 * TODO We will remove this after final release
135 143 */
136 144 $currentDBVersion = get_option('_fluent_cart_db_version');
@@ -136,13 +144,46 @@
136 144 $currentDBVersion = get_option('_fluent_cart_db_version');
137 145
138 146 if (!$currentDBVersion || version_compare($currentDBVersion, FLUENTCART_DB_VERSION, '<')) {
139 147
148 + // Right after a plugin update, multiple requests (admin page +
149 + // heartbeat/ajax) hit init before the version option is written and
150 + // would all run the schema changes in parallel, producing
151 + // duplicate-index / duplicate-entry errors. Only the request that
152 + // wins the advisory lock proceeds; the others skip — the winner
153 + // updates the version option for everyone.
154 + if (!self::acquireMigrationLock()) {
155 + return;
156 + }
157 +
140 158 update_option('_fluent_cart_db_version', FLUENTCART_DB_VERSION, 'no');
141 159
142 - // 2026-05-10
143 - AttributeGroupsMigrator::dropLegacyTitleUniqueIndexes();
160 + if (!$currentDBVersion) {
161 + // Brand-new store: default tax display to the simplified single line.
162 + // Existing stores fall through to the read-time default 'itemized'.
163 + $taxSettings = get_option('fluent_cart_tax_configuration_settings', []);
164 + if (!isset($taxSettings['checkout_tax_breakdown_display'])) {
165 + $taxSettings['checkout_tax_breakdown_display'] = 'simplified';
166 + update_option('fluent_cart_tax_configuration_settings', $taxSettings, true);
167 + }
168 + }
144 169
170 + // 2026-04-25
171 + TaxRatesMigrator::upgradeShippingOverridePrecision();
172 +
173 + // 2026-07-01
174 + OrderTaxRateMigrator::allowVirtualTaxRateIds();
175 +
176 + // 2026-05-27
177 + TaxRatesMigrator::fixPostcodeRangeSeparator();
178 + OrdersMigrator::addFeeTotalColumn();
179 +
180 + // 2026-07-24
181 + OrdersMigrator::addUuidIndex();
182 +
183 + // 2026-08-20
184 + ProductMetaMigrator::addObjectMetaIndex();
185 +
145 186 // let's check the orders table sequence number
146 187 global $wpdb;
147 188
148 189 // Product Meta unique index removal
@@ -442,10 +483,211 @@
442 483 // Shipping schema upgrades — also run here so plugin updates
443 484 // without deactivation/reactivation still apply new columns
444 485 \FluentCart\Database\Migrations\ShippingZonesMigrator::migrated();
445 486 \FluentCart\Database\Migrations\ShippingClassesMigrator::migrated();
487 + \FluentCart\Database\Migrations\SubscriptionsMigrator::migrated();
446 488
489 + // 2026-08-06
490 + // fct_order_transactions subscription_id index — also run here so
491 + // in-place plugin updates deliver it; migrate() only covers the
492 + // activation path.
493 + \FluentCart\Database\Migrations\OrderTransactionsMigrator::migrated();
494 +
495 +
496 + // 2026-05-06
497 + // Backfill, dedup, then add unique index — must run in this order
498 + // so the constraint can be applied without "Duplicate entry" errors.
499 + TaxClassesMigrator::backfillNullSlugs();
500 + TaxClassesMigrator::deduplicateSlugs();
501 + TaxClassesMigrator::addSlugUniqueIndex();
502 +
503 + // Ensure Standard tax class exists for all installs
504 + TaxClassesMigrator::seedDefaultTaxClass();
505 +
506 + // 2026-05-15
507 + // Move EU VAT country registrations from wp_options blob to fct_meta rows.
508 + \FluentCart\Database\Migrations\EuVatRegistrationMigrator::migrate();
509 +
510 + // 2026-06-10
511 + // One-time migration: move legacy price_suffix into the correct split field
512 + // based on the store's tax_inclusion setting at the time of migration.
513 + if (!get_option('_fluent_cart_price_suffix_migrated')) {
514 + $taxSettings = get_option('fluent_cart_tax_configuration_settings', []);
515 + $legacySuffix = isset($taxSettings['price_suffix']) ? $taxSettings['price_suffix'] : '';
516 + $hasIncluded = isset($taxSettings['price_suffix_included']) && $taxSettings['price_suffix_included'] !== '';
517 + $hasExcluded = isset($taxSettings['price_suffix_excluded']) && $taxSettings['price_suffix_excluded'] !== '';
518 +
519 + if ($legacySuffix !== '' && !$hasIncluded && !$hasExcluded) {
520 + $taxInclusion = isset($taxSettings['tax_inclusion']) ? $taxSettings['tax_inclusion'] : 'excluded';
521 + if ($taxInclusion === 'excluded') {
522 + $taxSettings['price_suffix_excluded'] = $legacySuffix;
523 + } else {
524 + $taxSettings['price_suffix_included'] = $legacySuffix;
525 + }
526 + update_option('fluent_cart_tax_configuration_settings', $taxSettings, true);
527 + }
528 + update_option('_fluent_cart_price_suffix_migrated', '1', 'no');
529 + }
530 +
531 + // 2026-07-08
532 + // One-time migration: rename the legacy tax-display value 'both'
533 + // (and the removed 'label'/'tooltip') to 'itemized'. Value-guarded —
534 + // once migrated the stored value is 'itemized' so this no-ops; no
535 + // separate flag option is written (nothing left behind when this
536 + // block is removed later). Safe to remove after ~2027-07.
537 + $fctTaxSettings = get_option('fluent_cart_tax_configuration_settings', []);
538 + if (isset($fctTaxSettings['checkout_tax_breakdown_display'])
539 + && in_array($fctTaxSettings['checkout_tax_breakdown_display'], ['both', 'label', 'tooltip'], true)) {
540 + $fctTaxSettings['checkout_tax_breakdown_display'] = 'itemized';
541 + update_option('fluent_cart_tax_configuration_settings', $fctTaxSettings, true);
542 + }
543 +
544 + // 2026-05-18
545 + // One-time migration: copy seller identity fields from the Pro seller_details fct_meta
546 + // entry into fluent_cart_store_settings. Guarded by a sentinel so it runs exactly once.
547 + if (!get_option('_fluent_cart_seller_details_migrated')) {
548 + $sellerDetails = fluent_cart_get_option('seller_details', []);
549 + if (is_array($sellerDetails) && !empty($sellerDetails)) {
550 + $storeSettingsOption = get_option('fluent_cart_store_settings', []);
551 + if (!is_array($storeSettingsOption)) {
552 + $storeSettingsOption = [];
553 + }
554 + $fieldMap = [
555 + 'seller_vat_id' => 'seller_vat_id',
556 + 'seller_tax_id' => 'seller_tax_id',
557 + 'seller_legal_name' => 'company_name',
558 + 'seller_legal_registration_id' => 'legal_registration_id',
559 + ];
560 + $changed = false;
561 + foreach ($fieldMap as $fromKey => $toKey) {
562 + if (!empty($sellerDetails[$fromKey]) && empty($storeSettingsOption[$toKey])) {
563 + $storeSettingsOption[$toKey] = sanitize_text_field($sellerDetails[$fromKey]);
564 + $changed = true;
565 + }
566 + }
567 + if ($changed) {
568 + update_option('fluent_cart_store_settings', $storeSettingsOption, true);
569 + }
570 + }
571 + update_option('_fluent_cart_seller_details_migrated', '1', 'no');
572 + }
573 +
574 + // 2026-06-03
575 + // Fix: convert zero-date datetime columns to NULL (Issue #1911).
576 + // Zero-dates arise on MySQL servers without STRICT_TRANS_TABLES when an
577 + // empty string is written to a DATETIME column.
578 + $table_name = $wpdb->prefix . 'fct_subscriptions';
579 + foreach (['next_billing_date', 'canceled_at', 'expire_at'] as $col) {
580 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
581 + $wpdb->query($wpdb->prepare(
582 + "UPDATE %i SET `{$col}` = NULL WHERE `{$col}` IN ('0000-00-00 00:00:00', '0000-00-00')",
583 + $table_name
584 + ));
585 + }
586 +
587 + // 2026-06-17
588 + // Advanced Variation - Create the attribute
589 + // schema (and seed the system-template groups) on in-place plugin
590 + // updates too — migrate() only runs on activation, but existing
591 + // installs reach this version-gated path without reactivating. Each
592 + // migrator is idempotent (dbDelta) and the seeder early-returns when
593 + // any group already exists, so re-runs never duplicate data. Order
594 + // matters: relations table must exist before AttributeTermsMigrator's
595 + // post-migration JOIN cleanup.
596 + AttributeGroupsMigrator::migrate();
597 + AttributeObjectRelationsMigrator::migrate();
598 + AttributeTermsMigrator::migrate();
599 + AttributeSeeder::seed();
600 +
601 + self::releaseMigrationLock();
447 602 }
603 + }
604 +
605 + /** Upgrade active stores without running index DDL on customer requests. */
606 + public static function maybeMigrateCustomerRecoveryIndexes(): void
607 + {
608 + $option = '_fluent_cart_customer_recovery_indexes_version';
609 + if (get_option($option) === '1') {
610 + return;
611 + }
612 + if (!(defined('WP_CLI') && WP_CLI) && (!is_admin() || !current_user_can('manage_options'))) {
613 + return;
614 + }
615 + if (!self::acquireMigrationLock()) {
616 + return;
617 + }
618 + try {
619 + if (get_option($option) === '1') {
620 + return;
621 + }
622 + foreach ([SubscriptionsMigrator::class, OrderDownloadPermissionsMigrator::class, CartMigrator::class] as $migrator) {
623 + if (!Schema::hasTable($migrator::$tableName)) {
624 + return;
625 + }
626 + $migrator::addCustomerRecoveryIndex();
627 + if (!$migrator::hasCustomerRecoveryIndex()) {
628 + return; // Keep the upgrade pending if ALTER failed.
629 + }
630 + }
631 + update_option($option, '1', false);
632 + } finally {
633 + self::releaseMigrationLock();
634 + }
635 + }
636 +
637 + /**
638 + * Try to acquire the cross-request migration lock.
639 + *
640 + * Uses a MySQL advisory lock (GET_LOCK with zero wait) so only one request
641 + * runs the schema upgrade at a time. If the connection dies mid-migration,
642 + * MySQL releases the lock automatically when the connection closes.
643 + *
644 + * @return bool True when this request may run the migration.
645 + */
646 + private static function acquireMigrationLock()
647 + {
648 + global $wpdb;
649 +
650 + if (Schema::isSqlite()) {
651 + // GET_LOCK is MySQL-only; SQLite has no concurrent writers.
652 + return true;
653 + }
654 +
655 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
656 + $acquired = $wpdb->get_var($wpdb->prepare(
657 + "SELECT GET_LOCK(%s, 0)",
658 + self::getMigrationLockName()
659 + ));
660 +
661 + // NULL means the server could not create the lock — fail open so a
662 + // locking hiccup can never block schema upgrades entirely.
663 + return $acquired === null || (string)$acquired === '1';
664 + }
665 +
666 + private static function releaseMigrationLock()
667 + {
668 + global $wpdb;
669 +
670 + if (Schema::isSqlite()) {
671 + return;
672 + }
673 +
674 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
675 + $wpdb->query($wpdb->prepare(
676 + "SELECT RELEASE_LOCK(%s)",
677 + self::getMigrationLockName()
678 + ));
679 + }
680 +
681 + private static function getMigrationLockName()
682 + {
683 + global $wpdb;
684 +
685 + // GET_LOCK names are server-wide; scope to this site's DB and prefix
686 + // so two WordPress installs on one MySQL server can't block each other.
687 + $dbName = defined('DB_NAME') ? DB_NAME : '';
688 +
689 + return 'fct_db_migration_' . md5($dbName . '|' . $wpdb->prefix);
448 690 }
449 691
450 692 public static function migrateDown($network_wide = false)
451 693 {