diff --git a/src/Migration/Destinations/CSV.php b/src/Migration/Destinations/CSV.php new file mode 100644 index 00000000..a61cc5bc --- /dev/null +++ b/src/Migration/Destinations/CSV.php @@ -0,0 +1,295 @@ +deviceForFiles = $deviceForFiles; + $this->resourceId = $resourceId; + $this->directory = $directory; + $this->outputFile = $this->sanitizeFilename($filename); + $this->local = new Local(\sys_get_temp_dir() . '/csv_export_' . uniqid()); + $this->local->setTransferChunkSize(Transfer::STORAGE_MAX_CHUNK_SIZE); + $this->createDirectory($this->local->getRoot()); + + foreach ($allowedColumns as $attribute) { + $this->allowedColumns[$attribute] = true; + } + } + + public static function getName(): string + { + return 'CSV'; + } + + public static function getSupportedResources(): array + { + return [ + Resource::TYPE_ROW, + ]; + } + + public function report(array $resources = []): array + { + return []; + } + + /** + * @param array $resources + * @throws \JsonException + * @throws \Exception + */ + protected function import(array $resources, callable $callback): void + { + $handle = null; // file handle + $buffer = ['lines' => [], 'size' => 0]; // Buffer for batching writes + $bufferBytes = 1024 * 1024; // 1MB + $log = $this->local->getRoot() . '/' . $this->outputFile . '.csv'; + + $flushBuffer = function () use ($log, &$handle, &$buffer) { + if (empty($buffer['lines'])) { + return; + } + try { + if (!isset($handle)) { + $handle = \fopen($log, 'a'); + if ($handle === false) { + throw new \Exception("Failed to open file for writing: $log"); + } + } + + foreach ($buffer['lines'] as $line) { + if (\fputcsv($handle, $line, $this->delimiter, $this->enclosure, $this->escape) === false) { + throw new \Exception("Failed to write CSV line to file: $log"); + } + } + + $buffer = [ + 'lines' => [], + 'size' => 0 + ]; + } catch (\Exception $e) { + // Close handle on error + if (isset($handle)) { + \fclose($handle); + unset($handle); + } + throw $e; + } + }; + + try { + foreach ($resources as $resource) { + if (!($resource instanceof Row)) { + continue; + } + + $csvData = $this->resourceToCSVData($resource); + + // Write headers if this is the first row of the file + if (!isset($csvHeader) && $this->includeHeaders) { + $headers = \array_keys($csvData); + $buffer['lines'][] = $headers; + $buffer['size'] += \strlen(\implode($this->delimiter, $headers)) + 2; // Approximate size + $csvHeader = true; + } + + $dataValues = \array_values($csvData); + $buffer['lines'][] = $dataValues; + $buffer['size'] += \strlen(\implode($this->delimiter, $dataValues)) + 2; // Approximate size + + if ($buffer['size'] >= $bufferBytes) { + $flushBuffer(); + } + + $resource->setStatus(Resource::STATUS_SUCCESS); + if (isset($this->cache)) { + $this->cache->update($resource); + } + } + + // Flush any remaining buffered lines + if (!empty($buffer['lines'])) { + $flushBuffer(); + } + } finally { + if (\is_resource($handle)) { + \fclose($handle); + } + } + + $callback($resources); + } + + /** + * @throws \Exception + */ + public function shutdown(): void + { + $filename = $this->outputFile . '.csv'; + $sourcePath = $this->local->getPath($filename); + $destPath = $this->deviceForFiles->getPath($this->directory . '/' . $filename); + + // Check if the CSV file was actually created + if (!$this->local->exists($sourcePath)) { + throw new \Exception("No data to export for resource: $this->resourceId"); + } + + try { + // Transfer expects absolute paths within each device + $result = $this->local->transfer( + $sourcePath, + $destPath, + $this->deviceForFiles + ); + if ($result === false) { + throw new \Exception('Error transferring to ' . $this->deviceForFiles->getRoot() . '/' . $filename); + } + if (!$this->deviceForFiles->exists($destPath)) { + throw new \Exception('File not found on destination: ' . $destPath); + } + } finally { + // Clean up the temporary directory + if (!$this->local->deletePath('') || $this->local->exists($this->local->getRoot())) { + Console::error('Error cleaning up: ' . $this->local->getRoot()); + } + } + } + + /** + * Helper to ensure a directory exists. + * @throws \Exception + */ + protected function createDirectory(string $path): void + { + if (!\file_exists($path)) { + if (!\mkdir($path, 0755, true)) { + throw new \Exception('Error creating directory: ' . $path); + } + } + } + + /** + * Sanitize a filename to make it filesystem-safe + */ + protected function sanitizeFilename(string $filename): string + { + // Replace problematic characters with underscores + $sanitized = \preg_replace('/[:\\/<>"|*?]/', '_', $filename); + $sanitized = \preg_replace('/[^\x20-\x7E]/', '_', $sanitized); + $sanitized = \trim($sanitized); + return empty($sanitized) ? 'export' : $sanitized; + } + + /** + * Convert a resource to CSV-compatible data + */ + protected function resourceToCSVData(Row $resource): array + { + $data = [ + '$id' => $resource->getId(), + '$permissions' => $resource->getPermissions(), + '$createdAt' => $resource->getCreatedAt(), + '$updatedAt' => $resource->getUpdatedAt(), + ]; + + // Add all attributes if no filter specified, otherwise only allowed ones + if (empty($this->allowedColumns)) { + $data = \array_merge($data, $resource->getData()); + } else { + foreach ($resource->getData() as $key => $value) { + if (isset($this->allowedColumns[$key])) { + $data[$key] = $value; + } + } + } + + foreach ($data as $key => $value) { + $data[$key] = $this->convertValueToCSV($value); + } + + return $data; + } + + /** + * Convert a single value to CSV-compatible format + */ + protected function convertValueToCSV(mixed $value): string + { + if (\is_null($value)) { + return 'null'; + } + if (\is_bool($value)) { + return $value ? 'true' : 'false'; + } + if (\is_array($value)) { + return $this->convertArrayToCSV($value); + } + if (\is_object($value)) { + return $this->convertObjectToCSV($value); + } + return (string)$value; + } + + /** + * Convert array to CSV format + */ + protected function convertArrayToCSV(array $value): string + { + if (empty($value)) { + return ''; + } + if (isset($value['$id'])) { + return $value['$id']; + } + return \json_encode($value); + } + + /** + * Convert object to CSV format + */ + protected function convertObjectToCSV($value): string + { + if ($value instanceof Row) { + return $value->getId(); + } + return \json_encode($value); + } + +} diff --git a/src/Migration/Resources/Database/Row.php b/src/Migration/Resources/Database/Row.php index cebd88e7..42d5bfca 100644 --- a/src/Migration/Resources/Database/Row.php +++ b/src/Migration/Resources/Database/Row.php @@ -14,10 +14,10 @@ class Row extends Resource * @param array $permissions */ public function __construct( - string $id, + string $id, private readonly Table $table, private readonly array $data = [], - array $permissions = [] + array $permissions = [] ) { $this->id = $id; $this->permissions = $permissions; diff --git a/src/Migration/Sources/Appwrite.php b/src/Migration/Sources/Appwrite.php index 27cc056f..9e333071 100644 --- a/src/Migration/Sources/Appwrite.php +++ b/src/Migration/Sources/Appwrite.php @@ -710,7 +710,17 @@ private function exportDatabases(int $batchSize): void $queries = [$this->database->queryLimit($batchSize)]; if ($this->rootResourceId !== '' && $this->rootResourceType === Resource::TYPE_DATABASE) { - $queries[] = $this->database->queryEqual('$id', [$this->rootResourceId]); + $targetDatabaseId = $this->rootResourceId; + + // Handle database:collection format - extract database ID + if (\str_contains($this->rootResourceId, ':')) { + $parts = \explode(':', $this->rootResourceId, 2); + if (\count($parts) === 2) { + $targetDatabaseId = $parts[0]; + } + } + + $queries[] = $this->database->queryEqual('$id', [$targetDatabaseId]); $queries[] = $this->database->queryLimit(1); } @@ -738,11 +748,11 @@ private function exportDatabases(int $batchSize): void break; } - $lastDatabase = $databases[count($databases) - 1]; + $lastDatabase = $databases[\count($databases) - 1]; $this->callback($databases); - if (count($databases) < $batchSize) { + if (\count($databases) < $batchSize) { break; } } @@ -757,14 +767,33 @@ private function exportTables(int $batchSize): void $databases = $this->cache->get(Database::getName()); foreach ($databases as $database) { + /** @var Database $database */ $lastTable = null; - /** @var Database $database */ while (true) { $queries = [$this->database->queryLimit($batchSize)]; $tables = []; - if ($lastTable) { + // Filter to specific table if rootResourceType is database with database:collection format + if ( + $this->rootResourceId !== '' && + $this->rootResourceType === Resource::TYPE_DATABASE && + \str_contains($this->rootResourceId, ':') + ) { + $parts = \explode(':', $this->rootResourceId, 2); + if (\count($parts) === 2) { + $targetTableId = $parts[1]; // table ID + $queries[] = $this->database->queryEqual('$id', [$targetTableId]); + $queries[] = $this->database->queryLimit(1); + } + } elseif ( + $this->rootResourceId !== '' && + $this->rootResourceType === Resource::TYPE_TABLE + ) { + $targetTableId = $this->rootResourceId; + $queries[] = $this->database->queryEqual('$id', [$targetTableId]); + $queries[] = $this->database->queryLimit(1); + } elseif ($lastTable) { $queries[] = $this->database->queryCursorAfter($lastTable); } @@ -790,9 +819,9 @@ private function exportTables(int $batchSize): void $this->callback($tables); - $lastTable = $tables[count($tables) - 1]; + $lastTable = $tables[\count($tables) - 1]; - if (count($tables) < $batchSize) { + if (\count($tables) < $batchSize) { break; } } @@ -807,7 +836,7 @@ private function exportColumns(int $batchSize): void { $tables = $this->cache->get(Table::getName()); - /** @var Table[] $tables */ + /** @var array $tables */ foreach ($tables as $table) { $lastColumn = null; diff --git a/src/Migration/Sources/CSV.php b/src/Migration/Sources/CSV.php index f43cb046..7ccc37ea 100644 --- a/src/Migration/Sources/CSV.php +++ b/src/Migration/Sources/CSV.php @@ -131,12 +131,13 @@ private function exportRows(int $batchSize): void $columns = []; $lastColumn = null; - [$databaseId, $tableId] = explode(':', $this->resourceId); + [$databaseId, $tableId] = \explode(':', $this->resourceId); $database = new Database($databaseId, ''); $table = new Table($database, '', $tableId); while (true) { $queries = [$this->database->queryLimit($batchSize)]; + if ($lastColumn) { $queries[] = $this->database->queryCursorAfter($lastColumn); } @@ -146,10 +147,10 @@ private function exportRows(int $batchSize): void break; } - array_push($columns, ...$fetched); - $lastColumn = $fetched[count($fetched) - 1]; + \array_push($columns, ...$fetched); + $lastColumn = $fetched[\count($fetched) - 1]; - if (count($fetched) < $batchSize) { + if (\count($fetched) < $batchSize) { break; } } @@ -206,7 +207,7 @@ private function exportRows(int $batchSize): void $buffer = []; while (($row = \fgetcsv($stream, 0, $delimiter, '"', '"')) !== false) { - if (count($row) !== count($headers)) { + if (\count($row) !== \count($headers)) { throw new \Exception('CSV row does not match the number of header columns.'); } @@ -273,7 +274,7 @@ private function exportRows(int $batchSize): void /** * Parsing logic for best compatibility with spec and 3rd party tools. - * - 'null' unquoted literal string is converted to null. + * - 'null' unquoted literal is converted to null. * - missing strings stay empty strings for best compatibility. * - missing numbers, booleans, and datetime's are converted to null. * - other values are parsed as per their type. diff --git a/tests/Migration/Unit/General/CSVTest.php b/tests/Migration/Unit/General/CSVTest.php index 7cae1c7b..d98ab0ac 100644 --- a/tests/Migration/Unit/General/CSVTest.php +++ b/tests/Migration/Unit/General/CSVTest.php @@ -3,7 +3,34 @@ namespace Migration\Unit\General; use PHPUnit\Framework\TestCase; +use Utopia\Migration\Destinations\CSV as DestinationCSV; +use Utopia\Migration\Resources\Database\Database; +use Utopia\Migration\Resources\Database\Row; +use Utopia\Migration\Resources\Database\Table; use Utopia\Migration\Sources\CSV; +use Utopia\Storage\Device\Local; + +/** + * Test-friendly CSV destination + */ +class TestCSV extends DestinationCSV +{ + public function testableImport(array $resources, callable $callback): void + { + $this->import($resources, $callback); + } + + public function getLocalRoot(): string + { + return $this->local->getRoot(); + } + + // Override shutdown to avoid transfer for testing + public function shutdown(): void + { + // Do nothing for testing - don't transfer files + } +} class CSVTest extends TestCase { @@ -46,4 +73,360 @@ public function testDetectDelimiter() $this->assertEquals($case['expected'], $delimiter, "Failed for {$case['file']}"); } } + + public function testCSVExportBasic() + { + $tempDir = sys_get_temp_dir() . '/csv_test_' . uniqid(); + mkdir($tempDir, 0755, true); + $exportDevice = new Local($tempDir); + + // Create CSV destination + $csvDestination = new TestCSV($exportDevice, 'test_db:test_table_id', '', 'test_db_test_table_id'); + + // Create test data + $database = new Database('test_db'); + $table = new Table($database, 'test_table', 'test_table_id'); + + $row1 = new Row('row1', $table, [ + 'name' => 'John Doe', + 'age' => 30, + 'email' => 'john@example.com' + ]); + $row1->setPermissions(['read' => ['user:123']]); + + $row2 = new Row('row2', $table, [ + 'name' => 'Jane Smith', + 'age' => 25, + 'email' => 'jane@example.com' + ]); + $row2->setPermissions(['read' => ['user:456']]); + + // Export the data + $csvDestination->testableImport([$row1, $row2], function ($resources) { + // Callback - verify resources are marked as successful + foreach ($resources as $resource) { + $this->assertEquals('success', $resource->getStatus()); + } + }); + + $csvDestination->shutdown(); + + // Verify CSV file was created in local temp directory + $expectedFile = $csvDestination->getLocalRoot() . '/test_db_test_table_id.csv'; + $this->assertFileExists($expectedFile, 'CSV file should exist'); + + // Use proper CSV parsing + $handle = fopen($expectedFile, 'r'); + $this->assertNotFalse($handle); + + $header = fgetcsv($handle, 0, ',', '"', '"'); + $row1Data = fgetcsv($handle, 0, ',', '"', '"'); + $row2Data = fgetcsv($handle, 0, ',', '"', '"'); + fclose($handle); + + $this->assertNotFalse($header); + $this->assertNotFalse($row1Data); + $this->assertNotFalse($row2Data); + + // Check header + $this->assertContains('$id', $header); + $this->assertContains('$permissions', $header); + $this->assertContains('$createdAt', $header); + $this->assertContains('$updatedAt', $header); + $this->assertContains('name', $header); + $this->assertContains('age', $header); + $this->assertContains('email', $header); + + // Check first row data + $this->assertEquals('row1', $row1Data[0]); // $id + $this->assertStringContainsString('user:123', $row1Data[1]); // $permissions + // $createdAt and $updatedAt are empty for test data + $this->assertEquals('John Doe', $row1Data[4]); // name + $this->assertEquals('30', $row1Data[5]); // age + $this->assertEquals('john@example.com', $row1Data[6]); // email + + // Cleanup + if (is_dir($tempDir)) { + $this->recursiveDelete($tempDir); + } + } + + public function testCSVExportWithSpecialCharacters() + { + $tempDir = sys_get_temp_dir() . '/csv_test_special_' . uniqid(); + $exportDevice = new Local($tempDir); + + $csvDestination = new TestCSV($exportDevice, 'test_db:test_table_id', '', 'test_db_test_table_id'); + + $database = new Database('test_db'); + $table = new Table($database, 'test_table', 'test_table_id'); + + // Test data with special characters that need escaping + $row = new Row('special_row', $table, [ + 'quote_field' => 'Text with "quotes"', + 'comma_field' => 'Text, with, commas', + 'newline_field' => "Text with\nnewlines", + 'mixed_field' => 'Text with "quotes", commas, and\nnewlines' + ]); + + $csvDestination->testableImport([$row], function ($resources) {}); + $csvDestination->shutdown(); + + $csvFile = $csvDestination->getLocalRoot() . '/test_db_test_table_id.csv'; + + // Use proper CSV parsing + $handle = fopen($csvFile, 'r'); + $this->assertNotFalse($handle); + + $header = fgetcsv($handle, 0, ',', '"', '"'); + $rowData = fgetcsv($handle, 0, ',', '"', '"'); + fclose($handle); + + $this->assertNotFalse($header); + $this->assertNotFalse($rowData); + + // Verify special characters are properly handled + // Indices are shifted by 2 due to $createdAt and $updatedAt + $this->assertEquals('Text with "quotes"', $rowData[4]); // quote_field + $this->assertEquals('Text, with, commas', $rowData[5]); // comma_field + $this->assertEquals("Text with\nnewlines", $rowData[6]); // newline_field + $this->assertEquals('Text with "quotes", commas, and\nnewlines', $rowData[7]); // mixed_field + + // Cleanup + if (is_dir($tempDir)) { + $this->recursiveDelete($tempDir); + } + } + + public function testCSVExportWithArrays() + { + $tempDir = sys_get_temp_dir() . '/csv_test_arrays_' . uniqid(); + $exportDevice = new Local($tempDir); + + $csvDestination = new TestCSV($exportDevice, 'test_db:test_table_id', '', 'test_db_test_table_id'); + + $database = new Database('test_db'); + $table = new Table($database, 'test_table', 'test_table_id'); + + $row = new Row('array_row', $table, [ + 'tags' => ['php', 'csv', 'export'], + 'metadata' => ['key1' => 'value1', 'key2' => 'value2'], + 'empty_array' => [], + 'nested' => [['id' => 1], ['id' => 2]] + ]); + + $csvDestination->testableImport([$row], function ($resources) {}); + $csvDestination->shutdown(); + + $csvFile = $csvDestination->getLocalRoot() . '/test_db_test_table_id.csv'; + + // Use proper CSV parsing + $handle = fopen($csvFile, 'r'); + $this->assertNotFalse($handle); + + $header = fgetcsv($handle, 0, ',', '"', '"'); + $rowData = fgetcsv($handle, 0, ',', '"', '"'); + fclose($handle); + + $this->assertNotFalse($header); + $this->assertNotFalse($rowData); + + // Arrays should be JSON encoded + // Indices are shifted by 2 due to $createdAt and $updatedAt + $this->assertEquals('["php","csv","export"]', $rowData[4]); // tags + $this->assertJson($rowData[5]); // metadata should be valid JSON + $this->assertEquals('', $rowData[6]); // empty_array + $this->assertJson($rowData[7]); // nested should be valid JSON + + // Cleanup + if (is_dir($tempDir)) { + $this->recursiveDelete($tempDir); + } + } + + public function testCSVExportWithNullValues() + { + $tempDir = sys_get_temp_dir() . '/csv_test_nulls_' . uniqid(); + $exportDevice = new Local($tempDir); + + $csvDestination = new TestCSV($exportDevice, 'test_db:test_table_id', '', 'test_db_test_table_id'); + + $database = new Database('test_db'); + $table = new Table($database, 'test_table', 'test_table_id'); + + $row = new Row('null_row', $table, [ + 'name' => 'Test', + 'null_field' => null, + 'empty_string' => '', + 'zero' => 0, + 'false_bool' => false + ]); + + $csvDestination->testableImport([$row], function ($resources) {}); + $csvDestination->shutdown(); + + $csvFile = $csvDestination->getLocalRoot() . '/test_db_test_table_id.csv'; + + // Use proper CSV parsing + $handle = fopen($csvFile, 'r'); + $this->assertNotFalse($handle); + + $header = fgetcsv($handle, 0, ',', '"', '"'); + $rowData = fgetcsv($handle, 0, ',', '"', '"'); + fclose($handle); + + $this->assertNotFalse($header); + $this->assertNotFalse($rowData); + + // Indices are shifted by 2 due to $createdAt and $updatedAt + $this->assertEquals('Test', $rowData[4]); // name + $this->assertEquals('null', $rowData[5]); // null_field -> "null" string + $this->assertEquals('', $rowData[6]); // empty_string + $this->assertEquals('0', $rowData[7]); // zero + $this->assertEquals('false', $rowData[8]); // false_bool + + // Cleanup + if (is_dir($tempDir)) { + $this->recursiveDelete($tempDir); + } + } + + public function testCSVExportWithAllowedAttributes() + { + $tempDir = sys_get_temp_dir() . '/csv_test_filtered_' . uniqid(); + $exportDevice = new Local($tempDir); + + // Only allow specific attributes + $csvDestination = new TestCSV($exportDevice, 'test_db:test_table_id', '', 'test_db_test_table_id', ['name', 'email']); + + $database = new Database('test_db'); + $table = new Table($database, 'test_table', 'test_table_id'); + + $row = new Row('filtered_row', $table, [ + 'name' => 'John Doe', + 'age' => 30, + 'email' => 'john@example.com', + 'secret' => 'should_not_appear' + ]); + + $csvDestination->testableImport([$row], function ($resources) {}); + $csvDestination->shutdown(); + + $csvFile = $csvDestination->getLocalRoot() . '/test_db_test_table_id.csv'; + + // Use proper CSV parsing + $handle = fopen($csvFile, 'r'); + $this->assertNotFalse($handle); + + $header = fgetcsv($handle, 0, ',', '"', '"'); + $rowData = fgetcsv($handle, 0, ',', '"', '"'); + fclose($handle); + + $this->assertNotFalse($header); + $this->assertNotFalse($rowData); + + // Should have $id, $permissions, $createdAt, $updatedAt, and only allowed attributes + $this->assertContains('$id', $header); + $this->assertContains('$permissions', $header); + $this->assertContains('$createdAt', $header); + $this->assertContains('$updatedAt', $header); + $this->assertContains('name', $header); + $this->assertContains('email', $header); + $this->assertNotContains('age', $header); + $this->assertNotContains('secret', $header); + + // Cleanup + if (is_dir($tempDir)) { + $this->recursiveDelete($tempDir); + } + } + + public function testCSVExportImportCompatibility() + { + $tempDir = sys_get_temp_dir() . '/csv_test_compat_' . uniqid(); + $exportDevice = new Local($tempDir); + + // Export data + $csvDestination = new TestCSV($exportDevice, 'test_db:test_table_id', '', 'test_db_test_table_id'); + + $database = new Database('test_db'); + $table = new Table($database, 'test_table', 'test_table_id'); + + $originalData = [ + 'name' => 'John Doe', + 'age' => 30, + 'tags' => ['php', 'csv'], + 'metadata' => ['key' => 'value'], + 'null_field' => null, + 'empty_field' => '', + 'bool_field' => true + ]; + + $row = new Row('compat_row', $table, $originalData); + $row->setPermissions(['read' => ['user:123']]); + + $csvDestination->testableImport([$row], function ($resources) {}); + $csvDestination->shutdown(); + + // Verify the exported CSV can be parsed by PHP's built-in CSV functions + $csvFile = $csvDestination->getLocalRoot() . '/test_db_test_table_id.csv'; + $this->assertFileExists($csvFile); + + $handle = fopen($csvFile, 'r'); + $this->assertNotFalse($handle); + + $header = fgetcsv($handle, 0, ',', '"', '"'); + $data = fgetcsv($handle, 0, ',', '"', '"'); + fclose($handle); + + $this->assertNotFalse($header); + $this->assertNotFalse($data); + + // Verify we can reconstruct the data + $reconstructed = \array_combine($header, $data); + + $this->assertEquals('compat_row', $reconstructed['$id']); + $this->assertEquals('John Doe', $reconstructed['name']); + $this->assertEquals('30', $reconstructed['age']); + $this->assertEquals('null', $reconstructed['null_field']); // null becomes "null" string + $this->assertEquals('', $reconstructed['empty_field']); + $this->assertEquals('true', $reconstructed['bool_field']); // bool becomes string + // Check that createdAt and updatedAt are in the reconstructed data + $this->assertArrayHasKey('$createdAt', $reconstructed); + $this->assertArrayHasKey('$updatedAt', $reconstructed); + + // Arrays should be valid JSON that can be decoded + $this->assertJson($reconstructed['tags']); + $this->assertJson($reconstructed['metadata']); + + $tagsArray = json_decode($reconstructed['tags'], true); + $metadataArray = json_decode($reconstructed['metadata'], true); + + $this->assertEquals(['php', 'csv'], $tagsArray); + $this->assertEquals(['key' => 'value'], $metadataArray); + + // Cleanup + if (is_dir($tempDir)) { + $this->recursiveDelete($tempDir); + } + } + + private function recursiveDelete(string $dir): void + { + if (is_dir($dir)) { + $objects = scandir($dir); + if ($objects !== false) { + foreach ($objects as $object) { + if ($object != "." && $object != "..") { + if (is_dir($dir."/".$object)) { + $this->recursiveDelete($dir."/".$object); + } else { + unlink($dir."/".$object); + } + } + } + } + rmdir($dir); + } + } }