PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
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
← All changes | database/DBMigrator.php +252 -8 1.3.26 → 1.6.5 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,15 @@
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();
133 140
134 141 /*
135 142 * TODO We will remove this after final release
136 143 */
@@ -137,10 +144,46 @@
137 144 $currentDBVersion = get_option('_fluent_cart_db_version');
138 145
139 146 if (!$currentDBVersion || version_compare($currentDBVersion, FLUENTCART_DB_VERSION, '<')) {
140 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 +
141 158 update_option('_fluent_cart_db_version', FLUENTCART_DB_VERSION, 'no');
142 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 +
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 +
143 186 // let's check the orders table sequence number
144 187 global $wpdb;
145 188
146 189 // Product Meta unique index removal
@@ -440,10 +483,211 @@
440 483 // Shipping schema upgrades — also run here so plugin updates
441 484 // without deactivation/reactivation still apply new columns
442 485 \FluentCart\Database\Migrations\ShippingZonesMigrator::migrated();
443 486 \FluentCart\Database\Migrations\ShippingClassesMigrator::migrated();
487 + \FluentCart\Database\Migrations\SubscriptionsMigrator::migrated();
444 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();
445 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);
446 690 }
447 691
448 692 public static function migrateDown($network_wide = false)
449 693 {