diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6537ca4 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 diff --git a/README.md b/README.md index c519ae8..b144c4f 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,18 @@ # orm-bundle [Cycle ORM](https://github.com/cycle/orm) integration bundle for MakiseCo Framework -## Usage -`ORMProvider` should be added to app provider. +## Installation +* Register [ORMProvider](src/ORMProvider.php) +* Register [commands](src/Console/Commands) + +## Available commands +* `make:migration` - Create new migration +* `migrate` - Run database migrations +* `migrate:replay` - Replay (down, up) one or multiple migrations +* `migrate:rollback` - Rollback one (default) or multiple migrations +* `migrate:status` - Get list of all available migrations and their statuses + +## Configuration Create new `database.php` config file in config folder: ```php diff --git a/composer.json b/composer.json index 51fd55a..defdd4f 100644 --- a/composer.json +++ b/composer.json @@ -1,35 +1,41 @@ { - "name": "makise-co/orm-bundle", - "description": "Cycle ORM integration with MakiseCo Framework", - "type": "library", - "license": "MIT", - "authors": [ - { - "name": "Dmitry K.", - "email": "coder1994@gmail.com" + "name": "makise-co/orm-bundle", + "description": "Cycle ORM integration with MakiseCo Framework", + "type": "library", + "license": "MIT", + "authors": [ + { + "name": "Dmitry K.", + "email": "coder1994@gmail.com" + } + ], + "minimum-stability": "dev", + "prefer-stable": true, + "config": { + "preferred-install": "dist", + "sort-packages": true + }, + "require": { + "php": "^7.4", + "makise-co/framework": "~2.0.0", + "makise-co/postgres-spiral-driver": "^1.0", + "cycle/orm": "^1.2", + "cycle/annotated": "^2.0", + "cycle/migrations": "^1.0", + "cycle/proxy-factory": "^1.2" + }, + "require-dev": { + "phpunit/phpunit": "^9.0", + "swoole/ide-helper": "^4.5" + }, + "autoload": { + "psr-4": { + "MakiseCo\\ORM\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "MakiseCo\\ORM\\Tests\\": "tests/" + } } - ], - "require": { - "php": "^7.4", - "makise-co/framework": "^1.0", - "makise-co/postgres-spiral-driver": "^1.0", - "cycle/orm": "^1.2", - "cycle/annotated": "^2.0", - "cycle/migrations": "^1.0", - "cycle/proxy-factory": "^1.2" - }, - "require-dev": { - "phpunit/phpunit": "~8.0", - "swoole/ide-helper": "^4.5" - }, - "autoload": { - "psr-4": { - "MakiseCo\\ORM\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "MakiseCo\\ORM\\Tests\\": "tests/" - } - } } diff --git a/config/app.php b/config/app.php new file mode 100644 index 0000000..b10ba87 --- /dev/null +++ b/config/app.php @@ -0,0 +1,28 @@ + + */ + +declare(strict_types=1); + +return [ + 'name' => 'makise-co', + + 'providers' => [ + \MakiseCo\Log\LoggerServiceProvider::class, + \MakiseCo\Event\EventDispatcherServiceProvider::class, + \MakiseCo\Console\ConsoleServiceProvider::class, + \MakiseCo\ORM\ORMProvider::class, + ], + + 'commands' => [ + \MakiseCo\ORM\Console\Commands\MakeCommand::class, + \MakiseCo\ORM\Console\Commands\MigrateCommand::class, + \MakiseCo\ORM\Console\Commands\ReplayCommand::class, + \MakiseCo\ORM\Console\Commands\RollbackCommand::class, + \MakiseCo\ORM\Console\Commands\StatusCommand::class, + ], +]; diff --git a/config/logging.php b/config/logging.php new file mode 100644 index 0000000..6946a1d --- /dev/null +++ b/config/logging.php @@ -0,0 +1,24 @@ + + */ + +declare(strict_types=1); + +use function MakiseCo\Env\env; + +return [ + [ + 'handler' => \MakiseCo\Log\Handler\StreamHandler::class, + 'formatter' => \MakiseCo\Log\Formatter\JsonFormatter::class, + // parameters passed to the handler constructor + 'handler_with' => [ + 'stream' => env('LOG_CHANNEL', 'php://stdout'), + ], + // parameters passed to the formatter constructor + 'formatter_with' => [], + ], +]; diff --git a/src/Console/Commands/AbstractCommand.php b/src/Console/Commands/AbstractCommand.php index be7916e..64f4cb2 100644 --- a/src/Console/Commands/AbstractCommand.php +++ b/src/Console/Commands/AbstractCommand.php @@ -10,26 +10,28 @@ namespace MakiseCo\ORM\Console\Commands; -use MakiseCo\ApplicationInterface; use MakiseCo\Console\Commands\AbstractCommand as BaseAbstractCommand; use Spiral\Migrations\Config\MigrationConfig; use Spiral\Migrations\Migrator; use Symfony\Component\Console\Helper\QuestionHelper; +use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Question\ConfirmationQuestion; abstract class AbstractCommand extends BaseAbstractCommand { - protected ?Migrator $migrator = null; + protected Migrator $migrator; + protected MigrationConfig $config; - protected ?MigrationConfig $config = null; - - public function __construct(ApplicationInterface $app, Migrator $migrator) + protected function initialize(InputInterface $input, OutputInterface $output): void { + $migrator = $this->makise->getContainer()->get(Migrator::class); + $this->migrator = $migrator; $this->config = $migrator->getConfig(); - parent::__construct($app); + parent::initialize($input, $output); } /** @@ -87,4 +89,14 @@ protected function askConfirmation(): bool new ConfirmationQuestion('Would you like to continue? ') ); } + + /** + * @inheritDoc + * @return null[] + */ + public function getServices(): array + { + // no any services should be loaded + return [null]; + } } diff --git a/src/Console/Commands/InitCommand.php b/src/Console/Commands/InitCommand.php deleted file mode 100644 index 1d877af..0000000 --- a/src/Console/Commands/InitCommand.php +++ /dev/null @@ -1,23 +0,0 @@ - - */ - -declare(strict_types=1); - -namespace MakiseCo\ORM\Console\Commands; - -class InitCommand extends AbstractCommand -{ - protected string $name = 'migrate:init'; - protected string $description = 'Init migrations component (create migrations table)'; - - public function handle(): void - { - $this->migrator->configure(); - $this->info('Migrations table were successfully created'); - } -} diff --git a/src/Http/CleanOrmHeapMiddleware.php b/src/Http/CleanOrmHeapMiddleware.php deleted file mode 100644 index 8b47686..0000000 --- a/src/Http/CleanOrmHeapMiddleware.php +++ /dev/null @@ -1,44 +0,0 @@ - - */ - -declare(strict_types=1); - -namespace MakiseCo\ORM\Http; - -use Cycle\ORM\ORMInterface; -use Psr\Container\ContainerInterface; -use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Server\MiddlewareInterface; -use Psr\Http\Server\RequestHandlerInterface; - -class CleanOrmHeapMiddleware implements MiddlewareInterface -{ - private ContainerInterface $container; - private ?ORMInterface $orm = null; - - public function __construct(ContainerInterface $container) - { - $this->container = $container; - } - - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface - { - $response = $handler->handle($request); - - // Lazy ORM resolving to prevent ORM resolution in the master process (when it compiles HTTP routes) - if ($this->orm === null) { - $this->orm = $this->container->get(ORMInterface::class); - } - - // cleaning ORM heap after each request - $this->orm->getHeap()->clean(); - - return $response; - } -} diff --git a/src/ORMProvider.php b/src/ORMProvider.php index 61139a8..35c2752 100644 --- a/src/ORMProvider.php +++ b/src/ORMProvider.php @@ -14,9 +14,8 @@ use Cycle\ORM; use Cycle\Schema; use DI\Container; +use MakiseCo\Bootstrapper; use MakiseCo\Config\ConfigRepositoryInterface; -use MakiseCo\Http\Events\WorkerExit; -use MakiseCo\Http\Events\WorkerStarted; use MakiseCo\Providers\ServiceProviderInterface; use Spiral\Database\Config\DatabaseConfig; use Spiral\Database\DatabaseInterface; @@ -24,27 +23,32 @@ use Spiral\Database\DatabaseProviderInterface; use Spiral\Migrations; use Spiral\Tokenizer; -use Symfony\Component\EventDispatcher\EventDispatcher; class ORMProvider implements ServiceProviderInterface { + public const SERVICE_NAME = 'cycle_orm'; + public function register(Container $container): void { - $container->get(EventDispatcher::class)->addListener(WorkerStarted::class, function () use ($container) { - // initialize ORM at service start to prevent concurrent initialization - $container->get(ORM\ORMInterface::class); - }); - - $container->get(EventDispatcher::class)->addListener(WorkerExit::class, function () use ($container) { - // close connection pools when worker should stop - foreach ($container->get(DatabaseManager::class)->getDatabases() as $database) { - $database->getDriver(DatabaseInterface::READ)->disconnect(); - $database->getDriver(DatabaseInterface::WRITE)->disconnect(); - } - }); + // cycle ORM bootstrapping + $container->get(Bootstrapper::class)->addService( + self::SERVICE_NAME, + static function () use ($container) { + // initialize ORM at service start to prevent concurrent initialization + $container->get(ORM\ORMInterface::class); + }, + static function () use ($container) { + $dbal = $container->get(DatabaseManager::class); + + // close connection pools when worker should stop + foreach ($dbal->getDatabases() as $database) { + $database->getDriver(DatabaseInterface::READ)->disconnect(); + $database->getDriver(DatabaseInterface::WRITE)->disconnect(); + } - // alias DatabaseProviderInterface to its implementation - $container->set(DatabaseProviderInterface::class, fn() => $container->get(DatabaseManager::class)); + $container->get(ORM\ORMInterface::class)->getHeap()->clean(); + } + ); $container->set( DatabaseManager::class, @@ -55,6 +59,9 @@ static function (ConfigRepositoryInterface $config) { } ); + // alias DatabaseProviderInterface to its implementation + $container->set(DatabaseProviderInterface::class, \DI\get(DatabaseManager::class)); + // migrator $container->set( Migrations\Migrator::class, @@ -71,53 +78,56 @@ static function (DatabaseManager $dbal, ConfigRepositoryInterface $config) { } ); - // alias ORMInterface to its implementation - $container->set(ORM\ORMInterface::class, fn() => $container->get(ORM\ORM::class)); + // register ORMInterface implementation + $container->set(ORM\ORMInterface::class, \Closure::fromCallable([$this, 'createORM'])); + } - $container->set( - ORM\ORM::class, - static function (Container $container, DatabaseManager $dbal, ConfigRepositoryInterface $config) { - // Class locator - $cl = (new Tokenizer\Tokenizer( - new Tokenizer\Config\TokenizerConfig( - [ - 'directories' => $config->get('database.orm.entityPath', []), - 'exclude' => $config->get('database.orm.entityExclude', []) - ] - ) - ))->classLocator(); - - $schema = (new Schema\Compiler())->compile( - new Schema\Registry($dbal), - [ - new Annotated\Embeddings($cl), // register annotated embeddings - new Annotated\Entities($cl), // register annotated entities - new Schema\Generator\ResetTables(), // re-declared table schemas (remove columns) - new Annotated\MergeColumns(), // register non field columns (table level) - new Schema\Generator\GenerateRelations(), // generate entity relations - new Schema\Generator\ValidateEntities(), // make sure all entity schemas are correct - new Schema\Generator\RenderTables(), // declare table schemas - new Schema\Generator\RenderRelations(), // declare relation keys and indexes - new Annotated\MergeIndexes(), // register non entity indexes (table level) - new Schema\Generator\GenerateTypecast(), // typecast non string columns - ] - ); + protected function createORM( + Container $container, + DatabaseManager $dbal, + ConfigRepositoryInterface $config + ): ORM\ORMInterface { + // Class locator + $cl = (new Tokenizer\Tokenizer( + new Tokenizer\Config\TokenizerConfig( + [ + 'directories' => $config->get('database.orm.entityPath', []), + 'exclude' => $config->get('database.orm.entityExclude', []) + ] + ) + ))->classLocator(); + + $schema = (new Schema\Compiler())->compile( + new Schema\Registry($dbal), + [ + new Annotated\Embeddings($cl), // register annotated embeddings + new Annotated\Entities($cl), // register annotated entities + new Schema\Generator\ResetTables(), // re-declared table schemas (remove columns) + new Annotated\MergeColumns(), // register non field columns (table level) + new Schema\Generator\GenerateRelations(), // generate entity relations + new Schema\Generator\ValidateEntities(), // make sure all entity schemas are correct + new Schema\Generator\RenderTables(), // declare table schemas + new Schema\Generator\RenderRelations(), // declare relation keys and indexes + new Annotated\MergeIndexes(), // register non entity indexes (table level) + new Schema\Generator\GenerateTypecast(), // typecast non string columns + ] + ); - $orm = new ORM\ORM( - new ORM\Factory($dbal, null, null, null), - new ORM\Schema($schema) - ); + $orm = new ORM\ORM( + new ORM\Factory($dbal, null, null, null), + new ORM\Schema($schema) + ); - $proxyFactory = $container->make(\Cycle\ORM\Promise\ProxyFactory::class); - $orm = $orm->withPromiseFactory($proxyFactory); + if ((bool)$config->get('database.orm.enableProxy', true)) { + $proxyFactory = $container->make(\Cycle\ORM\Promise\ProxyFactory::class); + $orm = $orm->withPromiseFactory($proxyFactory); + } - // enable coroutine safe heap - if ((bool)$config->get('database.orm.enableCoroutineHeap', true)) { - $orm = $orm->withHeap(new CoroutineHeap()); - } + // enable coroutine safe heap + if ((bool)$config->get('database.orm.enableCoroutineHeap', true)) { + $orm = $orm->withHeap(new CoroutineHeap()); + } - return $orm; - } - ); + return $orm; } } diff --git a/src/Testing/DatabaseTransactions.php b/src/Testing/DatabaseTransactions.php new file mode 100644 index 0000000..e2d0ab0 --- /dev/null +++ b/src/Testing/DatabaseTransactions.php @@ -0,0 +1,50 @@ + + */ + +declare(strict_types=1); + +namespace MakiseCo\ORM\Testing; + +use Spiral\Database\DatabaseManager; + +trait DatabaseTransactions +{ + protected function bootDatabaseTransactions(): void + { + /* @var DatabaseManager $db */ + $db = $this->container->get(DatabaseManager::class); + + foreach ($this->connectionsToTransact() as $connection) { + $db->database($connection)->begin(); + } + } + + protected function cleanupDatabaseTransactions(): void + { + /* @var DatabaseManager $db */ + $db = $this->container->get(DatabaseManager::class); + + foreach ($this->connectionsToTransact() as $connection) { + try { + $db->database($connection)->rollback(); + } catch (\Throwable $e) { + $this->addWarning("Unable to ROLLBACK transaction on \"{$connection}\" connection: {$e->getMessage()}"); + } + } + } + + /** + * The database connections that should have transactions. + * + * @return string[] + */ + protected function connectionsToTransact(): array + { + return property_exists($this, 'connectionsToTransact') ? $this->connectionsToTransact : []; + } +} diff --git a/tests/BundleTest.php b/tests/ORMProviderTest.php similarity index 79% rename from tests/BundleTest.php rename to tests/ORMProviderTest.php index 590c19e..1b79054 100644 --- a/tests/BundleTest.php +++ b/tests/ORMProviderTest.php @@ -10,36 +10,31 @@ namespace MakiseCo\ORM\Tests; -use Cycle\ORM\ORM; +use Cycle\ORM\ORMInterface; use Cycle\ORM\Transaction; use DI\Container; -use MakiseCo\Config\ConfigRepositoryInterface; -use MakiseCo\Config\Repository; -use MakiseCo\ORM\ORMProvider; +use MakiseCo\Application; use MakiseCo\ORM\Tests\Entity\User; use Spiral\Database\DatabaseInterface; use Spiral\Database\DatabaseManager; use Swoole\Coroutine; -class BundleTest extends CoroTestCase +class ORMProviderTest extends CoroTestCase { - private Container $container; - - private DatabaseManager $dbal; + protected Application $app; + protected Container $container; + protected DatabaseManager $dbal; protected function setUp(): void { - $this->container = $container = new Container(); - $provider = new ORMProvider(); - - $config = new Repository(); - $config['database'] = require __DIR__ . '/database.php'; - - $container->set(ConfigRepositoryInterface::class, $config); + $this->app = new Application( + dirname(__DIR__), + dirname(__DIR__) . '/tests/config' + ); - $provider->register($container); + $this->container = $this->app->getContainer(); - $this->dbal = $container->get(DatabaseManager::class); + $this->dbal = $this->container->get(DatabaseManager::class); $database = $this->dbal->database('default'); @@ -69,7 +64,7 @@ protected function createSchema(DatabaseInterface $database): \Spiral\Database\S public function testOrmWorks(): void { - $orm = $this->container->get(ORM::class); + $orm = $this->container->get(ORMInterface::class); $repo = $orm->getRepository(User::class); $rootUser = new User(); @@ -103,7 +98,7 @@ public function testOrmWorks(): void public function testCoroutineHeap(): void { - $orm = $this->container->get(ORM::class); + $orm = $this->container->get(ORMInterface::class); $rootUser = new User(); $rootUser->name = 'Root'; @@ -131,4 +126,4 @@ public function testCoroutineHeap(): void throw $res; } } -} \ No newline at end of file +} diff --git a/tests/Testing/DatabaseTransactionsTest.php b/tests/Testing/DatabaseTransactionsTest.php new file mode 100644 index 0000000..809c55f --- /dev/null +++ b/tests/Testing/DatabaseTransactionsTest.php @@ -0,0 +1,68 @@ + + */ + +declare(strict_types=1); + +namespace MakiseCo\ORM\Tests\Testing; + +use DI\Container; +use MakiseCo\Application; +use MakiseCo\ORM\Testing\DatabaseTransactions; +use MakiseCo\Testing\CoroutineTestCase; +use Spiral\Database\DatabaseManager; + +class DatabaseTransactionsTest extends CoroutineTestCase +{ + use DatabaseTransactions; + + protected array $connectionsToTransact = ['default']; + + protected Application $app; + protected Container $container; + + protected int $txId; + + protected function setUp(): void + { + $this->app = new Application( + dirname(__DIR__ . '/../'), + dirname(__DIR__) . '/../tests/config/' + ); + + $this->container = $this->app->getContainer(); + + $this->bootDatabaseTransactions(); + + /** @var DatabaseManager $dbal */ + $dbal = $this->container->get(DatabaseManager::class); + $this->txId = $dbal + ->database('default') + ->query('SELECT txid_current();') + ->fetchAll()[0]['txid_current']; + } + + protected function tearDown(): void + { + $this->cleanupDatabaseTransactions(); + /** @var DatabaseManager $dbal */ + $dbal = $this->container->get(DatabaseManager::class); + $dbal->database('default')->getDriver()->disconnect(); + } + + public function testTransaction(): void + { + /** @var DatabaseManager $dbal */ + $dbal = $this->container->get(DatabaseManager::class); + $txId = $dbal + ->database('default') + ->query('SELECT txid_current();') + ->fetchAll()[0]['txid_current']; + + self::assertEquals($this->txId, $txId); + } +} diff --git a/tests/config/app.php b/tests/config/app.php new file mode 100644 index 0000000..b10ba87 --- /dev/null +++ b/tests/config/app.php @@ -0,0 +1,28 @@ + + */ + +declare(strict_types=1); + +return [ + 'name' => 'makise-co', + + 'providers' => [ + \MakiseCo\Log\LoggerServiceProvider::class, + \MakiseCo\Event\EventDispatcherServiceProvider::class, + \MakiseCo\Console\ConsoleServiceProvider::class, + \MakiseCo\ORM\ORMProvider::class, + ], + + 'commands' => [ + \MakiseCo\ORM\Console\Commands\MakeCommand::class, + \MakiseCo\ORM\Console\Commands\MigrateCommand::class, + \MakiseCo\ORM\Console\Commands\ReplayCommand::class, + \MakiseCo\ORM\Console\Commands\RollbackCommand::class, + \MakiseCo\ORM\Console\Commands\StatusCommand::class, + ], +]; diff --git a/tests/database.php b/tests/config/database.php similarity index 57% rename from tests/database.php rename to tests/config/database.php index f23c285..05c210a 100644 --- a/tests/database.php +++ b/tests/config/database.php @@ -8,6 +8,8 @@ declare(strict_types=1); +use function MakiseCo\Env\env; + return [ 'default' => 'default', @@ -19,12 +21,12 @@ 'pgsql' => [ 'driver' => \MakiseCo\Database\Driver\MakisePostgres\PooledMakisePostgresDriver::class, 'options' => [ - 'host' => 'host.docker.internal', - 'port' => 15432, - 'username' => 'postgres', - 'password' => 'postgres', - 'database' => 'spiral', - // or 'connection' => env('DB_URL', 'host=127.0.0.1;dbname=' . env('DB_NAME')), + 'host' => env('DB_HOST', 'host.docker.internal'), + 'port' => env('DB_PORT', 5432), + 'username' => env('DB_USERNAME', 'makise'), + 'password' => env('DB_PASSWORD', 'el-psy-congroo'), + 'database' => env('DB_DATABASE', 'makise'), + // or 'connection' => env('DB_URL', 'host=127.0.0.1;dbname=makise'), 'schema' => ['public'], 'timezone' => 'UTC', 'charset' => 'utf8', @@ -33,9 +35,9 @@ 'connector' => \MakiseCo\Postgres\Driver\Pq\PqConnector::class, // connection pool configuration - 'poolMinActive' => 0, - 'poolMaxActive' => 2, - 'poolMaxIdleTime' => 30, + 'poolMinActive' => (int)env('DB_POOL_MIN_ACTIVE', 0), + 'poolMaxActive' => (int)env('DB_POOL_MAX_ACTIVE', 2), + 'poolMaxIdleTime' => (int)env('DB_POOL_MAX_IDLE_TIME', 30), 'poolValidationInterval' => 15.0, ], ], @@ -45,12 +47,14 @@ 'table' => 'migrations', 'namespace' => 'App\\Migrations', 'directory' => dirname(__DIR__) . '/src/Migrations', - 'safe' => false, + 'safe' => env('APP_ENV', 'production') !== 'production', ], 'orm' => [ + // enable coroutine safe heap? If enabled each coroutine will work with it's own heap + 'enableCoroutineHeap' => true, 'entityPath' => [ - dirname(__DIR__) . '/tests/Entity' + dirname(__DIR__) . '/Entity' ], ], -]; \ No newline at end of file +]; diff --git a/tests/config/logging.php b/tests/config/logging.php new file mode 100644 index 0000000..6946a1d --- /dev/null +++ b/tests/config/logging.php @@ -0,0 +1,24 @@ + + */ + +declare(strict_types=1); + +use function MakiseCo\Env\env; + +return [ + [ + 'handler' => \MakiseCo\Log\Handler\StreamHandler::class, + 'formatter' => \MakiseCo\Log\Formatter\JsonFormatter::class, + // parameters passed to the handler constructor + 'handler_with' => [ + 'stream' => env('LOG_CHANNEL', 'php://stdout'), + ], + // parameters passed to the formatter constructor + 'formatter_with' => [], + ], +];