Kaynağa Gözat

add command to synchronize database with entities

phpdevcommunity 1 ay önce
ebeveyn
işleme
1788901b01

+ 2 - 3
README.md

@@ -21,7 +21,7 @@ PaperORM is available via **Composer** and installs in seconds.
 
 ### 📦 Via Composer (recommended)
 ```bash
-composer require phpdevcommunity/paper-orm:1.0.10-alpha
+composer require phpdevcommunity/paper-orm:1.0.11-alpha
 ```  
 
 ### 🔧 Minimal Configuration
@@ -245,7 +245,7 @@ PaperORM est disponible via **Composer** et s'installe en quelques secondes.
 
 ### 📦 Via Composer (recommandé)
 ```bash
-composer require phpdevcommunity/paper-orm:1.0.10-alpha
+composer require phpdevcommunity/paper-orm:1.0.11-alpha
 ```  
 
 ### 🔧 Configuration minimale
@@ -452,4 +452,3 @@ La documentation complète est en cours de rédaction. Vous pouvez :
 ---
 
 *Le développement actif continue - restez à l'écoute pour les mises à jour !*
-

+ 2 - 2
composer.json

@@ -27,8 +27,8 @@
     "phpdevcommunity/php-console": "^1.0",
     "phpdevcommunity/michel-package-starter": "^1.0",
     "phpdevcommunity/php-filesystem": "^1.0",
-    "psr/log": "^1.1|^2.0|^3.0",
-    "phpdevcommunity/psr14-event-dispatcher": "^1.0"
+    "phpdevcommunity/psr14-event-dispatcher": "^1.0",
+    "psr/log": "^1.1|^2.0|^3.0"
   },
   "require-dev": {
     "phpdevcommunity/unitester": "^0.1.0@alpha"

+ 1 - 1
src/Command/DatabaseCreateCommand.php

@@ -50,7 +50,7 @@ class DatabaseCreateCommand implements CommandInterface
             $io->info(sprintf('The SQL database "%s" has been successfully created (if it did not already exist).', $platform->getDatabaseName()));
         } else {
             $platform->createDatabase();
-            $io->success(sprintf('The SQL database "%s" has been successfully created.', $platform->getDatabaseName()));
+            $io->success(sprintf('The SQL database "%s" has been successfully created.', $platform->getDatabaseName()));
         }
     }
 }

+ 1 - 1
src/Command/DatabaseDropCommand.php

@@ -58,7 +58,7 @@ class DatabaseDropCommand implements CommandInterface
 
         $platform = $this->entityManager->getPlatform();
         $platform->dropDatabase();
-        $io->success('The SQL database has been successfully dropped.');
+        $io->success('The SQL database has been successfully dropped.');
     }
 
     private function isEnabled(): bool

+ 106 - 0
src/Command/DatabaseSyncCommand.php

@@ -0,0 +1,106 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Command;
+
+use PhpDevCommunity\Console\InputInterface;
+use PhpDevCommunity\Console\Option\CommandOption;
+use PhpDevCommunity\Console\Output\ConsoleOutput;
+use PhpDevCommunity\Console\OutputInterface;
+use PhpDevCommunity\PaperORM\Migration\PaperMigration;
+use PhpDevCommunity\PaperORM\Tools\EntityExplorer;
+
+class DatabaseSyncCommand
+{
+
+    private PaperMigration $paperMigration;
+
+    private ?string $env;
+
+    private string $entityDir;
+
+    /**
+     * @param PaperMigration $paperMigration
+     * @param string $entityDir
+     * @param string|null $env
+     */
+    public function __construct(PaperMigration $paperMigration, string $entityDir, ?string $env = null)
+    {
+        $this->paperMigration = $paperMigration;
+        $this->env = $env;
+        $this->entityDir = $entityDir;
+    }
+
+    public function getName(): string
+    {
+        return 'paper:database:sync';
+    }
+
+    public function getDescription(): string
+    {
+        return 'Update the SQL database structure so it matches the current ORM entities.';
+    }
+
+    public function getOptions(): array
+    {
+        return [
+            new CommandOption('--no-execute', 'n', 'Show the generated SQL statements without executing them.', true)
+        ];
+    }
+
+    public function getArguments(): array
+    {
+        return [];
+    }
+
+    public function execute(InputInterface $input, OutputInterface $output): void
+    {
+        $io = ConsoleOutput::create($output);
+        if (!$this->isEnabled()) {
+            throw new \LogicException('This command is only available in `dev` environment.');
+        }
+
+        $noExecute = $input->getOptionValue('no-execute');
+        $platform = $this->paperMigration->getEntityManager()->getPlatform();
+
+        $io->title('Starting database sync on ' . $platform->getDatabaseName());
+        $io->list([
+            'Database : ' . $platform->getDatabaseName(),
+            'Entities directory : ' . $this->entityDir
+        ]);
+
+        $entities = EntityExplorer::getEntities($this->entityDir);
+        $io->title('Number of entities detected: ' . count($entities));
+        $io->listKeyValues($entities);
+
+        $updates = $this->paperMigration->getSqlDiffFromEntities($entities);
+        if (empty($updates)) {
+            $io->info('No differences detected — all entities are already in sync with the database schema.');
+            return;
+        }
+
+        $count = count($updates);
+        $io->writeln("📘 Database synchronization plan");
+        $io->writeln("{$count} SQL statements will be executed:");
+        $io->writeln("");
+        $io->numberedList($updates);
+        if ($noExecute) {
+            $io->info('Preview mode only — SQL statements were displayed but NOT executed.');
+            return;
+        }
+
+        $io->writeln("");
+        $io->writeln("🚀 Applying changes to database...");
+        $conn = $this->paperMigration->getEntityManager()->getConnection();
+        foreach ($updates as $sql) {
+            $conn->executeStatement($sql);
+            $io->writeln("✔ Executed: {$sql}");
+        }
+
+        $io->success("✅ Database successfully synchronized.");
+    }
+
+    private function isEnabled(): bool
+    {
+        return 'dev' === $this->env || 'test' === $this->env;
+    }
+}

