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.6 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 All 49 releases
← All changes | api/Resource/AttrTermResource.php +263 -173 1.4.0 → 1.6.5 View file →
@@ -1,8 +1,10 @@
1 1 <?php
2 2
3 3 namespace FluentCart\Api\Resource;
4 4
5 +use FluentCart\Api\Resource\BaseResourceApi;
6 +use FluentCart\App\App;
5 7 use FluentCart\App\Helpers\HelperTrait;
6 8 use FluentCart\App\Models\AttributeGroup;
7 9 use FluentCart\App\Models\AttributeRelation;
8 10 use FluentCart\App\Models\AttributeTerm;
@@ -7,255 +9,343 @@
7 9 use FluentCart\App\Models\AttributeRelation;
8 10 use FluentCart\App\Models\AttributeTerm;
9 11 use FluentCart\Framework\Database\Orm\Builder;
10 12 use FluentCart\Framework\Support\Arr;
13 +use FluentCart\App\Services\Filter\AttrTermFilter;
11 14
12 15 class AttrTermResource extends BaseResourceApi
13 16 {
14 17 use HelperTrait;
15 18
16 - private static array $orderByCols = ['id', 'title', 'slug', 'serial', 'created_at'];
17 -
18 - private static array $moveTo = ['up', 'down'];
19 -
20 19 public static function getQuery(): Builder
21 20 {
22 21 return AttributeTerm::query();
23 22 }
24 23
25 - /**
26 - * Retrieve attribute terms based on the provided parameters.
27 - *
28 - * @param array $params Optional. Params containing the necessary parameters to retrieve.
29 - * [
30 - * 'params' => [
31 - * "search" => (array) Optional.
32 - * [ "column name(e.g., title|slug)" => [
33 - * "column" => "column name(e.g., title|slug)",
34 - * "operator" => "(string)(e.g., like_all|rlike|or_rlike)",
35 - * "value" => (string|array) ]
36 - * ],
37 - * "filters" => (array) Optional.
38 - * [ "column name(e.g., title|slug)" => [
39 - * "column" => "column name(e.g., title|slug)",
40 - * "operator" => "(string)(e.g., in)",
41 - * "value" => (string|array) ]
42 - * ],
43 - * 'order_by' => (string) Optional. Column to order by,
44 - * 'order_type' => (string) Optional. Order type for sorting (ASC or DESC),
45 - * 'per_page' => (int) Optional. Number of items for per page,
46 - * 'page' => (int) Optional. Page number for pagination
47 - * ]
48 - * ]
49 - *
50 - */
51 24 public static function get(array $params = [])
52 25 {
53 - $orderBy = static::getValWithinEnum(Arr::get($params["params"], 'order_by', 'serial'), static::$orderByCols, 'serial');
54 - $orderDir = static::getValWithinEnum(Arr::get($params["params"], 'order_type', 'ASC'), static::$orderByEnum, 'ASC');
55 -
56 - return static::getQuery()->where('group_id', Arr::get($params, 'group_id'))
57 - ->when(Arr::get($params["params"], 'search'), function ($query) use ($params) {
58 - return $query->search(Arr::get($params["params"], 'search', ''));
59 - })
60 - ->applyCustomFilters(Arr::get($params["params"], 'filters', []))
61 - ->orderBy(
62 - sanitize_sql_orderby($orderBy),
63 - sanitize_sql_orderby($orderDir)
64 - )
65 - ->paginate(Arr::get($params["params"], 'per_page', 15), ['*'], 'page', Arr::get($params["params"], 'page'));
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();
66 32 }
67 33
68 - /**
69 - * Find and retrieve attribute term based on the ID and given params.
70 - *
71 - * @param int $id Required. The ID of the attribute term to find and retrieve.
72 - * @param array $params Optional. Additional parameters for finding attribute terms.
73 - * [
74 - * // Include optional parameters, if any.
75 - * ]
76 - *
77 - */
78 34 public static function find($id, $params = [])
79 35 {
80 36 return static::getQuery()->find($id);
81 37 }
82 38
83 - /**
84 - * Create attribute term with the provided data.
85 - *
86 - * @param array $data Required. Array containing the necessary parameters
87 - * $data => (array) Required. Array of attribute term data.
88 - * [
89 - * 'title' => (string) Required. The title of the attr term.
90 - * 'slug' => (string) Required. The slug of the attr term.
91 - * 'description' => (string) Optional. The description of the attr term.
92 - * 'serial' => (int) Optional. The serial of the attr term.
93 - * ]
94 - * @param array $params Required. Additional parameters for attribute term creation.
95 - * [
96 - * 'params' => [
97 - * 'group_id'=> (int) Required. The id of the attr group.
98 - * ]
99 - * ]
100 - *
101 - */
102 39 public static function create($data, $params = [])
103 40 {
104 - $groupId = Arr::get($params, 'group_id');
41 + $groupId = (int) Arr::get($params, 'group_id');
42 + $termInputs = Arr::get($data, 'terms', []);
43 + $group = AttributeGroup::query()->find($groupId);
105 44
106 - $group = AttributeGroup::query()->find($groupId);
107 -
108 45 if (!$group) {
109 46 return static::makeErrorResponse([
110 - [ 'code' => 404, 'message' => __('Information mismatch.', 'fluent-cart') ]
47 + ['code' => 404, 'message' => __('Attribute group not found.', 'fluent-cart')]
111 48 ]);
112 49 }
113 50
114 - $data['serial'] = empty($data['serial']) ? 10 : $data['serial'];
115 - $data['group_id'] = $group->id;
51 + $lastSerial = (int) AttributeTerm::query()->where('group_id', $group->id)->max('serial');
116 52
117 - $term = AttributeTerm::create($data);
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();
118 64
119 - if ($term) {
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) {
120 96 return static::makeSuccessResponse(
121 - $term,
122 - __('Successfully created!', 'fluent-cart')
97 + $createdTerms,
98 + __('Terms created successfully.', 'fluent-cart')
123 99 );
124 100 }
125 -
101 +
126 102 return static::makeErrorResponse([
127 - [ 'code' => 400, 'message' => __('Term creation failed.', 'fluent-cart') ]
103 + ['code' => 400, 'message' => __('Term creation failed.', 'fluent-cart')]
128 104 ]);
129 105 }
130 106
131 - /**
132 - * Update attribute term with the provided data.
133 - *
134 - * @param array $data Required. Array containing the necessary parameters
135 - * $data => (array) Required. Array of attribute term data.
136 - * [
137 - * 'title' => (string) Required. The title of the attr term.
138 - * 'slug' => (string) Required. The slug of the attr term.
139 - * 'description' => (string) Optional. The description of the attr term.
140 - * 'serial' => (int) Optional. The serial of the attr term.
141 - * ]
142 - * @param int $termId Required. The id of the attribute term to update.
143 - * @param array $params Required. Additional parameters for attribute term creation.
144 - * [
145 - * 'params' => [
146 - * 'group_id'=> (int) Required. The id of the attr group.
147 - * ]
148 - * ]
149 - *
150 - */
151 107 public static function update($data, $termId, $params = [])
152 108 {
153 109 $groupId = Arr::get($params, 'group_id');
154 110
155 - $term = static::getQuery()->where('id', $termId)->where('group_id', $groupId)->first();
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();
156 124
157 - if (!$term) {
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 + }
158 174 return static::makeErrorResponse([
159 - [ 'code' => 404, 'message' => __('Information mismatch.', 'fluent-cart') ]
175 + ['code' => 500, 'message' => __('Term update failed.', 'fluent-cart')]
160 176 ]);
161 177 }
162 178
163 - $isUpdated = $term->update($data);
164 -
165 179 if ($isUpdated) {
166 180 return static::makeSuccessResponse(
167 181 $isUpdated,
168 - __('Successfully updated!', 'fluent-cart')
182 + __('Term Successfully updated!', 'fluent-cart')
169 183 );
170 184 }
171 185
172 186 return static::makeErrorResponse([
173 - [ 'code' => 400, 'message' => __('Term info update failed.', 'fluent-cart') ]
174 - ]);
187 + ['code' => 400, 'message' => __('Term update failed.', 'fluent-cart')]
188 + ]);
175 189 }
176 190
177 - /**
178 - * Delete attribute term by term ID and given params.
179 - *
180 - * @param int $termId Required. The ID of the attribute term to delete.
181 - * @param array $params Required. Additional parameters for attribute term deletion.
182 - * [
183 - * 'params' => [
184 - * 'group_id'=> (int) Required. The id of the attr group.
185 - * ]
186 - * ]
187 - *
188 - */
189 191 public static function delete($termId, $params = [])
190 192 {
191 193 $groupId = Arr::get($params, 'group_id');
192 194
193 - $isUsed = AttributeRelation::query()->where('group_id', $groupId)->where('term_id', $termId)->first();
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);
194 204
195 - if (!$isUsed) {
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 + }
196 211
197 - $term = static::getQuery()->find($termId);
212 + $isUsed = AttributeRelation::query()
213 + ->where('group_id', $groupId)
214 + ->where('term_id', $termId)
215 + ->lockForUpdate()
216 + ->first();
198 217
199 - if ($term->group_id == $groupId) {
200 -
201 - $term->delete();
202 -
203 - return static::makeSuccessResponse(
204 - '',
205 - __('Attribute term successfully deleted!', 'fluent-cart')
206 - );
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 + ]);
207 223 }
208 -
224 +
225 + $term->delete();
226 + $db->commit();
227 + } catch (\Throwable $e) {
228 + $db->rollBack();
209 229 return static::makeErrorResponse([
210 - [ 'code' => 404, 'message' => __('Term not found in database, failed to remove.', 'fluent-cart') ]
230 + ['code' => 500, 'message' => __('Failed to delete attribute term.', 'fluent-cart')]
211 231 ]);
212 232 }
213 -
214 - return static::makeErrorResponse([
215 - [ 'code' => 403, 'message' => __('This term is already in use, can not be deleted.', 'fluent-cart') ]
216 - ]);
233 +
234 + return static::makeSuccessResponse(
235 + '',
236 + __('Attribute term successfully deleted!', 'fluent-cart')
237 + );
217 238 }
218 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 +
219 285 /**
220 - * Update attribute term serial index with the provided params.
221 - *
222 - * @param array $params Required. Array containing the necessary parameters
223 - * $params => (array) Required. Array of attribute term data.
224 - * [
225 - * 'term_id' => (int) Required. The term id of the attr term.
226 - * 'group_id' => (int) Required. The group id of the attr term.
227 - * 'move' => (int) Required. The move of the attr term.
228 - * ]
229 - *
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.
230 290 */
231 - public static function updateSerial($params = [])
291 + private static function isUniqueViolation(\Throwable $e): bool
232 292 {
233 - $termId = Arr::get($params, 'term_id');
234 - $groupId = Arr::get($params, 'group_id');
235 - $move = Arr::get($params, 'move');
293 + $msg = $e->getMessage();
294 + return strpos($msg, '1062') !== false
295 + || strpos($msg, 'Duplicate entry') !== false
296 + || strpos($msg, 'SQLSTATE[23000]') !== false;
297 + }
236 298
237 - $term = static::getQuery()->find($termId);
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 + }
238 309
239 - if ($term->group_id == $groupId) {
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 + }
240 316
241 - $move = static::getValWithinEnum($move, static::$moveTo, 'down');
317 + return $slugBase . '-' . ($highestSuffix + 1);
318 + }
242 319
243 - if ($move == 'up') {
244 - $term->serial = $term->serial > 0 ? ($term->serial - 1) : 0;
245 - } else {
246 - $term->serial++;
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]);
247 346 }
248 -
249 - $term->save();
250 -
251 - return static::makeSuccessResponse(
252 - $term->serial,
253 - __('Serial updated.', 'fluent-cart')
254 - );
255 347 }
256 348
257 - return static::makeErrorResponse([
258 - [ 'code' => 404, 'message' => __('Info mismatch.', 'fluent-cart') ]
259 - ]);
349 + return $base . '-' . ($max + 1);
260 350 }
261 -}
351 +}