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 / api / Resource / AttrTermResource.php

AttrTermResource.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.4, at api/Resource/AttrTermResource.php

352 lines 12.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\Api\Resource;
4
5 use FluentCart\Api\Resource\BaseResourceApi;
6 use FluentCart\App\App;
7 use FluentCart\App\Helpers\HelperTrait;
8 use FluentCart\App\Models\AttributeGroup;
9 use FluentCart\App\Models\AttributeRelation;
10 use FluentCart\App\Models\AttributeTerm;
11 use FluentCart\Framework\Database\Orm\Builder;
12 use FluentCart\Framework\Support\Arr;
13 use FluentCart\App\Services\Filter\AttrTermFilter;
14
15 class AttrTermResource extends BaseResourceApi
16 {
17 use HelperTrait;
18
19 public static function getQuery(): Builder
20 {
21 return AttributeTerm::query();
22 }
23
24 public static function get(array $params = [])
25 {
26 $filter = AttrTermFilter::make($params);
27 $groupId = Arr::get($params, 'group_id');
28 if ($groupId) {
29 $filter->setGroupId((int) $groupId);
30 }
31 return $filter->paginate();
32 }
33
34 public static function find($id, $params = [])
35 {
36 return static::getQuery()->find($id);
37 }
38
39 public static function create($data, $params = [])
40 {
41 $groupId = (int) Arr::get($params, 'group_id');
42 $termInputs = Arr::get($data, 'terms', []);
43 $group = AttributeGroup::query()->find($groupId);
44
45 if (!$group) {
46 return static::makeErrorResponse([
47 ['code' => 404, 'message' => __('Attribute group not found.', 'fluent-cart')]
48 ]);
49 }
50
51 $lastSerial = (int) AttributeTerm::query()->where('group_id', $group->id)->max('serial');
52
53 // One query fetches all existing slugs that share a base with any incoming
54 // term — covers uniqueness for every term without N per-term queries.
55 $slugBases = array_map(function ($termInput) {
56 $slugSource = !empty($termInput['slug']) ? $termInput['slug'] : $termInput['title'];
57 return sanitize_title($slugSource);
58 }, $termInputs);
59 $slugQuery = AttributeTerm::query()->where('group_id', $group->id);
60 foreach ($slugBases as $slugBase) {
61 $slugQuery->orWhere('slug', 'LIKE', $slugBase . '%');
62 }
63 $takenSlugs = $slugQuery->pluck('slug')->toArray();
64
65 $insertRows = [];
66 $insertedSlugs = [];
67 foreach ($termInputs as $index => $termInput) {
68 $slugSource = !empty($termInput['slug']) ? $termInput['slug'] : $termInput['title'];
69 $slugBase = sanitize_title($slugSource);
70 $uniqueSlug = static::resolveUniqueSlugFromSet($slugBase, $takenSlugs);
71 $takenSlugs[] = $uniqueSlug;
72 $insertedSlugs[] = $uniqueSlug;
73
74 $encodedSettings = !empty($termInput['settings']) && is_array($termInput['settings'])
75 ? json_encode($termInput['settings'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
76 : null;
77
78 $insertRows[] = [
79 'group_id' => $group->id,
80 'title' => $termInput['title'],
81 'slug' => $uniqueSlug,
82 'serial' => $lastSerial + $index + 1,
83 'settings' => $encodedSettings,
84 ];
85 }
86
87 AttributeTerm::query()->insert($insertRows);
88
89 $createdTerms = AttributeTerm::query()
90 ->where('group_id', $group->id)
91 ->whereIn('slug', $insertedSlugs)
92 ->get()
93 ->toArray();
94
95 if ($createdTerms) {
96 return static::makeSuccessResponse(
97 $createdTerms,
98 __('Terms created successfully.', 'fluent-cart')
99 );
100 }
101
102 return static::makeErrorResponse([
103 ['code' => 400, 'message' => __('Term creation failed.', 'fluent-cart')]
104 ]);
105 }
106
107 public static function update($data, $termId, $params = [])
108 {
109 $groupId = Arr::get($params, 'group_id');
110
111 // Wrap the lookup, slug regen, and update in a transaction with a
112 // row lock on the term. Symmetric with create() / delete(). Without
113 // the lock, the slug auto-regen path can read existing slugs, derive
114 // "red-2", and then collide with a concurrent insert of "red-2"
115 // between the read and the update.
116 $db = App::db();
117 $db->beginTransaction();
118 try {
119 $term = static::getQuery()
120 ->where('id', $termId)
121 ->where('group_id', $groupId)
122 ->lockForUpdate()
123 ->first();
124
125 if (!$term) {
126 $db->rollBack();
127 return static::makeErrorResponse([
128 ['code' => 404, 'message' => __('Attribute term not found.', 'fluent-cart')]
129 ]);
130 }
131
132 // Mirror create()'s auto-slug behaviour: when the client clears the slug field
133 // intending the server to regenerate it, derive a unique slug from the title
134 // instead of letting MySQL reject the NOT NULL column.
135 if (array_key_exists('slug', $data) && empty($data['slug']) && !empty($data['title'])) {
136 $data['slug'] = static::generateUniqueSlug($data['title'], (int) $term->group_id, (int) $term->id);
137 }
138
139 // Defence-in-depth: group_id is in AttributeTerm $fillable for the
140 // create flow, but updating it would let a client reparent a term
141 // into a different group via the wrong endpoint. The controller's
142 // Arr::only allowlist already excludes group_id, but stripping it
143 // here too means future controller refactors cannot regress this
144 // boundary.
145 unset($data['group_id']);
146
147 // Pre-check the composite UNIQUE (group_id, slug) when the slug is
148 // changing, so a clean 422 is returned without an UPDATE the DB
149 // rejects (which prints a raw wpdb error ahead of the JSON). The
150 // catch below stays as the concurrency backstop.
151 if (!empty($data['slug'])) {
152 $slugTaken = static::getQuery()
153 ->where('group_id', $term->group_id)
154 ->where('slug', $data['slug'])
155 ->where('id', '!=', $term->id)
156 ->count() > 0;
157 if ($slugTaken) {
158 $db->rollBack();
159 return static::makeErrorResponse([
160 ['code' => 422, 'message' => __('A term with this slug already exists in the group.', 'fluent-cart')]
161 ]);
162 }
163 }
164
165 $isUpdated = $term->update($data);
166 $db->commit();
167 } catch (\Throwable $e) {
168 $db->rollBack();
169 if (static::isUniqueViolation($e)) {
170 return static::makeErrorResponse([
171 ['code' => 422, 'message' => __('A term with this slug already exists in the group.', 'fluent-cart')]
172 ]);
173 }
174 return static::makeErrorResponse([
175 ['code' => 500, 'message' => __('Term update failed.', 'fluent-cart')]
176 ]);
177 }
178
179 if ($isUpdated) {
180 return static::makeSuccessResponse(
181 $isUpdated,
182 __('Term Successfully updated!', 'fluent-cart')
183 );
184 }
185
186 return static::makeErrorResponse([
187 ['code' => 400, 'message' => __('Term update failed.', 'fluent-cart')]
188 ]);
189 }
190
191 public static function delete($termId, $params = [])
192 {
193 $groupId = Arr::get($params, 'group_id');
194
195 // Wrap the use-check and the delete in a transaction with row-level
196 // locks so a concurrent variant attach between the check and the
197 // delete cannot leave orphan relation rows pointing at a deleted
198 // term. Without this, a TOCTOU race lets a relation row be inserted
199 // after isUsed returned null and before $term->delete() ran.
200 $db = App::db();
201 $db->beginTransaction();
202 try {
203 $term = static::getQuery()->lockForUpdate()->find($termId);
204
205 if (!$term || $term->group_id != $groupId) {
206 $db->rollBack();
207 return static::makeErrorResponse([
208 ['code' => 404, 'message' => __('Attribute term not found, failed to remove.', 'fluent-cart')]
209 ]);
210 }
211
212 $isUsed = AttributeRelation::query()
213 ->where('group_id', $groupId)
214 ->where('term_id', $termId)
215 ->lockForUpdate()
216 ->first();
217
218 if ($isUsed) {
219 $db->rollBack();
220 return static::makeErrorResponse([
221 ['code' => 403, 'message' => __('This term is already in use, can not be deleted.', 'fluent-cart')]
222 ]);
223 }
224
225 $term->delete();
226 $db->commit();
227 } catch (\Throwable $e) {
228 $db->rollBack();
229 return static::makeErrorResponse([
230 ['code' => 500, 'message' => __('Failed to delete attribute term.', 'fluent-cart')]
231 ]);
232 }
233
234 return static::makeSuccessResponse(
235 '',
236 __('Attribute term successfully deleted!', 'fluent-cart')
237 );
238 }
239
240 public static function reorder($params = [])
241 {
242 $groupId = (int) Arr::get($params, 'group_id');
243 $ids = array_slice(
244 array_values(array_filter(array_map('intval', (array) Arr::get($params, 'ids', [])))),
245 0,
246 500
247 );
248
249 if (empty($ids)) {
250 return static::makeErrorResponse([
251 ['code' => 422, 'message' => __('No term IDs provided.', 'fluent-cart')]
252 ]);
253 }
254
255 $ownedCount = AttributeTerm::query()
256 ->whereIn('id', $ids)
257 ->where('group_id', $groupId)
258 ->count();
259
260 if ($ownedCount !== count($ids)) {
261 return static::makeErrorResponse([
262 ['code' => 403, 'message' => __('One or more term IDs do not belong to this group.', 'fluent-cart')]
263 ]);
264 }
265
266 $values = [];
267 foreach ($ids as $index => $id) {
268 $values[] = ['id' => $id, 'serial' => $index + 1];
269 }
270
271 $db = App::db();
272 $db->beginTransaction();
273 try {
274 AttributeTerm::query()->batchUpdate($values);
275 $db->commit();
276 return static::makeSuccessResponse([], __('Terms reordered.', 'fluent-cart'));
277 } catch (\Throwable $e) {
278 $db->rollBack();
279 return static::makeErrorResponse([
280 ['code' => 500, 'message' => __('Failed to reorder terms.', 'fluent-cart')]
281 ]);
282 }
283 }
284
285 /**
286 * MySQL/MariaDB UNIQUE constraint violation detector. SQLSTATE 23000 covers
287 * duplicate-key on any UNIQUE index, including the composite (group_id, slug)
288 * on terms and slug on groups. Used so concurrent inserts return a clean 422
289 * instead of leaking a generic 500.
290 */
291 private static function isUniqueViolation(\Throwable $e): bool
292 {
293 $msg = $e->getMessage();
294 return strpos($msg, '1062') !== false
295 || strpos($msg, 'Duplicate entry') !== false
296 || strpos($msg, 'SQLSTATE[23000]') !== false;
297 }
298
299 /**
300 * Derive a unique slug from an in-memory set of already-taken slugs.
301 * Used by createBulk() so multiple titles can be resolved in one pass
302 * without a DB round-trip per title.
303 */
304 private static function resolveUniqueSlugFromSet(string $slugBase, array $takenSlugs): string
305 {
306 if (!in_array($slugBase, $takenSlugs, true)) {
307 return $slugBase;
308 }
309
310 $highestSuffix = 1;
311 foreach ($takenSlugs as $takenSlug) {
312 if (preg_match('/^' . preg_quote($slugBase, '/') . '-(\d+)$/', $takenSlug, $matches)) {
313 $highestSuffix = max($highestSuffix, (int) $matches[1]);
314 }
315 }
316
317 return $slugBase . '-' . ($highestSuffix + 1);
318 }
319
320 /**
321 * Generate a unique slug within a group, ignoring a specific term id (for update).
322 * Mirrors the auto-slug logic in create() so the two endpoints stay in sync.
323 */
324 private static function generateUniqueSlug(string $title, int $groupId, ?int $ignoreId = null): string
325 {
326 $base = sanitize_title($title);
327
328 $query = AttributeTerm::query()
329 ->where('group_id', $groupId)
330 ->where('slug', 'LIKE', $base . '%');
331
332 if ($ignoreId) {
333 $query->where('id', '!=', $ignoreId);
334 }
335
336 $taken = $query->pluck('slug')->toArray();
337
338 if (!in_array($base, $taken, true)) {
339 return $base;
340 }
341
342 $max = 1;
343 foreach ($taken as $existing) {
344 if (preg_match('/^' . preg_quote($base, '/') . '-(\d+)$/', $existing, $m)) {
345 $max = max($max, (int) $m[1]);
346 }
347 }
348
349 return $base . '-' . ($max + 1);
350 }
351 }
352