Skip to content

Commit

Permalink
Optimize code / add tests
Browse files Browse the repository at this point in the history
  • Loading branch information
wattnpapa committed Dec 30, 2024
1 parent 9bba682 commit 34b6a76
Show file tree
Hide file tree
Showing 2 changed files with 197 additions and 58 deletions.
149 changes: 91 additions & 58 deletions src/Snapshot.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,8 @@

use Carbon\Carbon;
use Illuminate\Contracts\Filesystem\Factory;
use Illuminate\Filesystem\FilesystemAdapter as Disk;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\LazyCollection;
use Spatie\DbSnapshots\Events\DeletedSnapshot;
use Spatie\DbSnapshots\Events\DeletingSnapshot;
use Spatie\DbSnapshots\Events\LoadedSnapshot;
Expand All @@ -15,7 +14,7 @@

class Snapshot
{
public Disk $disk;
public FilesystemAdapter $disk;

public string $fileName;

Expand All @@ -29,10 +28,9 @@ class Snapshot

protected Factory $filesystemFactory;

public function __construct(Disk $disk, string $fileName)
public function __construct(FilesystemAdapter $disk, string $fileName)
{
$this->disk = $disk;

$this->fileName = $fileName;

$pathinfo = pathinfo($fileName);
Expand All @@ -43,14 +41,12 @@ public function __construct(Disk $disk, string $fileName)
}

$this->name = pathinfo($fileName, PATHINFO_FILENAME);

$this->filesystemFactory = app(Factory::class);
}

public function useStream(): self
{
$this->useStream = true;

return $this;
}

Expand Down Expand Up @@ -79,6 +75,10 @@ protected function loadAsync(?string $connectionName = null): void
$dbDumpContents = gzdecode($dbDumpContents);
}

if (empty(trim($dbDumpContents))) {
return;
}

DB::connection($connectionName)->unprepared($dbDumpContents);
}

Expand All @@ -90,83 +90,116 @@ protected function isASqlComment(string $line): bool
protected function shouldIgnoreLine(string $line): bool
{
$line = trim($line);

return empty($line) || $this->isASqlComment($line);
}

protected function loadStream(?string $connectionName = null): void
{
$directory = (new TemporaryDirectory(config('db-snapshots.temporary_directory_path')))->create();
$temporaryDirectory = (new TemporaryDirectory(config('db-snapshots.temporary_directory_path')))->create();

$this->configureFilesystemDisk($temporaryDirectory->path());

$localDisk = $this->filesystemFactory->disk(self::class);

try {
$this->processStream($localDisk, $connectionName);
} finally {
$temporaryDirectory->delete();
}
}

private function configureFilesystemDisk(string $path): void
{
config([
'filesystems.disks.' . self::class => [
'driver' => 'local',
'root' => $directory->path(),
'root' => $path,
'throw' => false,
]
],
]);
}