+ 6 - 63
src/Command/Migration/MigrationDiffCommand.php

@@ -10,6 +10,7 @@ use PhpDevCommunity\Console\OutputInterface;
 use PhpDevCommunity\FileSystem\Tools\FileExplorer;
 use PhpDevCommunity\PaperORM\Entity\EntityInterface;
 use PhpDevCommunity\PaperORM\Migration\PaperMigration;
+use PhpDevCommunity\PaperORM\Tools\EntityExplorer;
 
 class MigrationDiffCommand implements CommandInterface
 {
@@ -35,8 +36,7 @@ class MigrationDiffCommand implements CommandInterface
     public function getOptions(): array
     {
         return [
-            new CommandOption('entities-dir', null, 'The directory where the entities are', false),
-            new CommandOption('output', 'o', 'The output file', true)
+            new CommandOption('entities-dir', null, 'The directory where the entities are', false)
         ];
     }
 
@@ -50,7 +50,7 @@ class MigrationDiffCommand implements CommandInterface
         $io = ConsoleOutput::create($output);
 
         $entitiesDir = $this->defaultEntitiesDir;
-        $printOutput = $input->getOptionValue('output');
+        $printOutput = $input->getOptionValue('verbose');
         if ($input->hasOption('entities-dir')) {
             $entitiesDir = $input->getOptionValue('entities-dir');
         }
@@ -67,20 +67,11 @@ class MigrationDiffCommand implements CommandInterface
             'Entities directory : ' . $entitiesDir
         ]);
 
-        $explorer = new FileExplorer($entitiesDir);
-        $files = $explorer->searchByExtension('php', true);
-        $entities = [];
-        foreach ($files as $file) {
-            $entityClass = self::extractNamespaceAndClass($file['path']);
-            if ($entityClass !== null && class_exists($entityClass) && is_subclass_of($entityClass, EntityInterface::class)) {
-                $entities[$file['path']] = $entityClass;
-            }
-        }
-
+        $entities = EntityExplorer::getEntities($entitiesDir);
         $io->title('Number of entities detected: ' . count($entities));
         $io->listKeyValues($entities);
 
-        $file = $this->paperMigration->diffEntities($entities);
+        $file = $this->paperMigration->generateMigrationFromEntities($entities);
         if ($file === null) {
             $io->info('No migration file was generated — all entities are already in sync with the database schema.');
             return;
@@ -96,55 +87,7 @@ class MigrationDiffCommand implements CommandInterface
             $io->listKeyValues($lines);
         }
 
-        $io->success('Migration file successfully generated: ' . $file);
+        $io->success('Migration file successfully generated: ' . $file);
     }
 
