diff --git a/config/autoload/local.php.dist b/config/autoload/local.php.dist index 3a261a3..f37da1e 100644 --- a/config/autoload/local.php.dist +++ b/config/autoload/local.php.dist @@ -5,15 +5,24 @@ declare(strict_types=1); $baseUrl = 'http://localhost:8080'; $databases = [ - 'default' => [ + 'mariadb' => [ 'host' => 'localhost', 'dbname' => 'dotkernel', 'user' => '', 'password' => '', 'port' => 3306, 'driver' => 'pdo_mysql', - 'charset' => 'utf8mb4', - 'collate' => 'utf8mb4_general_ci', + 'collation' => 'utf8mb4_general_ci', + 'table_prefix' => '', + ], + 'postgresql' => [ + 'host' => 'localhost', + 'dbname' => 'dotkernel', + 'user' => '', + 'password' => '', + 'port' => 5432, + 'driver' => 'pdo_pgsql', + 'collation' => 'utf8mb4_general_ci', 'table_prefix' => '', ], // you can add more database connections to this array @@ -22,10 +31,10 @@ $databases = [ return [ 'application' => [ 'name' => 'Dotkernel API', - 'version' => 6.0, + 'version' => 7.0, 'url' => $baseUrl, 'versioning' => [ - 'documentation_url' => 'https://docs.dotkernel.org/api-documentation/v6/core-features/versioning', + 'documentation_url' => 'https://docs.dotkernel.org/api-documentation/v7/tutorials/api-evolution/', ], ], 'authentication' => [ @@ -51,7 +60,7 @@ return [ 'doctrine' => [ 'connection' => [ 'orm_default' => [ - 'params' => $databases['default'], + 'params' => $databases['mariadb'], ], ], ], diff --git a/config/cli-config.php b/config/cli-config.php index 2a5bcc2..12829da 100644 --- a/config/cli-config.php +++ b/config/cli-config.php @@ -2,20 +2,35 @@ declare(strict_types=1); +use Core\App\Doctrine\MigrationsMigratedSubscriber; use Core\App\Event\TablePrefixEventListener; use Doctrine\Migrations\Configuration\EntityManager\ExistingEntityManager; use Doctrine\Migrations\Configuration\Migration\ConfigurationArray; use Doctrine\Migrations\DependencyFactory; use Doctrine\ORM\EntityManager; use Doctrine\ORM\Events; +use Dot\Log\LoggerInterface; +use Psr\Container\ContainerExceptionInterface; $container = require 'config/container.php'; -$entityManager = $container->get(EntityManager::class); -$entityManager->getEventManager() - ->addEventListener(Events::loadClassMetadata, $container->get(TablePrefixEventListener::class)); +try { + $entityManager = $container->get(EntityManager::class); + $entityManager->getEventManager() + ->addEventListener(Events::loadClassMetadata, $container->get(TablePrefixEventListener::class)); + $entityManager->getEventManager() + ->addEventSubscriber(new MigrationsMigratedSubscriber($container)); -return DependencyFactory::fromEntityManager( - new ConfigurationArray($container->get('config')['doctrine']['migrations']), - new ExistingEntityManager($entityManager) -); + return DependencyFactory::fromEntityManager( + new ConfigurationArray($container->get('config')['doctrine']['migrations']), + new ExistingEntityManager($entityManager) + ); +} catch (ContainerExceptionInterface $exception) { + try { + /** @var LoggerInterface $logger */ + $logger = $container->get('dot-log.default_logger'); + $logger->err($exception->getMessage()); + } catch (ContainerExceptionInterface $exception) { + error_log($exception->getMessage()); + } +} diff --git a/src/Core/src/Admin/src/DBAL/Types/AdminRoleEnumType.php b/src/Core/src/Admin/src/DBAL/Types/AdminRoleEnumType.php index 3900773..046535d 100644 --- a/src/Core/src/Admin/src/DBAL/Types/AdminRoleEnumType.php +++ b/src/Core/src/Admin/src/DBAL/Types/AdminRoleEnumType.php @@ -11,7 +11,7 @@ class AdminRoleEnumType extends AbstractEnumType { public const NAME = 'admin_role_enum'; - protected function getEnumClass(): string + public function getEnumClass(): string { return AdminRoleEnum::class; } diff --git a/src/Core/src/Admin/src/DBAL/Types/AdminStatusEnumType.php b/src/Core/src/Admin/src/DBAL/Types/AdminStatusEnumType.php index 53dd5e8..613c074 100644 --- a/src/Core/src/Admin/src/DBAL/Types/AdminStatusEnumType.php +++ b/src/Core/src/Admin/src/DBAL/Types/AdminStatusEnumType.php @@ -11,7 +11,7 @@ class AdminStatusEnumType extends AbstractEnumType { public const NAME = 'admin_status_enum'; - protected function getEnumClass(): string + public function getEnumClass(): string { return AdminStatusEnum::class; } diff --git a/src/Core/src/App/src/DBAL/Types/AbstractEnumType.php b/src/Core/src/App/src/DBAL/Types/AbstractEnumType.php index c62b044..4383697 100644 --- a/src/Core/src/App/src/DBAL/Types/AbstractEnumType.php +++ b/src/Core/src/App/src/DBAL/Types/AbstractEnumType.php @@ -6,6 +6,7 @@ use BackedEnum; use Doctrine\DBAL\Platforms\AbstractPlatform; +use Doctrine\DBAL\Platforms\PostgreSQLPlatform; use Doctrine\DBAL\Platforms\SQLitePlatform; use Doctrine\DBAL\Types\Type; @@ -17,11 +18,15 @@ abstract class AbstractEnumType extends Type { public function getSQLDeclaration(array $column, AbstractPlatform $platform): string { + if ($platform instanceof PostgreSQLPlatform) { + return $this->getName(); + } + if ($platform instanceof SQLitePlatform) { return 'TEXT'; } - $values = array_map(fn($case) => "'$case->value'", $this->getEnumValues()); + $values = array_map(fn($case) => "'$case->value'", $this->getEnumCases()); return sprintf('ENUM(%s)', implode(', ', $values)); } @@ -39,17 +44,30 @@ public function convertToDatabaseValue(mixed $value, AbstractPlatform $platform) /** * @return class-string */ - abstract protected function getEnumClass(): string; + abstract public function getEnumClass(): string; + + /** + * @return non-empty-string + */ + abstract public function getName(): string; /** * @return BackedEnum[] */ - private function getEnumValues(): array + public function getEnumCases(): array { return $this->getEnumClass()::cases(); } - private function getValue(mixed $value): mixed + /** + * @return list + */ + public function getEnumValues(): array + { + return $this->getEnumClass()::values(); + } + + public function getValue(mixed $value): mixed { if (! $value instanceof BackedEnum) { return $value; diff --git a/src/Core/src/App/src/DBAL/Types/SuccessFailureEnumType.php b/src/Core/src/App/src/DBAL/Types/SuccessFailureEnumType.php index 4950088..4a90283 100644 --- a/src/Core/src/App/src/DBAL/Types/SuccessFailureEnumType.php +++ b/src/Core/src/App/src/DBAL/Types/SuccessFailureEnumType.php @@ -10,7 +10,7 @@ class SuccessFailureEnumType extends AbstractEnumType { public const NAME = 'success_failure_enum'; - protected function getEnumClass(): string + public function getEnumClass(): string { return SuccessFailureEnum::class; } diff --git a/src/Core/src/App/src/DBAL/Types/YesNoEnumType.php b/src/Core/src/App/src/DBAL/Types/YesNoEnumType.php index 3b3774a..8bafeb0 100644 --- a/src/Core/src/App/src/DBAL/Types/YesNoEnumType.php +++ b/src/Core/src/App/src/DBAL/Types/YesNoEnumType.php @@ -10,7 +10,7 @@ class YesNoEnumType extends AbstractEnumType { public const NAME = 'yes_no_enum'; - protected function getEnumClass(): string + public function getEnumClass(): string { return YesNoEnum::class; } diff --git a/src/Core/src/App/src/Doctrine/MigrationsMigratedSubscriber.php b/src/Core/src/App/src/Doctrine/MigrationsMigratedSubscriber.php new file mode 100644 index 0000000..df6f911 --- /dev/null +++ b/src/Core/src/App/src/Doctrine/MigrationsMigratedSubscriber.php @@ -0,0 +1,198 @@ +entityManager = $container->get('doctrine.entity_manager.orm_default'); + $this->connection = $this->entityManager->getConnection(); + } + + /** + * @throws Exception + */ + public function getSubscribedEvents(): array + { + if (! $this->connection->getDatabasePlatform() instanceof PostgreSQLPlatform) { + return []; + } + + return [ + Events::onMigrationsMigrating, + ]; + } + + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + * @throws Exception + */ + public function onMigrationsMigrating(): void + { + $dbEnumTypes = $this->getCustomEnumTypesFromTheDatabase(); + $fsEnumTypes = $this->getCustomEnumTypesFromTheFileSystem(); + + $enumTypes = $this->mergeCustomEnumTypes($dbEnumTypes, $fsEnumTypes); + foreach ($enumTypes as $action => $enums) { + foreach ($enums as $type => $values) { + match ($action) { + 'create' => $this->createDatabaseType($type, $values), + 'delete' => $this->deleteDatabaseType($type), + 'update' => $this->updateDatabaseType($type, $values), + default => null, + }; + } + } + } + + /** + * @phpstan-return array + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + private function getCustomEnumTypesFromTheFileSystem(): array + { + $enumTypes = []; + + $customTypes = $this->container->get('config')['doctrine']['types'] ?? []; + foreach ($customTypes as $type => $class) { + $class = new $class(); + if (! $class instanceof AbstractEnumType) { + continue; + } + $enumTypes[$type] = $class; + } + + return $enumTypes; + } + + /** + * @phpstan-return list + * @throws Exception + */ + private function getDatabaseTypeValues(string $type): array + { + $results = $this->connection->executeQuery( + "SELECT e.enumlabel FROM pg_type t JOIN pg_enum e ON t.oid = e.enumtypid WHERE t.typname = '$type';" + )->fetchAllAssociative(); + + return array_column($results, 'enumlabel'); + } + + /** + * @return list + * @throws Exception + */ + private function getCustomEnumTypesFromTheDatabase(): array + { + return $this->connection->executeQuery( + 'SELECT t.typname FROM pg_type t JOIN pg_enum e ON t.oid = e.enumtypid GROUP BY t.typname' + )->fetchFirstColumn(); + } + + /** + * @param list $dbEnumTypes + * @param array $fsEnumTypes + * @return array{ + * create: array>, + * delete: array>, + * skip: array>, + * update: array> + * } + * @throws Exception + */ + private function mergeCustomEnumTypes(array $dbEnumTypes, array $fsEnumTypes): array + { + $enumTypes = [ + 'create' => [], + 'delete' => [], + 'skip' => [], + 'update' => [], + ]; + + /** @var AbstractEnumType $class */ + foreach ($fsEnumTypes as $type => $class) { + $fsTypeValues = $class->getEnumValues(); + if (in_array($type, $dbEnumTypes)) { + $dbTypeValues = $this->getDatabaseTypeValues($type); + if ($dbTypeValues === $fsTypeValues) { + $enumTypes['skip'][$type] = $fsTypeValues; + } else { + $enumTypes['update'][$type] = $fsTypeValues; + } + } else { + $enumTypes['create'][$type] = $fsTypeValues; + } + } + + foreach ($dbEnumTypes as $type) { + if (! array_key_exists($type, $fsEnumTypes)) { + $enumTypes['delete'][$type] = $this->getDatabaseTypeValues($type); + } + } + + return $enumTypes; + } + + /** + * @param non-empty-string $type + * @param list $values + * @throws Exception + */ + private function createDatabaseType(string $type, array $values): void + { + $this->connection->executeQuery( + sprintf("CREATE TYPE %s AS ENUM ('%s');", $type, implode("', '", $values)) + ); + } + + /** + * @throws Exception + */ + private function deleteDatabaseType(string $type): void + { + $this->connection->executeQuery( + sprintf('DROP TYPE %s;', $type) + ); + } + + /** + * @param non-empty-string $type + * @param list $values + * @throws Exception + */ + private function updateDatabaseType(string $type, array $values): void + { + $this->deleteDatabaseType($type); + $this->createDatabaseType($type, $values); + } +} diff --git a/src/Core/src/Setting/src/DBAL/Types/SettingIdentifierEnumType.php b/src/Core/src/Setting/src/DBAL/Types/SettingIdentifierEnumType.php index e5e0f93..d0edf7c 100644 --- a/src/Core/src/Setting/src/DBAL/Types/SettingIdentifierEnumType.php +++ b/src/Core/src/Setting/src/DBAL/Types/SettingIdentifierEnumType.php @@ -11,7 +11,7 @@ class SettingIdentifierEnumType extends AbstractEnumType { public const NAME = 'setting_enum'; - protected function getEnumClass(): string + public function getEnumClass(): string { return SettingIdentifierEnum::class; } diff --git a/src/Core/src/User/src/DBAL/Types/UserResetPasswordStatusEnumType.php b/src/Core/src/User/src/DBAL/Types/UserResetPasswordStatusEnumType.php index 2ffe59e..55cf851 100644 --- a/src/Core/src/User/src/DBAL/Types/UserResetPasswordStatusEnumType.php +++ b/src/Core/src/User/src/DBAL/Types/UserResetPasswordStatusEnumType.php @@ -11,7 +11,7 @@ class UserResetPasswordStatusEnumType extends AbstractEnumType { public const NAME = 'user_reset_password_status_enum'; - protected function getEnumClass(): string + public function getEnumClass(): string { return UserResetPasswordStatusEnum::class; } diff --git a/src/Core/src/User/src/DBAL/Types/UserRoleEnumType.php b/src/Core/src/User/src/DBAL/Types/UserRoleEnumType.php index 2b8d0f5..6025cab 100644 --- a/src/Core/src/User/src/DBAL/Types/UserRoleEnumType.php +++ b/src/Core/src/User/src/DBAL/Types/UserRoleEnumType.php @@ -11,7 +11,7 @@ class UserRoleEnumType extends AbstractEnumType { public const NAME = 'user_role_enum'; - protected function getEnumClass(): string + public function getEnumClass(): string { return UserRoleEnum::class; } diff --git a/src/Core/src/User/src/DBAL/Types/UserStatusEnumType.php b/src/Core/src/User/src/DBAL/Types/UserStatusEnumType.php index 5ea8711..7629839 100644 --- a/src/Core/src/User/src/DBAL/Types/UserStatusEnumType.php +++ b/src/Core/src/User/src/DBAL/Types/UserStatusEnumType.php @@ -11,7 +11,7 @@ class UserStatusEnumType extends AbstractEnumType { public const NAME = 'user_status_enum'; - protected function getEnumClass(): string + public function getEnumClass(): string { return UserStatusEnum::class; } diff --git a/src/Core/src/User/src/Enum/UserResetPasswordStatusEnum.php b/src/Core/src/User/src/Enum/UserResetPasswordStatusEnum.php index 4ed994a..303b0ad 100644 --- a/src/Core/src/User/src/Enum/UserResetPasswordStatusEnum.php +++ b/src/Core/src/User/src/Enum/UserResetPasswordStatusEnum.php @@ -4,8 +4,18 @@ namespace Core\User\Enum; +use function array_column; + enum UserResetPasswordStatusEnum: string { case Completed = 'completed'; case Requested = 'requested'; + + /** + * @return non-empty-string[] + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } } diff --git a/src/Core/src/User/src/Enum/UserRoleEnum.php b/src/Core/src/User/src/Enum/UserRoleEnum.php index b3c6b23..b2d89bd 100644 --- a/src/Core/src/User/src/Enum/UserRoleEnum.php +++ b/src/Core/src/User/src/Enum/UserRoleEnum.php @@ -4,6 +4,7 @@ namespace Core\User\Enum; +use function array_column; use function array_filter; enum UserRoleEnum: string @@ -18,4 +19,12 @@ public static function validCases(): array { return array_filter(self::cases(), fn (self $value) => $value !== self::Guest); } + + /** + * @return non-empty-string[] + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } } diff --git a/src/Core/src/User/src/Enum/UserStatusEnum.php b/src/Core/src/User/src/Enum/UserStatusEnum.php index 7bac8ea..ecaa696 100644 --- a/src/Core/src/User/src/Enum/UserStatusEnum.php +++ b/src/Core/src/User/src/Enum/UserStatusEnum.php @@ -18,6 +18,14 @@ enum UserStatusEnum: string * @return non-empty-string[] */ public static function values(): array + { + return array_column(self::cases(), 'value'); + } + + /** + * @return non-empty-string[] + */ + public static function validValues(): array { return array_column(self::validCases(), 'value'); } diff --git a/src/User/src/InputFilter/Input/StatusInput.php b/src/User/src/InputFilter/Input/StatusInput.php index 9dd755b..662edd0 100644 --- a/src/User/src/InputFilter/Input/StatusInput.php +++ b/src/User/src/InputFilter/Input/StatusInput.php @@ -25,7 +25,7 @@ public function __construct(?string $name = null, bool $isRequired = true) $this->getValidatorChain() ->attachByName(InArray::class, [ - 'haystack' => UserStatusEnum::values(), + 'haystack' => UserStatusEnum::validValues(), 'message' => Message::invalidValue('status'), ], true); }