Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.24% covered (success)
96.24%
179 / 186
68.75% covered (warning)
68.75%
11 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
BulkActionService
96.22% covered (success)
96.22%
178 / 185
68.75% covered (warning)
68.75%
11 / 16
60
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getBulkEditLimit
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
6
 executeBulkAction
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
5
 enqueueBulkAction
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
3
 applyActionToRecords
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 executeBulkUpdate
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
4
 resolveActionClauses
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
 resolveAccessColumn
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 resolveStatusClause
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 resolveDeleteClause
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 resolveEditClauses
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 executeHardDelete
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
2.01
 hasColumn
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 loadTableColumns
81.25% covered (warning)
81.25%
13 / 16
0.00% covered (danger)
0.00%
0 / 1
7.32
 assertAllowedAction
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 sanitizeRecordIds
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Engine\Application\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Engine\Domain\Model\RecordStatus;
12use App\Core\Engine\Domain\Repository\MetadataRepositoryInterface;
13use App\Modules\Automation\Queue\Application\Handler\BulkActionJobHandler;
14use App\Modules\Automation\Queue\Domain\Repository\QueueRepositoryInterface;
15use DateTimeImmutable;
16use InvalidArgumentException;
17use PDO;
18use Throwable;
19
20/**
21 * Bulk Action Service.
22 *
23 * Validates and executes mass operations directly in database with configurable record limit.
24 *
25 * @package App\Core\Engine\Application\Service
26 */
27final class BulkActionService
28{
29    private const int DEFAULT_BULK_LIMIT = 100;
30
31    /** @var array<string, array<string, bool>> Cache of existing columns per table. */
32    private static array $columnsCache = [];
33
34    /**
35     * BulkActionService constructor.
36     *
37     * @param QueueRepositoryInterface|null    $queueRepository    Queue storage contract.
38     * @param MetadataRepositoryInterface|null $metadataRepository Module metadata contract.
39     * @param PDO|null                         $pdo                Database connection for direct execution.
40     * @param string                           $tablePrefix        Database table prefix.
41     */
42    public function __construct(
43        private ?QueueRepositoryInterface $queueRepository = null,
44        private ?MetadataRepositoryInterface $metadataRepository = null,
45        private ?PDO $pdo = null,
46        private string $tablePrefix = 'a_'
47    ) {
48    }
49
50    /**
51     * Resolves configured default bulk edit limit from a_core_settings_records table.
52     *
53     * @return int Configured maximum record limit (defaults to 100).
54     */
55    public function getBulkEditLimit(): int
56    {
57        if ($this->pdo === null) {
58            return self::DEFAULT_BULK_LIMIT;
59        }
60
61        try {
62            $table = $this->tablePrefix . 'core_settings_records';
63            $sql = sprintf('SELECT `setting_value` FROM `%s` WHERE `setting_key` = :k LIMIT 1', $table);
64            $stmt = $this->pdo->prepare($sql);
65            $stmt->execute([':k' => 'bulk_edit_limit']);
66            $val = $stmt->fetchColumn();
67
68            return ($val !== false && is_numeric($val) && (int) $val > 0)
69                ? (int) $val
70                : self::DEFAULT_BULK_LIMIT;
71        } catch (Throwable) {
72            return self::DEFAULT_BULK_LIMIT;
73        }
74    }
75
76    /**
77     * Executes bulk action directly and synchronously in database.
78     *
79     * @param string                  $moduleName Module machine name.
80     * @param string                  $actionType Action identifier (bulk_edit, bulk_archive,
81     *                                            bulk_restore, bulk_delete).
82     * @param array<int, int|string>  $recordIds  List of record IDs to affect.
83     * @param array<string, mixed>    $updates    Field values dictionary for bulk edit.
84     * @param int                     $userId     Acting user identifier.
85     * @return array<string, mixed> Execution summary dictionary.
86     */
87    public function executeBulkAction(
88        string $moduleName,
89        string $actionType,
90        array $recordIds,
91        array $updates = [],
92        int $userId = 1
93    ): array {
94        $this->assertAllowedAction($actionType);
95        $cleanRecordIds = $this->sanitizeRecordIds($recordIds);
96
97        if ($this->metadataRepository === null) {
98            throw new InvalidArgumentException('Metadata repository is required for bulk action.');
99        }
100
101        if ($this->pdo === null) {
102            throw new InvalidArgumentException('Database connection is required for synchronous bulk action.');
103        }
104
105        $module = $this->metadataRepository->findModule($moduleName);
106        $totalRequested = count($cleanRecordIds);
107        $limit = $this->getBulkEditLimit();
108
109        $targetIds = $totalRequested > $limit
110            ? array_slice($cleanRecordIds, 0, $limit)
111            : $cleanRecordIds;
112
113        $tableName = $module->tableName;
114        $affectedCount = $this->applyActionToRecords($tableName, $actionType, $targetIds, $updates);
115
116        $message = sprintf('Successfully updated %d records.', $affectedCount);
117        if ($totalRequested > $limit) {
118            $message = sprintf(
119                'Updated %d records (operation was constrained to limit %d of %d requested).',
120                $affectedCount,
121                $limit,
122                $totalRequested
123            );
124        }
125
126        return [
127            'success'          => true,
128            'module'           => $module->name,
129            'action'           => $actionType,
130            'user_id'          => $userId,
131            'total_requested'  => $totalRequested,
132            'limit'            => $limit,
133            'total_records'    => count($targetIds),
134            'updated_records'  => $affectedCount,
135            'affected_records' => $affectedCount,
136            'message'          => $message,
137        ];
138    }
139
140    /**
141     * Enqueues a bulk action for asynchronous background processing (legacy fallback).
142     *
143     * @param string                  $moduleName Module machine name.
144     * @param string                  $actionType Action identifier.
145     * @param array<int, int|string>  $recordIds  List of record IDs to affect.
146     * @param array<string, mixed>    $updates    Field values dictionary for bulk edit.
147     * @param int                     $userId     Acting user identifier.
148     * @return array<string, mixed> Enqueue summary dictionary.
149     */
150    public function enqueueBulkAction(
151        string $moduleName,
152        string $actionType,
153        array $recordIds,
154        array $updates = [],
155        int $userId = 1
156    ): array {
157        $this->assertAllowedAction($actionType);
158        $cleanRecordIds = $this->sanitizeRecordIds($recordIds);
159
160        if ($this->metadataRepository === null || $this->queueRepository === null) {
161            throw new InvalidArgumentException('Metadata and queue repositories are required to enqueue action.');
162        }
163
164        $module = $this->metadataRepository->findModule($moduleName);
165        $totalCount = count($cleanRecordIds);
166
167        $payload = [
168            'module_name' => $module->name,
169            'action'      => $actionType,
170            'record_ids'  => $cleanRecordIds,
171            'updates'     => $updates,
172            'user_id'     => $userId,
173        ];
174
175        $jobId = $this->queueRepository->enqueue(
176            BulkActionJobHandler::JOB_TYPE,
177            sprintf('Mass %s on %s (%d records)', $actionType, $module->label, $totalCount),
178            $payload,
179            $userId,
180            $userId
181        );
182
183        return [
184            'success'       => true,
185            'job_id'        => $jobId,
186            'module'        => $module->name,
187            'action'        => $actionType,
188            'total_records' => $totalCount,
189            'message'       => sprintf(
190                'Mass action "%s" on %d records queued successfully (#%d).',
191                $actionType,
192                $totalCount,
193                $jobId
194            ),
195        ];
196    }
197
198    /**
199     * Applies action changes to a chunk of records in target table.
200     *
201     * @param string                 $tableName  Target table name.
202     * @param string                 $actionType Bulk action type.
203     * @param array<int, int|string> $targetIds  Record IDs to update.
204     * @param array<string, mixed>   $updates    Field values dictionary.
205     * @return int Number of affected records.
206     */
207    private function applyActionToRecords(
208        string $tableName,
209        string $actionType,
210        array $targetIds,
211        array $updates
212    ): int {
213        if ($targetIds === []) {
214            return 0;
215        }
216
217        $params = [];
218        $setClauses = $this->resolveActionClauses($tableName, $actionType, $updates, $params);
219        if ($setClauses === null) {
220            return $this->executeHardDelete($tableName, $targetIds);
221        }
222
223        return $this->executeBulkUpdate($tableName, $setClauses, $params, $targetIds);
224    }
225
226    /**
227     * Executes bulk UPDATE statement for resolved set clauses.
228     *
229     * @param string $tableName
230     * @param array<int, string> $setClauses
231     * @param array<int, mixed> $params
232     * @param array<int, int|string> $targetIds
233     * @return int
234     */
235    private function executeBulkUpdate(
236        string $tableName,
237        array $setClauses,
238        array $params,
239        array $targetIds
240    ): int {
241        if ($setClauses === []) {
242            return 0;
243        }
244
245        if ($this->hasColumn($tableName, 'updated_at')) {
246            $setClauses[] = '`updated_at` = ?';
247            $params[] = (new DateTimeImmutable())->format('Y-m-d H:i:s');
248        }
249
250        $placeholders = implode(',', array_fill(0, count($targetIds), '?'));
251        $extraWhere = $this->hasColumn($tableName, 'is_system')
252            ? ' AND (`is_system` IS NULL OR `is_system` != 1)'
253            : '';
254
255        $sql = sprintf(
256            'UPDATE `%s` SET %s WHERE `id` IN (%s)%s',
257            $tableName,
258            implode(', ', $setClauses),
259            $placeholders,
260            $extraWhere
261        );
262
263        $stmt = $this->pdo->prepare($sql);
264        $stmt->execute([...$params, ...$targetIds]);
265
266        return $stmt->rowCount();
267    }
268
269    /**
270     * Resolves SET clauses and query parameters based on action type.
271     *
272     * @param string               $tableName
273     * @param string               $actionType
274     * @param array<string, mixed> $updates
275     * @param array<int, mixed>    $params
276     * @param-out array<int, mixed> $params
277     * @return array<int, string>|null Returns null if hard delete should be executed.
278     */
279    private function resolveActionClauses(
280        string $tableName,
281        string $actionType,
282        array $updates,
283        array &$params
284    ): ?array {
285        $accessCol = $this->resolveAccessColumn($tableName);
286
287        return match ($actionType) {
288            'bulk_archive' => $this->resolveStatusClause($tableName, $accessCol, RecordStatus::ARCHIVED, $params),
289            'bulk_restore' => $this->resolveStatusClause($tableName, $accessCol, RecordStatus::AVAILABLE, $params),
290            'bulk_delete'  => $this->resolveDeleteClause($tableName, $accessCol, $params),
291            'bulk_edit'    => $this->resolveEditClauses($tableName, $updates, $params),
292            default        => throw new InvalidArgumentException(
293                sprintf('Unsupported bulk action type "%s"', $actionType)
294            ),
295        };
296    }
297
298    private function resolveAccessColumn(string $tableName): ?string
299    {
300        if ($this->hasColumn($tableName, 'special_access')) {
301            return 'special_access';
302        }
303        if ($this->hasColumn($tableName, 'record_status')) {
304            return 'record_status';
305        }
306        return null;
307    }
308
309    /**
310     * @param array<int, mixed> &$params
311     * @param-out array<int, mixed> $params
312     * @return array<int, string>
313     */
314    private function resolveStatusClause(string $tableName, ?string $accessCol, int $status, array &$params): array
315    {
316        if ($accessCol === null) {
317            throw new InvalidArgumentException(sprintf(
318                'Table "%s" does not support record statuses (missing special_access or record_status column).',
319                $tableName
320            ));
321        }
322        $params[] = $status;
323        return ["`{$accessCol}` = ?"];
324    }
325
326    /**
327     * @param array<int, mixed> &$params
328     * @param-out array<int, mixed> $params
329     * @return array<int, string>|null
330     */
331    private function resolveDeleteClause(string $tableName, ?string $accessCol, array &$params): ?array
332    {
333        if ($accessCol !== null) {
334            $params[] = RecordStatus::DELETED;
335            return ["`{$accessCol}` = ?"];
336        }
337
338        if ($this->hasColumn($tableName, 'is_active')) {
339            $params[] = 0;
340            return ['`is_active` = ?'];
341        }
342
343        return null;
344    }
345
346    /**
347     * @param array<string, mixed>  $updates
348     * @param array<int, mixed>     &$params
349     * @param-out array<int, mixed> $params
350     * @return array<int, string>
351     */
352    private function resolveEditClauses(string $tableName, array $updates, array &$params): array
353    {
354        $clauses = [];
355        $protectedColumns = ['id', 'created_at', 'created_by', 'is_system'];
356
357        foreach ($updates as $column => $value) {
358            $colName = (string) $column;
359            if (in_array($colName, $protectedColumns, true)) {
360                continue;
361            }
362            if (preg_match('/^\w+$/', $colName) && $this->hasColumn($tableName, $colName)) {
363                $clauses[] = "`{$colName}` = ?";
364                $params[] = $value;
365            }
366        }
367        return $clauses;
368    }
369
370    /**
371     * Executes physical deletion of records when soft deletion is not supported.
372     *
373     * @param string                 $tableName Target table name.
374     * @param array<int, int|string> $targetIds IDs to delete.
375     * @return int Number of deleted rows.
376     */
377    private function executeHardDelete(string $tableName, array $targetIds): int
378    {
379        $placeholders = implode(',', array_fill(0, count($targetIds), '?'));
380        $extraWhere = $this->hasColumn($tableName, 'is_system')
381            ? ' AND (`is_system` IS NULL OR `is_system` != 1)'
382            : '';
383
384        $sql = sprintf('DELETE FROM `%s` WHERE `id` IN (%s)%s', $tableName, $placeholders, $extraWhere);
385        $stmt = $this->pdo->prepare($sql);
386        $stmt->execute($targetIds);
387        return $stmt->rowCount();
388    }
389
390    /**
391     * Checks if a column exists in the target table.
392     */
393    private function hasColumn(string $tableName, string $columnName): bool
394    {
395        $normalizedCol = strtolower($columnName);
396        if (isset(self::$columnsCache[$tableName][$normalizedCol])) {
397            return self::$columnsCache[$tableName][$normalizedCol];
398        }
399
400        if (!preg_match('/^\w+$/', $tableName)) {
401            return false;
402        }
403
404        self::$columnsCache[$tableName] = $this->loadTableColumns($tableName);
405
406        return self::$columnsCache[$tableName][$normalizedCol] ?? false;
407    }
408
409    /**
410     * Loads column definitions for table from database schema.
411     *
412     * @param string $tableName Table name.
413     * @return array<string, bool> Map of lowercase column names.
414     */
415    private function loadTableColumns(string $tableName): array
416    {
417        try {
418            $driver = (string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
419            $query = $driver === 'sqlite'
420                ? sprintf('PRAGMA table_info(`%s`)', $tableName)
421                : sprintf('SHOW COLUMNS FROM `%s`', $tableName);
422
423            $stmt = $this->pdo->query($query);
424            if ($stmt === false) {
425                return [];
426            }
427
428            /** @var array<int, array<string, mixed>> $rows */
429            $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
430            $columns = [];
431            foreach ($rows as $row) {
432                $name = (string) ($driver === 'sqlite' ? ($row['name'] ?? '') : ($row['Field'] ?? ''));
433                if ($name !== '') {
434                    $columns[strtolower($name)] = true;
435                }
436            }
437
438            return $columns;
439        } catch (Throwable) {
440            return [];
441        }
442    }
443
444    private function assertAllowedAction(string $actionType): void
445    {
446        $allowedActions = ['bulk_edit', 'bulk_archive', 'bulk_restore', 'bulk_delete'];
447        if (!in_array($actionType, $allowedActions, true)) {
448            throw new InvalidArgumentException(sprintf('Unsupported bulk action type: "%s".', $actionType));
449        }
450    }
451
452    /**
453     * @param array<int, mixed> $recordIds
454     * @return array<int, int|string>
455     */
456    private function sanitizeRecordIds(array $recordIds): array
457    {
458        $clean = array_values(array_unique(array_filter(
459            $recordIds,
460            static fn($id): bool => $id !== null && $id !== '' && (is_numeric($id) || is_string($id))
461        )));
462
463        if ($clean === []) {
464            throw new InvalidArgumentException('No valid record IDs provided for mass action.');
465        }
466
467        return $clean;
468    }
469}