-    private static function extractNamespaceAndClass(string $filePath): ?string
-    {
-        if (!file_exists($filePath)) {
-            throw new \InvalidArgumentException('File not found: ' . $filePath);
-        }
-
-        $contents = file_get_contents($filePath);
-        $namespace = '';
-        $class = '';
-        $isExtractingNamespace = false;
-        $isExtractingClass = false;
-
-        foreach (token_get_all($contents) as $token) {
-            if (is_array($token) && $token[0] == T_NAMESPACE) {
-                $isExtractingNamespace = true;
-            }
-
-            if (is_array($token) && $token[0] == T_CLASS) {
-                $isExtractingClass = true;
-            }
-
-            if ($isExtractingNamespace) {
-                if (is_array($token) && in_array($token[0], [T_STRING, T_NS_SEPARATOR,  265 /* T_NAME_QUALIFIED For PHP 8*/])) {
-                    $namespace .= $token[1];
-                } else if ($token === ';') {
-                    $isExtractingNamespace = false;
-                }
-            }
-
-            if ($isExtractingClass) {
-                if (is_array($token) && $token[0] == T_STRING) {
-                    $class = $token[1];
-                    break;
-                }
-            }
-        }
-
-        if (empty($class)) {
-            return null;
-        }
-
-        $fullClass = $namespace ? $namespace . '\\' . $class : $class;
-        if (class_exists($fullClass) && is_subclass_of($fullClass, EntityInterface::class)) {
-            return $fullClass;
-        }
-
-        return null;
-    }
 }

+ 1 - 1
src/Command/Migration/MigrationMigrateCommand.php

