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/Seeder/AttributeSeeder.php +215 -233 1.3.21 → 1.6.5 View file →
@@ -6,265 +6,247 @@
6 6 use FluentCart\App\Models\AttributeTerm;
7 7
8 8 class AttributeSeeder
9 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 +
10 19 public static function seed()
11 20 {
12 - if (AttributeGroup::count() > 2) {
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)) {
13 61 return;
14 62 }
15 63
16 - $groups = [
17 - [
18 - 'title' => 'Digital Product Licensing',
19 - 'slug' => 'plugin-license',
20 - 'description' => 'Digital product license types.',
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'],
21 78 ],
22 - [
23 - 'title' => 'License Period',
24 - 'slug' => 'plugin-license-period',
25 - 'description' => 'Plugin license period i.e. - annual, lifetime.',
26 - ],
27 - [
28 - 'title' => 'Cloth sizes',
29 - 'slug' => 'cloth-size',
30 - 'description' => 'Clothes sizes',
31 - ],
32 - [
33 - 'title' => 'Cloth color',
34 - 'slug' => 'cloth-color',
35 - 'description' => 'Clothes color',
36 - ],
37 - [
38 - 'title' => 'Cloth attributes',
39 - 'slug' => 'cloth-attributes',
40 - 'description' => 'Clothes logo, collar, long sleeve',
41 - ],
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'],
42 88 ];
43 89
44 - $attr = [
45 - 'plugin-license' => [
46 - [
47 - 'serial' => 1,
48 - 'group_id' => 1,
49 - 'title' => 'Single License',
50 - 'slug' => 'single-license',
51 - ],
52 - [
53 - 'serial' => 2,
54 - 'group_id' => 1,
55 - 'title' => '1 Site License',
56 - 'slug' => '1-site-license',
57 - ],
58 - [
59 - 'serial' => 3,
60 - 'group_id' => 1,
61 - 'title' => '5 Sites License',
62 - 'slug' => '5-site-license',
63 - ],
64 - [
65 - 'serial' => 4,
66 - 'group_id' => 1,
67 - 'title' => '50 Sites License',
68 - 'slug' => '50-site-license',
69 - ],
70 - [
71 - 'serial' => 5,
72 - 'group_id' => 1,
73 - 'title' => 'Agency License',
74 - 'slug' => 'agency-license',
75 - ],
76 - [
77 - 'serial' => 6,
78 - 'group_id' => 1,
79 - 'title' => 'Unlimited License',
80 - 'slug' => 'unlimited-license',
81 - ],
82 - ],
83 - 'plugin-license-period' => [
84 - [
85 - 'serial' => 1,
86 - 'group_id' => 1,
87 - 'title' => 'Monthly',
88 - 'slug' => 'monthly',
89 - ],
90 - [
91 - 'serial' => 3,
92 - 'group_id' => 1,
93 - 'title' => 'Annual',
94 - 'slug' => 'annual',
95 - ],
96 - [
97 - 'serial' => 4,
98 - 'group_id' => 1,
99 - 'title' => 'Lifetime',
100 - 'slug' => 'lifetime',
101 - ],
102 - ],
103 - 'cloth-size' => [
104 - [
105 - 'serial' => 1,
106 - 'group_id' => 1,
107 - 'title' => 'XS',
108 - 'slug' => 'extra-small',
109 - ],
110 - [
111 - 'serial' => 2,
112 - 'group_id' => 1,
113 - 'title' => 'S',
114 - 'slug' => 'small',
115 - ],
116 - [
117 - 'serial' => 3,
118 - 'group_id' => 1,
119 - 'title' => 'M',
120 - 'slug' => 'medium',
121 - ],
122 - [
123 - 'serial' => 4,
124 - 'group_id' => 1,
125 - 'title' => 'L',
126 - 'slug' => 'large',
127 - ],
128 - [
129 - 'serial' => 5,
130 - 'group_id' => 1,
131 - 'title' => 'XL',
132 - 'slug' => 'extra-large',
133 - ],
134 - [
135 - 'serial' => 6,
136 - 'group_id' => 1,
137 - 'title' => 'XXL',
138 - 'slug' => 'extra-extra-large',
139 - ],
140 - [
141 - 'serial' => 7,
142 - 'group_id' => 1,
143 - 'title' => '3XL',
144 - 'slug' => 'extra-3-large',
145 - ],
146 - ],
147 - 'cloth-color' => [
148 - [
149 - 'serial' => 1,
150 - 'group_id' => 1,
151 - 'title' => 'Red',
152 - 'slug' => 'red',
153 - ],
154 - [
155 - 'serial' => 2,
156 - 'group_id' => 1,
157 - 'title' => 'Green',
158 - 'slug' => 'green',
159 - ],
160 - [
161 - 'serial' => 3,
162 - 'group_id' => 1,
163 - 'title' => 'Yellow',
164 - 'slug' => 'yellow',
165 - ],
166 - [
167 - 'serial' => 4,
168 - 'group_id' => 1,
169 - 'title' => 'Grey',
170 - 'slug' => 'grey',
171 - ],
172 - [
173 - 'serial' => 5,
174 - 'group_id' => 1,
175 - 'title' => 'Black',
176 - 'slug' => 'black',
177 - ],
178 - [
179 - 'serial' => 6,
180 - 'group_id' => 1,
181 - 'title' => 'White',
182 - 'slug' => 'white',
183 - ],
184 - [
185 - 'serial' => 7,
186 - 'group_id' => 1,
187 - 'title' => 'Gold',
188 - 'slug' => 'gold',
189 - ],
190 - ],
191 - 'cloth-attributes' => [
192 - [
193 - 'serial' => 1,
194 - 'group_id' => 1,
195 - 'title' => 'Logo',
196 - 'slug' => 'with-logo',
197 - ],
198 - [
199 - 'serial' => 2,
200 - 'group_id' => 1,
201 - 'title' => 'Without Logo',
202 - 'slug' => 'without-logo',
203 - ],
204 - [
205 - 'serial' => 3,
206 - 'group_id' => 1,
207 - 'title' => 'Collar',
208 - 'slug' => 'with-collar',
209 - ],
210 - [
211 - 'serial' => 4,
212 - 'group_id' => 1,
213 - 'title' => 'Long sleeve',
214 - 'slug' => 'long-sleeve',
215 - ],
216 - [
217 - 'serial' => 5,
218 - 'group_id' => 1,
219 - 'title' => 'Sleeveless',
220 - 'slug' => 'sleeveless',
221 - ],
222 - [
223 - 'serial' => 6,
224 - 'group_id' => 1,
225 - 'title' => 'Short sleeve',
226 - 'slug' => 'short-sleeve',
227 - ],
228 - [
229 - 'serial' => 7,
230 - 'group_id' => 1,
231 - 'title' => 'High collar',
232 - 'slug' => 'high-collar',
233 - ],
234 - ],
235 - ];
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;
236 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));
237 111
238 - AttributeGroup::insert($groups);
112 + if (empty($missingSlugs)) {
113 + $db->rollBack();
114 + return;
115 + }
239 116
240 - $grs = AttributeGroup::get();
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 + ));
241 127
242 - if (defined('WP_CLI') && WP_CLI) {
243 - $progress = \WP_CLI\Utils\make_progress_bar('%CSeeding Attributed', count($grs));
244 - }
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);
245 139
246 - foreach ($grs as $gr) {
247 - if (isset($attr[$gr->slug])) {
140 + if (!AttributeGroup::insert($rowsToInsert)) {
141 + throw new \RuntimeException('AttributeGroup::insert returned false during seeding.');
142 + }
248 143
249 - $tmp = [];
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();
250 148
251 - foreach ($attr[$gr->slug] as $item) {
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 + }
252 155
253 - $item['group_id'] = $gr->id;
254 - $tmp[] = $item;
156 + foreach ($savedGroups as $group) {
157 + if (!isset($terms[$group->slug])) {
158 + continue;
255 159 }
256 160
257 - AttributeTerm::insert($tmp);
258 - if (defined('WP_CLI') && WP_CLI) {
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) {
259 205 $progress->tick();
260 206 }
261 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;
262 222 }
263 - if (defined('WP_CLI') && WP_CLI) {
264 - $progress->tick();
223 +
224 + if ($progress) {
265 225 $progress->finish();
266 226 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
267 227 echo \WP_CLI::colorize('%n');
268 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 + ];
269 251 }
270 252 }