$localDisk = $this->filesystemFactory->disk(self::class);
private function processStream(FilesystemAdapter $localDisk, ?string $connectionName): void
{
$this->copyStreamToLocalDisk($localDisk);

$stream = $this->openStream($localDisk);

try {
LazyCollection::make(function () use ($localDisk) {
$localDisk->writeStream($this->fileName, $this->disk->readStream($this->fileName));

$stream = $this->compressionExtension === 'gz'
? gzopen($localDisk->path($this->fileName), 'r')
: $localDisk->readStream($this->fileName);

$statement = '';
while (!feof($stream)) {
$chunk = $this->compressionExtension === 'gz'
? gzread($stream, self::STREAM_BUFFER_SIZE)
: fread($stream, self::STREAM_BUFFER_SIZE);

$lines = explode("\n", $chunk);
foreach ($lines as $idx => $line) {
if ($this->shouldIgnoreLine($line)) {
continue;
}

$statement .= $line;

// Carry-over the last line to the next chunk since it
// is possible that this chunk finished mid-line right on
// a semi-colon.
if (count($lines) == $idx + 1) {
break;
}

if (str_ends_with(trim($statement), ';')) {
yield $statement;
$statement = '';
}
}
$this->processStatements($stream, $connectionName);
} finally {
$this->closeStream($stream);
}
}

private function copyStreamToLocalDisk(FilesystemAdapter $localDisk): void
{
$localDisk->writeStream($this->fileName, $this->disk->readStream($this->fileName));
}

private function openStream(FilesystemAdapter $localDisk): mixed
{
return $this->compressionExtension === 'gz'
? gzopen($localDisk->path($this->fileName), 'r')
: $localDisk->readStream($this->fileName);
}

private function closeStream(mixed $stream): void
{
$this->compressionExtension === 'gz' ? gzclose($stream) : fclose($stream);
}

private function processStatements(mixed $stream, ?string $connectionName): void
{
$statement = '';
while (!feof($stream)) {
$chunk = $this->readChunk($stream);
$lines = explode("\n", $chunk);

foreach ($lines as $idx => $line) {
if ($this->shouldIgnoreLine($line)) {
continue;
}

if (str_ends_with(trim($statement), ';')) {
yield $statement;
$statement .= $line;

if ($this->isLastLineOfChunk($lines, $idx)) {
break;
}

if ($this->compressionExtension === 'gz') {
gzclose($stream);
} else {
fclose($stream);
if ($this->isCompleteStatement($statement)) {
DB::connection($connectionName)->unprepared($statement);
$statement = '';
}
})->each(function (string $statement) use ($connectionName) {
DB::connection($connectionName)->unprepared($statement);
});
} finally {
$directory->delete();
}
}

if ($this->isCompleteStatement($statement)) {
DB::connection($connectionName)->unprepared($statement);
}
}

private function readChunk(mixed $stream): string
{
return $this->compressionExtension === 'gz'
? gzread($stream, self::STREAM_BUFFER_SIZE)
: fread($stream, self::STREAM_BUFFER_SIZE);
}

private function isLastLineOfChunk(array $lines, int $idx): bool
{
return count($lines) === $idx + 1;
}

private function isCompleteStatement(string $statement): bool
{
return str_ends_with(trim($statement), ';');
}

public function delete(): void
{
event(new DeletingSnapshot($this));

$this->disk->delete($this->fileName);

event(new DeletedSnapshot($this->fileName, $this->disk));
}

Expand Down
106 changes: 106 additions & 0 deletions tests/Commands/LoadTest.php
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
<?php

use Carbon\Carbon;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Mockery as m;

use Spatie\DbSnapshots\Events\DeletedSnapshot;
use Spatie\DbSnapshots\Snapshot;
use function Pest\Laravel\assertDatabaseCount;
use function PHPUnit\Framework\assertEquals;
use function PHPUnit\Framework\assertNotEquals;
Expand Down Expand Up @@ -143,3 +148,104 @@ function getNameOfLoadedSnapshot(): string

assertSnapshotLoaded('snapshot4');
});

it('throws an error when snapshot does not exist', function () {
$this->expectException(Exception::class);

$disk = m::mock(FilesystemAdapter::class);
$disk->shouldReceive('exists')
->with('nonexistent.sql')
->andReturn(false);

$snapshot = new Snapshot($disk, 'nonexistent.sql');
$snapshot->load();
});

it('throws an error for invalid SQL in snapshot', function () {
$disk = m::mock(FilesystemAdapter::class);
$disk->shouldReceive('get')
->andReturn("INVALID SQL;\n");

$snapshot = new Snapshot($disk, 'invalid.sql');

$this->expectException(Exception::class);
$snapshot->load();
});

it('deletes the snapshot and triggers event', function () {
Event::fake();

$disk = m::mock(FilesystemAdapter::class);
$disk->shouldReceive('delete')
->once()
->with('snapshot.sql')
->andReturn(true);

$snapshot = new Snapshot($disk, 'snapshot.sql');
$snapshot->delete();

Event::assertDispatched(DeletedSnapshot::class, function ($event) use ($snapshot) {
return $event->fileName === $snapshot->fileName && $event->disk === $snapshot->disk;
});
});

it('returns the correct size of the snapshot', function () {
$disk = m::mock(FilesystemAdapter::class);
$disk->shouldReceive('size')
->andReturn(2048);

$snapshot = new Snapshot($disk, 'snapshot.sql');

assertEquals(2048, $snapshot->size());
});

it('returns the correct creation date of the snapshot', function () {
$timestamp = Carbon::now()->timestamp;

$disk = m::mock(FilesystemAdapter::class);
$disk->shouldReceive('lastModified')
->andReturn($timestamp);

$snapshot = new Snapshot($disk, 'snapshot.sql');

assertEquals(Carbon::createFromTimestamp($timestamp), $snapshot->createdAt());
});

it('handles empty snapshots gracefully', function () {
$disk = m::mock(FilesystemAdapter::class);
$disk->shouldReceive('get')
->andReturn("");

$snapshot = new Snapshot($disk, 'empty.sql');

$snapshot->load();

// Expect no SQL to be executed
DB::shouldReceive('unprepared')
->never();
});

it('drops all current tables when requested', function () {
// Mock SchemaBuilder
$schemaBuilderMock = m::mock();
$schemaBuilderMock->shouldReceive('dropAllTables')->once();

// Mock DB facade
DB::shouldReceive('connection')
->andReturnSelf(); // Returns the DB connection
DB::shouldReceive('getSchemaBuilder')
->andReturn($schemaBuilderMock); // Returns the mocked schema builder
DB::shouldReceive('getDefaultConnection')
->andReturn('testing'); // Returns a mock default connection
DB::shouldReceive('reconnect')->once();

// Instance of Snapshot
$snapshot = new Snapshot(m::mock(FilesystemAdapter::class), 'snapshot.sql');

// Access protected method via Reflection
$reflection = new ReflectionMethod(Snapshot::class, 'dropAllCurrentTables');
$reflection->setAccessible(true);

// Invoke the protected method
$reflection->invoke($snapshot);
});

0 comments on commit 34b6a76

Please sign in to comment.