Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.81% covered (success)
93.81%
91 / 97
66.67% covered (warning)
66.67%
6 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
DataExportService
93.75% covered (success)
93.75%
90 / 96
66.67% covered (warning)
66.67%
6 / 9
30.22
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
 export
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
2
 verifyExportPermission
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
5.01
 resolveExportFields
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 createWriter
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 streamRecords
91.67% covered (success)
91.67%
22 / 24
0.00% covered (danger)
0.00%
0 / 1
8.04
 streamSelectedRecords
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 formatExportRow
66.67% covered (warning)
66.67%
6 / 9
0.00% covered (danger)
0.00%
0 / 1
4.59
 generateTempFilePath
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
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\DataExchange\Application\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\DataExchange\Domain\Model\ExportConfigDto;
12use App\Core\DataExchange\Infrastructure\Writer\CsvTabularStreamWriter;
13use App\Core\DataExchange\Infrastructure\Writer\TabularStreamWriterInterface;
14use App\Core\DataExchange\Infrastructure\Writer\XlsxTabularStreamWriter;
15use App\Core\Engine\Application\Service\UniversalCrudService;
16use App\Core\Engine\Domain\Exception\PermissionDeniedException;
17use App\Core\Engine\Domain\Model\FieldMetadata;
18use App\Core\Engine\Domain\Model\PermissionContext;
19use App\Core\Grid\GridRequest;
20use InvalidArgumentException;
21use PDO;
22
23/**
24 * Application service managing memory-efficient, streaming export of module records to CSV or XLSX.
25 *
26 * @package App\Core\DataExchange\Application\Service
27 */
28final class DataExportService implements DataExportServiceInterface
29{
30    private const int CHUNK_SIZE = 250;
31
32    /**
33     * DataExportService constructor.
34     *
35     * @param UniversalCrudService $crudService Core engine universal CRUD service.
36     * @param PDO                  $pdo         Database connection for streaming queries.
37     * @param string               $tablePrefix Database table prefix.
38     */
39    public function __construct(
40        private readonly UniversalCrudService $crudService,
41        private readonly PDO $pdo,
42        private readonly string $tablePrefix = 'a_'
43    ) {
44    }
45
46    /**
47     * Exports records for the requested module according to ExportConfigDto.
48     *
49     * @param string            $moduleName  Target module machine name.
50     * @param ExportConfigDto   $config      Export configuration DTO.
51     * @param PermissionContext $context     Active user security context.
52     * @param GridRequest|null  $gridRequest Active grid request for filtered scope.
53     * @return array{
54     *     file_path: string,
55     *     file_name: string,
56     *     mime_type: string,
57     *     total_rows: int,
58     *     size_bytes: int
59     * }
60     */
61    public function export(
62        string $moduleName,
63        ExportConfigDto $config,
64        PermissionContext $context,
65        ?GridRequest $gridRequest = null
66    ): array {
67        $metaRepo = $this->crudService->getMetadataRepository();
68        $module = $metaRepo->findModule($moduleName);
69
70        $this->verifyExportPermission($module->id, $context);
71
72        $allFields = $metaRepo->findFields($module->id);
73        $exportFields = $this->resolveExportFields($allFields, $config->selectedColumns);
74
75        $outputPath = $this->generateTempFilePath($moduleName, $config->format);
76        $writer = $this->createWriter($config, $outputPath);
77
78        $headers = array_map(static fn(FieldMetadata $f): string => $f->label, $exportFields);
79        $writer->writeHeaders($headers);
80
81        $totalRows = $this->streamRecords($moduleName, $exportFields, $config, $context, $gridRequest, $writer);
82        $writer->close();
83
84        $fileName = sprintf('%s_export_%s.%s', $moduleName, date('Y-m-d_His'), $writer->getFileExtension());
85
86        return [
87            'file_path' => $outputPath,
88            'file_name' => $fileName,
89            'mime_type' => $writer->getMimeType(),
90            'total_rows' => $totalRows,
91            'size_bytes' => file_exists($outputPath) ? (int) filesize($outputPath) : 0,
92        ];
93    }
94
95    /**
96     * Verifies if user has permission to export data from the specified module.
97     */
98    private function verifyExportPermission(int $moduleId, PermissionContext $context): void
99    {
100        if ($context->isSuperuser) {
101            return;
102        }
103
104        if ($context->actorProfileId === null) {
105            throw new PermissionDeniedException('User does not have export permission for this module.');
106        }
107
108        $sql = "SELECT can_export FROM {$this->tablePrefix}core_profile_modules " .
109            'WHERE profile_id = :profile_id AND module_id = :module_id LIMIT 1';
110        $stmt = $this->pdo->prepare($sql);
111        $stmt->execute([
112            ':profile_id' => $context->actorProfileId,
113            ':module_id' => $moduleId,
114        ]);
115
116        $canExport = $stmt->fetchColumn();
117        if ($canExport !== false && (int) $canExport === 0) {
118            throw new PermissionDeniedException('User does not have export permission for this module.');
119        }
120    }
121
122    /**
123     * Filters available fields based on requested field list.
124     *
125     * @param list<FieldMetadata> $allFields
126     * @param list<string>        $requestedFieldKeys
127     * @return list<FieldMetadata>
128     */
129    private function resolveExportFields(array $allFields, array $requestedFieldKeys): array
130    {
131        if (!empty($requestedFieldKeys)) {
132            $requestedLookup = array_flip($requestedFieldKeys);
133            return array_values(
134                array_filter($allFields, static fn(FieldMetadata $f): bool => isset($requestedLookup[$f->fieldKey]))
135            );
136        }
137
138        return array_values(
139            array_filter($allFields, static fn(FieldMetadata $f): bool => !$f->isSystem)
140        );
141    }
142
143    /**
144     * Instantiates appropriate stream writer based on configuration format.
145     */
146    private function createWriter(ExportConfigDto $config, string $outputPath): TabularStreamWriterInterface
147    {
148        return match ($config->format) {
149            'xlsx' => new XlsxTabularStreamWriter($outputPath),
150            'csv' => new CsvTabularStreamWriter($outputPath, $config->csvDelimiter, $config->includeBom),
151            default => throw new InvalidArgumentException("Unsupported export format: {$config->format}"),
152        };
153    }
154
155    /**
156     * Streams records in paginated batches and writes them to the tabular stream writer.
157     *
158     * @param list<FieldMetadata> $exportFields
159     */
160    private function streamRecords(
161        string $moduleName,
162        array $exportFields,
163        ExportConfigDto $config,
164        PermissionContext $context,
165        ?GridRequest $gridRequest,
166        TabularStreamWriterInterface $writer
167    ): int {
168        $totalWritten = 0;
169
170        if ($config->scope === 'selected') {
171            if (empty($config->recordIds)) {
172                return 0;
173            }
174            return $this->streamSelectedRecords($moduleName, $exportFields, $config->recordIds, $context, $writer);
175        }
176
177        $page = 1;
178        $req = $gridRequest ?? new GridRequest(limit: self::CHUNK_SIZE);
179
180        while (true) {
181            $pageReq = new GridRequest(
182                page: $page,
183                limit: self::CHUNK_SIZE,
184                sortColumn: $req->sortColumn,
185                sortDirection: $req->sortDirection,
186                filters: $config->scope === 'filtered' ? $req->filters : []
187            );
188
189            $result = $this->crudService->list($moduleName, $pageReq, $context);
190            if (empty($result->rows)) {
191                break;
192            }
193
194            foreach ($result->rows as $row) {
195                $rowValues = $this->formatExportRow($row, $exportFields);
196                $writer->writeRow($rowValues);
197                $totalWritten++;
198            }
199
200            if ($page >= $result->totalPages) {
201                break;
202            }
203
204            $page++;
205        }
206
207        return $totalWritten;
208    }
209
210    /**
211     * Streams specific records selected by ID.
212     *
213     * @param list<FieldMetadata> $exportFields
214     * @param list<int>           $selectedIds
215     */
216    private function streamSelectedRecords(
217        string $moduleName,
218        array $exportFields,
219        array $selectedIds,
220        PermissionContext $context,
221        TabularStreamWriterInterface $writer
222    ): int {
223        $written = 0;
224        $chunks = array_chunk($selectedIds, self::CHUNK_SIZE);
225
226        foreach ($chunks as $chunkIds) {
227            $chunkReq = new GridRequest(
228                limit: count($chunkIds),
229                filters: ['id' => implode(',', $chunkIds)]
230            );
231
232            $result = $this->crudService->list($moduleName, $chunkReq, $context);
233            foreach ($result->rows as $row) {
234                $rowValues = $this->formatExportRow($row, $exportFields);
235                $writer->writeRow($rowValues);
236                $written++;
237            }
238        }
239
240        return $written;
241    }
242
243    /**
244     * Extracts and formats cell values for a single record row.
245     *
246     * @param array<string, mixed> $row
247     * @param list<FieldMetadata>  $fields
248     * @return list<mixed>
249     */
250    private function formatExportRow(array $row, array $fields): array
251    {
252        $values = [];
253        foreach ($fields as $field) {
254            $raw = $row[$field->fieldKey] ?? null;
255            if (is_scalar($raw)) {
256                $values[] = (string) $raw;
257            } elseif ($raw === null) {
258                $values[] = '';
259            } else {
260                $values[] = (string) json_encode($raw);
261            }
262        }
263
264        return $values;
265    }
266
267    /**
268     * Generates a unique temporary file path for export streaming.
269     */
270    private function generateTempFilePath(string $moduleName, string $format): string
271    {
272        $dir = sys_get_temp_dir();
273        $random = bin2hex(random_bytes(6));
274        return sprintf('%s%sexport_%s_%s.%s', $dir, DIRECTORY_SEPARATOR, $moduleName, $random, $format);
275    }
276}