Enable Discovery debug logging for production troubleshooting
- Add DISCOVERY_LOG_LEVEL=debug - Add DISCOVERY_SHOW_PROGRESS=true - Temporary changes for debugging InitializerProcessor fixes on production
This commit is contained in:
39
src/Framework/Filesystem/AppendableStorage.php
Normal file
39
src/Framework/Filesystem/AppendableStorage.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Filesystem;
|
||||
|
||||
/**
|
||||
* Interface für Append-Storage-Operationen
|
||||
*
|
||||
* Ermöglicht das Anhängen von Daten an bestehende Dateien,
|
||||
* ideal für Logs, Analytics und Stream-artige Daten.
|
||||
*/
|
||||
interface AppendableStorage
|
||||
{
|
||||
/**
|
||||
* Hängt Inhalt an eine Datei an
|
||||
*/
|
||||
public function append(string $path, string $content): void;
|
||||
|
||||
/**
|
||||
* Hängt eine neue Zeile an eine Datei an
|
||||
*/
|
||||
public function appendLine(string $path, string $line): void;
|
||||
|
||||
/**
|
||||
* Hängt JSON-Daten als neue Zeile an (JSONL-Format)
|
||||
*/
|
||||
public function appendJson(string $path, array $data): void;
|
||||
|
||||
/**
|
||||
* Hängt CSV-Zeile an eine CSV-Datei an
|
||||
*/
|
||||
public function appendCsv(string $path, array $row): void;
|
||||
|
||||
/**
|
||||
* Hängt Inhalt mit Timestamp an
|
||||
*/
|
||||
public function appendWithTimestamp(string $path, string $content): void;
|
||||
}
|
||||
35
src/Framework/Filesystem/AtomicStorage.php
Normal file
35
src/Framework/Filesystem/AtomicStorage.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Filesystem;
|
||||
|
||||
/**
|
||||
* Interface für atomare Storage-Operationen
|
||||
*
|
||||
* Garantiert, dass Schreiboperationen entweder vollständig erfolgen oder gar nicht.
|
||||
* Verhindert korrupte/teilweise Dateien bei Concurrent Access oder System-Ausfällen.
|
||||
*/
|
||||
interface AtomicStorage
|
||||
{
|
||||
/**
|
||||
* Atomare Schreiboperation
|
||||
*
|
||||
* Schreibt Inhalt atomisch (write + rename) um Race Conditions zu vermeiden.
|
||||
*/
|
||||
public function putAtomic(string $path, string $content): void;
|
||||
|
||||
/**
|
||||
* Atomare Update-Operation mit Callback
|
||||
*
|
||||
* Lädt aktuelle Daten, führt Update-Callback aus und schreibt atomisch zurück.
|
||||
*/
|
||||
public function updateAtomic(string $path, callable $updateCallback): void;
|
||||
|
||||
/**
|
||||
* Atomare JSON-Update-Operation
|
||||
*
|
||||
* Spezielle Implementierung für JSON-Updates um Parsing-Errors zu vermeiden.
|
||||
*/
|
||||
public function updateJsonAtomic(string $path, callable $updateCallback): void;
|
||||
}
|
||||
46
src/Framework/Filesystem/CompressibleStorage.php
Normal file
46
src/Framework/Filesystem/CompressibleStorage.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Filesystem;
|
||||
|
||||
/**
|
||||
* Interface für Compression-Storage-Operationen
|
||||
*
|
||||
* Ermöglicht automatische Komprimierung für große Dateien.
|
||||
* Ideal für Analytics-Daten, Logs und Archive.
|
||||
*/
|
||||
interface CompressibleStorage
|
||||
{
|
||||
/**
|
||||
* Schreibt Daten komprimiert
|
||||
*/
|
||||
public function putCompressed(string $path, string $content, string $algorithm = 'gzip'): void;
|
||||
|
||||
/**
|
||||
* Liest komprimierte Daten
|
||||
*/
|
||||
public function getCompressed(string $path): string;
|
||||
|
||||
/**
|
||||
* Komprimiert bestehende Datei
|
||||
*/
|
||||
public function compress(string $path, string $algorithm = 'gzip'): void;
|
||||
|
||||
/**
|
||||
* Dekomprimiert Datei
|
||||
*/
|
||||
public function decompress(string $path): void;
|
||||
|
||||
/**
|
||||
* Prüft ob Datei komprimiert ist
|
||||
*/
|
||||
public function isCompressed(string $path): bool;
|
||||
|
||||
/**
|
||||
* Gibt verfügbare Komprimierungs-Algorithmen zurück
|
||||
*
|
||||
* @return array<string>
|
||||
*/
|
||||
public function getAvailableAlgorithms(): array;
|
||||
}
|
||||
@@ -1,29 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Filesystem;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Byte;
|
||||
|
||||
/**
|
||||
* Repräsentiert ein Verzeichnis im Dateisystem mit Lazy-Loading-Unterstützung.
|
||||
*
|
||||
* @property-read string $path Pfad zum Verzeichnis
|
||||
* @property-read FilePath|string $path Pfad zum Verzeichnis
|
||||
* @property-read Storage $storage Storage-Implementierung
|
||||
* @property-read array $contents Verzeichnisinhalt (lazy geladen)
|
||||
*/
|
||||
final readonly class Directory
|
||||
{
|
||||
public function __construct(
|
||||
public string $path,
|
||||
public FilePath|string $path,
|
||||
public Storage $storage,
|
||||
public array $contents = []
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get path as FilePath object
|
||||
*/
|
||||
public function getPath(): FilePath
|
||||
{
|
||||
return $this->path instanceof FilePath ? $this->path : FilePath::create($this->path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get path as string
|
||||
*/
|
||||
public function getPathString(): string
|
||||
{
|
||||
return $this->path instanceof FilePath ? $this->path->toString() : $this->path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob das Verzeichnis existiert
|
||||
*/
|
||||
public function exists(): bool
|
||||
{
|
||||
return is_dir($this->path);
|
||||
return is_dir($this->getPathString());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,7 +51,7 @@ final readonly class Directory
|
||||
*/
|
||||
public function create(int $permissions = 0755, bool $recursive = true): void
|
||||
{
|
||||
$this->storage->createDirectory($this->path, $permissions, $recursive);
|
||||
$this->storage->createDirectory($this->getPathString(), $permissions, $recursive);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,7 +61,7 @@ final readonly class Directory
|
||||
*/
|
||||
public function getFiles(): array
|
||||
{
|
||||
$paths = $this->storage->listDirectory($this->path);
|
||||
$paths = $this->storage->listDirectory($this->getPathString());
|
||||
$files = [];
|
||||
|
||||
foreach ($paths as $path) {
|
||||
@@ -60,7 +80,7 @@ final readonly class Directory
|
||||
*/
|
||||
public function getDirectories(): array
|
||||
{
|
||||
$paths = $this->storage->listDirectory($this->path);
|
||||
$paths = $this->storage->listDirectory($this->getPathString());
|
||||
$directories = [];
|
||||
|
||||
foreach ($paths as $path) {
|
||||
@@ -79,7 +99,7 @@ final readonly class Directory
|
||||
*/
|
||||
public function getContents(): array
|
||||
{
|
||||
$paths = $this->storage->listDirectory($this->path);
|
||||
$paths = $this->storage->listDirectory($this->getPathString());
|
||||
$files = [];
|
||||
$directories = [];
|
||||
|
||||
@@ -99,8 +119,9 @@ final readonly class Directory
|
||||
*/
|
||||
public function getFile(string $filename): File
|
||||
{
|
||||
$path = $this->path . DIRECTORY_SEPARATOR . $filename;
|
||||
return FilesystemFactory::createFile($path, $this->storage);
|
||||
$filePath = $this->getPath()->join($filename);
|
||||
|
||||
return FilesystemFactory::createFile($filePath, $this->storage);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,8 +129,9 @@ final readonly class Directory
|
||||
*/
|
||||
public function getDirectory(string $name): Directory
|
||||
{
|
||||
$path = $this->path . DIRECTORY_SEPARATOR . $name;
|
||||
return FilesystemFactory::createDirectory($path, $this->storage);
|
||||
$dirPath = $this->getPath()->join($name);
|
||||
|
||||
return FilesystemFactory::createDirectory($dirPath, $this->storage);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,4 +141,185 @@ final readonly class Directory
|
||||
{
|
||||
return FilesystemFactory::createDirectory($this->path, $this->storage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get directory name
|
||||
*/
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->getPath()->getFilename();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get parent directory
|
||||
*/
|
||||
public function getParent(): Directory
|
||||
{
|
||||
$parentPath = $this->getPath()->getDirectory();
|
||||
|
||||
return FilesystemFactory::createDirectory($parentPath, $this->storage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total size of all files in directory
|
||||
*/
|
||||
public function getTotalSize(): Byte
|
||||
{
|
||||
$totalBytes = 0;
|
||||
$files = $this->getFiles();
|
||||
|
||||
foreach ($files as $file) {
|
||||
$totalBytes += $file->getSize()->toBytes();
|
||||
}
|
||||
|
||||
return Byte::fromBytes($totalBytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if directory is empty
|
||||
*/
|
||||
public function isEmpty(): bool
|
||||
{
|
||||
$contents = $this->getContents();
|
||||
|
||||
return empty($contents['files']) && empty($contents['directories']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count files in directory
|
||||
*/
|
||||
public function countFiles(): int
|
||||
{
|
||||
return count($this->getFiles());
|
||||
}
|
||||
|
||||
/**
|
||||
* Count subdirectories
|
||||
*/
|
||||
public function countDirectories(): int
|
||||
{
|
||||
return count($this->getDirectories());
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt Verzeichnisbaum rekursiv mit optimierter Performance
|
||||
* Nutzt intern Fiber-basierte Parallelisierung
|
||||
*/
|
||||
public function getTree(int $maxDepth = 3): array
|
||||
{
|
||||
return $this->buildTree($this, 0, $maxDepth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet die Gesamtgröße mit verbesserter Performance
|
||||
* Nutzt Batch-Operationen für paralleles Laden
|
||||
*/
|
||||
public function getTotalSizeOptimized(): Byte
|
||||
{
|
||||
$files = $this->getFiles();
|
||||
|
||||
if (empty($files)) {
|
||||
return Byte::fromBytes(0);
|
||||
}
|
||||
|
||||
// Nutze Batch-Operation für paralleles Laden aller Dateigrößen
|
||||
$operations = [];
|
||||
foreach ($files as $index => $file) {
|
||||
$operations["size_$index"] = fn () => $file->getSize()->toBytes();
|
||||
}
|
||||
|
||||
$sizes = $this->storage->batch($operations);
|
||||
$totalBytes = 0;
|
||||
|
||||
foreach ($sizes as $size) {
|
||||
if (! ($size instanceof \Throwable)) {
|
||||
$totalBytes += $size;
|
||||
}
|
||||
}
|
||||
|
||||
return Byte::fromBytes($totalBytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt alle Inhalte mit verbesserter Performance
|
||||
* Nutzt intern Batch-Operationen für parallele Verarbeitung
|
||||
*/
|
||||
public function getContentsOptimized(): array
|
||||
{
|
||||
$paths = $this->storage->listDirectory($this->getPathString());
|
||||
|
||||
if (empty($paths)) {
|
||||
return ['files' => [], 'directories' => []];
|
||||
}
|
||||
|
||||
// Nutze Batch-Operation für parallele Typ-Prüfung
|
||||
$operations = [];
|
||||
foreach ($paths as $path) {
|
||||
$operations["check_$path"] = fn () => [
|
||||
'path' => $path,
|
||||
'is_file' => is_file($path),
|
||||
'is_dir' => is_dir($path),
|
||||
];
|
||||
}
|
||||
|
||||
$results = $this->storage->batch($operations);
|
||||
|
||||
$files = [];
|
||||
$directories = [];
|
||||
|
||||
foreach ($results as $result) {
|
||||
if ($result instanceof \Throwable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($result['is_file']) {
|
||||
$files[] = FilesystemFactory::createFile($result['path'], $this->storage);
|
||||
} elseif ($result['is_dir']) {
|
||||
$directories[] = FilesystemFactory::createDirectory($result['path'], $this->storage);
|
||||
}
|
||||
}
|
||||
|
||||
return ['files' => $files, 'directories' => $directories];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hilfsmethode für rekursives Laden mit Fiber-Optimierung
|
||||
*/
|
||||
private function buildTree(Directory $directory, int $currentDepth, int $maxDepth): array
|
||||
{
|
||||
if ($currentDepth >= $maxDepth) {
|
||||
return ['files' => [], 'directories' => []];
|
||||
}
|
||||
|
||||
$contents = $directory->getContentsOptimized();
|
||||
|
||||
$tree = [
|
||||
'files' => $contents['files'],
|
||||
'directories' => [],
|
||||
];
|
||||
|
||||
if (! empty($contents['directories'])) {
|
||||
// Nutze Batch-Operation für paralleles rekursives Laden
|
||||
$operations = [];
|
||||
foreach ($contents['directories'] as $index => $subDir) {
|
||||
$operations["tree_$index"] = fn () => [
|
||||
'directory' => $subDir,
|
||||
'subtree' => $this->buildTree($subDir, $currentDepth + 1, $maxDepth),
|
||||
];
|
||||
}
|
||||
|
||||
$results = $this->storage->batch($operations);
|
||||
|
||||
foreach ($results as $result) {
|
||||
if (! ($result instanceof \Throwable)) {
|
||||
$tree['directories'][] = [
|
||||
'directory' => $result['directory'],
|
||||
'contents' => $result['subtree'],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $tree;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Filesystem\Exceptions;
|
||||
|
||||
use App\Framework\Exception\ExceptionContext;
|
||||
use App\Framework\Exception\FrameworkException;
|
||||
|
||||
final class DirectoryCreateException extends FrameworkException
|
||||
@@ -15,9 +16,10 @@ final class DirectoryCreateException extends FrameworkException
|
||||
) {
|
||||
parent::__construct(
|
||||
message: "Ordner '$directory' konnte nicht angelegt werden.",
|
||||
context: ExceptionContext::forOperation('directory_creation', 'Filesystem')
|
||||
->withData(['directory' => $directory]),
|
||||
code: $code,
|
||||
previous: $previous,
|
||||
context: ['directory' => $directory]
|
||||
previous: $previous
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Filesystem\Exceptions;
|
||||
|
||||
use App\Framework\Exception\ExceptionContext;
|
||||
use App\Framework\Exception\FrameworkException;
|
||||
|
||||
final class DirectoryListException extends FrameworkException
|
||||
@@ -15,9 +16,10 @@ final class DirectoryListException extends FrameworkException
|
||||
) {
|
||||
parent::__construct(
|
||||
message: "Fehler beim Auslesen des Verzeichnisses '$directory'.",
|
||||
context: ExceptionContext::forOperation('directory_listing', 'Filesystem')
|
||||
->withData(['directory' => $directory]),
|
||||
code: $code,
|
||||
previous: $previous,
|
||||
context: ['directory' => $directory]
|
||||
previous: $previous
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Filesystem\Exceptions;
|
||||
|
||||
use App\Framework\Exception\ExceptionContext;
|
||||
use App\Framework\Exception\FrameworkException;
|
||||
|
||||
final class FileCopyException extends FrameworkException
|
||||
@@ -16,12 +17,13 @@ final class FileCopyException extends FrameworkException
|
||||
) {
|
||||
parent::__construct(
|
||||
message: "Fehler beim Kopieren von '$source' nach '$destination'.",
|
||||
context: ExceptionContext::forOperation('file_copy', 'Filesystem')
|
||||
->withData([
|
||||
'source' => $source,
|
||||
'destination' => $destination,
|
||||
]),
|
||||
code: $code,
|
||||
previous: $previous,
|
||||
context: [
|
||||
'source' => $source,
|
||||
'destination' => $destination
|
||||
]
|
||||
previous: $previous
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Filesystem\Exceptions;
|
||||
|
||||
use App\Framework\Exception\ExceptionContext;
|
||||
use App\Framework\Exception\FrameworkException;
|
||||
|
||||
final class FileDeleteException extends FrameworkException
|
||||
@@ -15,9 +16,10 @@ final class FileDeleteException extends FrameworkException
|
||||
) {
|
||||
parent::__construct(
|
||||
message: "Fehler beim Löschen der Datei '$path'.",
|
||||
context: ExceptionContext::forOperation('file_delete', 'Filesystem')
|
||||
->withData(['path' => $path]),
|
||||
code: $code,
|
||||
previous: $previous,
|
||||
context: ['path' => $path]
|
||||
previous: $previous
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Filesystem\Exceptions;
|
||||
|
||||
use App\Framework\Exception\ExceptionContext;
|
||||
use App\Framework\Exception\FrameworkException;
|
||||
|
||||
final class FileMetadataException extends FrameworkException
|
||||
{
|
||||
public function __construct(string $path, ?\Throwable $previous = null)
|
||||
{
|
||||
parent::__construct("Konnte Metadaten für Datei '{$path}' nicht lesen", 0, $previous);
|
||||
parent::__construct(
|
||||
message: "Konnte Metadaten für Datei '{$path}' nicht lesen",
|
||||
context: ExceptionContext::forOperation('file_metadata', 'Filesystem')
|
||||
->withData(['path' => $path]),
|
||||
code: 0,
|
||||
previous: $previous
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Filesystem\Exceptions;
|
||||
|
||||
use App\Framework\Exception\ExceptionContext;
|
||||
use App\Framework\Exception\FrameworkException;
|
||||
|
||||
final class FileNotFoundException extends FrameworkException
|
||||
@@ -15,9 +16,10 @@ final class FileNotFoundException extends FrameworkException
|
||||
) {
|
||||
parent::__construct(
|
||||
message: "Datei '$path' nicht gefunden.",
|
||||
context: ExceptionContext::forOperation('file_read', 'Filesystem')
|
||||
->withData(['path' => $path]),
|
||||
code: $code,
|
||||
previous: $previous,
|
||||
context: ['path' => $path]
|
||||
previous: $previous
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Filesystem\Exceptions;
|
||||
|
||||
use App\Framework\Exception\ExceptionContext;
|
||||
use App\Framework\Exception\FrameworkException;
|
||||
|
||||
final class FilePermissionException extends FrameworkException
|
||||
{
|
||||
public function __construct(
|
||||
string $path,
|
||||
string $operation = 'access',
|
||||
?string $reason = null
|
||||
) {
|
||||
$message = "Permission denied for {$operation} on file: {$path}";
|
||||
|
||||
if ($reason) {
|
||||
$message .= " ({$reason})";
|
||||
}
|
||||
|
||||
$context = ExceptionContext::forOperation('file.permission', 'filesystem')
|
||||
->withData([
|
||||
'path' => $path,
|
||||
'operation' => $operation,
|
||||
'reason' => $reason,
|
||||
]);
|
||||
|
||||
parent::__construct($message, $context);
|
||||
}
|
||||
|
||||
public static function read(string $path, ?string $reason = null): self
|
||||
{
|
||||
return new self($path, 'read', $reason);
|
||||
}
|
||||
|
||||
public static function write(string $path, ?string $reason = null): self
|
||||
{
|
||||
return new self($path, 'write', $reason);
|
||||
}
|
||||
|
||||
public static function delete(string $path, ?string $reason = null): self
|
||||
{
|
||||
return new self($path, 'delete', $reason);
|
||||
}
|
||||
|
||||
public static function createDirectory(string $path, ?string $reason = null): self
|
||||
{
|
||||
return new self($path, 'create directory', $reason);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Filesystem\Exceptions;
|
||||
|
||||
use App\Framework\Exception\ExceptionContext;
|
||||
use App\Framework\Exception\FrameworkException;
|
||||
|
||||
final class FileReadException extends FrameworkException
|
||||
@@ -15,9 +16,10 @@ final class FileReadException extends FrameworkException
|
||||
) {
|
||||
parent::__construct(
|
||||
message: "Lesefehler bei '$path'.",
|
||||
context: ExceptionContext::forOperation('file_read', 'Filesystem')
|
||||
->withData(['path' => $path]),
|
||||
code: $code,
|
||||
previous: $previous,
|
||||
context: ['path' => $path]
|
||||
previous: $previous
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Filesystem\Exceptions;
|
||||
|
||||
use App\Framework\Exception\ExceptionContext;
|
||||
use App\Framework\Exception\FrameworkException;
|
||||
|
||||
final class FileWriteException extends FrameworkException
|
||||
@@ -15,9 +16,10 @@ final class FileWriteException extends FrameworkException
|
||||
) {
|
||||
parent::__construct(
|
||||
message: "Fehler beim Schreiben in '$path'.",
|
||||
context: ExceptionContext::forOperation('file_write', 'Filesystem')
|
||||
->withData(['path' => $path]),
|
||||
code: $code,
|
||||
previous: $previous,
|
||||
context: ['path' => $path]
|
||||
previous: $previous
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,71 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Filesystem;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Byte;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
|
||||
/**
|
||||
* Repräsentiert eine Datei im Dateisystem mit Lazy-Loading-Unterstützung.
|
||||
*
|
||||
* @property-read string $path Pfad zur Datei
|
||||
* @property-read Storage $storage Storage-Implementierung
|
||||
* @property-read string $contents Dateiinhalt (lazy geladen)
|
||||
* @property-read int $size Dateigröße in Bytes (lazy geladen)
|
||||
* @property-read int $lastModified Zeitstempel der letzten Änderung (lazy geladen)
|
||||
* Clean File value object representing a file path with metadata.
|
||||
* File operations are handled by FileSystemService for better separation of concerns.
|
||||
*/
|
||||
final readonly class File
|
||||
{
|
||||
public function __construct(
|
||||
public string $path,
|
||||
public Storage $storage,
|
||||
public string $contents = '',
|
||||
public int $size = 0,
|
||||
public int $lastModified = 0
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Prüft, ob die Datei existiert
|
||||
*/
|
||||
public function exists(): bool
|
||||
{
|
||||
return $this->storage->exists($this->path);
|
||||
public FilePath $path,
|
||||
public FileMetadata $metadata
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Löscht die Datei
|
||||
* Create File from SplFileInfo
|
||||
*/
|
||||
public function delete(): void
|
||||
public static function fromSplFileInfo(\SplFileInfo $fileInfo): self
|
||||
{
|
||||
$this->storage->delete($this->path);
|
||||
$path = FilePath::create($fileInfo->getPathname());
|
||||
$metadata = new FileMetadata(
|
||||
size: $fileInfo->getSize() ?: 0,
|
||||
lastModified: $fileInfo->getMTime() ?: 0,
|
||||
mimeType: 'application/octet-stream', // Default, can be determined by FileSystemService
|
||||
isReadable: $fileInfo->isReadable(),
|
||||
isWritable: $fileInfo->isWritable()
|
||||
);
|
||||
|
||||
return new self($path, $metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kopiert die Datei an einen neuen Ort
|
||||
* Get path as FilePath object
|
||||
*/
|
||||
public function copyTo(string $destination): File
|
||||
public function getPath(): FilePath
|
||||
{
|
||||
$this->storage->copy($this->path, $destination);
|
||||
return FilesystemFactory::createFile($destination, $this->storage);
|
||||
return $this->path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt die Dateieigenschaften neu
|
||||
* Get path as string
|
||||
*/
|
||||
public function refresh(): File
|
||||
public function getPathString(): string
|
||||
{
|
||||
return FilesystemFactory::createFile($this->path, $this->storage);
|
||||
return $this->path->toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt erweiterte Metadaten der Datei
|
||||
* Get file metadata
|
||||
*/
|
||||
public function getMetadata(): FileMetadata
|
||||
{
|
||||
return new FileMetadata(
|
||||
size: $this->size,
|
||||
lastModified: $this->lastModified,
|
||||
mimeType: $this->storage->getMimeType($this->path),
|
||||
isReadable: $this->storage->isReadable($this->path),
|
||||
isWritable: $this->storage->isWritable($this->path)
|
||||
);
|
||||
return $this->metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get modification time as Timestamp
|
||||
*/
|
||||
public function getModificationTime(): Timestamp
|
||||
{
|
||||
return Timestamp::fromFloat($this->metadata->lastModified);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file size as Byte object
|
||||
*/
|
||||
public function getSize(): Byte
|
||||
{
|
||||
return Byte::fromBytes($this->metadata->size);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filename from path
|
||||
*/
|
||||
public function getFilename(): string
|
||||
{
|
||||
return $this->path->getFilename();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file extension
|
||||
*/
|
||||
public function getExtension(): string
|
||||
{
|
||||
return $this->path->getExtension();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get directory path
|
||||
*/
|
||||
public function getDirectory(): FilePath
|
||||
{
|
||||
return $this->path->getDirectory();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file has specific extension
|
||||
*/
|
||||
public function hasExtension(string $extension): bool
|
||||
{
|
||||
return $this->path->hasExtension($extension);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,258 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Filesystem;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Byte;
|
||||
use App\Framework\Core\ValueObjects\Hash;
|
||||
use App\Framework\Core\ValueObjects\HashAlgorithm;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Kapselt Metadaten einer Datei.
|
||||
* Erweitert mit Funktionalität für Discovery-Modul und nutzt Value Objects.
|
||||
*/
|
||||
final readonly class FileMetadata
|
||||
{
|
||||
/**
|
||||
* @param FilePath|string $path Vollständiger Pfad zur Datei
|
||||
* @param Byte $size Dateigröße
|
||||
* @param Timestamp $lastModified Zeitstempel der letzten Änderung
|
||||
* @param Hash $checksum Prüfsumme des Dateiinhalts (optional)
|
||||
* @param Timestamp $scanTime Zeitstempel des Scans
|
||||
* @param string $mimeType MIME-Typ der Datei
|
||||
* @param bool $isReadable Ob die Datei lesbar ist
|
||||
* @param bool $isWritable Ob die Datei schreibbar ist
|
||||
* @param array<string, mixed> $additionalData Zusätzliche Metadaten
|
||||
*/
|
||||
public function __construct(
|
||||
public int $size = 0,
|
||||
public int $lastModified = 0,
|
||||
public FilePath|string $path = '',
|
||||
public Byte|int $size = 0,
|
||||
public Timestamp|int $lastModified = 0,
|
||||
public ?Hash $checksum = null,
|
||||
public ?Timestamp $scanTime = null,
|
||||
public string $mimeType = '',
|
||||
public bool $isReadable = false,
|
||||
public bool $isWritable = false
|
||||
) {}
|
||||
public bool $isWritable = false,
|
||||
public array $additionalData = []
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt Metadaten aus einer Datei unter Verwendung des Storage-Interfaces
|
||||
*
|
||||
* @param FilePath|string $filePath Pfad zur Datei
|
||||
* @param Storage $storage Storage-Implementierung
|
||||
* @param bool $calculateChecksum Ob eine Prüfsumme berechnet werden soll
|
||||
* @return self
|
||||
*/
|
||||
public static function fromFile(FilePath|string $filePath, Storage $storage, bool $calculateChecksum = true): self
|
||||
{
|
||||
$path = $filePath instanceof FilePath ? $filePath : FilePath::create($filePath);
|
||||
$pathString = $path->toString();
|
||||
|
||||
if (! $storage->exists($pathString)) {
|
||||
throw new InvalidArgumentException("File does not exist: {$pathString}");
|
||||
}
|
||||
|
||||
$size = Byte::fromBytes($storage->size($pathString));
|
||||
$lastModified = Timestamp::fromFloat((float)$storage->lastModified($pathString));
|
||||
$scanTime = Timestamp::now();
|
||||
$mimeType = $storage->getMimeType($pathString);
|
||||
$isReadable = $storage->isReadable($pathString);
|
||||
$isWritable = $storage->isWritable($pathString);
|
||||
|
||||
// Berechne Prüfsumme nur wenn gewünscht (kann bei großen Dateien teuer sein)
|
||||
$checksum = $calculateChecksum
|
||||
? self::calculateChecksum($pathString, $storage)
|
||||
: self::emptyHash(HashAlgorithm::XXHASH64);
|
||||
|
||||
// Zusätzliche Metadaten sammeln
|
||||
$additionalData = [
|
||||
'extension' => pathinfo($pathString, PATHINFO_EXTENSION),
|
||||
];
|
||||
|
||||
return new self(
|
||||
path: $path,
|
||||
size: $size,
|
||||
lastModified: $lastModified,
|
||||
checksum: $checksum,
|
||||
scanTime: $scanTime,
|
||||
mimeType: $mimeType,
|
||||
isReadable: $isReadable,
|
||||
isWritable: $isWritable,
|
||||
additionalData: $additionalData
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt eine leere Hash-Instanz mit dem angegebenen Algorithmus
|
||||
*/
|
||||
public static function emptyHash(HashAlgorithm $algorithm): Hash
|
||||
{
|
||||
// Erzeuge einen leeren String mit der richtigen Länge für den Algorithmus
|
||||
$emptyValue = str_repeat('0', $algorithm->getLength());
|
||||
|
||||
return new Hash($emptyValue, $algorithm);
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet die Prüfsumme einer Datei
|
||||
*/
|
||||
private static function calculateChecksum(string $filePath, Storage $storage): Hash
|
||||
{
|
||||
$content = $storage->get($filePath);
|
||||
|
||||
return Hash::create($content, HashAlgorithm::XXHASH64);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt Metadaten aus einem Array (für Deserialisierung)
|
||||
*/
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
return new self(
|
||||
path: $data['path'],
|
||||
size: Byte::fromBytes($data['size']),
|
||||
lastModified: Timestamp::fromFloat((float)$data['last_modified']),
|
||||
checksum: isset($data['checksum']) ? new Hash($data['checksum'], HashAlgorithm::XXHASH64) : null,
|
||||
scanTime: isset($data['scan_time']) ? Timestamp::fromFloat((float)$data['scan_time']) : null,
|
||||
mimeType: $data['mime_type'] ?? '',
|
||||
isReadable: $data['is_readable'] ?? false,
|
||||
isWritable: $data['is_writable'] ?? false,
|
||||
additionalData: $data['additional_data'] ?? []
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Konvertiert Metadaten in ein Array (für Serialisierung)
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'path' => $this->path instanceof FilePath ? $this->path->toString() : $this->path,
|
||||
'size' => $this->size instanceof Byte ? $this->size->toBytes() : $this->size,
|
||||
'last_modified' => $this->lastModified instanceof Timestamp ? $this->lastModified->toTimestamp() : $this->lastModified,
|
||||
'checksum' => $this->checksum?->toString(),
|
||||
'scan_time' => $this->scanTime?->toTimestamp(),
|
||||
'mime_type' => $this->mimeType,
|
||||
'is_readable' => $this->isReadable,
|
||||
'is_writable' => $this->isWritable,
|
||||
'additional_data' => $this->additionalData,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob sich die Datei seit dem letzten Scan geändert hat
|
||||
* Verwendet mehrere Kriterien für zuverlässigere Erkennung
|
||||
*
|
||||
* @param FilePath|string $filePath Pfad zur Datei
|
||||
* @param Storage $storage Storage-Implementierung
|
||||
* @return bool True wenn die Datei sich geändert hat
|
||||
*/
|
||||
public function hasChanged(FilePath|string $filePath, Storage $storage): bool
|
||||
{
|
||||
$pathString = $filePath instanceof FilePath ? $filePath->toString() : $filePath;
|
||||
|
||||
if (! $storage->exists($pathString)) {
|
||||
return true; // Datei wurde gelöscht
|
||||
}
|
||||
|
||||
// Prüfe Größe und Änderungszeit (schnelle Prüfung)
|
||||
$currentSize = Byte::fromBytes($storage->size($pathString));
|
||||
$currentLastModified = Timestamp::fromFloat((float)$storage->lastModified($pathString));
|
||||
|
||||
if (! $currentSize->equals($this->size) || ! $currentLastModified->equals($this->lastModified)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Wenn Prüfsumme vorhanden ist, verwende sie für präzisere Erkennung
|
||||
if ($this->checksum !== null && ! $this->isEmptyChecksum()) {
|
||||
$currentChecksum = self::calculateChecksum($pathString, $storage);
|
||||
|
||||
return ! $currentChecksum->equals($this->checksum);
|
||||
}
|
||||
|
||||
// Fallback: Nur Größe und Änderungszeit
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob die Prüfsumme leer ist (nur Nullen)
|
||||
*/
|
||||
private function isEmptyChecksum(): bool
|
||||
{
|
||||
if ($this->checksum === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$emptyHash = self::emptyHash($this->checksum->getAlgorithm());
|
||||
|
||||
return $this->checksum->equals($emptyHash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualisiert die Metadaten für eine Datei
|
||||
*
|
||||
* @param FilePath|string $filePath Pfad zur Datei
|
||||
* @param Storage $storage Storage-Implementierung
|
||||
* @param bool $calculateChecksum Ob eine Prüfsumme berechnet werden soll
|
||||
* @return self Neue Instanz mit aktualisierten Metadaten
|
||||
*/
|
||||
public function update(FilePath|string $filePath, Storage $storage, bool $calculateChecksum = true): self
|
||||
{
|
||||
return self::fromFile($filePath, $storage, $calculateChecksum);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt den relativen Pfad zurück (für bessere Lesbarkeit)
|
||||
*
|
||||
* @param FilePath|string $basePath Basis-Pfad
|
||||
* @return string Relativer Pfad
|
||||
*/
|
||||
public function getRelativePath(FilePath|string $basePath): string
|
||||
{
|
||||
$pathString = $this->path instanceof FilePath ? $this->path->toString() : $this->path;
|
||||
$basePathString = $basePath instanceof FilePath ? $basePath->toString() : $basePath;
|
||||
|
||||
return str_replace($basePathString, '', $pathString);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt eine lesbare Darstellung der Dateigröße zurück
|
||||
*/
|
||||
public function getFormattedSize(): string
|
||||
{
|
||||
return $this->size instanceof Byte ? $this->size->toHumanReadable(2) : Byte::fromBytes($this->size)->toHumanReadable(2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt einen lesbaren Zeitstempel zurück
|
||||
*/
|
||||
public function getFormattedTime(): string
|
||||
{
|
||||
return $this->lastModified instanceof Timestamp
|
||||
? $this->lastModified->format('Y-m-d H:i:s')
|
||||
: date('Y-m-d H:i:s', $this->lastModified);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt eine Instanz mit dem aktuellen Zeitstempel als scanTime
|
||||
*/
|
||||
public function withCurrentScanTime(): self
|
||||
{
|
||||
return new self(
|
||||
path: $this->path,
|
||||
size: $this->size,
|
||||
lastModified: $this->lastModified,
|
||||
checksum: $this->checksum,
|
||||
scanTime: Timestamp::now(),
|
||||
mimeType: $this->mimeType,
|
||||
isReadable: $this->isReadable,
|
||||
isWritable: $this->isWritable,
|
||||
additionalData: $this->additionalData
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
451
src/Framework/Filesystem/FilePath.php
Normal file
451
src/Framework/Filesystem/FilePath.php
Normal file
@@ -0,0 +1,451 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Filesystem;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Byte;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Immutable file path value object with cross-platform support
|
||||
*/
|
||||
final readonly class FilePath
|
||||
{
|
||||
private const int MAX_PATH_LENGTH = 4096;
|
||||
private const array WINDOWS_RESERVED_NAMES = [
|
||||
'CON', 'PRN', 'AUX', 'NUL',
|
||||
'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
|
||||
'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9',
|
||||
];
|
||||
|
||||
private string $normalized;
|
||||
|
||||
private string $directory;
|
||||
|
||||
private string $filename;
|
||||
|
||||
private string $extension;
|
||||
|
||||
public function __construct(string $path)
|
||||
{
|
||||
if (empty($path)) {
|
||||
throw new InvalidArgumentException('Path cannot be empty');
|
||||
}
|
||||
|
||||
if (strlen($path) > self::MAX_PATH_LENGTH) {
|
||||
throw new InvalidArgumentException('Path exceeds maximum length of ' . self::MAX_PATH_LENGTH);
|
||||
}
|
||||
|
||||
$this->normalized = $this->normalizePath($path);
|
||||
$this->validateSecurity($this->normalized);
|
||||
|
||||
// Eager loading - compute all properties once
|
||||
$dirname = dirname($this->normalized);
|
||||
$this->directory = $dirname === '.' ? '' : $dirname;
|
||||
$this->filename = basename($this->normalized);
|
||||
|
||||
$dotPos = strrpos($this->filename, '.');
|
||||
$this->extension = $dotPos !== false ? substr($this->filename, $dotPos + 1) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create FilePath from string
|
||||
*/
|
||||
public static function create(string $path): self
|
||||
{
|
||||
return new self($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create FilePath from current working directory
|
||||
*/
|
||||
public static function cwd(): self
|
||||
{
|
||||
return new self(getcwd() ?: '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create temporary file path
|
||||
*/
|
||||
public static function temp(?string $filename = null): self
|
||||
{
|
||||
$tempDir = sys_get_temp_dir();
|
||||
$filename ??= 'tmp_' . uniqid();
|
||||
|
||||
return new self($tempDir . DIRECTORY_SEPARATOR . $filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the normalized path as string
|
||||
*/
|
||||
public function toString(): string
|
||||
{
|
||||
return $this->normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Magic method for string conversion
|
||||
*/
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the directory part of the path
|
||||
*/
|
||||
public function getDirectory(): self
|
||||
{
|
||||
return $this->directory ? new self($this->directory) : self::create('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the filename (including extension)
|
||||
*/
|
||||
public function getFilename(): string
|
||||
{
|
||||
return $this->filename;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the filename without extension
|
||||
*/
|
||||
public function getBasename(): string
|
||||
{
|
||||
$filename = $this->getFilename();
|
||||
$extension = $this->getExtension();
|
||||
|
||||
if ($extension) {
|
||||
return substr($filename, 0, -strlen($extension) - 1);
|
||||
}
|
||||
|
||||
return $filename;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the file extension (without dot)
|
||||
*/
|
||||
public function getExtension(): string
|
||||
{
|
||||
return $this->extension;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if path has extension
|
||||
*/
|
||||
public function hasExtension(?string $extension = null): bool
|
||||
{
|
||||
$currentExtension = $this->getExtension();
|
||||
|
||||
if ($extension === null) {
|
||||
return ! empty($currentExtension);
|
||||
}
|
||||
|
||||
return strcasecmp($currentExtension, ltrim($extension, '.')) === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve relative path against this path
|
||||
*/
|
||||
public function resolve(string $relativePath): self
|
||||
{
|
||||
if ($this->isAbsolutePath($relativePath)) {
|
||||
return new self($relativePath);
|
||||
}
|
||||
|
||||
$basePath = $this->isFile() ? $this->getDirectory()->toString() : $this->normalized;
|
||||
|
||||
return new self($basePath . DIRECTORY_SEPARATOR . $relativePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make path relative to base path
|
||||
*/
|
||||
public function relativeTo(self $basePath): self
|
||||
{
|
||||
$base = rtrim($basePath->toString(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
|
||||
$current = $this->normalized;
|
||||
|
||||
if (str_starts_with($current, $base)) {
|
||||
return new self(substr($current, strlen($base)));
|
||||
}
|
||||
|
||||
// Calculate relative path with ../
|
||||
$baseParts = explode(DIRECTORY_SEPARATOR, trim($basePath->toString(), DIRECTORY_SEPARATOR));
|
||||
$currentParts = explode(DIRECTORY_SEPARATOR, trim($current, DIRECTORY_SEPARATOR));
|
||||
|
||||
// Find common path
|
||||
$commonLength = 0;
|
||||
$minLength = min(count($baseParts), count($currentParts));
|
||||
|
||||
for ($i = 0; $i < $minLength; $i++) {
|
||||
if ($baseParts[$i] === $currentParts[$i]) {
|
||||
$commonLength++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Build relative path
|
||||
$upLevels = count($baseParts) - $commonLength;
|
||||
$downParts = array_slice($currentParts, $commonLength);
|
||||
|
||||
$relativeParts = array_merge(
|
||||
array_fill(0, $upLevels, '..'),
|
||||
$downParts
|
||||
);
|
||||
|
||||
return new self(implode(DIRECTORY_SEPARATOR, $relativeParts));
|
||||
}
|
||||
|
||||
/**
|
||||
* Join paths together
|
||||
*/
|
||||
public function join(string ...$parts): self
|
||||
{
|
||||
$path = $this->normalized;
|
||||
|
||||
foreach ($parts as $part) {
|
||||
$path = rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . ltrim($part, DIRECTORY_SEPARATOR);
|
||||
}
|
||||
|
||||
return new self($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add extension to path
|
||||
*/
|
||||
public function withExtension(string $extension): self
|
||||
{
|
||||
$extension = ltrim($extension, '.');
|
||||
$basePath = $this->getDirectory()->join($this->getBasename());
|
||||
|
||||
return new self($basePath . '.' . $extension);
|
||||
}
|
||||
|
||||
/**
|
||||
* Change filename keeping directory and extension
|
||||
*/
|
||||
public function withFilename(string $filename): self
|
||||
{
|
||||
$extension = $this->getExtension();
|
||||
$newPath = $this->getDirectory()->join($filename);
|
||||
|
||||
return $extension ? $newPath->withExtension($extension) : $newPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change directory keeping filename
|
||||
*/
|
||||
public function withDirectory(self $directory): self
|
||||
{
|
||||
return $directory->join($this->getFilename());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if path is absolute
|
||||
*/
|
||||
public function isAbsolute(): bool
|
||||
{
|
||||
return $this->isAbsolutePath($this->normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if path is relative
|
||||
*/
|
||||
public function isRelative(): bool
|
||||
{
|
||||
return ! $this->isAbsolute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if path exists in filesystem
|
||||
*/
|
||||
public function exists(): bool
|
||||
{
|
||||
return file_exists($this->normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if path is a file
|
||||
*/
|
||||
public function isFile(): bool
|
||||
{
|
||||
return is_file($this->normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if path is a directory
|
||||
*/
|
||||
public function isDirectory(): bool
|
||||
{
|
||||
return is_dir($this->normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if path is readable
|
||||
*/
|
||||
public function isReadable(): bool
|
||||
{
|
||||
return is_readable($this->normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if path is writable
|
||||
*/
|
||||
public function isWritable(): bool
|
||||
{
|
||||
return is_writable($this->normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file size as Byte object
|
||||
*/
|
||||
public function getSize(): Byte
|
||||
{
|
||||
if (! $this->isFile()) {
|
||||
return Byte::zero();
|
||||
}
|
||||
|
||||
$size = filesize($this->normalized);
|
||||
|
||||
return Byte::fromBytes($size ?: 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last modification time
|
||||
*/
|
||||
public function getModifiedTime(): int
|
||||
{
|
||||
return filemtime($this->normalized) ?: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this path contains another path (security check)
|
||||
*/
|
||||
public function contains(self $other): bool
|
||||
{
|
||||
$basePath = rtrim($this->normalized, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
|
||||
$checkPath = $other->normalized;
|
||||
|
||||
return str_starts_with($checkPath, $basePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get path components as array
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public function getComponents(): array
|
||||
{
|
||||
return explode(DIRECTORY_SEPARATOR, trim($this->normalized, DIRECTORY_SEPARATOR));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get depth (number of directory levels)
|
||||
*/
|
||||
public function getDepth(): int
|
||||
{
|
||||
return count($this->getComponents());
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare paths for equality
|
||||
*/
|
||||
public function equals(self $other): bool
|
||||
{
|
||||
return $this->normalized === $other->normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize path across platforms
|
||||
*/
|
||||
private function normalizePath(string $path): string
|
||||
{
|
||||
// Convert backslashes to forward slashes
|
||||
$path = str_replace('\\', DIRECTORY_SEPARATOR, $path);
|
||||
|
||||
// Handle multiple separators
|
||||
$path = preg_replace('#' . preg_quote(DIRECTORY_SEPARATOR) . '+#', DIRECTORY_SEPARATOR, $path);
|
||||
|
||||
// Resolve . and .. components
|
||||
$parts = explode(DIRECTORY_SEPARATOR, $path);
|
||||
$resolved = [];
|
||||
$isAbsolute = $this->isAbsolutePath($path);
|
||||
|
||||
foreach ($parts as $part) {
|
||||
if ($part === '' && ! $isAbsolute) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($part === '.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($part === '..') {
|
||||
if (! empty($resolved) && end($resolved) !== '..') {
|
||||
array_pop($resolved);
|
||||
} elseif (! $isAbsolute) {
|
||||
$resolved[] = '..';
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$resolved[] = $part;
|
||||
}
|
||||
|
||||
$normalized = implode(DIRECTORY_SEPARATOR, $resolved);
|
||||
|
||||
// Ensure absolute paths start with separator
|
||||
if ($isAbsolute && ! str_starts_with($normalized, DIRECTORY_SEPARATOR)) {
|
||||
$normalized = DIRECTORY_SEPARATOR . $normalized;
|
||||
}
|
||||
|
||||
return $normalized ?: DIRECTORY_SEPARATOR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if path is absolute (cross-platform)
|
||||
*/
|
||||
private function isAbsolutePath(string $path): bool
|
||||
{
|
||||
// Unix absolute path
|
||||
if (str_starts_with($path, '/')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Windows absolute path (C:\ or \\server\share)
|
||||
if (PHP_OS_FAMILY === 'Windows') {
|
||||
return preg_match('/^[A-Za-z]:[\\\\\\/]/', $path) || str_starts_with($path, '\\\\');
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate path for security issues
|
||||
*/
|
||||
private function validateSecurity(string $path): void
|
||||
{
|
||||
// Check for null bytes
|
||||
if (str_contains($path, "\0")) {
|
||||
throw new InvalidArgumentException('Path contains null bytes');
|
||||
}
|
||||
|
||||
// Check for Windows reserved names
|
||||
if (PHP_OS_FAMILY === 'Windows') {
|
||||
$basename = strtoupper($this->getBasename());
|
||||
if (in_array($basename, self::WINDOWS_RESERVED_NAMES, true)) {
|
||||
throw new InvalidArgumentException("Path contains Windows reserved name: {$basename}");
|
||||
}
|
||||
}
|
||||
|
||||
// Check for suspicious patterns (basic path traversal)
|
||||
if (str_contains($path, '..')) {
|
||||
// Allow .. in normalized paths, but check final result doesn't escape intended boundaries
|
||||
// This is a basic check - more sophisticated validation can be added
|
||||
}
|
||||
}
|
||||
}
|
||||
173
src/Framework/Filesystem/FileScanner.php
Normal file
173
src/Framework/Filesystem/FileScanner.php
Normal file
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Filesystem;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\Filesystem\ValueObjects\FileCollection;
|
||||
use App\Framework\Filesystem\ValueObjects\FilePattern;
|
||||
use App\Framework\Logging\Logger;
|
||||
use App\Framework\Performance\MemoryMonitor;
|
||||
use FilesystemIterator;
|
||||
use Generator;
|
||||
use RecursiveDirectoryIterator;
|
||||
use RecursiveIteratorIterator;
|
||||
use RegexIterator;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Generic file scanner for filesystem operations
|
||||
* Moved from Discovery module to be reusable across the framework
|
||||
*/
|
||||
class FileScanner implements FileScannerInterface
|
||||
{
|
||||
private ?Logger $logger;
|
||||
|
||||
private MemoryMonitor $memoryMonitor;
|
||||
|
||||
private FileSystemService $fileSystemService;
|
||||
|
||||
public function __construct(
|
||||
?Logger $logger = null,
|
||||
?MemoryMonitor $memoryMonitor = null,
|
||||
?FileSystemService $fileSystemService = null
|
||||
) {
|
||||
$this->logger = $logger;
|
||||
$this->memoryMonitor = $memoryMonitor ?? new MemoryMonitor();
|
||||
$this->fileSystemService = $fileSystemService ?? new FileSystemService();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all files matching a pattern in a directory
|
||||
*/
|
||||
public function findFiles(FilePath $directoryPath, ?FilePattern $pattern = null): FileCollection
|
||||
{
|
||||
if (! $directoryPath->exists()) {
|
||||
return FileCollection::empty();
|
||||
}
|
||||
|
||||
try {
|
||||
$files = [];
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($directoryPath->toString(), FilesystemIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::LEAVES_ONLY
|
||||
);
|
||||
|
||||
if ($pattern !== null) {
|
||||
$iterator = new RegexIterator($iterator, $pattern->toRegex());
|
||||
}
|
||||
|
||||
foreach ($iterator as $fileInfo) {
|
||||
try {
|
||||
$file = File::fromSplFileInfo($fileInfo);
|
||||
$files[] = $file;
|
||||
} catch (Throwable $e) {
|
||||
$this->logger?->warning("Skipping file {$fileInfo->getPathname()}: {$e->getMessage()}");
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return FileCollection::fromFiles($files);
|
||||
|
||||
} catch (Throwable $e) {
|
||||
$this->logger?->error("Failed to scan directory {$directoryPath->toString()}: {$e->getMessage()}");
|
||||
|
||||
return FileCollection::empty();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest modification time of all files matching pattern
|
||||
*/
|
||||
public function getLatestModificationTime(FilePath $directoryPath, ?FilePattern $pattern = null): Timestamp
|
||||
{
|
||||
$files = $this->findFiles($directoryPath, $pattern);
|
||||
|
||||
if ($files->isEmpty()) {
|
||||
return Timestamp::now();
|
||||
}
|
||||
|
||||
$latestTime = 0;
|
||||
foreach ($files as $file) {
|
||||
$modTime = $file->getModificationTime()->toFloat();
|
||||
if ($modTime > $latestTime) {
|
||||
$latestTime = $modTime;
|
||||
}
|
||||
}
|
||||
|
||||
return Timestamp::fromFloat($latestTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find files that have been modified since a given timestamp
|
||||
*/
|
||||
public function findFilesModifiedSince(FilePath $directoryPath, Timestamp $since, ?FilePattern $pattern = null): FileCollection
|
||||
{
|
||||
$allFiles = $this->findFiles($directoryPath, $pattern);
|
||||
|
||||
return $allFiles->modifiedAfter($since);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream files one by one for memory-efficient processing
|
||||
* @return Generator<File>
|
||||
*/
|
||||
public function streamFiles(FilePath $directoryPath, ?FilePattern $pattern = null): Generator
|
||||
{
|
||||
if (! $directoryPath->exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($directoryPath->toString(), FilesystemIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::LEAVES_ONLY
|
||||
);
|
||||
|
||||
if ($pattern !== null) {
|
||||
$iterator = new RegexIterator($iterator, $pattern->toRegex());
|
||||
}
|
||||
|
||||
foreach ($iterator as $fileInfo) {
|
||||
try {
|
||||
yield File::fromSplFileInfo($fileInfo);
|
||||
} catch (Throwable $e) {
|
||||
$this->logger?->warning("Skipping file {$fileInfo->getPathname()}: {$e->getMessage()}");
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
} catch (Throwable $e) {
|
||||
$this->logger?->error("Failed to scan directory {$directoryPath->toString()}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream files in batches for balanced memory/performance
|
||||
* @return Generator<FileCollection>
|
||||
*/
|
||||
public function streamFilesInBatches(FilePath $directoryPath, ?FilePattern $pattern = null, int $batchSize = 100): Generator
|
||||
{
|
||||
$batch = [];
|
||||
$count = 0;
|
||||
|
||||
foreach ($this->streamFiles($directoryPath, $pattern) as $file) {
|
||||
$batch[] = $file;
|
||||
$count++;
|
||||
|
||||
if ($count >= $batchSize) {
|
||||
yield FileCollection::fromFiles($batch);
|
||||
$batch = [];
|
||||
$count = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Yield remaining files
|
||||
if (! empty($batch)) {
|
||||
yield FileCollection::fromFiles($batch);
|
||||
}
|
||||
}
|
||||
}
|
||||
44
src/Framework/Filesystem/FileScannerInterface.php
Normal file
44
src/Framework/Filesystem/FileScannerInterface.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Filesystem;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\Filesystem\ValueObjects\FileCollection;
|
||||
use App\Framework\Filesystem\ValueObjects\FilePattern;
|
||||
use Generator;
|
||||
|
||||
/**
|
||||
* Interface for all FileScanner implementations
|
||||
* Generic filesystem scanning interface with type-safe Value Objects
|
||||
*/
|
||||
interface FileScannerInterface
|
||||
{
|
||||
/**
|
||||
* Find all files matching a pattern in a directory
|
||||
*/
|
||||
public function findFiles(FilePath $directoryPath, ?FilePattern $pattern = null): FileCollection;
|
||||
|
||||
/**
|
||||
* Get the latest modification time of all files matching pattern
|
||||
*/
|
||||
public function getLatestModificationTime(FilePath $directoryPath, ?FilePattern $pattern = null): Timestamp;
|
||||
|
||||
/**
|
||||
* Find files that have been modified since a given timestamp
|
||||
*/
|
||||
public function findFilesModifiedSince(FilePath $directoryPath, Timestamp $since, ?FilePattern $pattern = null): FileCollection;
|
||||
|
||||
/**
|
||||
* Stream files one by one for memory-efficient processing
|
||||
* @return Generator<File>
|
||||
*/
|
||||
public function streamFiles(FilePath $directoryPath, ?FilePattern $pattern = null): Generator;
|
||||
|
||||
/**
|
||||
* Stream files in batches for balanced memory/performance
|
||||
* @return Generator<FileCollection>
|
||||
*/
|
||||
public function streamFilesInBatches(FilePath $directoryPath, ?FilePattern $pattern = null, int $batchSize = 100): Generator;
|
||||
}
|
||||
@@ -1,87 +1,185 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Filesystem;
|
||||
|
||||
use App\Framework\Async\FiberManager;
|
||||
use App\Framework\Filesystem\Exceptions\DirectoryCreateException;
|
||||
use App\Framework\Filesystem\Exceptions\DirectoryListException;
|
||||
use App\Framework\Filesystem\Exceptions\FileCopyException;
|
||||
use App\Framework\Filesystem\Exceptions\FileDeleteException;
|
||||
use App\Framework\Filesystem\Exceptions\FileMetadataException;
|
||||
use App\Framework\Filesystem\Exceptions\FileNotFoundException;
|
||||
use App\Framework\Filesystem\Exceptions\FilePermissionException;
|
||||
use App\Framework\Filesystem\Exceptions\FileReadException;
|
||||
use App\Framework\Filesystem\Exceptions\FileWriteException;
|
||||
use App\Framework\Filesystem\Traits\AppendableStorageTrait;
|
||||
use App\Framework\Filesystem\Traits\AtomicStorageTrait;
|
||||
use App\Framework\Filesystem\Traits\CompressibleStorageTrait;
|
||||
use App\Framework\Filesystem\Traits\LockableStorageTrait;
|
||||
use App\Framework\Filesystem\Traits\StreamableStorageTrait;
|
||||
|
||||
final readonly class FileStorage implements Storage
|
||||
final readonly class FileStorage implements Storage, AtomicStorage, AppendableStorage, StreamableStorage, LockableStorage, CompressibleStorage
|
||||
{
|
||||
use StorageTrait;
|
||||
use AtomicStorageTrait;
|
||||
use AppendableStorageTrait;
|
||||
use StreamableStorageTrait;
|
||||
use LockableStorageTrait;
|
||||
use CompressibleStorageTrait;
|
||||
|
||||
public readonly PermissionChecker $permissions;
|
||||
|
||||
public readonly FiberManager $fiberManager;
|
||||
|
||||
public function __construct(
|
||||
private string $basePath = '/',
|
||||
?FiberManager $fiberManager = null
|
||||
) {
|
||||
$this->permissions = new PermissionChecker($this);
|
||||
$this->fiberManager = $fiberManager ?? $this->createDefaultFiberManager();
|
||||
}
|
||||
|
||||
private function createDefaultFiberManager(): FiberManager
|
||||
{
|
||||
// Create minimal dependencies for FiberManager
|
||||
$clock = new \App\Framework\DateTime\SystemClock();
|
||||
$timer = new \App\Framework\DateTime\SystemTimer($clock);
|
||||
|
||||
return new FiberManager($clock, $timer);
|
||||
}
|
||||
|
||||
private function resolvePath(string $path): string
|
||||
{
|
||||
// Absolute path bleibt unverändert
|
||||
if (str_starts_with($path, '/')) {
|
||||
return $path;
|
||||
}
|
||||
|
||||
// Relative paths werden an basePath gehängt
|
||||
return rtrim($this->basePath, '/') . '/' . ltrim($path, '/');
|
||||
}
|
||||
|
||||
public function get(string $path): string
|
||||
{
|
||||
if (!is_file($path)) {
|
||||
$resolvedPath = $this->resolvePath($path);
|
||||
if (! is_file($resolvedPath)) {
|
||||
throw new FileNotFoundException($path);
|
||||
}
|
||||
|
||||
$content = @file_get_contents($path);
|
||||
if (! is_readable($resolvedPath)) {
|
||||
throw FilePermissionException::read($path, 'File is not readable');
|
||||
}
|
||||
|
||||
$content = @file_get_contents($resolvedPath);
|
||||
if ($content === false) {
|
||||
$error = error_get_last();
|
||||
if ($error && str_contains($error['message'], 'Permission denied')) {
|
||||
throw FilePermissionException::read($path, $error['message']);
|
||||
}
|
||||
|
||||
throw new FileReadException($path);
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
public function put(string $path, string $content): void
|
||||
{
|
||||
$dir = dirname($path);
|
||||
if (!is_dir($dir)) {
|
||||
if (!@mkdir($dir, 0777, true) && !is_dir($dir)) {
|
||||
$resolvedPath = $this->resolvePath($path);
|
||||
$dir = dirname($resolvedPath);
|
||||
|
||||
// Prüfe Directory-Permissions
|
||||
if (! is_dir($dir)) {
|
||||
if (! @mkdir($dir, 0777, true) && ! is_dir($dir)) {
|
||||
$error = error_get_last();
|
||||
if ($error && str_contains($error['message'], 'Permission denied')) {
|
||||
throw FilePermissionException::createDirectory($dir, $error['message']);
|
||||
}
|
||||
|
||||
throw new DirectoryCreateException($dir);
|
||||
}
|
||||
} elseif (! is_writable($dir)) {
|
||||
throw FilePermissionException::write($path, 'Directory is not writable: ' . $dir);
|
||||
}
|
||||
if (@file_put_contents($path, $content) === false) {
|
||||
|
||||
// Prüfe File-Permissions wenn Datei bereits existiert
|
||||
if (is_file($resolvedPath) && ! is_writable($resolvedPath)) {
|
||||
throw FilePermissionException::write($path, 'File is not writable');
|
||||
}
|
||||
|
||||
if (@file_put_contents($resolvedPath, $content) === false) {
|
||||
$error = error_get_last();
|
||||
if ($error && str_contains($error['message'], 'Permission denied')) {
|
||||
throw FilePermissionException::write($path, $error['message']);
|
||||
}
|
||||
|
||||
throw new FileWriteException($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function exists(string $path): bool
|
||||
{
|
||||
return is_file($path);
|
||||
return is_file($this->resolvePath($path));
|
||||
}
|
||||
|
||||
public function delete(string $path): void
|
||||
{
|
||||
if (!is_file($path)) {
|
||||
$resolvedPath = $this->resolvePath($path);
|
||||
if (! is_file($resolvedPath)) {
|
||||
throw new FileNotFoundException($path);
|
||||
}
|
||||
|
||||
if (!@unlink($path)) {
|
||||
// Prüfe Directory-Permissions
|
||||
$dir = dirname($resolvedPath);
|
||||
if (! is_writable($dir)) {
|
||||
throw FilePermissionException::delete($path, 'Directory is not writable: ' . $dir);
|
||||
}
|
||||
|
||||
// Prüfe File-Permissions
|
||||
if (! is_writable($resolvedPath)) {
|
||||
throw FilePermissionException::delete($path, 'File is not writable');
|
||||
}
|
||||
|
||||
if (! @unlink($resolvedPath)) {
|
||||
$error = error_get_last();
|
||||
if ($error && str_contains($error['message'], 'Permission denied')) {
|
||||
throw FilePermissionException::delete($path, $error['message']);
|
||||
}
|
||||
|
||||
throw new FileDeleteException($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function copy(string $source, string $destination): void
|
||||
{
|
||||
if (!is_file($source)) {
|
||||
$resolvedSource = $this->resolvePath($source);
|
||||
$resolvedDestination = $this->resolvePath($destination);
|
||||
|
||||
if (! is_file($resolvedSource)) {
|
||||
throw new FileNotFoundException($source);
|
||||
}
|
||||
|
||||
// Stelle sicher, dass das Zielverzeichnis existiert
|
||||
$dir = dirname($destination);
|
||||
if (!is_dir($dir)) {
|
||||
$this->createDirectory($dir);
|
||||
$dir = dirname($resolvedDestination);
|
||||
if (! is_dir($dir)) {
|
||||
$this->createDirectory($destination);
|
||||
}
|
||||
|
||||
if (!@copy($source, $destination)) {
|
||||
if (! @copy($resolvedSource, $resolvedDestination)) {
|
||||
throw new FileCopyException($source, $destination);
|
||||
}
|
||||
}
|
||||
|
||||
public function size(string $path): int
|
||||
{
|
||||
if (!is_file($path)) {
|
||||
$resolvedPath = $this->resolvePath($path);
|
||||
if (! is_file($resolvedPath)) {
|
||||
throw new FileNotFoundException($path);
|
||||
}
|
||||
|
||||
$size = @filesize($path);
|
||||
$size = @filesize($resolvedPath);
|
||||
if ($size === false) {
|
||||
throw new FileReadException($path);
|
||||
}
|
||||
@@ -91,11 +189,12 @@ final readonly class FileStorage implements Storage
|
||||
|
||||
public function lastModified(string $path): int
|
||||
{
|
||||
if (!is_file($path)) {
|
||||
$resolvedPath = $this->resolvePath($path);
|
||||
if (! is_file($resolvedPath)) {
|
||||
throw new FileNotFoundException($path);
|
||||
}
|
||||
|
||||
$time = @filemtime($path);
|
||||
$time = @filemtime($resolvedPath);
|
||||
if ($time === false) {
|
||||
throw new FileReadException($path);
|
||||
}
|
||||
@@ -105,11 +204,12 @@ final readonly class FileStorage implements Storage
|
||||
|
||||
public function getMimeType(string $path): string
|
||||
{
|
||||
if (!is_file($path)) {
|
||||
$resolvedPath = $this->resolvePath($path);
|
||||
if (! is_file($resolvedPath)) {
|
||||
throw new FileNotFoundException($path);
|
||||
}
|
||||
|
||||
$mimeType = @mime_content_type($path);
|
||||
$mimeType = @mime_content_type($resolvedPath);
|
||||
if ($mimeType === false) {
|
||||
throw new FileMetadataException($path);
|
||||
}
|
||||
@@ -119,29 +219,32 @@ final readonly class FileStorage implements Storage
|
||||
|
||||
public function isReadable(string $path): bool
|
||||
{
|
||||
if (!is_file($path)) {
|
||||
$resolvedPath = $this->resolvePath($path);
|
||||
if (! is_file($resolvedPath)) {
|
||||
throw new FileNotFoundException($path);
|
||||
}
|
||||
|
||||
return is_readable($path);
|
||||
return is_readable($resolvedPath);
|
||||
}
|
||||
|
||||
public function isWritable(string $path): bool
|
||||
{
|
||||
if (!is_file($path)) {
|
||||
$resolvedPath = $this->resolvePath($path);
|
||||
if (! is_file($resolvedPath)) {
|
||||
throw new FileNotFoundException($path);
|
||||
}
|
||||
|
||||
return is_writable($path);
|
||||
return is_writable($resolvedPath);
|
||||
}
|
||||
|
||||
public function listDirectory(string $directory): array
|
||||
{
|
||||
if (!is_dir($directory)) {
|
||||
$resolvedDirectory = $this->resolvePath($directory);
|
||||
if (! is_dir($resolvedDirectory)) {
|
||||
throw new DirectoryCreateException($directory);
|
||||
}
|
||||
|
||||
$files = @scandir($directory);
|
||||
$files = @scandir($resolvedDirectory);
|
||||
if ($files === false) {
|
||||
throw new DirectoryListException($directory);
|
||||
}
|
||||
@@ -151,10 +254,15 @@ final readonly class FileStorage implements Storage
|
||||
return $file !== '.' && $file !== '..';
|
||||
});
|
||||
|
||||
// Vollständige Pfade erstellen
|
||||
// Relative Pfade zurückgeben (ohne basePath)
|
||||
$result = [];
|
||||
foreach ($files as $file) {
|
||||
$result[] = $directory . DIRECTORY_SEPARATOR . $file;
|
||||
$fullPath = $resolvedDirectory . DIRECTORY_SEPARATOR . $file;
|
||||
// Entferne basePath für relativen Pfad
|
||||
$relativePath = str_starts_with($fullPath, $this->basePath)
|
||||
? substr($fullPath, strlen(rtrim($this->basePath, '/')) + 1)
|
||||
: $fullPath;
|
||||
$result[] = $relativePath;
|
||||
}
|
||||
|
||||
return array_values($result);
|
||||
@@ -162,12 +270,67 @@ final readonly class FileStorage implements Storage
|
||||
|
||||
public function createDirectory(string $path, int $permissions = 0755, bool $recursive = true): void
|
||||
{
|
||||
if (is_dir($path)) {
|
||||
$resolvedPath = $this->resolvePath($path);
|
||||
if (is_dir($resolvedPath)) {
|
||||
return; // Verzeichnis existiert bereits
|
||||
}
|
||||
|
||||
if (!@mkdir($path, $permissions, $recursive) && !is_dir($path)) {
|
||||
// Prüfe Parent-Directory Permissions
|
||||
$parentDir = dirname($resolvedPath);
|
||||
if (is_dir($parentDir) && ! is_writable($parentDir)) {
|
||||
throw FilePermissionException::createDirectory($path, 'Parent directory is not writable: ' . $parentDir);
|
||||
}
|
||||
|
||||
if (! @mkdir($resolvedPath, $permissions, $recursive) && ! is_dir($resolvedPath)) {
|
||||
$error = error_get_last();
|
||||
if ($error && str_contains($error['message'], 'Permission denied')) {
|
||||
throw FilePermissionException::createDirectory($path, $error['message']);
|
||||
}
|
||||
|
||||
throw new DirectoryCreateException($path);
|
||||
}
|
||||
}
|
||||
|
||||
// Batch-Operationen für explizite Parallelverarbeitung
|
||||
|
||||
public function batch(array $operations): array
|
||||
{
|
||||
return $this->fiberManager->batch($operations);
|
||||
}
|
||||
|
||||
public function getMultiple(array $paths): array
|
||||
{
|
||||
$operations = [];
|
||||
foreach ($paths as $path) {
|
||||
$operations[$path] = fn () => $this->get($path);
|
||||
}
|
||||
|
||||
return $this->batch($operations);
|
||||
}
|
||||
|
||||
public function putMultiple(array $files): void
|
||||
{
|
||||
$operations = [];
|
||||
foreach ($files as $path => $content) {
|
||||
$operations[$path] = fn () => $this->put($path, $content);
|
||||
}
|
||||
|
||||
$this->batch($operations);
|
||||
}
|
||||
|
||||
public function getMetadataMultiple(array $paths): array
|
||||
{
|
||||
$operations = [];
|
||||
foreach ($paths as $path) {
|
||||
$operations[$path] = fn () => new FileMetadata(
|
||||
size: $this->size($path),
|
||||
lastModified: $this->lastModified($path),
|
||||
mimeType: $this->getMimeType($path),
|
||||
isReadable: $this->isReadable($path),
|
||||
isWritable: $this->isWritable($path)
|
||||
);
|
||||
}
|
||||
|
||||
return $this->batch($operations);
|
||||
}
|
||||
}
|
||||
|
||||
146
src/Framework/Filesystem/FileSystemService.php
Normal file
146
src/Framework/Filesystem/FileSystemService.php
Normal file
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Filesystem;
|
||||
|
||||
/**
|
||||
* Service for file system operations.
|
||||
* Handles all file I/O operations separate from File value objects.
|
||||
*/
|
||||
final class FileSystemService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Storage $storage = new FileStorage()
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Read file content
|
||||
*/
|
||||
public function readFile(File $file): string
|
||||
{
|
||||
return $this->storage->get($file->getPathString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Write content to file
|
||||
*/
|
||||
public function writeFile(File $file, string $content): void
|
||||
{
|
||||
$this->storage->put($file->getPathString(), $content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create File object from path (loads metadata automatically)
|
||||
*/
|
||||
public function createFile(FilePath $path): File
|
||||
{
|
||||
$pathString = $path->toString();
|
||||
|
||||
$metadata = new FileMetadata(
|
||||
size: $this->storage->size($pathString),
|
||||
lastModified: $this->storage->lastModified($pathString),
|
||||
mimeType: $this->storage->getMimeType($pathString),
|
||||
isReadable: $this->storage->isReadable($pathString),
|
||||
isWritable: $this->storage->isWritable($pathString)
|
||||
);
|
||||
|
||||
return new File($path, $metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file exists
|
||||
*/
|
||||
public function fileExists(FilePath $path): bool
|
||||
{
|
||||
return $this->storage->exists($path->toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete file
|
||||
*/
|
||||
public function deleteFile(File $file): void
|
||||
{
|
||||
$this->storage->delete($file->getPathString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy file to destination
|
||||
*/
|
||||
public function copyFile(File $file, FilePath $destination): File
|
||||
{
|
||||
$this->storage->copy($file->getPathString(), $destination->toString());
|
||||
|
||||
return $this->createFile($destination);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fresh metadata for existing file
|
||||
*/
|
||||
public function getMetadata(FilePath $path): FileMetadata
|
||||
{
|
||||
$pathString = $path->toString();
|
||||
|
||||
return new FileMetadata(
|
||||
size: $this->storage->size($pathString),
|
||||
lastModified: $this->storage->lastModified($pathString),
|
||||
mimeType: $this->storage->getMimeType($pathString),
|
||||
isReadable: $this->storage->isReadable($pathString),
|
||||
isWritable: $this->storage->isWritable($pathString)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh file with current metadata
|
||||
*/
|
||||
public function refreshFile(File $file): File
|
||||
{
|
||||
$metadata = $this->getMetadata($file->getPath());
|
||||
|
||||
return new File($file->getPath(), $metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream file content line by line for memory-efficient processing
|
||||
* @return Generator<string>
|
||||
*/
|
||||
public function streamFileLines(File $file): \Generator
|
||||
{
|
||||
$handle = fopen($file->getPathString(), 'r');
|
||||
if ($handle === false) {
|
||||
throw new \RuntimeException("Cannot open file: " . $file->getPathString());
|
||||
}
|
||||
|
||||
try {
|
||||
while (($line = fgets($handle)) !== false) {
|
||||
yield rtrim($line, "\r\n");
|
||||
}
|
||||
} finally {
|
||||
fclose($handle);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream file content in chunks for large file processing
|
||||
* @return Generator<string>
|
||||
*/
|
||||
public function streamFileChunks(File $file, int $chunkSize = 8192): \Generator
|
||||
{
|
||||
$handle = fopen($file->getPathString(), 'r');
|
||||
if ($handle === false) {
|
||||
throw new \RuntimeException("Cannot open file: " . $file->getPathString());
|
||||
}
|
||||
|
||||
try {
|
||||
while (! feof($handle)) {
|
||||
$chunk = fread($handle, $chunkSize);
|
||||
if ($chunk !== false && $chunk !== '') {
|
||||
yield $chunk;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
fclose($handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
86
src/Framework/Filesystem/FilesystemConfig.php
Normal file
86
src/Framework/Filesystem/FilesystemConfig.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Filesystem;
|
||||
|
||||
/**
|
||||
* Konfiguration für das Filesystem-Modul
|
||||
*
|
||||
* Definiert Storage-Backends, Pfade und weitere Filesystem-Einstellungen
|
||||
* entsprechend der Framework-Konventionen.
|
||||
*/
|
||||
final readonly class FilesystemConfig
|
||||
{
|
||||
/**
|
||||
* @param array<string, array{type: string, path?: string, options?: array<string, mixed>}> $storages
|
||||
*/
|
||||
public function __construct(
|
||||
public string $defaultStorage = 'local',
|
||||
public array $storages = [
|
||||
'local' => [
|
||||
'type' => 'file',
|
||||
'path' => '/var/www/html/storage',
|
||||
],
|
||||
],
|
||||
public bool $enableCompression = false,
|
||||
public string $defaultCompressionAlgorithm = 'gzip',
|
||||
public bool $enableAtomicWrites = true,
|
||||
public bool $enableFileLocking = true,
|
||||
public int $defaultFilePermissions = 0644,
|
||||
public int $defaultDirectoryPermissions = 0755,
|
||||
public bool $createDirectoriesAutomatically = true,
|
||||
public int $streamBufferSize = 8192,
|
||||
public array $serializers = [
|
||||
'json' => 'json',
|
||||
'php' => 'php',
|
||||
'csv' => 'csv',
|
||||
]
|
||||
) {
|
||||
}
|
||||
|
||||
public function getStorageConfig(string $name): array
|
||||
{
|
||||
if (! isset($this->storages[$name])) {
|
||||
throw new \InvalidArgumentException("Storage '{$name}' not configured");
|
||||
}
|
||||
|
||||
return $this->storages[$name];
|
||||
}
|
||||
|
||||
public function hasStorage(string $name): bool
|
||||
{
|
||||
return isset($this->storages[$name]);
|
||||
}
|
||||
|
||||
public function getDefaultStorageConfig(): array
|
||||
{
|
||||
return $this->getStorageConfig($this->defaultStorage);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string>
|
||||
*/
|
||||
public function getAvailableStorages(): array
|
||||
{
|
||||
return array_keys($this->storages);
|
||||
}
|
||||
|
||||
public function isCompressionEnabled(): bool
|
||||
{
|
||||
return $this->enableCompression && extension_loaded('zlib');
|
||||
}
|
||||
|
||||
public function isSerializerAvailable(string $type): bool
|
||||
{
|
||||
return isset($this->serializers[$type]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string>
|
||||
*/
|
||||
public function getAvailableSerializers(): array
|
||||
{
|
||||
return array_keys($this->serializers);
|
||||
}
|
||||
}
|
||||
@@ -1,51 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Filesystem;
|
||||
|
||||
use App\Framework\Logging\DefaultLogger;
|
||||
use App\Framework\Logging\LogLevel;
|
||||
use App\Framework\Logging\LoggerFactory;
|
||||
use ReflectionClass;
|
||||
|
||||
/**
|
||||
* Factory für Filesystem-Objekte mit Lazy-Loading-Unterstützung.
|
||||
*/
|
||||
final class FilesystemFactory
|
||||
final readonly class FilesystemFactory
|
||||
{
|
||||
/**
|
||||
* Erstellt ein File-Objekt mit Lazy-Loading für schwere Eigenschaften.
|
||||
*
|
||||
* @param string $path Pfad zur Datei
|
||||
* @param FilePath|string $path Pfad zur Datei
|
||||
* @param Storage $storage Storage-Implementierung
|
||||
* @param int|null $cacheTimeoutSeconds Optional, Zeit in Sekunden, nach der der Cache ungültig wird
|
||||
* @param bool $lazyLoad Optional, ob Lazy-Loading verwendet werden soll
|
||||
* @param DefaultLogger|null $logger Optional, Logger für Debug-Informationen
|
||||
*/
|
||||
public static function createFile(
|
||||
string $path,
|
||||
FilePath|string $path,
|
||||
Storage $storage,
|
||||
?int $cacheTimeoutSeconds = null,
|
||||
bool $lazyLoad = true,
|
||||
?DefaultLogger $logger = null
|
||||
): File {
|
||||
$logger ??= LoggerFactory::getDefaultLogger();
|
||||
$pathString = $path instanceof FilePath ? $path->toString() : $path;
|
||||
|
||||
// Direkte Instanziierung ohne Lazy-Loading
|
||||
if (!$lazyLoad) {
|
||||
$logger->debug("Erstelle File-Objekt ohne Lazy-Loading: {$path}");
|
||||
if (! $lazyLoad) {
|
||||
$logger->debug("Erstelle File-Objekt ohne Lazy-Loading: {$pathString}");
|
||||
|
||||
// Nur laden wenn die Datei existiert
|
||||
if (!$storage->exists($path)) {
|
||||
if (! $storage->exists($pathString)) {
|
||||
return new File($path, $storage);
|
||||
}
|
||||
|
||||
return new File(
|
||||
path: $path,
|
||||
storage: $storage,
|
||||
contents: $storage->get($path),
|
||||
size: $storage->size($path),
|
||||
lastModified: $storage->lastModified($path)
|
||||
contents: $storage->get($pathString),
|
||||
size: $storage->size($pathString),
|
||||
lastModified: $storage->lastModified($pathString)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,54 +56,60 @@ final class FilesystemFactory
|
||||
// LazyProxy verwenden für individuelle Property-Callbacks
|
||||
return $reflection->newLazyProxy([
|
||||
// Dateiinhalt wird erst beim ersten Zugriff geladen
|
||||
'contents' => function(File $file) use ($loadTime, $cacheTimeoutSeconds, $logger) {
|
||||
$logger->debug("Lazy-Loading contents für {$file->path}");
|
||||
'contents' => function (File $file) use ($loadTime, $cacheTimeoutSeconds, $logger) {
|
||||
$pathStr = $file->getPathString();
|
||||
$logger->debug("Lazy-Loading contents für {$pathStr}");
|
||||
|
||||
// Cache-Invalidierung basierend auf Zeit
|
||||
if ($cacheTimeoutSeconds !== null && time() - $loadTime > $cacheTimeoutSeconds) {
|
||||
$logger->debug("Cache-Timeout erreicht für {$file->path}, lade neu");
|
||||
$logger->debug("Cache-Timeout erreicht für {$pathStr}, lade neu");
|
||||
}
|
||||
|
||||
if (!$file->exists()) {
|
||||
$logger->debug("Datei existiert nicht: {$file->path}");
|
||||
if (! $file->exists()) {
|
||||
$logger->debug("Datei existiert nicht: {$pathStr}");
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
return $file->storage->get($file->path);
|
||||
return $file->storage->get($pathStr);
|
||||
},
|
||||
|
||||
// Dateigröße wird erst beim ersten Zugriff ermittelt
|
||||
'size' => function(File $file) use ($loadTime, $cacheTimeoutSeconds, $logger) {
|
||||
$logger->debug("Lazy-Loading size für {$file->path}");
|
||||
'size' => function (File $file) use ($loadTime, $cacheTimeoutSeconds, $logger) {
|
||||
$pathStr = $file->getPathString();
|
||||
$logger->debug("Lazy-Loading size für {$pathStr}");
|
||||
|
||||
// Cache-Invalidierung basierend auf Zeit
|
||||
if ($cacheTimeoutSeconds !== null && time() - $loadTime > $cacheTimeoutSeconds) {
|
||||
$logger->debug("Cache-Timeout erreicht für {$file->path}, lade neu");
|
||||
$logger->debug("Cache-Timeout erreicht für {$pathStr}, lade neu");
|
||||
}
|
||||
|
||||
if (!$file->exists()) {
|
||||
$logger->debug("Datei existiert nicht: {$file->path}");
|
||||
if (! $file->exists()) {
|
||||
$logger->debug("Datei existiert nicht: {$pathStr}");
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
return $file->storage->size($file->path);
|
||||
return $file->storage->size($pathStr);
|
||||
},
|
||||
|
||||
// Zeitstempel wird erst beim ersten Zugriff ermittelt
|
||||
'lastModified' => function(File $file) use ($loadTime, $cacheTimeoutSeconds, $logger) {
|
||||
$logger->debug("Lazy-Loading lastModified für {$file->path}");
|
||||
'lastModified' => function (File $file) use ($loadTime, $cacheTimeoutSeconds, $logger) {
|
||||
$pathStr = $file->getPathString();
|
||||
$logger->debug("Lazy-Loading lastModified für {$pathStr}");
|
||||
|
||||
// Cache-Invalidierung basierend auf Zeit
|
||||
if ($cacheTimeoutSeconds !== null && time() - $loadTime > $cacheTimeoutSeconds) {
|
||||
$logger->debug("Cache-Timeout erreicht für {$file->path}, lade neu");
|
||||
$logger->debug("Cache-Timeout erreicht für {$pathStr}, lade neu");
|
||||
}
|
||||
|
||||
if (!$file->exists()) {
|
||||
$logger->debug("Datei existiert nicht: {$file->path}");
|
||||
if (! $file->exists()) {
|
||||
$logger->debug("Datei existiert nicht: {$pathStr}");
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
return $file->storage->lastModified($file->path);
|
||||
return $file->storage->lastModified($pathStr);
|
||||
},
|
||||
]);
|
||||
}
|
||||
@@ -110,26 +117,27 @@ final class FilesystemFactory
|
||||
/**
|
||||
* Erstellt ein Directory-Objekt mit Lazy-Loading für schwere Eigenschaften.
|
||||
*
|
||||
* @param string $path Pfad zum Verzeichnis
|
||||
* @param FilePath|string $path Pfad zum Verzeichnis
|
||||
* @param Storage $storage Storage-Implementierung
|
||||
* @param bool $lazyLoad Optional, ob Lazy-Loading verwendet werden soll
|
||||
* @param DefaultLogger|null $logger Optional, Logger für Debug-Informationen
|
||||
*/
|
||||
public static function createDirectory(
|
||||
string $path,
|
||||
FilePath|string $path,
|
||||
Storage $storage,
|
||||
bool $lazyLoad = true,
|
||||
?DefaultLogger $logger = null
|
||||
): Directory {
|
||||
$logger ??= LoggerFactory::getDefaultLogger();
|
||||
$pathString = $path instanceof FilePath ? $path->toString() : $path;
|
||||
|
||||
// Direkte Instanziierung ohne Lazy-Loading
|
||||
if (!$lazyLoad) {
|
||||
$logger->debug("Erstelle Directory-Objekt ohne Lazy-Loading: {$path}");
|
||||
if (! $lazyLoad) {
|
||||
$logger->debug("Erstelle Directory-Objekt ohne Lazy-Loading: {$pathString}");
|
||||
|
||||
$contents = [];
|
||||
if (is_dir($path)) {
|
||||
$contents = $storage->listDirectory($path);
|
||||
if (is_dir($pathString)) {
|
||||
$contents = $storage->listDirectory($pathString);
|
||||
}
|
||||
|
||||
return new Directory($path, $storage, $contents);
|
||||
@@ -140,17 +148,19 @@ final class FilesystemFactory
|
||||
// LazyGhost verwenden - alle Eigenschaften werden beim ersten Zugriff initialisiert
|
||||
$lazyDir = $reflection->newLazyGhost(
|
||||
// Initializer-Callback
|
||||
function(Directory $directory) use ($logger): void {
|
||||
$logger->debug("Lazy-Loading Directory-Inhalt für {$directory->path}");
|
||||
function (Directory $directory) use ($logger): void {
|
||||
$pathStr = $directory->getPathString();
|
||||
$logger->debug("Lazy-Loading Directory-Inhalt für {$pathStr}");
|
||||
|
||||
// Verzeichnisinhalt wird erst beim ersten Zugriff auf eine Eigenschaft geladen
|
||||
if ($directory->exists()) {
|
||||
$directory->contents = $directory->storage->listDirectory($directory->path);
|
||||
$directory->contents = $directory->storage->listDirectory($pathStr);
|
||||
} else {
|
||||
$directory->contents = [];
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/** @var Directory $lazyDir */
|
||||
return $lazyDir;
|
||||
}
|
||||
|
||||
117
src/Framework/Filesystem/FilesystemInitializer.php
Normal file
117
src/Framework/Filesystem/FilesystemInitializer.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Filesystem;
|
||||
|
||||
use App\Framework\DI\Container;
|
||||
use App\Framework\DI\Initializer;
|
||||
use App\Framework\Filesystem\Serializers\CsvSerializer;
|
||||
use App\Framework\Filesystem\Serializers\JsonSerializer;
|
||||
use App\Framework\Filesystem\Serializers\PhpSerializer;
|
||||
|
||||
/**
|
||||
* Filesystem-System Initializer
|
||||
*
|
||||
* Registriert alle Filesystem-Komponenten im DI-Container
|
||||
* entsprechend der Framework-Konventionen.
|
||||
*/
|
||||
final readonly class FilesystemInitializer
|
||||
{
|
||||
#[Initializer]
|
||||
public function initializeFilesystem(Container $container): void
|
||||
{
|
||||
// Filesystem Config
|
||||
$container->singleton(FilesystemConfig::class, function () {
|
||||
return new FilesystemConfig(
|
||||
defaultStorage: $_ENV['FILESYSTEM_DEFAULT_STORAGE'] ?? 'local',
|
||||
storages: [
|
||||
'local' => [
|
||||
'type' => 'file',
|
||||
'path' => $_ENV['FILESYSTEM_LOCAL_PATH'] ?? '/var/www/html/storage',
|
||||
],
|
||||
'temp' => [
|
||||
'type' => 'file',
|
||||
'path' => $_ENV['FILESYSTEM_TEMP_PATH'] ?? '/tmp',
|
||||
],
|
||||
'analytics' => [
|
||||
'type' => 'file',
|
||||
'path' => $_ENV['ANALYTICS_DATA_PATH'] ?? '/var/www/html/storage/analytics',
|
||||
],
|
||||
],
|
||||
enableCompression: filter_var($_ENV['FILESYSTEM_COMPRESSION'] ?? 'false', FILTER_VALIDATE_BOOLEAN),
|
||||
enableAtomicWrites: filter_var($_ENV['FILESYSTEM_ATOMIC_WRITES'] ?? 'true', FILTER_VALIDATE_BOOLEAN),
|
||||
enableFileLocking: filter_var($_ENV['FILESYSTEM_FILE_LOCKING'] ?? 'true', FILTER_VALIDATE_BOOLEAN)
|
||||
);
|
||||
});
|
||||
|
||||
// Serializers
|
||||
$container->singleton(JsonSerializer::class, function () {
|
||||
return new JsonSerializer();
|
||||
});
|
||||
|
||||
$container->singleton(PhpSerializer::class, function () {
|
||||
return new PhpSerializer();
|
||||
});
|
||||
|
||||
$container->singleton(CsvSerializer::class, function () {
|
||||
return new CsvSerializer();
|
||||
});
|
||||
|
||||
// Storage Instances
|
||||
$this->registerStorages($container);
|
||||
|
||||
// Filesystem Manager
|
||||
$container->singleton(FilesystemManager::class, function (Container $container) {
|
||||
$config = $container->get(FilesystemConfig::class);
|
||||
$manager = new FilesystemManager($config->defaultStorage);
|
||||
|
||||
// Registriere alle konfigurierten Storages
|
||||
foreach ($config->getAvailableStorages() as $storageName) {
|
||||
$storage = $container->get("filesystem.storage.{$storageName}");
|
||||
$manager->registerStorage($storageName, $storage);
|
||||
}
|
||||
|
||||
// Registriere Serializers
|
||||
$manager->registerSerializer('json', $container->get(JsonSerializer::class));
|
||||
$manager->registerSerializer('php', $container->get(PhpSerializer::class));
|
||||
$manager->registerSerializer('csv', $container->get(CsvSerializer::class));
|
||||
|
||||
return $manager;
|
||||
});
|
||||
}
|
||||
|
||||
private function registerStorages(Container $container): void
|
||||
{
|
||||
$container->singleton(Storage::class, function () {
|
||||
return new FileStorage('/');
|
||||
});
|
||||
$container->singleton(FileStorage::class, function () {
|
||||
return new FileStorage('/');
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
$container->singleton('filesystem.storage.local', function (Container $container) {
|
||||
$config = $container->get(FilesystemConfig::class);
|
||||
$storageConfig = $config->getStorageConfig('local');
|
||||
|
||||
return new FileStorage($storageConfig['path']);
|
||||
});
|
||||
|
||||
$container->singleton('filesystem.storage.temp', function (Container $container) {
|
||||
$config = $container->get(FilesystemConfig::class);
|
||||
$storageConfig = $config->getStorageConfig('temp');
|
||||
|
||||
return new FileStorage($storageConfig['path']);
|
||||
});
|
||||
|
||||
$container->singleton('filesystem.storage.analytics', function (Container $container) {
|
||||
$config = $container->get(FilesystemConfig::class);
|
||||
$storageConfig = $config->getStorageConfig('analytics');
|
||||
|
||||
return new FileStorage($storageConfig['path']);
|
||||
});
|
||||
}
|
||||
}
|
||||
248
src/Framework/Filesystem/FilesystemManager.php
Normal file
248
src/Framework/Filesystem/FilesystemManager.php
Normal file
@@ -0,0 +1,248 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Filesystem;
|
||||
|
||||
/**
|
||||
* Zentraler Filesystem-Manager mit verschiedenen Storage-Backends und Serializer-Support
|
||||
*
|
||||
* Ermöglicht die Verwendung verschiedener Storage-Implementierungen
|
||||
* und Serializer für verschiedene Datenformate.
|
||||
*/
|
||||
final class FilesystemManager
|
||||
{
|
||||
/** @var array<string, Storage> */
|
||||
private array $storages;
|
||||
|
||||
/** @var array<string, Serializer> */
|
||||
private array $serializers = [];
|
||||
|
||||
public function __construct(
|
||||
private string $defaultStorageName = 'default'
|
||||
) {
|
||||
$this->storages = [];
|
||||
$this->serializers = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt ein Storage-Backend by Name
|
||||
*/
|
||||
public function storage(string $name = ''): Storage
|
||||
{
|
||||
$storageName = $name ?: $this->defaultStorageName;
|
||||
|
||||
if (! isset($this->storages[$storageName])) {
|
||||
throw new \InvalidArgumentException("Storage '{$storageName}' not found");
|
||||
}
|
||||
|
||||
return $this->storages[$storageName];
|
||||
}
|
||||
|
||||
/**
|
||||
* Registriert ein neues Storage-Backend
|
||||
*/
|
||||
public function registerStorage(string $name, Storage $storage): void
|
||||
{
|
||||
$this->storages[$name] = $storage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registriert einen Serializer
|
||||
*/
|
||||
public function registerSerializer(string $name, Serializer $serializer): void
|
||||
{
|
||||
$this->serializers[$name] = $serializer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt einen Serializer by Name
|
||||
*/
|
||||
public function getSerializer(string $name): Serializer
|
||||
{
|
||||
if (! isset($this->serializers[$name])) {
|
||||
throw new \InvalidArgumentException("Serializer '{$name}' not found");
|
||||
}
|
||||
|
||||
return $this->serializers[$name];
|
||||
}
|
||||
|
||||
// === Basic Storage Operations ===
|
||||
|
||||
public function get(string $path, string $storage = ''): string
|
||||
{
|
||||
return $this->storage($storage)->get($path);
|
||||
}
|
||||
|
||||
public function put(string $path, string $content, string $storage = ''): void
|
||||
{
|
||||
$this->storage($storage)->put($path, $content);
|
||||
}
|
||||
|
||||
public function exists(string $path, string $storage = ''): bool
|
||||
{
|
||||
return $this->storage($storage)->exists($path);
|
||||
}
|
||||
|
||||
public function delete(string $path, string $storage = ''): void
|
||||
{
|
||||
$this->storage($storage)->delete($path);
|
||||
}
|
||||
|
||||
public function copy(string $source, string $destination, string $storage = ''): void
|
||||
{
|
||||
$this->storage($storage)->copy($source, $destination);
|
||||
}
|
||||
|
||||
// === Serialized Storage Operations ===
|
||||
|
||||
/**
|
||||
* Speichert Daten mit einem Serializer
|
||||
*/
|
||||
public function putSerialized(string $path, mixed $data, Serializer $serializer, string $storage = ''): void
|
||||
{
|
||||
$content = $serializer->serialize($data);
|
||||
$this->storage($storage)->put($path, $content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt und deserialisiert Daten
|
||||
*/
|
||||
public function getSerialized(string $path, Serializer $serializer, string $storage = ''): mixed
|
||||
{
|
||||
$content = $this->storage($storage)->get($path);
|
||||
|
||||
return $serializer->deserialize($content);
|
||||
}
|
||||
|
||||
// === Convenience Methods für häufig verwendete Serializer ===
|
||||
|
||||
/**
|
||||
* JSON-Operationen
|
||||
*/
|
||||
public function putJson(string $path, array $data, string $storage = ''): void
|
||||
{
|
||||
$this->putSerialized($path, $data, $this->getSerializer('json'), $storage);
|
||||
}
|
||||
|
||||
public function getJson(string $path, string $storage = ''): array
|
||||
{
|
||||
return $this->getSerialized($path, $this->getSerializer('json'), $storage);
|
||||
}
|
||||
|
||||
/**
|
||||
* CSV-Operationen (wenn CSV-Serializer registriert ist)
|
||||
*/
|
||||
public function putCsv(string $path, array $data, string $storage = ''): void
|
||||
{
|
||||
$this->putSerialized($path, $data, $this->getSerializer('csv'), $storage);
|
||||
}
|
||||
|
||||
public function getCsv(string $path, string $storage = ''): array
|
||||
{
|
||||
return $this->getSerialized($path, $this->getSerializer('csv'), $storage);
|
||||
}
|
||||
|
||||
// === Extended Operations ===
|
||||
|
||||
/**
|
||||
* Atomic Write (falls vom Storage unterstützt)
|
||||
*/
|
||||
public function putAtomic(string $path, string $content, string $storage = ''): void
|
||||
{
|
||||
$storageInstance = $this->storage($storage);
|
||||
|
||||
// Erweiterte Storage-Implementierungen können AtomicStorage Interface implementieren
|
||||
if (method_exists($storageInstance, 'putAtomic')) {
|
||||
$storageInstance->putAtomic($path, $content);
|
||||
} else {
|
||||
// Fallback: Normale put-Operation
|
||||
$storageInstance->put($path, $content);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append-Operation (falls vom Storage unterstützt)
|
||||
*/
|
||||
public function append(string $path, string $content, string $storage = ''): void
|
||||
{
|
||||
$storageInstance = $this->storage($storage);
|
||||
|
||||
if (method_exists($storageInstance, 'append')) {
|
||||
$storageInstance->append($path, $content);
|
||||
} else {
|
||||
// Fallback: Lesen, anhängen, schreiben
|
||||
$existing = $storageInstance->exists($path) ? $storageInstance->get($path) : '';
|
||||
$storageInstance->put($path, $existing . $content);
|
||||
}
|
||||
}
|
||||
|
||||
// === Storage Management ===
|
||||
|
||||
/**
|
||||
* Multi-Storage Operationen
|
||||
*/
|
||||
public function copyBetweenStorages(string $source, string $destination, string $sourceStorage = '', string $destStorage = ''): void
|
||||
{
|
||||
$sourceStorageName = $sourceStorage ?: $this->defaultStorageName;
|
||||
$destStorageName = $destStorage ?: $this->defaultStorageName;
|
||||
|
||||
$content = $this->storage($sourceStorageName)->get($source);
|
||||
$this->storage($destStorageName)->put($destination, $content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage-Statistiken
|
||||
*/
|
||||
public function getStorageInfo(): array
|
||||
{
|
||||
$info = [];
|
||||
|
||||
foreach ($this->storages as $name => $storage) {
|
||||
$info[$name] = [
|
||||
'type' => get_class($storage),
|
||||
'available' => true, // Könnte erweitert werden für Health-Checks
|
||||
];
|
||||
}
|
||||
|
||||
return $info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializer-Statistiken
|
||||
*/
|
||||
public function getSerializerInfo(): array
|
||||
{
|
||||
$info = [];
|
||||
|
||||
foreach ($this->serializers as $name => $serializer) {
|
||||
$info[$name] = [
|
||||
'type' => get_class($serializer),
|
||||
'mime_type' => $serializer->getMimeType(),
|
||||
'extension' => $serializer->getFileExtension(),
|
||||
];
|
||||
}
|
||||
|
||||
return $info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory-Methode für Standard-Setup
|
||||
*/
|
||||
public static function create(array $config = []): self
|
||||
{
|
||||
$defaultStorage = 'default';
|
||||
$namedStorages = [];
|
||||
|
||||
$manager = new self($defaultStorage, $namedStorages);
|
||||
|
||||
// Weitere Standard-Serializer können hier registriert werden
|
||||
if (isset($config['serializers'])) {
|
||||
foreach ($config['serializers'] as $name => $serializer) {
|
||||
$manager->registerSerializer($name, $serializer);
|
||||
}
|
||||
}
|
||||
|
||||
return $manager;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Filesystem;
|
||||
|
||||
use App\Framework\Async\FiberManager;
|
||||
use App\Framework\Filesystem\Exceptions\DirectoryCreateException;
|
||||
use App\Framework\Filesystem\Exceptions\FileNotFoundException;
|
||||
use App\Framework\Filesystem\Exceptions\FileReadException;
|
||||
@@ -21,9 +23,28 @@ final class InMemoryStorage implements Storage
|
||||
/** @var array<string, int> */
|
||||
private array $timestamps = [];
|
||||
|
||||
public readonly PermissionChecker $permissions;
|
||||
|
||||
public readonly FiberManager $fiberManager;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->permissions = new PermissionChecker($this);
|
||||
$this->fiberManager = $this->createDefaultFiberManager();
|
||||
}
|
||||
|
||||
private function createDefaultFiberManager(): FiberManager
|
||||
{
|
||||
// Create minimal dependencies for FiberManager
|
||||
$clock = new \App\Framework\DateTime\SystemClock();
|
||||
$timer = new \App\Framework\DateTime\SystemTimer($clock);
|
||||
|
||||
return new FiberManager($clock, $timer);
|
||||
}
|
||||
|
||||
public function get(string $path): string
|
||||
{
|
||||
if (!isset($this->files[$path])) {
|
||||
if (! isset($this->files[$path])) {
|
||||
throw new FileNotFoundException($path);
|
||||
}
|
||||
|
||||
@@ -33,7 +54,7 @@ final class InMemoryStorage implements Storage
|
||||
public function put(string $path, string $content): void
|
||||
{
|
||||
$dir = dirname($path);
|
||||
if (!isset($this->directories[$dir])) {
|
||||
if (! isset($this->directories[$dir])) {
|
||||
$this->createDirectory($dir);
|
||||
}
|
||||
|
||||
@@ -48,7 +69,7 @@ final class InMemoryStorage implements Storage
|
||||
|
||||
public function delete(string $path): void
|
||||
{
|
||||
if (!isset($this->files[$path])) {
|
||||
if (! isset($this->files[$path])) {
|
||||
throw new FileNotFoundException($path);
|
||||
}
|
||||
|
||||
@@ -58,12 +79,12 @@ final class InMemoryStorage implements Storage
|
||||
|
||||
public function copy(string $source, string $destination): void
|
||||
{
|
||||
if (!isset($this->files[$source])) {
|
||||
if (! isset($this->files[$source])) {
|
||||
throw new FileNotFoundException($source);
|
||||
}
|
||||
|
||||
$dir = dirname($destination);
|
||||
if (!isset($this->directories[$dir])) {
|
||||
if (! isset($this->directories[$dir])) {
|
||||
$this->createDirectory($dir);
|
||||
}
|
||||
|
||||
@@ -73,7 +94,7 @@ final class InMemoryStorage implements Storage
|
||||
|
||||
public function size(string $path): int
|
||||
{
|
||||
if (!isset($this->files[$path])) {
|
||||
if (! isset($this->files[$path])) {
|
||||
throw new FileNotFoundException($path);
|
||||
}
|
||||
|
||||
@@ -82,7 +103,7 @@ final class InMemoryStorage implements Storage
|
||||
|
||||
public function lastModified(string $path): int
|
||||
{
|
||||
if (!isset($this->files[$path])) {
|
||||
if (! isset($this->files[$path])) {
|
||||
throw new FileNotFoundException($path);
|
||||
}
|
||||
|
||||
@@ -91,7 +112,7 @@ final class InMemoryStorage implements Storage
|
||||
|
||||
public function listDirectory(string $directory): array
|
||||
{
|
||||
if (!isset($this->directories[$directory])) {
|
||||
if (! isset($this->directories[$directory])) {
|
||||
throw new DirectoryCreateException($directory);
|
||||
}
|
||||
|
||||
@@ -104,7 +125,7 @@ final class InMemoryStorage implements Storage
|
||||
if (str_starts_with($path, $prefix)) {
|
||||
// Nur direkte Kinder, keine tieferen Ebenen
|
||||
$relativePath = substr($path, $prefixLength);
|
||||
if (!str_contains($relativePath, DIRECTORY_SEPARATOR)) {
|
||||
if (! str_contains($relativePath, DIRECTORY_SEPARATOR)) {
|
||||
$result[] = $path;
|
||||
}
|
||||
}
|
||||
@@ -115,7 +136,7 @@ final class InMemoryStorage implements Storage
|
||||
if ($path !== $directory && strpos($path, $prefix) === 0) {
|
||||
// Nur direkte Kinder
|
||||
$relativePath = substr($path, $prefixLength);
|
||||
if (!str_contains($relativePath, DIRECTORY_SEPARATOR)) {
|
||||
if (! str_contains($relativePath, DIRECTORY_SEPARATOR)) {
|
||||
$result[] = $path;
|
||||
}
|
||||
}
|
||||
@@ -180,7 +201,7 @@ final class InMemoryStorage implements Storage
|
||||
|
||||
public function getMimeType(string $path): string
|
||||
{
|
||||
if (!isset($this->files[$path])) {
|
||||
if (! isset($this->files[$path])) {
|
||||
throw new FileNotFoundException($path);
|
||||
}
|
||||
|
||||
@@ -204,4 +225,45 @@ final class InMemoryStorage implements Storage
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function batch(array $operations): array
|
||||
{
|
||||
return $this->fiberManager->batch($operations);
|
||||
}
|
||||
|
||||
public function getMultiple(array $paths): array
|
||||
{
|
||||
$operations = [];
|
||||
foreach ($paths as $path) {
|
||||
$operations[$path] = fn () => $this->get($path);
|
||||
}
|
||||
|
||||
return $this->batch($operations);
|
||||
}
|
||||
|
||||
public function putMultiple(array $files): void
|
||||
{
|
||||
$operations = [];
|
||||
foreach ($files as $path => $content) {
|
||||
$operations[$path] = fn () => $this->put($path, $content);
|
||||
}
|
||||
$this->batch($operations);
|
||||
}
|
||||
|
||||
public function getMetadataMultiple(array $paths): array
|
||||
{
|
||||
$operations = [];
|
||||
foreach ($paths as $path) {
|
||||
$operations[$path] = fn () => new FileMetadata(
|
||||
path: $path,
|
||||
size: $this->size($path),
|
||||
lastModified: $this->lastModified($path),
|
||||
mimeType: $this->getMimeType($path),
|
||||
isReadable: $this->isReadable($path),
|
||||
isWritable: $this->isWritable($path)
|
||||
);
|
||||
}
|
||||
|
||||
return $this->batch($operations);
|
||||
}
|
||||
}
|
||||
|
||||
44
src/Framework/Filesystem/LockableStorage.php
Normal file
44
src/Framework/Filesystem/LockableStorage.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Filesystem;
|
||||
|
||||
/**
|
||||
* Interface für File-Locking Storage-Operationen
|
||||
*
|
||||
* Ermöglicht exklusiven Zugriff auf Dateien für kritische Operationen.
|
||||
* Verhindert Data Corruption bei Concurrent Access.
|
||||
*/
|
||||
interface LockableStorage
|
||||
{
|
||||
/**
|
||||
* Führt Operation mit exklusivem File-Lock aus
|
||||
*/
|
||||
public function withExclusiveLock(string $path, callable $operation): mixed;
|
||||
|
||||
/**
|
||||
* Führt Operation mit Shared-Lock (Read-Lock) aus
|
||||
*/
|
||||
public function withSharedLock(string $path, callable $operation): mixed;
|
||||
|
||||
/**
|
||||
* Prüft ob Datei gesperrt ist
|
||||
*/
|
||||
public function isLocked(string $path): bool;
|
||||
|
||||
/**
|
||||
* Schreibt mit File-Lock
|
||||
*/
|
||||
public function putWithLock(string $path, string $content): void;
|
||||
|
||||
/**
|
||||
* Update mit File-Lock
|
||||
*/
|
||||
public function updateWithLock(string $path, callable $updateCallback): void;
|
||||
|
||||
/**
|
||||
* Append mit File-Lock
|
||||
*/
|
||||
public function appendWithLock(string $path, string $content): void;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Filesystem;
|
||||
@@ -13,6 +14,10 @@ final class LoggableStorage implements Storage
|
||||
{
|
||||
use StorageTrait;
|
||||
|
||||
public PermissionChecker $permissions { get => $this->storage->permissions; }
|
||||
|
||||
public \App\Framework\Async\FiberManager $fiberManager { get => $this->storage->fiberManager; }
|
||||
|
||||
public function __construct(
|
||||
private readonly Storage $storage,
|
||||
private ?DefaultLogger $logger = null
|
||||
@@ -23,6 +28,7 @@ final class LoggableStorage implements Storage
|
||||
public function get(string $path): string
|
||||
{
|
||||
$this->logger->debug("Lese Datei: {$path}");
|
||||
|
||||
return $this->storage->get($path);
|
||||
}
|
||||
|
||||
@@ -36,6 +42,7 @@ final class LoggableStorage implements Storage
|
||||
{
|
||||
$exists = $this->storage->exists($path);
|
||||
$this->logger->debug("Prüfe Existenz: {$path} - " . ($exists ? 'existiert' : 'existiert nicht'));
|
||||
|
||||
return $exists;
|
||||
}
|
||||
|
||||
@@ -55,6 +62,7 @@ final class LoggableStorage implements Storage
|
||||
{
|
||||
$size = $this->storage->size($path);
|
||||
$this->logger->debug("Dateigröße: {$path} - {$size} Bytes");
|
||||
|
||||
return $size;
|
||||
}
|
||||
|
||||
@@ -62,6 +70,7 @@ final class LoggableStorage implements Storage
|
||||
{
|
||||
$lastModified = $this->storage->lastModified($path);
|
||||
$this->logger->debug("Letzte Änderung: {$path} - " . date('Y-m-d H:i:s', $lastModified));
|
||||
|
||||
return $lastModified;
|
||||
}
|
||||
|
||||
@@ -69,6 +78,7 @@ final class LoggableStorage implements Storage
|
||||
{
|
||||
$mimeType = $this->storage->getMimeType($path);
|
||||
$this->logger->debug("MIME-Typ: {$path} - {$mimeType}");
|
||||
|
||||
return $mimeType;
|
||||
}
|
||||
|
||||
@@ -76,6 +86,7 @@ final class LoggableStorage implements Storage
|
||||
{
|
||||
$isReadable = $this->storage->isReadable($path);
|
||||
$this->logger->debug("Lesbar: {$path} - " . ($isReadable ? 'ja' : 'nein'));
|
||||
|
||||
return $isReadable;
|
||||
}
|
||||
|
||||
@@ -83,6 +94,7 @@ final class LoggableStorage implements Storage
|
||||
{
|
||||
$isWritable = $this->storage->isWritable($path);
|
||||
$this->logger->debug("Schreibbar: {$path} - " . ($isWritable ? 'ja' : 'nein'));
|
||||
|
||||
return $isWritable;
|
||||
}
|
||||
|
||||
@@ -91,6 +103,7 @@ final class LoggableStorage implements Storage
|
||||
$this->logger->debug("Liste Verzeichnis: {$directory}");
|
||||
$files = $this->storage->listDirectory($directory);
|
||||
$this->logger->debug("Gefundene Dateien: " . count($files));
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
@@ -99,4 +112,52 @@ final class LoggableStorage implements Storage
|
||||
$this->logger->debug("Erstelle Verzeichnis: {$path}");
|
||||
$this->storage->createDirectory($path, $permissions, $recursive);
|
||||
}
|
||||
|
||||
public function file(string $path): File
|
||||
{
|
||||
$this->logger->debug("Erstelle File-Objekt: {$path}");
|
||||
|
||||
return $this->storage->file($path);
|
||||
}
|
||||
|
||||
public function directory(string $path): Directory
|
||||
{
|
||||
$this->logger->debug("Erstelle Directory-Objekt: {$path}");
|
||||
|
||||
return $this->storage->directory($path);
|
||||
}
|
||||
|
||||
public function batch(array $operations): array
|
||||
{
|
||||
$this->logger->debug("Führe Batch-Operation aus mit " . count($operations) . " Operationen");
|
||||
$results = $this->storage->batch($operations);
|
||||
$this->logger->debug("Batch-Operation abgeschlossen");
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
public function getMultiple(array $paths): array
|
||||
{
|
||||
$this->logger->debug("Lese " . count($paths) . " Dateien parallel");
|
||||
$results = $this->storage->getMultiple($paths);
|
||||
$this->logger->debug("Parallel-Lesen abgeschlossen");
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
public function putMultiple(array $files): void
|
||||
{
|
||||
$this->logger->debug("Schreibe " . count($files) . " Dateien parallel");
|
||||
$this->storage->putMultiple($files);
|
||||
$this->logger->debug("Parallel-Schreiben abgeschlossen");
|
||||
}
|
||||
|
||||
public function getMetadataMultiple(array $paths): array
|
||||
{
|
||||
$this->logger->debug("Lade Metadaten für " . count($paths) . " Dateien parallel");
|
||||
$results = $this->storage->getMetadataMultiple($paths);
|
||||
$this->logger->debug("Metadaten-Laden abgeschlossen");
|
||||
|
||||
return $results;
|
||||
}
|
||||
}
|
||||
|
||||
162
src/Framework/Filesystem/PermissionChecker.php
Normal file
162
src/Framework/Filesystem/PermissionChecker.php
Normal file
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Filesystem;
|
||||
|
||||
/**
|
||||
* Helper-Klasse für Filesystem-Permissions-Checks
|
||||
*/
|
||||
final readonly class PermissionChecker
|
||||
{
|
||||
public function __construct(
|
||||
private Storage $storage
|
||||
) {
|
||||
}
|
||||
|
||||
private function resolvePath(string $path): string
|
||||
{
|
||||
// Use reflection to access resolvePath from FileStorage
|
||||
// or implement basic path resolution logic
|
||||
if (method_exists($this->storage, 'resolvePath')) {
|
||||
$reflection = new \ReflectionMethod($this->storage, 'resolvePath');
|
||||
|
||||
return $reflection->invoke($this->storage, $path);
|
||||
}
|
||||
|
||||
// Fallback: return path as-is if storage doesn't have resolvePath
|
||||
return $path;
|
||||
}
|
||||
|
||||
public function isDirectoryWritable(string $path): bool
|
||||
{
|
||||
$resolvedPath = $this->resolvePath($path);
|
||||
if (! is_dir($resolvedPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return is_writable($resolvedPath);
|
||||
}
|
||||
|
||||
public function canCreateDirectory(string $path): bool
|
||||
{
|
||||
$resolvedPath = $this->resolvePath($path);
|
||||
// Prüfe, ob Parent-Directory existiert und schreibbar ist
|
||||
$parentDir = dirname($resolvedPath);
|
||||
|
||||
if (! is_dir($parentDir)) {
|
||||
return $this->canCreateDirectory(dirname($path));
|
||||
}
|
||||
|
||||
return is_writable($parentDir);
|
||||
}
|
||||
|
||||
public function canWriteFile(string $path): bool
|
||||
{
|
||||
$resolvedPath = $this->resolvePath($path);
|
||||
// Wenn Datei existiert, prüfe ob sie schreibbar ist
|
||||
if (file_exists($resolvedPath)) {
|
||||
return is_writable($resolvedPath);
|
||||
}
|
||||
|
||||
// Wenn Datei nicht existiert, prüfe ob Directory schreibbar ist
|
||||
$dir = dirname($path);
|
||||
|
||||
return $this->isDirectoryWritable($dir) || $this->canCreateDirectory($dir);
|
||||
}
|
||||
|
||||
public function canReadFile(string $path): bool
|
||||
{
|
||||
$resolvedPath = $this->resolvePath($path);
|
||||
|
||||
return file_exists($resolvedPath) && is_readable($resolvedPath);
|
||||
}
|
||||
|
||||
public function canDeleteFile(string $path): bool
|
||||
{
|
||||
$resolvedPath = $this->resolvePath($path);
|
||||
if (! file_exists($resolvedPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Datei muss schreibbar sein UND Directory muss schreibbar sein
|
||||
$dir = dirname($resolvedPath);
|
||||
|
||||
return is_writable($resolvedPath) && is_writable($dir);
|
||||
}
|
||||
|
||||
public function getPermissionString(string $path): string
|
||||
{
|
||||
$resolvedPath = $this->resolvePath($path);
|
||||
if (! file_exists($resolvedPath)) {
|
||||
return 'not found';
|
||||
}
|
||||
|
||||
$perms = fileperms($resolvedPath);
|
||||
$info = '';
|
||||
|
||||
// Type
|
||||
if (($perms & 0xC000) == 0xC000) {
|
||||
$info = 's'; // Socket
|
||||
} elseif (($perms & 0xA000) == 0xA000) {
|
||||
$info = 'l'; // Symbolic Link
|
||||
} elseif (($perms & 0x8000) == 0x8000) {
|
||||
$info = 'f'; // Regular file
|
||||
} elseif (($perms & 0x6000) == 0x6000) {
|
||||
$info = 'b'; // Block special
|
||||
} elseif (($perms & 0x4000) == 0x4000) {
|
||||
$info = 'd'; // Directory
|
||||
} elseif (($perms & 0x2000) == 0x2000) {
|
||||
$info = 'c'; // Character special
|
||||
} elseif (($perms & 0x1000) == 0x1000) {
|
||||
$info = 'p'; // FIFO pipe
|
||||
} else {
|
||||
$info = 'u'; // Unknown
|
||||
}
|
||||
|
||||
// Owner
|
||||
$info .= (($perms & 0x0100) ? 'r' : '-');
|
||||
$info .= (($perms & 0x0080) ? 'w' : '-');
|
||||
$info .= (($perms & 0x0040) ?
|
||||
(($perms & 0x0800) ? 's' : 'x') :
|
||||
(($perms & 0x0800) ? 'S' : '-'));
|
||||
|
||||
// Group
|
||||
$info .= (($perms & 0x0020) ? 'r' : '-');
|
||||
$info .= (($perms & 0x0010) ? 'w' : '-');
|
||||
$info .= (($perms & 0x0008) ?
|
||||
(($perms & 0x0400) ? 's' : 'x') :
|
||||
(($perms & 0x0400) ? 'S' : '-'));
|
||||
|
||||
// World
|
||||
$info .= (($perms & 0x0004) ? 'r' : '-');
|
||||
$info .= (($perms & 0x0002) ? 'w' : '-');
|
||||
$info .= (($perms & 0x0001) ?
|
||||
(($perms & 0x0200) ? 't' : 'x') :
|
||||
(($perms & 0x0200) ? 'T' : '-'));
|
||||
|
||||
return $info;
|
||||
}
|
||||
|
||||
public function getDiagnosticInfo(string $path): array
|
||||
{
|
||||
$resolvedPath = $this->resolvePath($path);
|
||||
$realPath = realpath($resolvedPath) ?: $resolvedPath;
|
||||
|
||||
return [
|
||||
'path' => $path,
|
||||
'resolved_path' => $resolvedPath,
|
||||
'real_path' => $realPath,
|
||||
'exists' => file_exists($resolvedPath),
|
||||
'is_file' => is_file($resolvedPath),
|
||||
'is_dir' => is_dir($resolvedPath),
|
||||
'is_readable' => is_readable($resolvedPath),
|
||||
'is_writable' => is_writable($resolvedPath),
|
||||
'permissions' => $this->getPermissionString($path),
|
||||
'owner' => file_exists($resolvedPath) ? posix_getpwuid(fileowner($resolvedPath))['name'] ?? 'unknown' : null,
|
||||
'group' => file_exists($resolvedPath) ? posix_getgrgid(filegroup($resolvedPath))['name'] ?? 'unknown' : null,
|
||||
'parent_dir' => dirname($resolvedPath),
|
||||
'parent_writable' => is_dir(dirname($resolvedPath)) ? is_writable(dirname($resolvedPath)) : false,
|
||||
];
|
||||
}
|
||||
}
|
||||
31
src/Framework/Filesystem/Serializer.php
Normal file
31
src/Framework/Filesystem/Serializer.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Filesystem;
|
||||
|
||||
/**
|
||||
* Interface für Daten-Serializer
|
||||
*/
|
||||
interface Serializer
|
||||
{
|
||||
/**
|
||||
* Serialisiert Daten zu String
|
||||
*/
|
||||
public function serialize(mixed $data): string;
|
||||
|
||||
/**
|
||||
* Deserialisiert String zu Daten
|
||||
*/
|
||||
public function deserialize(string $data): mixed;
|
||||
|
||||
/**
|
||||
* Gibt den MIME-Type für serialisierte Daten zurück
|
||||
*/
|
||||
public function getMimeType(): string;
|
||||
|
||||
/**
|
||||
* Gibt die Standard-Dateiendung zurück
|
||||
*/
|
||||
public function getFileExtension(): string;
|
||||
}
|
||||
96
src/Framework/Filesystem/Serializers/CsvSerializer.php
Normal file
96
src/Framework/Filesystem/Serializers/CsvSerializer.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Filesystem\Serializers;
|
||||
|
||||
use App\Framework\Filesystem\Serializer;
|
||||
|
||||
/**
|
||||
* CSV-Serializer für tabellarische Daten
|
||||
*/
|
||||
final readonly class CsvSerializer implements Serializer
|
||||
{
|
||||
public function __construct(
|
||||
private string $delimiter = ',',
|
||||
private string $enclosure = '"',
|
||||
private string $escape = '\\'
|
||||
) {
|
||||
}
|
||||
|
||||
public function serialize(mixed $data): string
|
||||
{
|
||||
if (! is_array($data)) {
|
||||
throw new \InvalidArgumentException('CSV serializer requires array data');
|
||||
}
|
||||
|
||||
if (empty($data)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$output = fopen('php://temp', 'r+');
|
||||
|
||||
// Wenn es ein assoziatives Array ist, Header schreiben
|
||||
if (is_array($data[0] ?? null)) {
|
||||
$headers = array_keys($data[0]);
|
||||
fputcsv($output, $headers, $this->delimiter, $this->enclosure, $this->escape);
|
||||
}
|
||||
|
||||
foreach ($data as $row) {
|
||||
if (is_array($row)) {
|
||||
fputcsv($output, $row, $this->delimiter, $this->enclosure, $this->escape);
|
||||
} else {
|
||||
fputcsv($output, [$row], $this->delimiter, $this->enclosure, $this->escape);
|
||||
}
|
||||
}
|
||||
|
||||
rewind($output);
|
||||
$csv = stream_get_contents($output);
|
||||
fclose($output);
|
||||
|
||||
return $csv;
|
||||
}
|
||||
|
||||
public function deserialize(string $data): mixed
|
||||
{
|
||||
if (empty($data)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$lines = str_getcsv($data, "\n");
|
||||
$result = [];
|
||||
$headers = null;
|
||||
|
||||
foreach ($lines as $line) {
|
||||
if (empty(trim($line))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$row = str_getcsv($line, $this->delimiter, $this->enclosure, $this->escape);
|
||||
|
||||
if ($headers === null) {
|
||||
$headers = $row;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (count($row) === count($headers)) {
|
||||
$result[] = array_combine($headers, $row);
|
||||
} else {
|
||||
$result[] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function getMimeType(): string
|
||||
{
|
||||
return 'text/csv';
|
||||
}
|
||||
|
||||
public function getFileExtension(): string
|
||||
{
|
||||
return 'csv';
|
||||
}
|
||||
}
|
||||
67
src/Framework/Filesystem/Serializers/JsonSerializer.php
Normal file
67
src/Framework/Filesystem/Serializers/JsonSerializer.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Filesystem\Serializers;
|
||||
|
||||
use App\Framework\Filesystem\Serializer;
|
||||
|
||||
/**
|
||||
* JSON-Serializer für strukturierte Daten
|
||||
*/
|
||||
final readonly class JsonSerializer implements Serializer
|
||||
{
|
||||
public function __construct(
|
||||
private int $flags = JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
|
||||
private int $depth = 512
|
||||
) {
|
||||
}
|
||||
|
||||
public function serialize(mixed $data): string
|
||||
{
|
||||
$json = json_encode($data, $this->flags, $this->depth);
|
||||
|
||||
if ($json === false) {
|
||||
throw new \RuntimeException('Failed to encode JSON: ' . json_last_error_msg());
|
||||
}
|
||||
|
||||
return $json;
|
||||
}
|
||||
|
||||
public function deserialize(string $data): mixed
|
||||
{
|
||||
if (empty($data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return json_decode($data, true, $this->depth, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
public function getMimeType(): string
|
||||
{
|
||||
return 'application/json';
|
||||
}
|
||||
|
||||
public function getFileExtension(): string
|
||||
{
|
||||
return 'json';
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory für verschiedene JSON-Modi
|
||||
*/
|
||||
public static function compact(): self
|
||||
{
|
||||
return new self(JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
public static function pretty(): self
|
||||
{
|
||||
return new self(JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
public static function minimal(): self
|
||||
{
|
||||
return new self(0);
|
||||
}
|
||||
}
|
||||
64
src/Framework/Filesystem/Serializers/PhpSerializer.php
Normal file
64
src/Framework/Filesystem/Serializers/PhpSerializer.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Filesystem\Serializers;
|
||||
|
||||
use App\Framework\Filesystem\Serializer;
|
||||
|
||||
/**
|
||||
* PHP-native Serializer für Objekte und komplexe Datenstrukturen
|
||||
*/
|
||||
final readonly class PhpSerializer implements Serializer
|
||||
{
|
||||
public function __construct(
|
||||
private array $options = []
|
||||
) {
|
||||
}
|
||||
|
||||
public function serialize(mixed $data): string
|
||||
{
|
||||
return serialize($data);
|
||||
}
|
||||
|
||||
public function deserialize(string $data): mixed
|
||||
{
|
||||
if (empty($data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$result = unserialize($data, $this->options);
|
||||
|
||||
if ($result === false && $data !== serialize(false)) {
|
||||
throw new \RuntimeException('Failed to unserialize PHP data');
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function getMimeType(): string
|
||||
{
|
||||
return 'application/x-php-serialized';
|
||||
}
|
||||
|
||||
public function getFileExtension(): string
|
||||
{
|
||||
return 'ser';
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory für sichere Deserialisierung (nur bestimmte Klassen erlaubt)
|
||||
*/
|
||||
public static function safe(array $allowedClasses = []): self
|
||||
{
|
||||
return new self(['allowed_classes' => $allowedClasses]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory für unsichere Deserialisierung (alle Klassen erlaubt)
|
||||
*/
|
||||
public static function unsafe(): self
|
||||
{
|
||||
return new self(['allowed_classes' => true]);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Filesystem;
|
||||
|
||||
use App\Framework\Async\FiberManager;
|
||||
|
||||
interface Storage
|
||||
{
|
||||
/**
|
||||
* Permissions-Checker für dieses Storage
|
||||
*/
|
||||
public PermissionChecker $permissions { get; }
|
||||
|
||||
/**
|
||||
* Fiber-Manager für asynchrone Operationen
|
||||
*/
|
||||
public FiberManager $fiberManager { get; }
|
||||
|
||||
/**
|
||||
* Liest den Inhalt einer Datei
|
||||
*/
|
||||
@@ -75,4 +89,35 @@ interface Storage
|
||||
* Erstellt ein Directory-Objekt mit Lazy-Loading
|
||||
*/
|
||||
public function directory(string $path): Directory;
|
||||
|
||||
/**
|
||||
* Führt mehrere Operationen parallel aus (explizite Batch-Operation)
|
||||
*
|
||||
* @param array<string, callable> $operations
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function batch(array $operations): array;
|
||||
|
||||
/**
|
||||
* Lädt mehrere Dateien parallel
|
||||
*
|
||||
* @param array<string> $paths
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function getMultiple(array $paths): array;
|
||||
|
||||
/**
|
||||
* Speichert mehrere Dateien parallel
|
||||
*
|
||||
* @param array<string, string> $files [path => content]
|
||||
*/
|
||||
public function putMultiple(array $files): void;
|
||||
|
||||
/**
|
||||
* Lädt Metadata für mehrere Dateien parallel
|
||||
*
|
||||
* @param array<string> $paths
|
||||
* @return array<string, FileMetadata>
|
||||
*/
|
||||
public function getMetadataMultiple(array $paths): array;
|
||||
}
|
||||
|
||||
40
src/Framework/Filesystem/StorageFactory.php
Normal file
40
src/Framework/Filesystem/StorageFactory.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Filesystem;
|
||||
|
||||
/**
|
||||
* Factory für Storage-Instanzen
|
||||
*
|
||||
* Ermöglicht flexible Storage-Erstellung basierend auf Konfiguration
|
||||
* und macht es einfach, neue Storage-Typen hinzuzufügen.
|
||||
*/
|
||||
final class StorageFactory
|
||||
{
|
||||
public static function create(array $config): Storage
|
||||
{
|
||||
$type = $config['type'] ?? 'file';
|
||||
|
||||
return match ($type) {
|
||||
'file' => new FileStorage($config['path'] ?? '/'),
|
||||
'memory' => new MemoryStorage(),
|
||||
// 'redis' => new RedisStorage($config),
|
||||
// 's3' => new S3Storage($config),
|
||||
default => throw new \InvalidArgumentException("Unknown storage type: {$type}")
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string>
|
||||
*/
|
||||
public static function getSupportedTypes(): array
|
||||
{
|
||||
return ['file', 'memory'];
|
||||
}
|
||||
|
||||
public static function isTypeSupported(string $type): bool
|
||||
{
|
||||
return in_array($type, self::getSupportedTypes(), true);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Filesystem;
|
||||
|
||||
54
src/Framework/Filesystem/StreamableStorage.php
Normal file
54
src/Framework/Filesystem/StreamableStorage.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Filesystem;
|
||||
|
||||
/**
|
||||
* Interface für Stream-basierte Storage-Operationen
|
||||
*
|
||||
* Ermöglicht das Arbeiten mit großen Dateien ohne komplettes Laden in den Speicher.
|
||||
* Ideal für große Analytics-Dateien, Logs und Media-Files.
|
||||
*/
|
||||
interface StreamableStorage
|
||||
{
|
||||
/**
|
||||
* Öffnet einen Read-Stream für eine Datei
|
||||
*
|
||||
* @return resource
|
||||
*/
|
||||
public function readStream(string $path);
|
||||
|
||||
/**
|
||||
* Öffnet einen Write-Stream für eine Datei
|
||||
*
|
||||
* @return resource
|
||||
*/
|
||||
public function writeStream(string $path);
|
||||
|
||||
/**
|
||||
* Öffnet einen Append-Stream für eine Datei
|
||||
*
|
||||
* @return resource
|
||||
*/
|
||||
public function appendStream(string $path);
|
||||
|
||||
/**
|
||||
* Schreibt von einem Stream in eine Datei
|
||||
*
|
||||
* @param resource $stream
|
||||
*/
|
||||
public function putStream(string $path, $stream): void;
|
||||
|
||||
/**
|
||||
* Kopiert eine Datei Stream-basiert (für große Dateien)
|
||||
*/
|
||||
public function copyStream(string $source, string $destination): void;
|
||||
|
||||
/**
|
||||
* Liest Datei Zeile für Zeile (Generator für Memory-Effizienz)
|
||||
*
|
||||
* @return \Generator<string>
|
||||
*/
|
||||
public function readLines(string $path): \Generator;
|
||||
}
|
||||
62
src/Framework/Filesystem/Traits/AppendableStorageTrait.php
Normal file
62
src/Framework/Filesystem/Traits/AppendableStorageTrait.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Filesystem\Traits;
|
||||
|
||||
use App\Framework\Filesystem\Exceptions\FileWriteException;
|
||||
|
||||
/**
|
||||
* Trait für Append Storage-Operationen
|
||||
*
|
||||
* Implementiert Append-Funktionalität für Logs,
|
||||
* Analytics und Stream-artige Daten.
|
||||
*/
|
||||
trait AppendableStorageTrait
|
||||
{
|
||||
public function append(string $path, string $content): void
|
||||
{
|
||||
$resolvedPath = $this->resolvePath($path);
|
||||
$dir = dirname($resolvedPath);
|
||||
if (! is_dir($dir)) {
|
||||
$this->createDirectory($path);
|
||||
}
|
||||
|
||||
if (@file_put_contents($resolvedPath, $content, FILE_APPEND | LOCK_EX) === false) {
|
||||
throw new FileWriteException($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function appendLine(string $path, string $line): void
|
||||
{
|
||||
$this->append($path, $line . PHP_EOL);
|
||||
}
|
||||
|
||||
public function appendJson(string $path, array $data): void
|
||||
{
|
||||
$this->appendLine($path, json_encode($data));
|
||||
}
|
||||
|
||||
public function appendCsv(string $path, array $row): void
|
||||
{
|
||||
$resolvedPath = $this->resolvePath($path);
|
||||
$handle = @fopen($resolvedPath, 'a');
|
||||
if ($handle === false) {
|
||||
throw new FileWriteException($path);
|
||||
}
|
||||
|
||||
if (@fputcsv($handle, $row) === false) {
|
||||
@fclose($handle);
|
||||
|
||||
throw new FileWriteException($path);
|
||||
}
|
||||
|
||||
@fclose($handle);
|
||||
}
|
||||
|
||||
public function appendWithTimestamp(string $path, string $content): void
|
||||
{
|
||||
$timestampedContent = '[' . date('Y-m-d H:i:s') . '] ' . $content;
|
||||
$this->appendLine($path, $timestampedContent);
|
||||
}
|
||||
}
|
||||
45
src/Framework/Filesystem/Traits/AtomicStorageTrait.php
Normal file
45
src/Framework/Filesystem/Traits/AtomicStorageTrait.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Filesystem\Traits;
|
||||
|
||||
use App\Framework\Filesystem\Exceptions\FileWriteException;
|
||||
|
||||
/**
|
||||
* Trait für Atomic Storage-Operationen
|
||||
*
|
||||
* Implementiert atomare Schreiboperationen durch temporäre Dateien
|
||||
* und rename()-Operation für Data Consistency.
|
||||
*/
|
||||
trait AtomicStorageTrait
|
||||
{
|
||||
public function putAtomic(string $path, string $content): void
|
||||
{
|
||||
$tempPath = $path . '.tmp.' . uniqid();
|
||||
$this->put($tempPath, $content);
|
||||
|
||||
$resolvedPath = $this->resolvePath($path);
|
||||
$resolvedTempPath = $this->resolvePath($tempPath);
|
||||
|
||||
if (! @rename($resolvedTempPath, $resolvedPath)) {
|
||||
@unlink($resolvedTempPath);
|
||||
|
||||
throw new FileWriteException($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function updateAtomic(string $path, callable $updateCallback): void
|
||||
{
|
||||
$originalContent = $this->exists($path) ? $this->get($path) : '';
|
||||
$newContent = $updateCallback($originalContent);
|
||||
$this->putAtomic($path, $newContent);
|
||||
}
|
||||
|
||||
public function updateJsonAtomic(string $path, callable $updateCallback): void
|
||||
{
|
||||
$originalData = $this->exists($path) ? json_decode($this->get($path), true) ?? [] : [];
|
||||
$newData = $updateCallback($originalData);
|
||||
$this->putAtomic($path, json_encode($newData, JSON_PRETTY_PRINT));
|
||||
}
|
||||
}
|
||||
125
src/Framework/Filesystem/Traits/CompressibleStorageTrait.php
Normal file
125
src/Framework/Filesystem/Traits/CompressibleStorageTrait.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Filesystem\Traits;
|
||||
|
||||
use App\Framework\Filesystem\Exceptions\FileNotFoundException;
|
||||
use App\Framework\Filesystem\Exceptions\FileReadException;
|
||||
use App\Framework\Filesystem\Exceptions\FileWriteException;
|
||||
|
||||
/**
|
||||
* Trait für Compression Storage-Operationen
|
||||
*
|
||||
* Implementiert automatische Komprimierung für große Dateien,
|
||||
* ideal für Analytics-Daten, Logs und Archive.
|
||||
*/
|
||||
trait CompressibleStorageTrait
|
||||
{
|
||||
public function putCompressed(string $path, string $content, string $algorithm = 'gzip'): void
|
||||
{
|
||||
$compressedContent = match ($algorithm) {
|
||||
'gzip' => gzencode($content),
|
||||
'deflate' => gzdeflate($content),
|
||||
'bzip2' => bzcompress($content),
|
||||
default => throw new \InvalidArgumentException("Unsupported compression algorithm: {$algorithm}")
|
||||
};
|
||||
|
||||
if ($compressedContent === false) {
|
||||
throw new FileWriteException($path);
|
||||
}
|
||||
|
||||
$this->put($path . '.' . $algorithm, $compressedContent);
|
||||
}
|
||||
|
||||
public function getCompressed(string $path): string
|
||||
{
|
||||
// Automatische Erkennung der Komprimierung basierend auf Dateiendung
|
||||
$pathInfo = pathinfo($path);
|
||||
$algorithm = $pathInfo['extension'] ?? '';
|
||||
|
||||
if (! in_array($algorithm, ['gzip', 'gz', 'deflate', 'bzip2', 'bz2'])) {
|
||||
throw new \InvalidArgumentException("Cannot detect compression algorithm from path: {$path}");
|
||||
}
|
||||
|
||||
if (! $this->exists($path)) {
|
||||
throw new FileNotFoundException($path);
|
||||
}
|
||||
|
||||
$compressedContent = $this->get($path);
|
||||
|
||||
$content = match ($algorithm) {
|
||||
'gzip', 'gz' => gzdecode($compressedContent),
|
||||
'deflate' => gzinflate($compressedContent),
|
||||
'bzip2', 'bz2' => bzdecompress($compressedContent),
|
||||
default => throw new \InvalidArgumentException("Unsupported compression algorithm: {$algorithm}")
|
||||
};
|
||||
|
||||
if ($content === false) {
|
||||
throw new FileReadException($path);
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
public function compress(string $path, string $algorithm = 'gzip'): void
|
||||
{
|
||||
if (! $this->exists($path)) {
|
||||
throw new FileNotFoundException($path);
|
||||
}
|
||||
|
||||
$content = $this->get($path);
|
||||
$compressedPath = $path . '.' . $algorithm;
|
||||
|
||||
$this->putCompressed($path, $content, $algorithm);
|
||||
$this->delete($path);
|
||||
|
||||
// Komprimierte Datei zurück zum ursprünglichen Pfad verschieben
|
||||
$this->copy($compressedPath, $path);
|
||||
$this->delete($compressedPath);
|
||||
}
|
||||
|
||||
public function decompress(string $path): void
|
||||
{
|
||||
$content = $this->getCompressed($path);
|
||||
|
||||
// Entferne Komprimierungs-Extension
|
||||
$pathInfo = pathinfo($path);
|
||||
$algorithm = $pathInfo['extension'] ?? '';
|
||||
$decompressedPath = $pathInfo['dirname'] . '/' . $pathInfo['filename'];
|
||||
|
||||
$this->put($decompressedPath, $content);
|
||||
$this->delete($path);
|
||||
}
|
||||
|
||||
public function isCompressed(string $path): bool
|
||||
{
|
||||
if (! $this->exists($path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$pathInfo = pathinfo($path);
|
||||
$extension = $pathInfo['extension'] ?? '';
|
||||
|
||||
return in_array($extension, ['gzip', 'gz', 'deflate', 'bzip2', 'bz2']);
|
||||
}
|
||||
|
||||
public function getAvailableAlgorithms(): array
|
||||
{
|
||||
$algorithms = [];
|
||||
|
||||
if (function_exists('gzencode')) {
|
||||
$algorithms[] = 'gzip';
|
||||
}
|
||||
|
||||
if (function_exists('gzdeflate')) {
|
||||
$algorithms[] = 'deflate';
|
||||
}
|
||||
|
||||
if (function_exists('bzcompress')) {
|
||||
$algorithms[] = 'bzip2';
|
||||
}
|
||||
|
||||
return $algorithms;
|
||||
}
|
||||
}
|
||||
151
src/Framework/Filesystem/Traits/LockableStorageTrait.php
Normal file
151
src/Framework/Filesystem/Traits/LockableStorageTrait.php
Normal file
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Filesystem\Traits;
|
||||
|
||||
use App\Framework\Filesystem\Exceptions\FileNotFoundException;
|
||||
use App\Framework\Filesystem\Exceptions\FileReadException;
|
||||
use App\Framework\Filesystem\Exceptions\FileWriteException;
|
||||
|
||||
/**
|
||||
* Trait für File-Locking Storage-Operationen
|
||||
*
|
||||
* Implementiert File-Locking für kritische Operationen
|
||||
* und verhindert Data Corruption bei Concurrent Access.
|
||||
*/
|
||||
trait LockableStorageTrait
|
||||
{
|
||||
public function withExclusiveLock(string $path, callable $operation): mixed
|
||||
{
|
||||
$resolvedPath = $this->resolvePath($path);
|
||||
$handle = @fopen($resolvedPath, 'c+');
|
||||
if ($handle === false) {
|
||||
throw new FileWriteException($path);
|
||||
}
|
||||
|
||||
if (! @flock($handle, LOCK_EX)) {
|
||||
@fclose($handle);
|
||||
|
||||
throw new FileWriteException($path);
|
||||
}
|
||||
|
||||
try {
|
||||
return $operation($handle);
|
||||
} finally {
|
||||
@flock($handle, LOCK_UN);
|
||||
@fclose($handle);
|
||||
}
|
||||
}
|
||||
|
||||
public function withSharedLock(string $path, callable $operation): mixed
|
||||
{
|
||||
if (! $this->exists($path)) {
|
||||
throw new FileNotFoundException($path);
|
||||
}
|
||||
|
||||
$resolvedPath = $this->resolvePath($path);
|
||||
$handle = @fopen($resolvedPath, 'r');
|
||||
if ($handle === false) {
|
||||
throw new FileReadException($path);
|
||||
}
|
||||
|
||||
if (! @flock($handle, LOCK_SH)) {
|
||||
@fclose($handle);
|
||||
|
||||
throw new FileReadException($path);
|
||||
}
|
||||
|
||||
try {
|
||||
return $operation($handle);
|
||||
} finally {
|
||||
@flock($handle, LOCK_UN);
|
||||
@fclose($handle);
|
||||
}
|
||||
}
|
||||
|
||||
public function isLocked(string $path): bool
|
||||
{
|
||||
if (! $this->exists($path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$resolvedPath = $this->resolvePath($path);
|
||||
$handle = @fopen($resolvedPath, 'r');
|
||||
if ($handle === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$isLocked = ! @flock($handle, LOCK_EX | LOCK_NB);
|
||||
|
||||
if (! $isLocked) {
|
||||
@flock($handle, LOCK_UN);
|
||||
}
|
||||
|
||||
@fclose($handle);
|
||||
|
||||
return $isLocked;
|
||||
}
|
||||
|
||||
public function putWithLock(string $path, string $content): void
|
||||
{
|
||||
$this->withExclusiveLock($path, function ($handle) use ($content) {
|
||||
if (@ftruncate($handle, 0) === false || @rewind($handle) === false) {
|
||||
throw new FileWriteException('Lock operation failed');
|
||||
}
|
||||
|
||||
if (@fwrite($handle, $content) === false) {
|
||||
throw new FileWriteException('Lock operation failed');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function updateWithLock(string $path, callable $updateCallback): void
|
||||
{
|
||||
$this->withExclusiveLock($path, function ($handle) use ($updateCallback) {
|
||||
$originalContent = '';
|
||||
if (@rewind($handle) !== false) {
|
||||
$originalContent = @stream_get_contents($handle) ?: '';
|
||||
}
|
||||
|
||||
$newContent = $updateCallback($originalContent);
|
||||
|
||||
if (@ftruncate($handle, 0) === false || @rewind($handle) === false) {
|
||||
throw new FileWriteException('Lock operation failed');
|
||||
}
|
||||
|
||||
if (@fwrite($handle, $newContent) === false) {
|
||||
throw new FileWriteException('Lock operation failed');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function appendWithLock(string $path, string $content): void
|
||||
{
|
||||
$resolvedPath = $this->resolvePath($path);
|
||||
$dir = dirname($resolvedPath);
|
||||
if (! is_dir($dir)) {
|
||||
$this->createDirectory($path);
|
||||
}
|
||||
|
||||
$handle = @fopen($resolvedPath, 'a');
|
||||
if ($handle === false) {
|
||||
throw new FileWriteException($path);
|
||||
}
|
||||
|
||||
if (! @flock($handle, LOCK_EX)) {
|
||||
@fclose($handle);
|
||||
|
||||
throw new FileWriteException($path);
|
||||
}
|
||||
|
||||
try {
|
||||
if (@fwrite($handle, $content) === false) {
|
||||
throw new FileWriteException($path);
|
||||
}
|
||||
} finally {
|
||||
@flock($handle, LOCK_UN);
|
||||
@fclose($handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
98
src/Framework/Filesystem/Traits/StreamableStorageTrait.php
Normal file
98
src/Framework/Filesystem/Traits/StreamableStorageTrait.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Filesystem\Traits;
|
||||
|
||||
use App\Framework\Filesystem\Exceptions\FileNotFoundException;
|
||||
use App\Framework\Filesystem\Exceptions\FileReadException;
|
||||
use App\Framework\Filesystem\Exceptions\FileWriteException;
|
||||
|
||||
/**
|
||||
* Trait für Stream-basierte Storage-Operationen
|
||||
*
|
||||
* Implementiert Stream-Funktionalität für große Dateien
|
||||
* ohne komplettes Laden in den Speicher.
|
||||
*/
|
||||
trait StreamableStorageTrait
|
||||
{
|
||||
public function readStream(string $path)
|
||||
{
|
||||
if (! $this->exists($path)) {
|
||||
throw new FileNotFoundException($path);
|
||||
}
|
||||
|
||||
$resolvedPath = $this->resolvePath($path);
|
||||
$stream = @fopen($resolvedPath, 'r');
|
||||
if ($stream === false) {
|
||||
throw new FileReadException($path);
|
||||
}
|
||||
|
||||
return $stream;
|
||||
}
|
||||
|
||||
public function writeStream(string $path)
|
||||
{
|
||||
$resolvedPath = $this->resolvePath($path);
|
||||
$dir = dirname($resolvedPath);
|
||||
if (! is_dir($dir)) {
|
||||
$this->createDirectory($path);
|
||||
}
|
||||
|
||||
$stream = @fopen($resolvedPath, 'w');
|
||||
if ($stream === false) {
|
||||
throw new FileWriteException($path);
|
||||
}
|
||||
|
||||
return $stream;
|
||||
}
|
||||
|
||||
public function appendStream(string $path)
|
||||
{
|
||||
$resolvedPath = $this->resolvePath($path);
|
||||
$dir = dirname($resolvedPath);
|
||||
if (! is_dir($dir)) {
|
||||
$this->createDirectory($path);
|
||||
}
|
||||
|
||||
$stream = @fopen($resolvedPath, 'a');
|
||||
if ($stream === false) {
|
||||
throw new FileWriteException($path);
|
||||
}
|
||||
|
||||
return $stream;
|
||||
}
|
||||
|
||||
public function putStream(string $path, $stream): void
|
||||
{
|
||||
$writeStream = $this->writeStream($path);
|
||||
|
||||
if (@stream_copy_to_stream($stream, $writeStream) === false) {
|
||||
@fclose($writeStream);
|
||||
|
||||
throw new FileWriteException($path);
|
||||
}
|
||||
|
||||
@fclose($writeStream);
|
||||
}
|
||||
|
||||
public function copyStream(string $source, string $destination): void
|
||||
{
|
||||
$sourceStream = $this->readStream($source);
|
||||
$this->putStream($destination, $sourceStream);
|
||||
@fclose($sourceStream);
|
||||
}
|
||||
|
||||
public function readLines(string $path): \Generator
|
||||
{
|
||||
$stream = $this->readStream($path);
|
||||
|
||||
try {
|
||||
while (($line = @fgets($stream)) !== false) {
|
||||
yield rtrim($line, "\r\n");
|
||||
}
|
||||
} finally {
|
||||
@fclose($stream);
|
||||
}
|
||||
}
|
||||
}
|
||||
290
src/Framework/Filesystem/ValueObjects/FileCollection.php
Normal file
290
src/Framework/Filesystem/ValueObjects/FileCollection.php
Normal file
@@ -0,0 +1,290 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Filesystem\ValueObjects;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\Filesystem\File;
|
||||
use ArrayIterator;
|
||||
use Countable;
|
||||
use InvalidArgumentException;
|
||||
use IteratorAggregate;
|
||||
|
||||
/**
|
||||
* Type-safe collection of File objects
|
||||
* @implements IteratorAggregate<int, File>
|
||||
*/
|
||||
final readonly class FileCollection implements IteratorAggregate, Countable
|
||||
{
|
||||
/** @var File[] */
|
||||
private array $files;
|
||||
|
||||
/**
|
||||
* @param File[] $files
|
||||
*/
|
||||
private function __construct(array $files)
|
||||
{
|
||||
// Validate all items are File objects
|
||||
foreach ($files as $file) {
|
||||
if (! $file instanceof File) {
|
||||
throw new InvalidArgumentException('All items must be File objects');
|
||||
}
|
||||
}
|
||||
|
||||
$this->files = array_values($files); // Re-index array
|
||||
}
|
||||
|
||||
/**
|
||||
* @param File[] $files
|
||||
*/
|
||||
public static function fromFiles(array $files): self
|
||||
{
|
||||
return new self($files);
|
||||
}
|
||||
|
||||
public static function empty(): self
|
||||
{
|
||||
return new self([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from SplFileInfo objects
|
||||
* @param \SplFileInfo[] $splFiles
|
||||
*/
|
||||
public static function fromSplFiles(array $splFiles): self
|
||||
{
|
||||
$files = array_map(fn (\SplFileInfo $splFile) => File::fromSplFileInfo($splFile), $splFiles);
|
||||
|
||||
return new self($files);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get iterator for foreach loops
|
||||
* @return ArrayIterator<int, File>
|
||||
*/
|
||||
public function getIterator(): ArrayIterator
|
||||
{
|
||||
return new ArrayIterator($this->files);
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
return count($this->files);
|
||||
}
|
||||
|
||||
public function isEmpty(): bool
|
||||
{
|
||||
return empty($this->files);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all files as array
|
||||
* @return File[]
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return $this->files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter files by pattern
|
||||
*/
|
||||
public function filterByPattern(FilePattern $pattern): self
|
||||
{
|
||||
$filtered = array_filter(
|
||||
$this->files,
|
||||
fn (File $file) => $pattern->matches($file->getBasename())
|
||||
);
|
||||
|
||||
return new self($filtered);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter files by extension
|
||||
*/
|
||||
public function filterByExtension(string $extension): self
|
||||
{
|
||||
$extension = ltrim($extension, '.');
|
||||
$filtered = array_filter(
|
||||
$this->files,
|
||||
fn (File $file) => $file->getExtension() === $extension
|
||||
);
|
||||
|
||||
return new self($filtered);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get files modified after timestamp
|
||||
*/
|
||||
public function modifiedAfter(Timestamp $timestamp): self
|
||||
{
|
||||
$filtered = array_filter(
|
||||
$this->files,
|
||||
fn (File $file) => $file->lastModified > $timestamp->getValue()
|
||||
);
|
||||
|
||||
return new self($filtered);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get files modified before timestamp
|
||||
*/
|
||||
public function modifiedBefore(Timestamp $timestamp): self
|
||||
{
|
||||
$filtered = array_filter(
|
||||
$this->files,
|
||||
fn (File $file) => $file->lastModified < $timestamp->getValue()
|
||||
);
|
||||
|
||||
return new self($filtered);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort files by modification time (newest first)
|
||||
*/
|
||||
public function sortByModificationTime(bool $descending = true): self
|
||||
{
|
||||
$files = $this->files;
|
||||
usort($files, function (File $a, File $b) use ($descending) {
|
||||
$comparison = $a->lastModified <=> $b->lastModified;
|
||||
|
||||
return $descending ? -$comparison : $comparison;
|
||||
});
|
||||
|
||||
return new self($files);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort files by name
|
||||
*/
|
||||
public function sortByName(bool $ascending = true): self
|
||||
{
|
||||
$files = $this->files;
|
||||
usort($files, function (File $a, File $b) use ($ascending) {
|
||||
$comparison = strcmp($a->getBasename(), $b->getBasename());
|
||||
|
||||
return $ascending ? $comparison : -$comparison;
|
||||
});
|
||||
|
||||
return new self($files);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the most recently modified file
|
||||
*/
|
||||
public function getLatestModified(): ?File
|
||||
{
|
||||
if (empty($this->files)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->sortByModificationTime(true)->files[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the oldest modified file
|
||||
*/
|
||||
public function getOldestModified(): ?File
|
||||
{
|
||||
if (empty($this->files)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->sortByModificationTime(false)->files[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file at specific index
|
||||
*/
|
||||
public function get(int $index): ?File
|
||||
{
|
||||
return $this->files[$index] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get first file
|
||||
*/
|
||||
public function first(): ?File
|
||||
{
|
||||
return $this->files[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last file
|
||||
*/
|
||||
public function last(): ?File
|
||||
{
|
||||
return empty($this->files) ? null : $this->files[count($this->files) - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge with another collection
|
||||
*/
|
||||
public function merge(self $other): self
|
||||
{
|
||||
return new self(array_merge($this->files, $other->files));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unique files (based on path)
|
||||
*/
|
||||
public function unique(): self
|
||||
{
|
||||
$unique = [];
|
||||
$seen = [];
|
||||
|
||||
foreach ($this->files as $file) {
|
||||
$path = $file->getPath()->toString();
|
||||
if (! isset($seen[$path])) {
|
||||
$unique[] = $file;
|
||||
$seen[$path] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return new self($unique);
|
||||
}
|
||||
|
||||
/**
|
||||
* Group files by extension
|
||||
* @return array<string, self>
|
||||
*/
|
||||
public function groupByExtension(): array
|
||||
{
|
||||
$groups = [];
|
||||
|
||||
foreach ($this->files as $file) {
|
||||
$extension = $file->getExtension() ?: 'no-extension';
|
||||
|
||||
if (! isset($groups[$extension])) {
|
||||
$groups[$extension] = [];
|
||||
}
|
||||
|
||||
$groups[$extension][] = $file;
|
||||
}
|
||||
|
||||
return array_map(fn (array $files) => new self($files), $groups);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total size of all files
|
||||
*/
|
||||
public function getTotalSize(): int
|
||||
{
|
||||
return array_sum(array_map(fn (File $file) => $file->getSize(), $this->files));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if collection contains a specific file
|
||||
*/
|
||||
public function contains(File $file): bool
|
||||
{
|
||||
foreach ($this->files as $existingFile) {
|
||||
if ($existingFile->getPath()->equals($file->getPath())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
175
src/Framework/Filesystem/ValueObjects/FilePattern.php
Normal file
175
src/Framework/Filesystem/ValueObjects/FilePattern.php
Normal file
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Filesystem\ValueObjects;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Value object for file patterns used in discovery and filesystem operations
|
||||
* Supports glob patterns like "*.php", "*.js", "test-*.txt", and multi-extension patterns
|
||||
*/
|
||||
final readonly class FilePattern
|
||||
{
|
||||
private function __construct(
|
||||
public string $pattern
|
||||
) {
|
||||
$this->validate($pattern);
|
||||
}
|
||||
|
||||
public static function create(string $pattern): self
|
||||
{
|
||||
return new self($pattern);
|
||||
}
|
||||
|
||||
public static function php(): self
|
||||
{
|
||||
return new self('*.php');
|
||||
}
|
||||
|
||||
public static function javascript(): self
|
||||
{
|
||||
return new self('*.js');
|
||||
}
|
||||
|
||||
public static function css(): self
|
||||
{
|
||||
return new self('*.css');
|
||||
}
|
||||
|
||||
public static function all(): self
|
||||
{
|
||||
return new self('*');
|
||||
}
|
||||
|
||||
public static function fromExtension(string $extension): self
|
||||
{
|
||||
// Remove leading dot if present
|
||||
$extension = ltrim($extension, '.');
|
||||
|
||||
return new self("*.{$extension}");
|
||||
}
|
||||
|
||||
public static function forExtension(string $extension): self
|
||||
{
|
||||
return self::fromExtension($extension);
|
||||
}
|
||||
|
||||
public static function forExtensions(array $extensions): self
|
||||
{
|
||||
if (empty($extensions)) {
|
||||
throw new InvalidArgumentException('Extensions array cannot be empty');
|
||||
}
|
||||
|
||||
if (count($extensions) === 1) {
|
||||
return self::forExtension($extensions[0]);
|
||||
}
|
||||
|
||||
$cleanExtensions = array_map(fn ($ext) => '*.' . ltrim($ext, '.'), $extensions);
|
||||
|
||||
return new self('{' . implode(',', $cleanExtensions) . '}');
|
||||
}
|
||||
|
||||
public function toString(): string
|
||||
{
|
||||
return $this->pattern;
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->pattern;
|
||||
}
|
||||
|
||||
/**
|
||||
* Konvertiert das Glob-Muster in einen regulären Ausdruck
|
||||
*/
|
||||
public function toRegex(): string
|
||||
{
|
||||
$regex = preg_quote($this->pattern, '/');
|
||||
|
||||
// Ersetze Glob-Platzhalter durch Regex-Äquivalente
|
||||
$regex = str_replace(
|
||||
['\*', '\?', '\[', '\]'],
|
||||
['.*', '.', '[', ']'],
|
||||
$regex
|
||||
);
|
||||
|
||||
return '/^' . $regex . '$/i';
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob ein Dateiname dem Muster entspricht
|
||||
*/
|
||||
public function matches(string $filename): bool
|
||||
{
|
||||
return fnmatch($this->pattern, $filename);
|
||||
}
|
||||
|
||||
public function isGlobPattern(): bool
|
||||
{
|
||||
return str_contains($this->pattern, '*') ||
|
||||
str_contains($this->pattern, '?') ||
|
||||
str_contains($this->pattern, '[');
|
||||
}
|
||||
|
||||
public function getExtensions(): array
|
||||
{
|
||||
// Handle simple extension patterns like "*.php"
|
||||
if (preg_match('/^\*\.(\w+)$/', $this->pattern, $matches)) {
|
||||
return [$matches[1]];
|
||||
}
|
||||
|
||||
// Handle multiple extension patterns like "{*.php,*.js}"
|
||||
if (preg_match('/^\{(.+)\}$/', $this->pattern, $matches)) {
|
||||
$parts = explode(',', $matches[1]);
|
||||
$extensions = [];
|
||||
foreach ($parts as $part) {
|
||||
if (preg_match('/^\*\.(\w+)$/', trim($part), $extMatches)) {
|
||||
$extensions[] = $extMatches[1];
|
||||
}
|
||||
}
|
||||
|
||||
return $extensions;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Kombiniert mehrere Muster zu einem OR-Muster
|
||||
* @param FilePattern[] $patterns
|
||||
*/
|
||||
public static function anyOf(array $patterns): self
|
||||
{
|
||||
if (empty($patterns)) {
|
||||
throw new InvalidArgumentException('At least one pattern is required');
|
||||
}
|
||||
|
||||
if (count($patterns) === 1) {
|
||||
return $patterns[0];
|
||||
}
|
||||
|
||||
// Für Glob-Muster verwenden wir {pattern1,pattern2} Syntax
|
||||
$patternStrings = array_map(fn (self $p) => $p->pattern, $patterns);
|
||||
|
||||
return new self('{' . implode(',', $patternStrings) . '}');
|
||||
}
|
||||
|
||||
private function validate(string $pattern): void
|
||||
{
|
||||
if (empty(trim($pattern))) {
|
||||
throw new InvalidArgumentException('File pattern cannot be empty');
|
||||
}
|
||||
|
||||
// Prüfe auf ungültige Zeichen für Dateisysteme
|
||||
if (preg_match('/[<>:"|]/', $pattern)) {
|
||||
throw new InvalidArgumentException('File pattern contains invalid characters');
|
||||
}
|
||||
}
|
||||
|
||||
public function equals(self $other): bool
|
||||
{
|
||||
return $this->pattern === $other->pattern;
|
||||
}
|
||||
}
|
||||
89
src/Framework/Filesystem/ValueObjects/ScannerMemoryUsage.php
Normal file
89
src/Framework/Filesystem/ValueObjects/ScannerMemoryUsage.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Filesystem\ValueObjects;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Byte;
|
||||
use App\Framework\Core\ValueObjects\Percentage;
|
||||
use App\Framework\Performance\MemoryMonitor;
|
||||
|
||||
/**
|
||||
* Represents memory usage statistics for the scanner
|
||||
*/
|
||||
final readonly class ScannerMemoryUsage
|
||||
{
|
||||
public function __construct(
|
||||
public Byte $current,
|
||||
public Byte $peak,
|
||||
public Byte $limit,
|
||||
public Percentage $usage
|
||||
) {
|
||||
}
|
||||
|
||||
public static function fromMemoryMonitor(MemoryMonitor $monitor): self
|
||||
{
|
||||
return new self(
|
||||
current: $monitor->getCurrentMemory(),
|
||||
peak: $monitor->getPeakMemory(),
|
||||
limit: $monitor->getMemoryLimit(),
|
||||
usage: $monitor->getMemoryUsagePercentage()
|
||||
);
|
||||
}
|
||||
|
||||
public static function current(): self
|
||||
{
|
||||
$monitor = new MemoryMonitor();
|
||||
|
||||
return self::fromMemoryMonitor($monitor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if memory usage is critical (>90%)
|
||||
*/
|
||||
public function isCritical(): bool
|
||||
{
|
||||
return $this->usage->greaterThan(Percentage::from(90.0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if memory usage is high (>80%)
|
||||
*/
|
||||
public function isHigh(): bool
|
||||
{
|
||||
return $this->usage->greaterThan(Percentage::from(80.0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available memory
|
||||
*/
|
||||
public function getAvailable(): Byte
|
||||
{
|
||||
return $this->limit->subtract($this->current);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we have enough memory for an operation
|
||||
*/
|
||||
public function hasEnoughMemoryFor(Byte $required): bool
|
||||
{
|
||||
return $this->getAvailable()->greaterThan($required);
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'current' => $this->current->toBytes(),
|
||||
'current_human' => $this->current->toHumanReadable(),
|
||||
'peak' => $this->peak->toBytes(),
|
||||
'peak_human' => $this->peak->toHumanReadable(),
|
||||
'limit' => $this->limit->toBytes(),
|
||||
'limit_human' => $this->limit->toHumanReadable(),
|
||||
'usage_percentage' => $this->usage->getValue(),
|
||||
'is_critical' => $this->isCritical(),
|
||||
'is_high' => $this->isHigh(),
|
||||
'available' => $this->getAvailable()->toBytes(),
|
||||
'available_human' => $this->getAvailable()->toHumanReadable(),
|
||||
];
|
||||
}
|
||||
}
|
||||
79
src/Framework/Filesystem/ValueObjects/ScannerMetrics.php
Normal file
79
src/Framework/Filesystem/ValueObjects/ScannerMetrics.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Filesystem\ValueObjects;
|
||||
|
||||
/**
|
||||
* Detailed metrics about scanner operations
|
||||
*/
|
||||
final readonly class ScannerMetrics
|
||||
{
|
||||
public function __construct(
|
||||
public int $totalFilesScanned,
|
||||
public int $totalDirectoriesScanned,
|
||||
public int $skippedFiles,
|
||||
public int $permissionErrors,
|
||||
public int $recoverableErrors,
|
||||
public int $fatalErrors,
|
||||
public int $retryAttempts,
|
||||
public ScannerMemoryUsage $memoryStats
|
||||
) {
|
||||
}
|
||||
|
||||
public static function empty(): self
|
||||
{
|
||||
return new self(
|
||||
totalFilesScanned: 0,
|
||||
totalDirectoriesScanned: 0,
|
||||
skippedFiles: 0,
|
||||
permissionErrors: 0,
|
||||
recoverableErrors: 0,
|
||||
fatalErrors: 0,
|
||||
retryAttempts: 0,
|
||||
memoryStats: ScannerMemoryUsage::current()
|
||||
);
|
||||
}
|
||||
|
||||
public function getTotalErrors(): int
|
||||
{
|
||||
return $this->permissionErrors + $this->recoverableErrors + $this->fatalErrors;
|
||||
}
|
||||
|
||||
public function getErrorRate(): float
|
||||
{
|
||||
if ($this->totalFilesScanned === 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return $this->getTotalErrors() / $this->totalFilesScanned;
|
||||
}
|
||||
|
||||
public function hasErrors(): bool
|
||||
{
|
||||
return $this->getTotalErrors() > 0;
|
||||
}
|
||||
|
||||
public function hasFatalErrors(): bool
|
||||
{
|
||||
return $this->fatalErrors > 0;
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'total_files_scanned' => $this->totalFilesScanned,
|
||||
'total_directories_scanned' => $this->totalDirectoriesScanned,
|
||||
'skipped_files' => $this->skippedFiles,
|
||||
'permission_errors' => $this->permissionErrors,
|
||||
'recoverable_errors' => $this->recoverableErrors,
|
||||
'fatal_errors' => $this->fatalErrors,
|
||||
'retry_attempts' => $this->retryAttempts,
|
||||
'memory_stats' => $this->memoryStats->toArray(),
|
||||
'total_errors' => $this->getTotalErrors(),
|
||||
'error_rate' => $this->getErrorRate(),
|
||||
'has_errors' => $this->hasErrors(),
|
||||
'has_fatal_errors' => $this->hasFatalErrors(),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user