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