diff --git a/composer.json b/composer.json index 300ff37c1..966cb4510 100644 --- a/composer.json +++ b/composer.json @@ -43,7 +43,8 @@ "yiisoft/html": "^3.0@dev", "yiisoft/security": "^3.0@dev", "cycle/orm": "^1.1", - "cycle/annotated": "^2.0" + "cycle/annotated": "^2.0", + "cycle/migrations": "^1.0" }, "require-dev": { "hiqdev/composer-config-plugin": "^1.0@dev" diff --git a/config/common.php b/config/common.php index bfa44be2f..8c7cff154 100644 --- a/config/common.php +++ b/config/common.php @@ -1,10 +1,13 @@ '@root/src', '@runtime' => '@root/runtime', ], - CacheInterface::class => [ - '__class' => Cache::class, - 'handler' => [ - '__class' => ArrayCache::class, - ], - ], + Psr\SimpleCache\CacheInterface::class => ArrayCache::class, + CacheInterface::class => Cache::class, LoggerInterface::class => new LoggerFactory(), FileRotatorInterface::class => [ '__class' => FileRotator::class, @@ -36,9 +35,9 @@ 10 ] ], - \Swift_Transport::class => \Swift_SmtpTransport::class, - \Swift_SmtpTransport::class => [ - '__class' => \Swift_SmtpTransport::class, + Swift_Transport::class => Swift_SmtpTransport::class, + Swift_SmtpTransport::class => [ + '__class' => Swift_SmtpTransport::class, '__construct()' => [ 'host' => $params['mailer.host'], 'port' => $params['mailer.port'], @@ -49,6 +48,17 @@ ], MailerInterface::class => new MailerFactory(), + + // Cycle DBAL + DatabaseManager::class => new CycleDbalFactory(), // Cycle ORM ORMInterface::class => new CycleOrmFactory(), + // Cycle Entity Finder + CycleOrmHelper::class => [ + '__class' => CycleOrmHelper::class, + 'addEntityPaths()' => [ + 'paths' => $params['entityPaths'], + ], + ], + ]; diff --git a/config/console.php b/config/console.php index f279d0262..f1aeb8913 100644 --- a/config/console.php +++ b/config/console.php @@ -1,8 +1,29 @@ CreateUser::class, + + // Cycle Migrations + Migrator::class => new CycleMigratorFactory(), + // Migration Config + MigrationConfig::class => function (ContainerInterface $container) { + $aliases = $container->get(Aliases::class); + return new MigrationConfig([ + 'directory' => $aliases->get('@src/Console/Migration'), + 'namespace' => 'App\Console\Migration', + 'table' => 'migration', + 'safe' => false, + ]); + } ]; diff --git a/config/params.php b/config/params.php index 5cac1ad2a..2d0370de9 100644 --- a/config/params.php +++ b/config/params.php @@ -1,6 +1,10 @@ 'smtp.example.com', @@ -13,5 +17,13 @@ 'commands' => [ 'user/create' => CreateUser::class, + 'migrate/generate' => MigrateGenerate::class, + 'migrate/up' => MigrateUp::class, + 'migrate/down' => MigrateDown::class, + 'migrate/list' => MigrateList::class, ], + + 'entityPaths' => [ + '@src/Entity' + ] ]; diff --git a/src/ConsoleCommand/CreateUser.php b/src/Console/Command/CreateUser.php similarity index 98% rename from src/ConsoleCommand/CreateUser.php rename to src/Console/Command/CreateUser.php index f733c543b..ac95499c1 100644 --- a/src/ConsoleCommand/CreateUser.php +++ b/src/Console/Command/CreateUser.php @@ -1,8 +1,7 @@ config = $conf; + $this->migrator = $migrator; + $this->cycleOrmHelper = $cycleOrmHelper; + } + + public function configure(): void + { + $this + ->setDescription('Rollback last migration'); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + // drop cached schema + $this->cycleOrmHelper->dropCurrentSchemaCache(); + + $list = $this->migrator->getMigrations(); + $output->writeln('' . count($list) . ' migrations found in ' . $this->config->getDirectory() . ''); + + $statuses = [ + State::STATUS_UNDEFINED => 'undefined', + State::STATUS_PENDING => 'pending', + State::STATUS_EXECUTED => 'executed', + ]; + try { + $migration = $this->migrator->rollback(); + if (!$migration instanceof MigrationInterface) { + throw new \Exception('Migration not found'); + } + + $state = $migration->getState(); + $status = $state->getStatus(); + $output->writeln($state->getName() . ': ' . ($statuses[$status] ?? $status)); + } catch (\Throwable $e) { + $output->writeln([ + '===================', + 'Error!', + $e->getMessage(), + ]); + return; + } + } +} diff --git a/src/Console/Command/MigrateGenerate.php b/src/Console/Command/MigrateGenerate.php new file mode 100644 index 000000000..78f7389d6 --- /dev/null +++ b/src/Console/Command/MigrateGenerate.php @@ -0,0 +1,54 @@ +migrator = $migrator; + $this->config = $conf; + $this->cycleHelper = $cycleHelper; + } + + public function configure(): void + { + $this->setDescription('Generates a migration'); + } + + protected function execute(InputInterface $input, OutputInterface $output): void + { + // check existing unapplied migrations + $list = $this->migrator->getMigrations(); + foreach ($list as $migration) { + if ($migration->getState()->getStatus() !== State::STATUS_EXECUTED) { + $output->writeln('Outstanding migrations found, run `migrate/up` first.'); + return; + } + } + + $this->cycleHelper->generateMigration($this->migrator, $this->config); + } +} diff --git a/src/Console/Command/MigrateList.php b/src/Console/Command/MigrateList.php new file mode 100644 index 000000000..3ccd0b74d --- /dev/null +++ b/src/Console/Command/MigrateList.php @@ -0,0 +1,54 @@ +config = $conf; + $this->migrator = $migrator; + parent::__construct(); + } + + public function configure(): void + { + $this + ->setDescription('Print list of all migrations'); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $list = $this->migrator->getMigrations(); + $output->writeln('' . count($list) . ' migrations found in ' . $this->config->getDirectory() . ':'); + + $statuses = [ + State::STATUS_UNDEFINED => 'undefined', + State::STATUS_PENDING => 'pending', + State::STATUS_EXECUTED => 'executed', + ]; + $list = $this->migrator->getMigrations(); + + foreach ($list as $migration) { + $state = $migration->getState(); + $output->writeln($state->getName() . ' [' . ($statuses[$state->getStatus()] ?? '?') . ']'); + } + } +} diff --git a/src/Console/Command/MigrateUp.php b/src/Console/Command/MigrateUp.php new file mode 100644 index 000000000..c993bd980 --- /dev/null +++ b/src/Console/Command/MigrateUp.php @@ -0,0 +1,80 @@ +config = $conf; + $this->migrator = $migrator; + $this->cycleOrmHelper = $cycleOrmHelper; + } + + public function configure(): void + { + $this + ->setDescription('Execute all new migrations'); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + // drop cached schema + $this->cycleOrmHelper->dropCurrentSchemaCache(); + + $list = $this->migrator->getMigrations(); + $output->writeln('' . count($list) . ' migrations found in ' . $this->config->getDirectory() . ''); + + $limit = PHP_INT_MAX; + $statuses = [ + State::STATUS_UNDEFINED => 'undefined', + State::STATUS_PENDING => 'pending', + State::STATUS_EXECUTED => 'executed', + ]; + try { + do { + $migration = $this->migrator->run(); + if (!$migration instanceof MigrationInterface) { + break; + } + + $state = $migration->getState(); + $status = $state->getStatus(); + $output->writeln($state->getName() . ': ' . ($statuses[$status] ?? $status)); + } while (--$limit > 0); + } catch (\Throwable $e) { + $output->writeln([ + '===================', + 'Error!', + $e->getMessage(), + ]); + return; + } + } +} diff --git a/src/Console/Migration/.gitignore b/src/Console/Migration/.gitignore new file mode 100644 index 000000000..f935021a8 --- /dev/null +++ b/src/Console/Migration/.gitignore @@ -0,0 +1 @@ +!.gitignore diff --git a/src/Controller.php b/src/Controller.php index 35cf6db3b..ccb77a3d8 100644 --- a/src/Controller.php +++ b/src/Controller.php @@ -64,7 +64,7 @@ private function findLayoutFile(?string $file): ?string return $file; } - return $file . '.' . $this->view->defaultExtension; + return $file . '.' . $this->view->getDefaultExtension(); } abstract protected function getId(): string; diff --git a/src/Entity/User.php b/src/Entity/User.php index ee204c82d..bf3587678 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -8,7 +8,7 @@ use Yiisoft\Yii\Web\User\IdentityInterface; /** - * @Entity + * @Entity(repository="App\Repository\UserRepository") */ class User implements IdentityInterface { diff --git a/src/Factory/CycleDbalFactory.php b/src/Factory/CycleDbalFactory.php new file mode 100644 index 000000000..294bdb7f5 --- /dev/null +++ b/src/Factory/CycleDbalFactory.php @@ -0,0 +1,37 @@ +get(Aliases::class); + $databasePath = $aliases->get('@runtime/database.db'); + + $dbal = new DatabaseManager( + new DatabaseConfig([ + 'default' => 'default', + 'databases' => [ + 'default' => ['connection' => 'sqlite'] + ], + 'connections' => [ + 'sqlite' => [ + 'driver' => SQLiteDriver::class, + 'connection' => 'sqlite:' . $databasePath, + 'username' => '', + 'password' => '', + ] + ] + ]) + ); + + return $dbal; + } +} diff --git a/src/Factory/CycleMigratorFactory.php b/src/Factory/CycleMigratorFactory.php new file mode 100644 index 000000000..e2a3d7677 --- /dev/null +++ b/src/Factory/CycleMigratorFactory.php @@ -0,0 +1,24 @@ +get(MigrationConfig::class); + + $dbal = $container->get(DatabaseManager::class); + + $migrator = new Migrator($migConf, $dbal, new FileRepository($migConf)); + // Init migration table + $migrator->configure(); + return $migrator; + } +} diff --git a/src/Factory/CycleOrmFactory.php b/src/Factory/CycleOrmFactory.php index ef92ba22a..830b33e55 100644 --- a/src/Factory/CycleOrmFactory.php +++ b/src/Factory/CycleOrmFactory.php @@ -2,86 +2,25 @@ namespace App\Factory; -use Cycle\Annotated; +use App\Helper\CycleOrmHelper; use Cycle\ORM\Factory; use Cycle\ORM\ORM; use Cycle\ORM\Schema; -use Cycle\ORM\SchemaInterface; -use Cycle\Schema\Compiler; -use Cycle\Schema\Generator\GenerateRelations; -use Cycle\Schema\Generator\GenerateTypecast; -use Cycle\Schema\Generator\RenderRelations; -use Cycle\Schema\Generator\RenderTables; -use Cycle\Schema\Generator\ResetTables; -use Cycle\Schema\Generator\SyncTables; -use Cycle\Schema\Generator\ValidateEntities; -use Cycle\Schema\Registry; -use Doctrine\Common\Annotations\AnnotationRegistry; use Psr\Container\ContainerInterface; -use Spiral\Database\Config\DatabaseConfig; use Spiral\Database\DatabaseManager; -use Spiral\Database\Driver\SQLite\SQLiteDriver; -use Spiral\Tokenizer\ClassLocator; -use Symfony\Component\Finder\Finder; -use Yiisoft\Aliases\Aliases; class CycleOrmFactory { + /** @var ContainerInterface */ + private $container; + public function __invoke(ContainerInterface $container) { - $aliases = $container->get(Aliases::class); - - $entityPaths = [ - $aliases->get('@src/Entity') - ]; - - $databasePath = $aliases->get('@runtime/database.db'); + $this->container = $container; + $dbal = $container->get(DatabaseManager::class); - $dbal = new DatabaseManager( - new DatabaseConfig([ - 'default' => 'default', - 'databases' => [ - 'default' => ['connection' => 'sqlite'] - ], - 'connections' => [ - 'sqlite' => [ - 'driver' => SQLiteDriver::class, - 'connection' => 'sqlite:' . $databasePath, - 'username' => '', - 'password' => '', - ] - ] - ]) - ); - - // autoload annotations - AnnotationRegistry::registerLoader('class_exists'); - - $schema = $this->getSchema($entityPaths, $dbal); + $schema = new Schema($this->container->get(CycleOrmHelper::class)->getCurrentSchemaArray()); return (new ORM(new Factory($dbal)))->withSchema($schema); } - - private function getSchema(array $entityPaths, DatabaseManager $dbal): SchemaInterface - { - $finder = (new Finder()) - ->files() - ->in($entityPaths); - - $classLocator = new ClassLocator($finder); - - $schema = (new Compiler())->compile(new Registry($dbal), [ - new Annotated\Embeddings($classLocator), // register embeddable entities - new Annotated\Entities($classLocator), // register annotated entities - new ResetTables(), // re-declared table schemas (remove columns) - new GenerateRelations(), // generate entity relations - new ValidateEntities(), // make sure all entity schemas are correct - new RenderTables(), // declare table schemas - new RenderRelations(), // declare relation keys and indexes - new SyncTables(), // sync table changes to database - new GenerateTypecast(), // typecast non string columns - ]); - - return new Schema($schema); - } } diff --git a/src/Helper/CycleOrmHelper.php b/src/Helper/CycleOrmHelper.php new file mode 100644 index 000000000..0ea30ab1d --- /dev/null +++ b/src/Helper/CycleOrmHelper.php @@ -0,0 +1,129 @@ +aliases = $aliases; + $this->dbal = $dbal; + $this->cache = $cache; + } + + /** + * @param string|string[] $paths + */ + public function addEntityPaths($paths): void + { + $paths = (array)$paths; + foreach ($paths as $path) { + $this->entityPaths[] = $path; + } + } + + public function dropCurrentSchemaCache(): void + { + $this->cache->delete($this->cacheKey); + } + + public function generateMigration(Migrator $migrator, MigrationConfig $config): void + { + $classLocator = $this->getEntityClassLocator(); + + // autoload annotations + AnnotationRegistry::registerLoader('class_exists'); + + (new Compiler())->compile(new Registry($this->dbal), [ + new Annotated\Embeddings($classLocator), // register embeddable entities + new Annotated\Entities($classLocator, null, $this->tableNaming), // register annotated entities + new ResetTables(), // re-declared table schemas (remove columns) + new GenerateRelations(), // generate entity relations + new ValidateEntities(), // make sure all entity schemas are correct + new RenderTables(), // declare table schemas + new RenderRelations(), // declare relation keys and indexes + new GenerateMigrations($migrator->getRepository(), $config), // generate migrations + new GenerateTypecast(), // typecast non string columns + ]); + } + + public function getCurrentSchemaArray($fromCache = true): array + { + $getSchemaArray = function () { + $classLocator = $this->getEntityClassLocator(); + // autoload annotations + AnnotationRegistry::registerLoader('class_exists'); + + return (new Compiler())->compile(new Registry($this->dbal), [ + new Annotated\Embeddings($classLocator), // register embeddable entities + new Annotated\Entities($classLocator, null, $this->tableNaming), // register annotated entities + new ResetTables(), // re-declared table schemas (remove columns) + new GenerateRelations(), // generate entity relations + new ValidateEntities(), // make sure all entity schemas are correct + new RenderTables(), // declare table schemas + new RenderRelations(), // declare relation keys and indexes + new SyncTables(), // sync table changes to database + new GenerateTypecast(), // typecast non string columns + ]); + }; + + if ($fromCache) { + return $this->cache->getOrSet($this->cacheKey, $getSchemaArray); + } else { + $schema = $getSchemaArray(); + $this->cache->set($this->cacheKey, $schema); + return $schema; + } + } + + private function getEntityClassLocator(): ClassLocator + { + $list = []; + foreach ($this->entityPaths as $path) { + $list[] = $this->aliases->get($path); + } + $finder = (new Finder()) + ->files() + ->in($list); + + return new ClassLocator($finder); + } +} diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index 9de33ddd2..0fa55a640 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -4,26 +4,25 @@ use App\Entity\User; use Cycle\ORM\ORMInterface; +use Cycle\ORM\Select; use Yiisoft\Yii\Web\User\IdentityInterface; use Yiisoft\Yii\Web\User\IdentityRepositoryInterface; -class UserRepository implements IdentityRepositoryInterface +class UserRepository extends Select\Repository implements IdentityRepositoryInterface { - private $orm; - - public function __construct(ORMInterface $orm) + public function __construct(ORMInterface $orm, $role = User::class) { - $this->orm = $orm; + parent::__construct(new Select($orm, $role)); } private function findIdentityBy(string $field, string $value): ?IdentityInterface { - return $this->orm->getRepository(User::class)->findOne([$field => $value]); + return $this->findOne([$field => $value]); } public function findIdentity(string $id): ?IdentityInterface { - return $this->findIdentityBy('id', $id); + return $this->findByPK($id); } public function findIdentityByToken(string $token, string $type): ?IdentityInterface @@ -31,7 +30,7 @@ public function findIdentityByToken(string $token, string $type): ?IdentityInter return $this->findIdentityBy('token', $token); } - public function findByLogin(string $login): ?User + public function findByLogin(string $login): ?IdentityInterface { return $this->findIdentityBy('login', $login); }