Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/Contract/AuditExporterInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<AuditLog> $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;
}
25 changes: 19 additions & 6 deletions src/Controller/Admin/AuditLogCrudController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -244,16 +246,27 @@ public function exportCsv(AdminContext $context): Response
/**
* @param AdminContext<AuditLog> $context
*/
private function doExport(AdminContext $context, string $format): Response
private function doExport(AdminContext $context, string $format): StreamedResponse
{
$filters = $this->getFiltersFromRequest($context);
/** @var iterable<AuditLog> $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<AuditLog> $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));

Expand Down
147 changes: 95 additions & 52 deletions src/Service/AuditExporter.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Rcsofttech\AuditTrailBundle\Service;

use DateTimeInterface;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Override;
use Rcsofttech\AuditTrailBundle\Contract\AuditExporterInterface;
Expand All @@ -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;

Expand All @@ -24,6 +26,11 @@

final readonly class AuditExporter implements AuditExporterInterface
{
public function __construct(
private ?EntityManagerInterface $entityManager = null,
) {
}

/**
* @param iterable<AuditLog> $audits
*/
Expand All @@ -39,70 +46,36 @@ public function formatAudits(iterable $audits, string $format): string

/**
* @param iterable<AuditLog> $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<AuditLog> $audits
*/
public function formatAsJson(iterable $audits): string
{
return $this->formatViaStream(fn ($stream) => $this->writeJsonToStream($audits, $stream));
}

/**
* @param iterable<AuditLog> $audits
*/
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));
}

/**
Expand Down Expand Up @@ -140,6 +113,76 @@ public function formatFileSize(int $bytes): string
return sprintf('%.2f %s', $bytes / (1024 ** $i), $units[$i]);
}

/**
* @param iterable<AuditLog> $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<AuditLog> $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)) {
Expand Down
52 changes: 52 additions & 0 deletions tests/Unit/Service/AuditExporterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
Loading