| @@ -2,9 +2,11 @@ | ||
| 2 | 2 | |
| 3 | 3 | namespace FluentCart\Database\Migrations; |
| 4 | 4 | |
| 5 | 5 | |
| 6 | +use FluentCart\App\Models\TaxClass; | |
| 6 | 7 | use FluentCart\Framework\Database\Schema; |
| 8 | +use FluentCart\Framework\Support\Str; | |
| 7 | 9 | |
| 8 | 10 | class TaxClassesMigrator extends Migrator |
| 9 | 11 | { |
| 10 | 12 | |
| @@ -27,8 +29,12 @@ | ||
| 27 | 29 | static::renameCategoriesToMeta(); |
| 28 | 30 | static::addMetaColumn(); |
| 29 | 31 | static::addSlugColumn(); |
| 30 | 32 | static::addDescriptionColumn(); |
| 33 | + static::backfillNullSlugs(); | |
| 34 | + static::deduplicateSlugs(); | |
| 35 | + static::addSlugUniqueIndex(); | |
| 36 | + static::seedDefaultTaxClass(); | |
| 31 | 37 | } |
| 32 | 38 | |
| 33 | 39 | public static function renameCategoriesToMeta() |
| 34 | 40 | { |
| @@ -57,6 +63,113 @@ | ||
| 57 | 63 | public static function addDescriptionColumn() |
| 58 | 64 | { |
| 59 | 65 | // "ALTER TABLE %i ADD COLUMN `description` LONGTEXT NULL AFTER `slug`" |
| 60 | 66 | static::addColumnIfNotExists('description', 'LONGTEXT NULL', 'slug'); |
| 67 | + } | |
| 68 | + | |
| 69 | + /** | |
| 70 | + * Backfill NULL slugs for rows created before the slug column existed. | |
| 71 | + */ | |
| 72 | + public static function backfillNullSlugs() | |
| 73 | + { | |
| 74 | + $rows = TaxClass::query()->whereNull('slug')->get(); | |
| 75 | + | |
| 76 | + foreach ($rows as $row) { | |
| 77 | + $base = Str::slug($row->title); | |
| 78 | + if (!$base) { | |
| 79 | + $base = 'tax-class'; | |
| 80 | + } | |
| 81 | + | |
| 82 | + $slug = $base; | |
| 83 | + $suffix = 2; | |
| 84 | + | |
| 85 | + while (TaxClass::query()->where('slug', $slug)->where('id', '!=', $row->id)->exists()) { | |
| 86 | + $slug = $base . '-' . $suffix; | |
| 87 | + $suffix++; | |
| 88 | + } | |
| 89 | + | |
| 90 | + TaxClass::query()->where('id', $row->id)->update(['slug' => $slug]); | |
| 91 | + } | |
| 92 | + } | |
| 93 | + | |
| 94 | + /** | |
| 95 | + * Remove duplicate slug rows, keeping the one with the lowest ID. | |
| 96 | + * Repoints `fct_tax_rates.class_id` references to the kept row first | |
| 97 | + * to avoid orphaning tax rates. | |
| 98 | + */ | |
| 99 | + public static function deduplicateSlugs() | |
| 100 | + { | |
| 101 | + global $wpdb; | |
| 102 | + $table = $wpdb->prefix . static::$tableName; | |
| 103 | + $ratesTable = $wpdb->prefix . 'fct_tax_rates'; | |
| 104 | + | |
| 105 | + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching | |
| 106 | + $duplicates = $wpdb->get_results( | |
| 107 | + "SELECT `slug`, MIN(`id`) AS keep_id, COUNT(*) AS cnt FROM `{$table}` WHERE `slug` IS NOT NULL GROUP BY `slug` HAVING cnt > 1" | |
| 108 | + ); | |
| 109 | + | |
| 110 | + if (empty($duplicates)) { | |
| 111 | + return; | |
| 112 | + } | |
| 113 | + | |
| 114 | + foreach ($duplicates as $dup) { | |
| 115 | + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching | |
| 116 | + $deleteIds = $wpdb->get_col($wpdb->prepare( | |
| 117 | + "SELECT `id` FROM `{$table}` WHERE `slug` = %s AND `id` != %d", | |
| 118 | + $dup->slug, | |
| 119 | + $dup->keep_id | |
| 120 | + )); | |
| 121 | + | |
| 122 | + if (empty($deleteIds)) { | |
| 123 | + continue; | |
| 124 | + } | |
| 125 | + | |
| 126 | + $placeholders = implode(',', array_fill(0, count($deleteIds), '%d')); | |
| 127 | + | |
| 128 | + // Repoint tax rates to the kept class so rates aren't orphaned. | |
| 129 | + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching | |
| 130 | + $wpdb->query($wpdb->prepare( | |
| 131 | + "UPDATE `{$ratesTable}` SET `class_id` = %d WHERE `class_id` IN ({$placeholders})", | |
| 132 | + array_merge([$dup->keep_id], $deleteIds) | |
| 133 | + )); | |
| 134 | + | |
| 135 | + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching | |
| 136 | + $wpdb->query($wpdb->prepare( | |
| 137 | + "DELETE FROM `{$table}` WHERE `id` IN ({$placeholders})", | |
| 138 | + $deleteIds | |
| 139 | + )); | |
| 140 | + } | |
| 141 | + } | |
| 142 | + | |
| 143 | + /** | |
| 144 | + * Add a UNIQUE index on the slug column. | |
| 145 | + */ | |
| 146 | + public static function addSlugUniqueIndex() | |
| 147 | + { | |
| 148 | + $indexName = static::getDbPrefix() . 'fct_tcl_slug_unq'; | |
| 149 | + static::addIndexIfNotExists($indexName, 'slug', true); | |
| 150 | + } | |
| 151 | + | |
| 152 | + public static function seedDefaultTaxClass() | |
| 153 | + { | |
| 154 | + global $wpdb; | |
| 155 | + | |
| 156 | + if (TaxClass::query()->where('slug', 'standard')->exists()) { | |
| 157 | + return; | |
| 158 | + } | |
| 159 | + | |
| 160 | + $table = static::getTableName(); | |
| 161 | + | |
| 162 | + // INSERT IGNORE so a concurrent migration run that inserted the row | |
| 163 | + // between our exists() check and this insert can't raise a | |
| 164 | + // duplicate-entry error on the unique slug index. | |
| 165 | + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching | |
| 166 | + $wpdb->query($wpdb->prepare( | |
| 167 | + "INSERT IGNORE INTO %i (`slug`, `title`, `created_at`, `updated_at`) VALUES (%s, %s, %s, %s)", | |
| 168 | + $table, | |
| 169 | + 'standard', | |
| 170 | + 'Standard', | |
| 171 | + current_time('mysql', true), | |
| 172 | + current_time('mysql', true) | |
| 173 | + )); | |
| 61 | 174 | } |
| 62 | 175 | } |