@@ -56,7 +56,7 @@ class MigrationMigrateCommand implements CommandInterface
         }
 
         foreach ($successList as $version) {
-            $io->success('Migration successfully executed: version ' . $version);
+            $io->success('Migration successfully executed: version ' . $version);
         }
 
         if (empty($successList) && $error === null) {

+ 19 - 17
src/Michel/Package/MichelPaperORMPackage.php

@@ -13,7 +13,6 @@ use PhpDevCommunity\PaperORM\Command\ShowTablesCommand;
 use PhpDevCommunity\PaperORM\EntityManager;
 use PhpDevCommunity\PaperORM\EntityManagerInterface;
 use PhpDevCommunity\PaperORM\Migration\PaperMigration;
-use PhpDevCommunity\PaperORM\Parser\DSNParser;
 use Psr\Container\ContainerInterface;
 
 class MichelPaperORMPackage implements PackageInterface
@@ -25,23 +24,22 @@ class MichelPaperORMPackage implements PackageInterface
                 return $container->get(EntityManager::class);
             },
             EntityManager::class => static function (ContainerInterface $container) {
-                $dsn = $container->get('database.dsn');
-                if (!is_string($dsn) || empty($dsn)) {
-                    throw new LogicException('Database DSN not found, please set DATABASE_DSN in .env file or database.dsn in config');
-                }
-                $params = DSNParser::parse($container->get('database.dsn'));
-                $params['options']['debug'] = $container->get('michel.debug');
-                return new EntityManager($params);
+                return EntityManager::createFromDsn(
+                    $container->get('paper.orm.dsn'),
+                    $container->get('paper.orm.debug'),
+                    $container->get('paper.orm.logger'),
+                    []
+                );
             },
             PaperMigration::class => static function (ContainerInterface $container) {
                 return PaperMigration::create(
                     $container->get(EntityManagerInterface::class),
-                    $container->get('paper.migration.table'),
-                    $container->get('paper.migration.dir')
+                    $container->get('paper.orm.migrations_table'),
+                    $container->get('paper.orm.migrations_dir')
                 );
             },
             MigrationDiffCommand::class => static function (ContainerInterface $container) {
-                return new MigrationDiffCommand($container->get(PaperMigration::class), $container->get('paper.entity.dir'));
+                return new MigrationDiffCommand($container->get(PaperMigration::class), $container->get('paper.entity_dir'));
             },
             DatabaseDropCommand::class => static function (ContainerInterface $container) {
                 return new DatabaseDropCommand($container->get(EntityManagerInterface::class), $container->get('michel.environment'));
@@ -52,22 +50,26 @@ class MichelPaperORMPackage implements PackageInterface
     public function getParameters(): array
     {
         return [
-            'database.dsn' => getenv('DATABASE_DSN') ?? '',
-            'paper.migration.dir' => getenv('PAPER_MIGRATION_DIR') ?: function (ContainerInterface $container) {
-                $folder = $container->get('michel.project_dir') . DIRECTORY_SEPARATOR . 'migrations';
+            'paper.orm.dsn' => getenv('DATABASE_URL') ?? '',
+            'paper.orm.debug' => static function (ContainerInterface $container) {
+                return $container->get('michel.debug');
+            },
+            'paper.orm.logger' => null,
+            'paper.orm.entity_dir' => getenv('PAPER_ORM_ENTITY_DIR') ?: static function (ContainerInterface $container) {
+                $folder = $container->get('michel.project_dir') . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR . 'Entity';
                 if (!is_dir($folder)) {
                     mkdir($folder, 0777, true);
                 }
                 return $folder;
             },
-            'paper.migration.table' => getenv('PAPER_MIGRATION_TABLE') ?: 'mig_versions',
-            'paper.entity.dir' => getenv('PAPER_ENTITY_DIR') ?: function (ContainerInterface $container) {
-                $folder = $container->get('michel.project_dir') . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR . 'Entity';
+            'paper.orm.migrations_dir' => getenv('PAPER_ORM_MIGRATIONS_DIR') ?: static function (ContainerInterface $container) {
+                $folder = $container->get('michel.project_dir') . DIRECTORY_SEPARATOR . 'migrations';
                 if (!is_dir($folder)) {
                     mkdir($folder, 0777, true);
                 }
                 return $folder;
             },
+            'paper.orm.migrations_table' => getenv('PAPER_ORM_MIGRATIONS_TABLE') ?: 'mig_versions',
         ];
     }
 

+ 42 - 8
src/Migration/PaperMigration.php

@@ -118,22 +118,42 @@ SQL;
         }
     }
 
-    public function diffEntities(array $entities): ?string
+    public function generateMigrationFromEntities(array $entities): ?string
     {
-        return $this->diff(self::transformEntitiesToTables($entities));
+        $tables = self::transformEntitiesToTables($entities);
+        $diff = $this->computeDiffTables($tables);
+
+        if (!$diff) {
+            return null;
+        }
+
+        return $this->generateMigration($diff['up'], $diff['down']);
     }
 
-    public function diff(array $tables): ?string
+    /**
+     * Compute the SQL diff (UP part) for preview only.
+     * Does not generate any file.
+     */
+    public function getSqlDiffFromEntities(array $entities): array
     {
-        $statements = (new SchemaDiffGenerator($this->platform))->generateDiffStatements($tables);
-        $sqlUp = $statements['up'];
-        $sqlDown = $statements['down'];
+        $tables = self::transformEntitiesToTables($entities);
+        $diff = $this->computeDiffTables($tables);
 
-        if (empty($sqlUp)) {
+        return $diff['up'] ?: [];
+    }
+
+    /**
+     * Generate and write a migration file based on table differences.
+     * Returns the path of the generated file.
+     */
+    public function generateMigrationFromTables(array $tables): ?string
+    {
+        $diff = $this->computeDiffTables($tables);
+        if (!$diff) {
             return null;
         }
 
-        return $this->generateMigration($sqlUp, $sqlDown);
+        return $this->generateMigration($diff['up'], $diff['down']);
     }
 
     public function up(string $version): void
@@ -241,6 +261,20 @@ SQL;
         return true;
     }
 
+    private function computeDiffTables(array $tables): ?array
+    {
+        $statements = (new SchemaDiffGenerator($this->platform))->generateDiffStatements($tables);
+
+        if (empty($statements['up'])) {
+            return null;
+        }
+
+        return [
+            'up'   => $statements['up'],
+            'down' => $statements['down'] ?? '',
+        ];
+    }
+
     private static function contentUp(string $migration): string
     {
         return trim(str_replace('-- UP MIGRATION --', '', self::content($migration)[0]));

+ 9 - 5
src/Persistence/EntityPersistence.php

@@ -53,8 +53,8 @@ class EntityPersistence
             $qb->setValue($schema->quote($key), ":$key");
             $values[$key] = $value;
         }
+        $rows = $this->execute($qb, $values);
         $conn = $this->platform->getConnection();
-        $rows = $conn->executeStatement($qb, $values);
         $lastInsertId = $conn->getPdo()->lastInsertId();
         if ($rows > 0) {
             $primaryKeyColumn = ColumnMapper::getPrimaryKeyColumnName($entity);
@@ -105,8 +105,7 @@ class EntityPersistence
             $qb->set($schema->quote($key), ":$key");
             $values[$key] = $value;
         }
-        $conn = $this->platform->getConnection();
-        $rows = $conn->executeStatement($qb, $values);
+        $rows = $this->execute($qb, $values);
         if ($rows > 0 && $entity instanceof ProxyInterface) {
             $entity->__reset();
         }
@@ -128,8 +127,7 @@ class EntityPersistence
         $qb = QueryBuilder::delete($schema->quote($tableName))
             ->where(sprintf('%s = :id', $schema->quote(ColumnMapper::getPrimaryKeyColumnName($entity))));
 
-        $conn = $this->platform->getConnection();
-        $rows = $conn->executeStatement($qb,  [
+        $rows = $this->execute($qb, [
             'id' => $entity->getPrimaryKeyValue(),
         ]);
         if ($rows > 0) {
@@ -143,6 +141,12 @@ class EntityPersistence
         return $rows;
     }
 
+    private function execute(string $query, array $params = []): int
+    {
+        $conn = $this->platform->getConnection();
+        return $conn->executeStatement($query, $params);
+    }
+
     private function checkEntity(object $entity,  bool $requireManaged = false): void
     {
         if (!$entity instanceof EntityInterface) {

+ 75 - 0
src/Tools/EntityExplorer.php

@@ -0,0 +1,75 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Tools;
+
+use PhpDevCommunity\FileSystem\Tools\FileExplorer;
+use PhpDevCommunity\PaperORM\Entity\EntityInterface;
+
+final class EntityExplorer
+{
+
+    public static function getEntities(string $dir): array
+    {
+        $explorer = new FileExplorer($dir);
+        $files = $explorer->searchByExtension('php', true);
+        $entities = [];
+        foreach ($files as $file) {
+            $entityClass = self::extractNamespaceAndClass($file['path']);
+            if ($entityClass !== null && class_exists($entityClass) && is_subclass_of($entityClass, EntityInterface::class)) {
+                $entities[$file['path']] = $entityClass;
+            }
+        }
+
+        return $entities;
+    }
+
+    private static function extractNamespaceAndClass(string $filePath): ?string
+    {
+        if (!file_exists($filePath)) {
+            throw new \InvalidArgumentException('File not found: ' . $filePath);
+        }
+
+        $contents = file_get_contents($filePath);
+        $namespace = '';
+        $class = '';
+        $isExtractingNamespace = false;
+        $isExtractingClass = false;
+
+        foreach (token_get_all($contents) as $token) {
+            if (is_array($token) && $token[0] == T_NAMESPACE) {
+                $isExtractingNamespace = true;
+            }
+
+            if (is_array($token) && $token[0] == T_CLASS) {
+                $isExtractingClass = true;
+            }
+
+            if ($isExtractingNamespace) {
+                if (is_array($token) && in_array($token[0], [T_STRING, T_NS_SEPARATOR,  265 /* T_NAME_QUALIFIED For PHP 8*/])) {
+                    $namespace .= $token[1];
+                } else if ($token === ';') {
+                    $isExtractingNamespace = false;
+                }
+            }
+
+            if ($isExtractingClass) {
+                if (is_array($token) && $token[0] == T_STRING) {
+                    $class = $token[1];
+                    break;
+                }
+            }
+        }
+
+        if (empty($class)) {
+            return null;
+        }
+
+        $fullClass = $namespace ? $namespace . '\\' . $class : $class;
+        if (class_exists($fullClass) && is_subclass_of($fullClass, EntityInterface::class)) {
+            return $fullClass;
+        }
+
+        return null;
+    }
+
+}

+ 3 - 3
tests/MigrationTest.php

@@ -55,7 +55,7 @@ class MigrationTest extends TestCase
         $em = $paperMigration->getEntityManager();
         $driver = $em->getConnection()->getDriver();
         $em->getConnection()->close();
-        $migrationFile = $paperMigration->diffEntities([
+        $migrationFile = $paperMigration->generateMigrationFromEntities([
             UserTest::class,
             PostTest::class
         ]);
@@ -107,7 +107,7 @@ class MigrationTest extends TestCase
         $successList = $paperMigration->getSuccessList();
         $this->assertTrue(count($successList) === 1);
 
-        $migrationFile = $paperMigration->diffEntities([UserTest::class]);
+        $migrationFile = $paperMigration->generateMigrationFromEntities([UserTest::class]);
         $this->assertNull($migrationFile);
     }
 
@@ -119,7 +119,7 @@ class MigrationTest extends TestCase
         $userColumns = ColumnMapper::getColumns(UserTest::class);
         $userColumns[3] = (new StringColumn(null, 255, true, null, true))->bindProperty('email');
         $userColumns[] = (new IntColumn(null, false, 0))->bindProperty('childs');
-        $migrationFile = $paperMigration->diff([
+        $migrationFile = $paperMigration->generateMigrationFromTables([
             'user' => [
                 'columns' => $userColumns,
                 'indexes' => []