PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.4
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.4
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 1.2.0 All 47 releases
fluent-cart / database / Seeder / AttributeSeeder.php

AttributeSeeder.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.4, at database/Seeder/AttributeSeeder.php

253 lines 11.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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