diff --git a/src/Snapshot.php b/src/Snapshot.php index ceebb5f..721a81d 100644 --- a/src/Snapshot.php +++ b/src/Snapshot.php @@ -79,6 +79,11 @@ protected function loadAsync(?string $connectionName = null): void $dbDumpContents = gzdecode($dbDumpContents); } + if (empty(trim($dbDumpContents))) { + // Ignoriere leeren Snapshot + return; + } + DB::connection($connectionName)->unprepared($dbDumpContents); } @@ -96,69 +101,105 @@ protected function shouldIgnoreLine(string $line): bool 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($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($localDisk): void + { + $localDisk->writeStream($this->fileName, $this->disk->readStream($this->fileName)); + } + + private function openStream($localDisk) + { + return $this->compressionExtension === 'gz' + ? gzopen($localDisk->path($this->fileName), 'r') + : $localDisk->readStream($this->fileName); + } + + private function closeStream($stream): void + { + $this->compressionExtension === 'gz' ? gzclose($stream) : fclose($stream); + } + + private function processStatements($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($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 diff --git a/tests/Commands/LoadTest.php b/tests/Commands/LoadTest.php index eb21cb6..5d0b122 100644 --- a/tests/Commands/LoadTest.php +++ b/tests/Commands/LoadTest.php @@ -1,9 +1,14 @@ 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 () { + $schemaBuilderMock = m::mock(); + $schemaBuilderMock->shouldReceive('dropAllTables') + ->once(); + + DB::shouldReceive('connection') + ->andReturnSelf(); + DB::shouldReceive('getSchemaBuilder') + ->andReturn($schemaBuilderMock); + DB::shouldReceive('reconnect') + ->once(); + + $snapshot = new Snapshot(m::mock(FilesystemAdapter::class), 'snapshot.sql'); + + // Zugriff auf die geschützte Methode mit Reflection + $reflection = new ReflectionMethod(Snapshot::class, 'dropAllCurrentTables'); + $reflection->setAccessible(true); + + $reflection->invoke($snapshot); +});