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: 7 additions & 1 deletion config/audit-logger.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
| This option controls the default audit driver that will be used to store
| audit logs.
|
| Supported: "mysql"
| Supported: "mysql", "postgresql"
|
*/
'default' => env('AUDIT_DRIVER', 'mysql'),
Expand All @@ -30,6 +30,12 @@
'table_prefix' => env('AUDIT_TABLE_PREFIX', 'audit_'),
'table_suffix' => env('AUDIT_TABLE_SUFFIX', '_logs'),
],

'postgresql' => [
'connection' => env('AUDIT_PGSQL_CONNECTION', config('database.default')),
'table_prefix' => env('AUDIT_TABLE_PREFIX', 'audit_'),
'table_suffix' => env('AUDIT_TABLE_SUFFIX', '_logs'),
],
],

/*
Expand Down
7 changes: 5 additions & 2 deletions src/AuditLoggerServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use iamfarhad\LaravelAuditLog\Contracts\CauserResolverInterface;
use iamfarhad\LaravelAuditLog\Contracts\RetentionServiceInterface;
use iamfarhad\LaravelAuditLog\Drivers\MySQLDriver;
use iamfarhad\LaravelAuditLog\Drivers\PostgreSQLDriver;
use iamfarhad\LaravelAuditLog\DTOs\AuditLog;
use iamfarhad\LaravelAuditLog\Services\AuditLogger;
use iamfarhad\LaravelAuditLog\Services\CauserResolver;
Expand Down Expand Up @@ -42,10 +43,12 @@ public function register(): void

// Register the main audit logger service - use fully qualified namespace
$this->app->singleton(AuditLogger::class, function ($app) {
$connection = $app['config']['audit-logger.drivers.mysql.connection'] ?? config('database.default');
$driverName = $app['config']['audit-logger.default'] ?? 'mysql';
$connection = $app['config']["audit-logger.drivers.{$driverName}.connection"] ?? config('database.default');

$driver = match ($app['config']['audit-logger.default']) {
$driver = match ($driverName) {
'mysql' => new MySQLDriver($connection),
'postgresql' => new PostgreSQLDriver($connection),
default => new MySQLDriver($connection),
};

Expand Down
237 changes: 237 additions & 0 deletions src/Drivers/PostgreSQLDriver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
<?php

declare(strict_types=1);

namespace iamfarhad\LaravelAuditLog\Drivers;

use iamfarhad\LaravelAuditLog\Contracts\AuditDriverInterface;
use iamfarhad\LaravelAuditLog\Contracts\AuditLogInterface;
use iamfarhad\LaravelAuditLog\Models\EloquentAuditLog;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;

final class PostgreSQLDriver implements AuditDriverInterface
{
private string $tablePrefix;

private string $tableSuffix;

private array $config;

private string $connection;

/**
* Cache for table existence checks to avoid repeated schema queries.
*/
private static array $existingTables = [];

/**
* Cache for configuration values to avoid repeated config() calls.
*/
private static ?array $configCache = null;

public function __construct(?string $connection = null)
{
$this->config = self::getConfigCache();
$this->connection = $connection ?? $this->config['drivers']['postgresql']['connection'] ?? config('database.default');
$this->tablePrefix = $this->config['drivers']['postgresql']['table_prefix'] ?? 'audit_';
$this->tableSuffix = $this->config['drivers']['postgresql']['table_suffix'] ?? '_logs';
}

/**
* Get cached configuration to avoid repeated config() calls.
*/
private static function getConfigCache(): array
{
if (self::$configCache === null) {
self::$configCache = config('audit-logger');
}

return self::$configCache;
}

/**
* Validate that the entity type is a valid class.
* In testing environment, we allow fake class names for flexibility.
*/
private function validateEntityType(string $entityType): void
{
// Skip validation in testing environment to allow fake class names
if (app()->environment('testing')) {
return;
}

if (! class_exists($entityType)) {
throw new \InvalidArgumentException("Entity type '{$entityType}' is not a valid class.");
}
}

public function store(AuditLogInterface $log): void
{
$this->validateEntityType($log->getEntityType());
$tableName = $this->getTableName($log->getEntityType());

$this->ensureStorageExists($log->getEntityType());

try {
$model = EloquentAuditLog::forEntity(entityClass: $log->getEntityType());

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The EloquentAuditLog::forEntity() method appears to be hardcoded to use the mysql driver's configuration for determining table names (i.e., table_prefix and table_suffix). This will cause the PostgreSQLDriver to generate incorrect table names if its prefix/suffix configuration differs from the mysql driver's settings, breaking the driver's functionality. This issue also affects the call on line 122.

To resolve this, EloquentAuditLog::forEntity() should be updated to accept the driver name (e.g., 'postgresql') and use it to retrieve the correct configuration. The MySQLDriver would also need to be updated to pass its driver name.

$model->setConnection($this->connection);
$model->fill([
'entity_id' => $log->getEntityId(),
'action' => $log->getAction(),
'old_values' => $log->getOldValues(), // Remove manual json_encode - let Eloquent handle it
'new_values' => $log->getNewValues(), // Remove manual json_encode - let Eloquent handle it
'causer_type' => $log->getCauserType(),
'causer_id' => $log->getCauserId(),
'metadata' => $log->getMetadata(), // Remove manual json_encode - let Eloquent handle it
'created_at' => $log->getCreatedAt(),
'source' => $log->getSource(),
]);
$model->save();
} catch (\Exception $e) {
throw $e;
}
Comment on lines +77 to +94

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The try-catch block here is redundant because it only catches the exception to re-throw it. Removing it will make the code simpler and easier to read without changing the functionality.

        $model = EloquentAuditLog::forEntity(entityClass: $log->getEntityType());
        $model->setConnection($this->connection);
        $model->fill([
            'entity_id' => $log->getEntityId(),
            'action' => $log->getAction(),
            'old_values' => $log->getOldValues(), // Remove manual json_encode - let Eloquent handle it
            'new_values' => $log->getNewValues(), // Remove manual json_encode - let Eloquent handle it
            'causer_type' => $log->getCauserType(),
            'causer_id' => $log->getCauserId(),
            'metadata' => $log->getMetadata(), // Remove manual json_encode - let Eloquent handle it
            'created_at' => $log->getCreatedAt(),
            'source' => $log->getSource(),
        ]);
        $model->save();

}

