Skip to content

Commit fb0fb7a

Browse files
committed
feat: add PostgreSQL driver support
-Adds native PostgreSQL support to Laravel Audit Logger via a new `PostgreSQLDriver The driver mirrors the MySQL implementation while using PostgreSQL’s jsonb columns and indexing for better performance and schema compatibility. Key Changes - Added PostgreSQLDriver under src/Drivers/ - Updated AuditLoggerServiceProvider to resolve drivers dynamically - Added postgresql config block to config/audit-logger.php - Added PostgreSQLDriverTest with full CRUD, schema, and migration coverage Usage AUDIT_DRIVER=postgresql AUDIT_PGSQL_CONNECTION=pgsql Testing composer test --filter=PostgreSQLDriverTest
1 parent 34ef06b commit fb0fb7a

File tree

4 files changed

+425
-3
lines changed

4 files changed

+425
-3
lines changed

config/audit-logger.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
| This option controls the default audit driver that will be used to store
1212
| audit logs.
1313
|
14-
| Supported: "mysql"
14+
| Supported: "mysql", "postgresql"
1515
|
1616
*/
1717
'default' => env('AUDIT_DRIVER', 'mysql'),
@@ -30,6 +30,12 @@
3030
'table_prefix' => env('AUDIT_TABLE_PREFIX', 'audit_'),
3131
'table_suffix' => env('AUDIT_TABLE_SUFFIX', '_logs'),
3232
],
33+
34+
'postgresql' => [
35+
'connection' => env('AUDIT_PGSQL_CONNECTION', config('database.default')),
36+
'table_prefix' => env('AUDIT_TABLE_PREFIX', 'audit_'),
37+
'table_suffix' => env('AUDIT_TABLE_SUFFIX', '_logs'),
38+
],
3339
],
3440

3541
/*

src/AuditLoggerServiceProvider.php

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use iamfarhad\LaravelAuditLog\Contracts\CauserResolverInterface;
1010
use iamfarhad\LaravelAuditLog\Contracts\RetentionServiceInterface;
1111
use iamfarhad\LaravelAuditLog\Drivers\MySQLDriver;
12+
use iamfarhad\LaravelAuditLog\Drivers\PostgreSQLDriver;
1213
use iamfarhad\LaravelAuditLog\DTOs\AuditLog;
1314
use iamfarhad\LaravelAuditLog\Services\AuditLogger;
1415
use iamfarhad\LaravelAuditLog\Services\CauserResolver;
@@ -42,10 +43,12 @@ public function register(): void
4243

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

47-
$driver = match ($app['config']['audit-logger.default']) {
49+
$driver = match ($driverName) {
4850
'mysql' => new MySQLDriver($connection),
51+
'postgresql' => new PostgreSQLDriver($connection),
4952
default => new MySQLDriver($connection),
5053
};
5154

src/Drivers/PostgreSQLDriver.php

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace iamfarhad\LaravelAuditLog\Drivers;
6+
7+
use iamfarhad\LaravelAuditLog\Contracts\AuditDriverInterface;
8+
use iamfarhad\LaravelAuditLog\Contracts\AuditLogInterface;
9+
use iamfarhad\LaravelAuditLog\Models\EloquentAuditLog;
10+
use Illuminate\Database\Schema\Blueprint;
11+
use Illuminate\Support\Facades\Schema;
12+
use Illuminate\Support\Str;
13+
14+
final class PostgreSQLDriver implements AuditDriverInterface
15+
{
16+
private string $tablePrefix;
17+
18+
private string $tableSuffix;
19+
20+
private array $config;
21+
22+
private string $connection;
23+
24+
/**
25+
* Cache for table existence checks to avoid repeated schema queries.
26+
*/
27+
private static array $existingTables = [];
28+
29+
/**
30+
* Cache for configuration values to avoid repeated config() calls.
31+
*/
32+
private static ?array $configCache = null;
33+
34+
public function __construct(?string $connection = null)
35+
{
36+
$this->config = self::getConfigCache();
37+
$this->connection = $connection ?? $this->config['drivers']['postgresql']['connection'] ?? config('database.default');
38+
$this->tablePrefix = $this->config['drivers']['postgresql']['table_prefix'] ?? 'audit_';
39+
$this->tableSuffix = $this->config['drivers']['postgresql']['table_suffix'] ?? '_logs';
40+
}
41+
42+
/**
43+
* Get cached configuration to avoid repeated config() calls.
44+
*/
45+
private static function getConfigCache(): array
46+
{
47+
if (self::$configCache === null) {
48+
self::$configCache = config('audit-logger');
49+
}
50+
51+
return self::$configCache;
52+
}
53+
54+
/**
55+
* Validate that the entity type is a valid class.
56+
* In testing environment, we allow fake class names for flexibility.
57+
*/
58+
private function validateEntityType(string $entityType): void
59+
{
60+
// Skip validation in testing environment to allow fake class names
61+
if (app()->environment('testing')) {
62+
return;
63+
}
64+
65+
if (! class_exists($entityType)) {
66+
throw new \InvalidArgumentException("Entity type '{$entityType}' is not a valid class.");
67+
}
68+
}
69+
70+
public function store(AuditLogInterface $log): void
71+
{
72+
$this->validateEntityType($log->getEntityType());
73+
$tableName = $this->getTableName($log->getEntityType());
74+
75+
$this->ensureStorageExists($log->getEntityType());
76+
77+
try {
78+
$model = EloquentAuditLog::forEntity(entityClass: $log->getEntityType());
79+
$model->setConnection($this->connection);
80+
$model->fill([
81+
'entity_id' => $log->getEntityId(),
82+
'action' => $log->getAction(),
83+
'old_values' => $log->getOldValues(), // Remove manual json_encode - let Eloquent handle it
84+
'new_values' => $log->getNewValues(), // Remove manual json_encode - let Eloquent handle it
85+
'causer_type' => $log->getCauserType(),
86+
'causer_id' => $log->getCauserId(),
87+
'metadata' => $log->getMetadata(), // Remove manual json_encode - let Eloquent handle it
88+
'created_at' => $log->getCreatedAt(),
89+
'source' => $log->getSource(),
90+
]);
91+
$model->save();
92+
} catch (\Exception $e) {
93+
throw $e;
94+
}
95+
}
96+
97+
/**
98+
* Store multiple audit logs using Eloquent models with proper casting.
99+
*
100+
* @param array<AuditLogInterface> $logs
101+
*/
102+
public function storeBatch(array $logs): void
103+
{
104+
if (empty($logs)) {
105+
return;
106+
}
107+
108+
// Group logs by entity type (and thus by table)
109+
$groupedLogs = [];
110+
foreach ($logs as $log) {
111+
$this->validateEntityType($log->getEntityType());
112+
$entityType = $log->getEntityType();
113+
$groupedLogs[$entityType][] = $log;
114+
}
115+
116+
// Process each entity type separately using Eloquent models to leverage casting
117+
foreach ($groupedLogs as $entityType => $entityLogs) {
118+
$this->ensureStorageExists($entityType);
119+
120+
// Use Eloquent models to leverage automatic JSON casting
121+
foreach ($entityLogs as $log) {
122+
$model = EloquentAuditLog::forEntity(entityClass: $entityType);
123+
$model->setConnection($this->connection);
124+
$model->fill([
125+
'entity_id' => $log->getEntityId(),
126+
'action' => $log->getAction(),
127+
'old_values' => $log->getOldValues(), // Eloquent casting handles JSON encoding
128+
'new_values' => $log->getNewValues(), // Eloquent casting handles JSON encoding
129+
'causer_type' => $log->getCauserType(),
130+
'causer_id' => $log->getCauserId(),
131+
'metadata' => $log->getMetadata(), // Eloquent casting handles JSON encoding
132+
'created_at' => $log->getCreatedAt(),
133+
'source' => $log->getSource(),
134+
]);
135+
$model->save();
136+
}
137+
}
138+
}
139+
140+
public function createStorageForEntity(string $entityClass): void
141+
{
142+
$this->validateEntityType($entityClass);
143+
$tableName = $this->getTableName($entityClass);
144+
145+
Schema::connection($this->connection)->create($tableName, function (Blueprint $table) {
146+
$table->id();
147+
$table->string('entity_id');
148+
$table->string('action');
149+
// PostgreSQL supports both json and jsonb. Using jsonb for better performance
150+
$table->jsonb('old_values')->nullable();
151+
$table->jsonb('new_values')->nullable();
152+
$table->string('causer_type')->nullable();
153+
$table->string('causer_id')->nullable();
154+
$table->jsonb('metadata')->nullable();
155+
$table->timestamp('created_at');
156+
$table->string('source')->nullable();
157+
$table->timestamp('anonymized_at')->nullable();
158+
159+
// Basic indexes
160+
$table->index('entity_id');
161+
$table->index('causer_id');
162+
$table->index('created_at');
163+
$table->index('action');
164+
$table->index('anonymized_at');
165+
166+
// Composite indexes for common query patterns
167+
$table->index(['entity_id', 'action']);
168+
$table->index(['entity_id', 'created_at']);
169+
$table->index(['causer_id', 'action']);
170+
$table->index(['action', 'created_at']);
171+
});
172+
173+
// Cache the newly created table
174+
self::$existingTables[$tableName] = true;
175+
}
176+
177+
public function storageExistsForEntity(string $entityClass): bool
178+
{
179+
$tableName = $this->getTableName($entityClass);
180+
181+
// Check cache first to avoid repeated schema queries
182+
if (isset(self::$existingTables[$tableName])) {
183+
return self::$existingTables[$tableName];
184+
}
185+
186+
// Check database and cache the result
187+
$exists = Schema::connection($this->connection)->hasTable($tableName);
188+
self::$existingTables[$tableName] = $exists;
189+
190+
return $exists;
191+
}
192+
193+
/**
194+
* Ensures the audit storage exists for the entity if auto_migration is enabled.
195+
*/
196+
public function ensureStorageExists(string $entityClass): void
197+
{
198+
$autoMigration = $this->config['auto_migration'] ?? true;
199+
if ($autoMigration === false) {
200+
return;
201+
}
202+
203+
if (! $this->storageExistsForEntity($entityClass)) {
204+
$this->createStorageForEntity($entityClass);
205+
}
206+
}
207+
208+
/**
209+
* Clear the table existence cache and config cache.
210+
* Useful for testing or when tables are dropped/recreated.
211+
*/
212+
public static function clearCache(): void
213+
{
214+
self::$existingTables = [];
215+
self::$configCache = null;
216+
}
217+
218+
/**
219+
* Clear only the table existence cache.
220+
*/
221+
public static function clearTableCache(): void
222+
{
223+
self::$existingTables = [];
224+
}
225+
226+
private function getTableName(string $entityType): string
227+
{
228+
// Extract class name without namespace
229+
$className = Str::snake(class_basename($entityType));
230+
231+
// Handle pluralization
232+
$tableName = Str::plural($className);
233+
234+
return "{$this->tablePrefix}{$tableName}{$this->tableSuffix}";
235+
}
236+
}
237+

0 commit comments

Comments
 (0)