| 1 |
<?php |
| 2 |
|
| 3 |
namespace FluentCart\Database\Seeder; |
| 4 |
|
| 5 |
use FluentCart\App\Models\AttributeGroup; |
| 6 |
use FluentCart\App\Models\AttributeTerm; |
| 7 |
|
| 8 |
class AttributeSeeder |
| 9 |
{ |
| 10 |
/** |
| 11 |
* JSON encoding flags must match AttributeGroup / AttributeTerm |
| 12 |
* setSettingsAttribute mutators. Bulk insert() bypasses mutators, so any |
| 13 |
* encoding-flag drift between seed-time and runtime writes would store |
| 14 |
* the same logical value with two different byte representations |
| 15 |
* (UTF-8 unicode + forward slashes are the practical divergences). |
| 16 |
*/ |
| 17 |
private const JSON_FLAGS = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES; |
| 18 |
|
| 19 |
public static function seed() |
| 20 |
{ |
| 21 |
// settings.type controls how the editor renders a group's term picker: |
| 22 |
// 'options' (default, text chips), 'color' (swatch + hex), 'image' (thumbnail). |
| 23 |
// Read by AdvancedVariationConfig.vue::getGroupType(). |
| 24 |
// |
| 25 |
// is_system = 1 marks these as protected store-level templates — the UI hides |
| 26 |
// destructive actions (edit/delete the group itself) on system rows. Merchant- |
| 27 |
// created groups stay is_system = 0 and are fully editable. |
| 28 |
// Every row must declare the same keys — WPFluent's bulk insert() builds the |
| 29 |
// column list from the first row and breaks if later rows omit a key. |
| 30 |
$now = gmdate('Y-m-d H:i:s'); |
| 31 |
|
| 32 |
$groups = [ |
| 33 |
self::groupRow('Color', 'color', json_encode(['type' => 'color'], self::JSON_FLAGS), $now), |
| 34 |
self::groupRow('Size', 'size', null, $now), |
| 35 |
self::groupRow('Material', 'material', null, $now), |
| 36 |
self::groupRow('Storage', 'storage', null, $now), |
| 37 |
self::groupRow('Memory (RAM)', 'memory', null, $now), |
| 38 |
self::groupRow('Weight', 'weight', null, $now), |
| 39 |
self::groupRow('Style', 'style', null, $now), |
| 40 |
self::groupRow('Pattern', 'pattern', null, $now), |
| 41 |
self::groupRow('Age Group', 'age-group', null, $now), |
| 42 |
self::groupRow('Target Gender', 'target-gender', null, $now), |
| 43 |
]; |
| 44 |
|
| 45 |
$systemSlugs = array_column($groups, 'slug'); |
| 46 |
|
| 47 |
// Slug-based idempotency, NOT table-emptiness. A merchant on an |
| 48 |
// older Pro could have created their own groups before this seeder |
| 49 |
// existed; the seeder must still backfill any of the 10 canonical |
| 50 |
// system templates that are missing. We detect which of OUR slugs |
| 51 |
// are already present and skip only those — never touching merchant |
| 52 |
// rows, never duplicating system rows. |
| 53 |
$existingSystemSlugs = AttributeGroup::query() |
| 54 |
->whereIn('slug', $systemSlugs) |
| 55 |
->pluck('slug') |
| 56 |
->toArray(); |
| 57 |
|
| 58 |
$missingSlugs = array_values(array_diff($systemSlugs, $existingSystemSlugs)); |
| 59 |
|
| 60 |
if (empty($missingSlugs)) { |
| 61 |
return; |
| 62 |
} |
| 63 |
|
| 64 |
// Term sets keyed by group slug. Plain strings = title only. Arrays let us |
| 65 |
// attach per-term settings (e.g. hex color for the swatch renderer). |
| 66 |
$terms = [ |
| 67 |
'color' => [ |
| 68 |
['title' => 'Red', 'color' => '#ef4444'], |
| 69 |
['title' => 'Blue', 'color' => '#3b82f6'], |
| 70 |
['title' => 'Green', 'color' => '#22c55e'], |
| 71 |
['title' => 'Yellow', 'color' => '#eab308'], |
| 72 |
['title' => 'Black', 'color' => '#000000'], |
| 73 |
['title' => 'White', 'color' => '#ffffff'], |
| 74 |
['title' => 'Grey', 'color' => '#6b7280'], |
| 75 |
['title' => 'Gold', 'color' => '#d4af37'], |
| 76 |
['title' => 'Silver', 'color' => '#c0c0c0'], |
| 77 |
['title' => 'Beige', 'color' => '#f5f5dc'], |
| 78 |
], |
| 79 |
'size' => ['XS', 'S', 'M', 'L', 'XL', 'XXL'], |
| 80 |
'material' => ['Cotton', 'Polyester', 'Silk', 'Wool', 'Leather', 'Linen', 'Nylon', 'Wood', 'Metal', 'Plastic'], |
| 81 |
'storage' => ['64 GB', '128 GB', '256 GB', '512 GB', '1 TB', '2 TB'], |
| 82 |
'memory' => ['4 GB', '8 GB', '16 GB', '32 GB', '64 GB'], |
| 83 |
'weight' => ['100 g', '250 g', '500 g', '1 kg', '2 kg', '5 kg'], |
| 84 |
'style' => ['Classic', 'Modern', 'Vintage', 'Minimalist'], |
| 85 |
'pattern' => ['Solid', 'Striped', 'Checked', 'Floral', 'Geometric'], |
| 86 |
'age-group' => ['Newborn', 'Infant', 'Toddler', 'Kids', 'Teen', 'Adult', 'All Ages'], |
| 87 |
'target-gender' => ['Female', 'Male', 'Unisex'], |
| 88 |
]; |
| 89 |
|
| 90 |
// Wrap the bulk-insert chain in a single transaction. Without this, a |
| 91 |
// partial failure during the per-group term insert leaves the store |
| 92 |
// with some groups seeded and others empty — and because the |
| 93 |
// idempotency guard above is slug-based, the next activation would |
| 94 |
// see the partially-seeded slugs as "already present" and skip |
| 95 |
// them, leaving their terms permanently missing. |
| 96 |
$db = AttributeGroup::query()->getConnection(); |
| 97 |
$db->beginTransaction(); |
| 98 |
$progress = null; |
| 99 |
|
| 100 |
try { |
| 101 |
// Authoritative re-check INSIDE the transaction. Closes the |
| 102 |
// race window between the pre-flight check above and |
| 103 |
// beginTransaction(): if a parallel process inserted any of |
| 104 |
// our missing system slugs while we were preparing, recompute |
| 105 |
// the set so the slug UNIQUE constraint can't trip us. |
| 106 |
$existingSystemSlugs = AttributeGroup::query() |
| 107 |
->whereIn('slug', $systemSlugs) |
| 108 |
->pluck('slug') |
| 109 |
->toArray(); |
| 110 |
$missingSlugs = array_values(array_diff($systemSlugs, $existingSystemSlugs)); |
| 111 |
|
| 112 |
if (empty($missingSlugs)) { |
| 113 |
$db->rollBack(); |
| 114 |
return; |
| 115 |
} |
| 116 |
|
| 117 |
// Filter the row set to only the missing slugs so we never |
| 118 |
// try to re-insert a system group the merchant (or a parallel |
| 119 |
// activation) already has. |
| 120 |
$missingSlugSet = array_flip($missingSlugs); |
| 121 |
$rowsToInsert = array_values(array_filter( |
| 122 |
$groups, |
| 123 |
static function ($row) use ($missingSlugSet) { |
| 124 |
return isset($missingSlugSet[$row['slug']]); |
| 125 |
} |
| 126 |
)); |
| 127 |
|
| 128 |
// Give each seeded template a real serial so it joins the manual |
| 129 |
// drag-order with a position instead of the column default (0). |
| 130 |
// Continue after the current max so templates seeded later (e.g. on |
| 131 |
// an upgrade where the merchant already has groups) append to the end |
| 132 |
// rather than colliding at 0. On a fresh install max is 0, so the |
| 133 |
// templates take 1..N in the curated order above (Color, Size, …). |
| 134 |
$nextSerial = (int) AttributeGroup::query()->max('serial'); |
| 135 |
foreach ($rowsToInsert as &$rowToInsert) { |
| 136 |
$rowToInsert['serial'] = ++$nextSerial; |
| 137 |
} |
| 138 |
unset($rowToInsert); |
| 139 |
|
| 140 |
if (!AttributeGroup::insert($rowsToInsert)) { |
| 141 |
throw new \RuntimeException('AttributeGroup::insert returned false during seeding.'); |
| 142 |
} |
| 143 |
|
| 144 |
// Fetch back ONLY the rows we just inserted so the term-seed |
| 145 |
// loop below doesn't accidentally re-seed terms for system |
| 146 |
// groups the merchant already had (or modified). |
| 147 |
$savedGroups = AttributeGroup::query()->whereIn('slug', $missingSlugs)->get(); |
| 148 |
|
| 149 |
if (defined('WP_CLI') && WP_CLI) { |
| 150 |
$progress = \WP_CLI\Utils\make_progress_bar( |
| 151 |
__('Seeding attributes', 'fluent-cart'), |
| 152 |
count($savedGroups) |
| 153 |
); |
| 154 |
} |
| 155 |
|
| 156 |
foreach ($savedGroups as $group) { |
| 157 |
if (!isset($terms[$group->slug])) { |
| 158 |
continue; |
| 159 |
} |
| 160 |
|
| 161 |
$rows = []; |
| 162 |
$serial = 1; |
| 163 |
|
| 164 |
foreach ($terms[$group->slug] as $term) { |
| 165 |
// Terms can be plain strings or ['title' => ..., 'color' => ...] arrays. |
| 166 |
// Color hex is stored as JSON in term.settings.color so the swatch |
| 167 |
// renderer in AdvancedVariationConfig.vue can read it. |
| 168 |
if (is_array($term)) { |
| 169 |
$title = $term['title'] ?? null; |
| 170 |
if ($title === null) { |
| 171 |
// Defensive: a future maintainer who adds a term entry |
| 172 |
// without a 'title' key shouldn't crash the activation. |
| 173 |
continue; |
| 174 |
} |
| 175 |
$settings = isset($term['color']) |
| 176 |
? json_encode(['color' => $term['color']], self::JSON_FLAGS) |
| 177 |
: null; |
| 178 |
} else { |
| 179 |
$title = $term; |
| 180 |
$settings = null; |
| 181 |
} |
| 182 |
|
| 183 |
$rows[] = [ |
| 184 |
'serial' => $serial++, |
| 185 |
'group_id' => $group->id, |
| 186 |
'title' => $title, |
| 187 |
'slug' => sanitize_title($title), |
| 188 |
'settings' => $settings, |
| 189 |
'created_at' => $now, |
| 190 |
'updated_at' => $now, |
| 191 |
]; |
| 192 |
} |
| 193 |
|
| 194 |
if (empty($rows)) { |
| 195 |
continue; |
| 196 |
} |
| 197 |
|
| 198 |
if (!AttributeTerm::insert($rows)) { |
| 199 |
throw new \RuntimeException( |
| 200 |
sprintf('AttributeTerm::insert returned false for group "%s".', $group->slug) |
| 201 |
); |
| 202 |
} |
| 203 |
|
| 204 |
if ($progress) { |
| 205 |
$progress->tick(); |
| 206 |
} |
| 207 |
} |
| 208 |
|
| 209 |
$db->commit(); |
| 210 |
} catch (\Throwable $e) { |
| 211 |
$db->rollBack(); |
| 212 |
if ($progress) { |
| 213 |
// Make sure the CLI progress bar doesn't leave a stuck cursor |
| 214 |
// when the seeder bails out partway through. |
| 215 |
$progress->finish(); |
| 216 |
} |
| 217 |
// Rethrow so the caller (the fluent_cart/after_migrate hook fired |
| 218 |
// from DBMigrator::migrate() during plugin activation/migrate) can |
| 219 |
// surface a visible activation failure instead of silently |
| 220 |
// committing a half-seeded state. |
| 221 |
throw $e; |
| 222 |
} |
| 223 |
|
| 224 |
if ($progress) { |
| 225 |
$progress->finish(); |
| 226 |
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped |
| 227 |
echo \WP_CLI::colorize('%n'); |
| 228 |
} |
| 229 |
} |
| 230 |
|
| 231 |
/** |
| 232 |
* Builds a group row with the shared column set. Centralised so all 10 |
| 233 |
* group rows declare the same keys (WPFluent's bulk insert() builds the |
| 234 |
* column list from the first row and silently drops keys missing from |
| 235 |
* later rows). |
| 236 |
*/ |
| 237 |
private static function groupRow(string $title, string $slug, $settings, string $now): array |
| 238 |
{ |
| 239 |
return [ |
| 240 |
'title' => $title, |
| 241 |
'slug' => $slug, |
| 242 |
'settings' => $settings, |
| 243 |
// Placeholder — overwritten with a real position just before insert |
| 244 |
// (continuing after the current max serial). Declared here so every |
| 245 |
// row carries the same key set for WPFluent's bulk insert(). |
| 246 |
'serial' => 0, |
| 247 |
'is_system' => 1, |
| 248 |
'created_at' => $now, |
| 249 |
'updated_at' => $now, |
| 250 |
]; |
| 251 |
} |
| 252 |
} |
| 253 |
|