/**
* Store multiple audit logs using Eloquent models with proper casting.
*
* @param array<AuditLogInterface> $logs
*/
public function storeBatch(array $logs): void
{
if (empty($logs)) {
return;
}

// Group logs by entity type (and thus by table)
$groupedLogs = [];
foreach ($logs as $log) {
$this->validateEntityType($log->getEntityType());
$entityType = $log->getEntityType();
$groupedLogs[$entityType][] = $log;
}

// Process each entity type separately using Eloquent models to leverage casting
foreach ($groupedLogs as $entityType => $entityLogs) {
$this->ensureStorageExists($entityType);

// Use Eloquent models to leverage automatic JSON casting
foreach ($entityLogs as $log) {
$model = EloquentAuditLog::forEntity(entityClass: $entityType);
$model->setConnection($this->connection);
$model->fill([
'entity_id' => $log->getEntityId(),
'action' => $log->getAction(),
'old_values' => $log->getOldValues(), // Eloquent casting handles JSON encoding
'new_values' => $log->getNewValues(), // Eloquent casting handles JSON encoding
'causer_type' => $log->getCauserType(),
'causer_id' => $log->getCauserId(),
'metadata' => $log->getMetadata(), // Eloquent casting handles JSON encoding
'created_at' => $log->getCreatedAt(),
'source' => $log->getSource(),
]);
$model->save();
}
}
}
Comment on lines +102 to +138

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The storeBatch method currently iterates through logs and saves them individually, resulting in multiple database queries. This is inefficient for a batch operation and the method name is misleading. For large numbers of logs, this could cause performance problems.

For a true batch operation, you should consider collecting all log data into an array and using a single insert statement. This would be much more performant. Note that this would require manually encoding JSON fields, as a mass-insert operation bypasses Eloquent's automatic casting.


public function createStorageForEntity(string $entityClass): void
{
$this->validateEntityType($entityClass);
$tableName = $this->getTableName($entityClass);

Schema::connection($this->connection)->create($tableName, function (Blueprint $table) {
$table->id();
$table->string('entity_id');
$table->string('action');
// PostgreSQL supports both json and jsonb. Using jsonb for better performance
$table->jsonb('old_values')->nullable();
$table->jsonb('new_values')->nullable();
$table->string('causer_type')->nullable();
$table->string('causer_id')->nullable();
$table->jsonb('metadata')->nullable();
$table->timestamp('created_at');
$table->string('source')->nullable();
$table->timestamp('anonymized_at')->nullable();

// Basic indexes
$table->index('entity_id');
$table->index('causer_id');
$table->index('created_at');
$table->index('action');
$table->index('anonymized_at');

// Composite indexes for common query patterns
$table->index(['entity_id', 'action']);
$table->index(['entity_id', 'created_at']);
$table->index(['causer_id', 'action']);
$table->index(['action', 'created_at']);
});

// Cache the newly created table
self::$existingTables[$tableName] = true;
}

public function storageExistsForEntity(string $entityClass): bool
{
$tableName = $this->getTableName($entityClass);

// Check cache first to avoid repeated schema queries
if (isset(self::$existingTables[$tableName])) {
return self::$existingTables[$tableName];
}

// Check database and cache the result
$exists = Schema::connection($this->connection)->hasTable($tableName);
self::$existingTables[$tableName] = $exists;

return $exists;
}

/**
* Ensures the audit storage exists for the entity if auto_migration is enabled.
*/
public function ensureStorageExists(string $entityClass): void
{
$autoMigration = $this->config['auto_migration'] ?? true;
if ($autoMigration === false) {
return;
}

if (! $this->storageExistsForEntity($entityClass)) {
$this->createStorageForEntity($entityClass);
}
}

/**
* Clear the table existence cache and config cache.
* Useful for testing or when tables are dropped/recreated.
*/
public static function clearCache(): void
{
self::$existingTables = [];
self::$configCache = null;
}

/**
* Clear only the table existence cache.
*/
public static function clearTableCache(): void
{
self::$existingTables = [];
}

private function getTableName(string $entityType): string
{
// Extract class name without namespace
$className = Str::snake(class_basename($entityType));

// Handle pluralization
$tableName = Str::plural($className);

return "{$this->tablePrefix}{$tableName}{$this->tableSuffix}";
}
}

Loading