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 +165 -9 1.4.0 → 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,
@@ -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,20 +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
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 + }
169 +
142 170 // 2026-04-25
143 171 TaxRatesMigrator::upgradeShippingOverridePrecision();
144 172
173 + // 2026-07-01
174 + OrderTaxRateMigrator::allowVirtualTaxRateIds();
175 +
145 176 // 2026-05-27
146 177 TaxRatesMigrator::fixPostcodeRangeSeparator();
147 178 OrdersMigrator::addFeeTotalColumn();
148 179
149 - // 2026-05-10
150 - AttributeGroupsMigrator::dropLegacyTitleUniqueIndexes();
180 + // 2026-07-24
181 + OrdersMigrator::addUuidIndex();
151 182
183 + // 2026-08-20
184 + ProductMetaMigrator::addObjectMetaIndex();
185 +
152 186 // let's check the orders table sequence number
153 187 global $wpdb;
154 188
155 189 // Product Meta unique index removal
@@ -451,9 +485,15 @@
451 485 \FluentCart\Database\Migrations\ShippingZonesMigrator::migrated();
452 486 \FluentCart\Database\Migrations\ShippingClassesMigrator::migrated();
453 487 \FluentCart\Database\Migrations\SubscriptionsMigrator::migrated();
454 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();
455 494
495 +
456 496 // 2026-05-06
457 497 // Backfill, dedup, then add unique index — must run in this order
458 498 // so the constraint can be applied without "Duplicate entry" errors.
459 499 TaxClassesMigrator::backfillNullSlugs();
@@ -487,8 +527,21 @@
487 527 }
488 528 update_option('_fluent_cart_price_suffix_migrated', '1', 'no');
489 529 }
490 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 +
491 544 // 2026-05-18
492 545 // One-time migration: copy seller identity fields from the Pro seller_details fct_meta
493 546 // entry into fluent_cart_store_settings. Guarded by a sentinel so it runs exactly once.
494 547 if (!get_option('_fluent_cart_seller_details_migrated')) {
@@ -529,9 +582,112 @@
529 582 "UPDATE %i SET `{$col}` = NULL WHERE `{$col}` IN ('0000-00-00 00:00:00', '0000-00-00')",
530 583 $table_name
531 584 ));
532 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();
533 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);
534 690 }
535 691
536 692 public static function migrateDown($network_wide = false)
537 693 {