diff --git a/src/Contract/AuditExporterInterface.php b/src/Contract/AuditExporterInterface.php index 96b5907..0d15a79 100644 --- a/src/Contract/AuditExporterInterface.php +++ b/src/Contract/AuditExporterInterface.php @@ -13,5 +13,13 @@ interface AuditExporterInterface */ public function formatAudits(iterable $audits, string $format): string; + /** + * Write audit logs directly to a stream resource, avoiding full string materialization. + * + * @param iterable $audits + * @param resource $stream A writable stream resource (e.g. fopen('php://output', 'wb')) + */ + public function exportToStream(iterable $audits, string $format, mixed $stream): void; + public function formatFileSize(int $bytes): string; } diff --git a/src/Controller/Admin/AuditLogCrudController.php b/src/Controller/Admin/AuditLogCrudController.php index cc571e0..201174b 100644 --- a/src/Controller/Admin/AuditLogCrudController.php +++ b/src/Controller/Admin/AuditLogCrudController.php @@ -32,9 +32,11 @@ use Rcsofttech\AuditTrailBundle\Service\RevertPreviewFormatter; use Rcsofttech\AuditTrailBundle\Service\TransactionDrilldownService; use Rcsofttech\AuditTrailBundle\Util\ClassNameHelperTrait; +use RuntimeException; use Stringable; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\StreamedResponse; use Throwable; use function is_scalar; @@ -244,16 +246,27 @@ public function exportCsv(AdminContext $context): Response /** * @param AdminContext $context */ - private function doExport(AdminContext $context, string $format): Response + private function doExport(AdminContext $context, string $format): StreamedResponse { $filters = $this->getFiltersFromRequest($context); - /** @var iterable $audits */ - $audits = $this->repository->findAllWithFilters($filters); - - $content = $this->exporter->formatAudits($audits, $format); $fileName = sprintf('audit_logs_%s.%s', new DateTimeImmutable()->format('Y-m-d_His'), $format); - $response = new Response($content); + $response = new StreamedResponse(function () use ($filters, $format): void { + /** @var iterable $audits */ + $audits = $this->repository->findAllWithFilters($filters); + $output = fopen('php://output', 'w'); + + if ($output === false) { + throw new RuntimeException('Failed to open output stream for export'); + } + + try { + $this->exporter->exportToStream($audits, $format, $output); + } finally { + fclose($output); + } + }); + $response->headers->set('Content-Type', $format === 'json' ? 'application/json' : 'text/csv'); $response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $fileName)); diff --git a/src/Service/AuditExporter.php b/src/Service/AuditExporter.php index 77ee97e..9def574 100644 --- a/src/Service/AuditExporter.php +++ b/src/Service/AuditExporter.php @@ -5,6 +5,7 @@ namespace Rcsofttech\AuditTrailBundle\Service; use DateTimeInterface; +use Doctrine\ORM\EntityManagerInterface; use InvalidArgumentException; use Override; use Rcsofttech\AuditTrailBundle\Contract\AuditExporterInterface; @@ -15,6 +16,7 @@ use function count; use function in_array; use function is_array; +use function is_resource; use function is_scalar; use function sprintf; @@ -24,6 +26,11 @@ final readonly class AuditExporter implements AuditExporterInterface { + public function __construct( + private ?EntityManagerInterface $entityManager = null, + ) { + } + /** * @param iterable $audits */ @@ -39,34 +46,28 @@ public function formatAudits(iterable $audits, string $format): string /** * @param iterable $audits + * @param resource $stream */ - public function formatAsJson(iterable $audits): string + #[Override] + public function exportToStream(iterable $audits, string $format, mixed $stream): void { - $output = fopen('php://temp', 'r+'); - if ($output === false) { - throw new RuntimeException('Failed to open temp stream for JSON generation'); + if (!is_resource($stream) || get_resource_type($stream) !== 'stream') { + throw new InvalidArgumentException('Expected a writable stream resource'); } - try { - fwrite($output, '['); - $first = true; - - foreach ($audits as $audit) { - if (!$first) { - fwrite($output, ','); - } - fwrite($output, json_encode($this->auditToArray($audit), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)); - $first = false; - } - - fwrite($output, ']'); - rewind($output); - $json = stream_get_contents($output); + match ($format) { + 'json' => $this->writeJsonToStream($audits, $stream), + 'csv' => $this->writeCsvToStream($audits, $stream), + default => throw new InvalidArgumentException(sprintf('Unsupported format: %s', $format)), + }; + } - return $json !== false ? $json : '[]'; - } finally { - fclose($output); - } + /** + * @param iterable $audits + */ + public function formatAsJson(iterable $audits): string + { + return $this->formatViaStream(fn ($stream) => $this->writeJsonToStream($audits, $stream)); } /** @@ -74,35 +75,7 @@ public function formatAsJson(iterable $audits): string */ public function formatAsCsv(iterable $audits): string { - $output = fopen('php://temp', 'r+'); - if ($output === false) { - throw new RuntimeException('Failed to open temp stream for CSV generation'); - } - - try { - $headerWritten = false; - - foreach ($audits as $audit) { - $row = $this->auditToArray($audit); - if (!$headerWritten) { - fputcsv($output, array_keys($row), ',', '"', '\\'); - $headerWritten = true; - } - - $csvRow = array_map( - fn ($value) => is_array($value) ? json_encode($value, JSON_THROW_ON_ERROR) : $this->sanitizeCsvValue((string) (is_scalar($value) || $value instanceof Stringable ? $value : '')), - $row - ); - fputcsv($output, $csvRow, ',', '"', '\\'); - } - - rewind($output); - $csv = stream_get_contents($output); - - return $csv !== false ? $csv : ''; - } finally { - fclose($output); - } + return $this->formatViaStream(fn ($stream) => $this->writeCsvToStream($audits, $stream)); } /** @@ -140,6 +113,76 @@ public function formatFileSize(int $bytes): string return sprintf('%.2f %s', $bytes / (1024 ** $i), $units[$i]); } + /** + * @param iterable $audits + * @param resource $stream + */ + private function writeJsonToStream(iterable $audits, mixed $stream): void + { + fwrite($stream, '['); + $first = true; + + foreach ($audits as $audit) { + if (!$first) { + fwrite($stream, ','); + } + fwrite($stream, json_encode($this->auditToArray($audit), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)); + $first = false; + + $this->entityManager?->detach($audit); + } + + fwrite($stream, ']'); + } + + /** + * @param iterable $audits + * @param resource $stream + */ + private function writeCsvToStream(iterable $audits, mixed $stream): void + { + $headerWritten = false; + + foreach ($audits as $audit) { + $row = $this->auditToArray($audit); + if (!$headerWritten) { + fputcsv($stream, array_keys($row), ',', '"', '\\'); + $headerWritten = true; + } + + $csvRow = array_map( + fn ($value) => is_array($value) ? json_encode($value, JSON_THROW_ON_ERROR) : $this->sanitizeCsvValue((string) (is_scalar($value) || $value instanceof Stringable ? $value : '')), + $row + ); + fputcsv($stream, $csvRow, ',', '"', '\\'); + + $this->entityManager?->detach($audit); + } + } + + /** + * Helper: run a stream-writing callback against php://temp and return the result as a string. + * + * @param callable(resource): void $writer + */ + private function formatViaStream(callable $writer): string + { + $stream = fopen('php://temp', 'r+'); + if ($stream === false) { + throw new RuntimeException('Failed to open temp stream for export'); + } + + try { + $writer($stream); + rewind($stream); + $content = stream_get_contents($stream); + + return $content !== false ? $content : ''; + } finally { + fclose($stream); + } + } + private function sanitizeCsvValue(string $value): string { if ($value !== '' && in_array(mb_substr($value, 0, 1), ['=', '+', '-', '@'], true)) { diff --git a/tests/Unit/Service/AuditExporterTest.php b/tests/Unit/Service/AuditExporterTest.php index 983a554..6479521 100644 --- a/tests/Unit/Service/AuditExporterTest.php +++ b/tests/Unit/Service/AuditExporterTest.php @@ -150,4 +150,56 @@ public function testAuditToArrayVisibility(): void $array = $this->exporter->auditToArray($log); self::assertArrayHasKey('action', $array); } + + public function testExportToStreamJson(): void + { + $log1 = new AuditLog('User', '1', 'create', new DateTimeImmutable('2024-01-01 12:00:00')); + $log2 = new AuditLog('User', '2', 'update', new DateTimeImmutable('2024-01-02 12:00:00')); + + $stream = fopen('php://temp', 'r+'); + self::assertIsResource($stream); + + $this->exporter->exportToStream([$log1, $log2], 'json', $stream); + + rewind($stream); + $streamOutput = stream_get_contents($stream); + fclose($stream); + + $stringOutput = $this->exporter->formatAsJson([$log1, $log2]); + + self::assertSame($stringOutput, $streamOutput); + } + + public function testExportToStreamCsv(): void + { + $log1 = new AuditLog('User', '1', 'create', new DateTimeImmutable('2024-01-01 12:00:00')); + $log2 = new AuditLog('Post', '99', 'delete', new DateTimeImmutable('2024-01-02 12:00:00')); + + $stream = fopen('php://temp', 'r+'); + self::assertIsResource($stream); + + $this->exporter->exportToStream([$log1, $log2], 'csv', $stream); + + rewind($stream); + $streamOutput = stream_get_contents($stream); + fclose($stream); + + $stringOutput = $this->exporter->formatAsCsv([$log1, $log2]); + + self::assertSame($stringOutput, $streamOutput); + } + + public function testExportToStreamThrowsOnInvalidFormat(): void + { + $this->expectException(InvalidArgumentException::class); + + $stream = fopen('php://temp', 'r+'); + self::assertIsResource($stream); + + try { + $this->exporter->exportToStream([], 'xml', $stream); + } finally { + fclose($stream); + } + } }