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