From 84424c32170ec02681d275ab8e5c8811f7ea9e12 Mon Sep 17 00:00:00 2001 From: Madeorsk Date: Fri, 8 Nov 2024 16:33:44 +0100 Subject: [PATCH] Initialize Nest databases library. --- .gitignore | 6 + composer.json | 57 ++ composer.lock | 928 ++++++++++++++++++ src/Cli/Migrations/MigrateCommand.php | 43 + src/Cli/Migrations/MigrateOneCommand.php | 43 + src/Cli/Migrations/MigrationsCommands.php | 47 + src/Cli/Migrations/NewCommand.php | 18 + src/Cli/Migrations/RollbackCommand.php | 33 + src/Configuration/DatabaseFactory.php | 51 + .../DatabasesArrayConfiguration.php | 40 + src/Configuration/DatabasesConfiguration.php | 14 + src/Database.php | 144 +++ src/DatabaseAdapter.php | 199 ++++ src/Databases.php | 57 ++ src/DatabasesService.php | 39 + .../PdoDatabaseAfterConnectionEvent.php | 13 + .../PdoDatabaseBeforeConnectionEvent.php | 12 + ...ingRequiredConfigurationValueException.php | 24 + .../UndefinedDatabaseTypeException.php | 22 + src/Exceptions/DatabaseException.php | 9 + .../InvalidTransactionStateException.php | 14 + .../Migrations/CannotRollbackException.php | 23 + .../Migrations/MigrationNotFoundException.php | 22 + .../UndefinedNewColumnTypeException.php | 23 + .../NotCurrentTransactionException.php | 26 + .../Query/MissingConditionValueException.php | 21 + .../Query/NoPrimaryFieldException.php | 22 + .../Query/QueryBuilderException.php | 9 + src/Exceptions/UnknownDatabaseException.php | 21 + .../Configuration/MigrationsConfiguration.php | 47 + .../Configuration/MigrationsConfigurator.php | 106 ++ src/Migrations/Diff/Table.php | 137 +++ src/Migrations/Diff/TableColumn.php | 188 ++++ src/Migrations/Diff/TableColumnIndex.php | 21 + src/Migrations/Diff/TableForeignKey.php | 102 ++ src/Migrations/Diff/TableIndex.php | 167 ++++ src/Migrations/Migration.php | 67 ++ src/Migrations/Migrations.php | 572 +++++++++++ src/Migrations/MigrationsService.php | 42 + src/Migrations/Model/Migration.php | 44 + src/PdoDatabase.php | 111 +++ src/PostgreSql/Columns/Bit.php | 22 + src/PostgreSql/Columns/Char.php | 22 + src/PostgreSql/Columns/Numeric.php | 24 + src/PostgreSql/Columns/Time.php | 22 + src/PostgreSql/Columns/Timestamp.php | 22 + src/PostgreSql/Columns/Type.php | 62 ++ src/PostgreSql/Columns/Varbit.php | 22 + src/PostgreSql/Columns/Varchar.php | 22 + src/PostgreSql/PostgreSql.php | 23 + src/PostgreSql/PostgreSqlAdapter.php | 501 ++++++++++ src/PostgreSql/PostgreSqlDatabase.php | 51 + src/Query/DeleteQuery.php | 43 + src/Query/InsertQuery.php | 75 ++ src/Query/Join/HasJoin.php | 78 ++ src/Query/Join/JoinBuilder.php | 75 ++ src/Query/QueryBuilder.php | 92 ++ src/Query/Raw.php | 23 + src/Query/SelectQuery.php | 135 +++ src/Query/UpdateQuery.php | 80 ++ src/Query/Where/ConditionBuilder.php | 215 ++++ src/Query/Where/HasWhere.php | 154 +++ src/Transactions/Transaction.php | 139 +++ 63 files changed, 5486 insertions(+) create mode 100644 .gitignore create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 src/Cli/Migrations/MigrateCommand.php create mode 100644 src/Cli/Migrations/MigrateOneCommand.php create mode 100644 src/Cli/Migrations/MigrationsCommands.php create mode 100644 src/Cli/Migrations/NewCommand.php create mode 100644 src/Cli/Migrations/RollbackCommand.php create mode 100644 src/Configuration/DatabaseFactory.php create mode 100644 src/Configuration/DatabasesArrayConfiguration.php create mode 100644 src/Configuration/DatabasesConfiguration.php create mode 100644 src/Database.php create mode 100644 src/DatabaseAdapter.php create mode 100644 src/Databases.php create mode 100644 src/DatabasesService.php create mode 100644 src/Events/PdoDatabaseAfterConnectionEvent.php create mode 100644 src/Events/PdoDatabaseBeforeConnectionEvent.php create mode 100644 src/Exceptions/Configuration/MissingRequiredConfigurationValueException.php create mode 100644 src/Exceptions/Configuration/UndefinedDatabaseTypeException.php create mode 100644 src/Exceptions/DatabaseException.php create mode 100644 src/Exceptions/InvalidTransactionStateException.php create mode 100644 src/Exceptions/Migrations/CannotRollbackException.php create mode 100644 src/Exceptions/Migrations/MigrationNotFoundException.php create mode 100644 src/Exceptions/Migrations/UndefinedNewColumnTypeException.php create mode 100644 src/Exceptions/NotCurrentTransactionException.php create mode 100644 src/Exceptions/Query/MissingConditionValueException.php create mode 100644 src/Exceptions/Query/NoPrimaryFieldException.php create mode 100644 src/Exceptions/Query/QueryBuilderException.php create mode 100644 src/Exceptions/UnknownDatabaseException.php create mode 100644 src/Migrations/Configuration/MigrationsConfiguration.php create mode 100644 src/Migrations/Configuration/MigrationsConfigurator.php create mode 100644 src/Migrations/Diff/Table.php create mode 100644 src/Migrations/Diff/TableColumn.php create mode 100644 src/Migrations/Diff/TableColumnIndex.php create mode 100644 src/Migrations/Diff/TableForeignKey.php create mode 100644 src/Migrations/Diff/TableIndex.php create mode 100644 src/Migrations/Migration.php create mode 100644 src/Migrations/Migrations.php create mode 100644 src/Migrations/MigrationsService.php create mode 100644 src/Migrations/Model/Migration.php create mode 100644 src/PdoDatabase.php create mode 100644 src/PostgreSql/Columns/Bit.php create mode 100644 src/PostgreSql/Columns/Char.php create mode 100644 src/PostgreSql/Columns/Numeric.php create mode 100644 src/PostgreSql/Columns/Time.php create mode 100644 src/PostgreSql/Columns/Timestamp.php create mode 100644 src/PostgreSql/Columns/Type.php create mode 100644 src/PostgreSql/Columns/Varbit.php create mode 100644 src/PostgreSql/Columns/Varchar.php create mode 100644 src/PostgreSql/PostgreSql.php create mode 100644 src/PostgreSql/PostgreSqlAdapter.php create mode 100644 src/PostgreSql/PostgreSqlDatabase.php create mode 100644 src/Query/DeleteQuery.php create mode 100644 src/Query/InsertQuery.php create mode 100644 src/Query/Join/HasJoin.php create mode 100644 src/Query/Join/JoinBuilder.php create mode 100644 src/Query/QueryBuilder.php create mode 100644 src/Query/Raw.php create mode 100644 src/Query/SelectQuery.php create mode 100644 src/Query/UpdateQuery.php create mode 100644 src/Query/Where/ConditionBuilder.php create mode 100644 src/Query/Where/HasWhere.php create mode 100644 src/Transactions/Transaction.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2dfd9ec --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# IDEA +.idea/ +*.iml + +# Composer +vendor/ diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..9ecc91f --- /dev/null +++ b/composer.json @@ -0,0 +1,57 @@ +{ + "version": "1.0", + "name": "nest/database", + "description": "Nest databases service.", + "type": "library", + "authors": [ + { + "name": "Madeorsk", + "email": "madeorsk@protonmail.com" + } + ], + "autoload": { + "psr-4": { + "Nest\\Database\\": "src/" + } + }, + "repositories": [ + { + "type": "vcs", + "url": "https://code.zeptotech.net/Nest/Core" + }, + { + "type": "vcs", + "url": "https://code.zeptotech.net/Nest/Events" + }, + { + "type": "vcs", + "url": "https://code.zeptotech.net/Nest/Types" + }, + { + "type": "vcs", + "url": "https://code.zeptotech.net/Nest/Cli" + }, + { + "type": "vcs", + "url": "https://code.zeptotech.net/Nest/Model" + }, + { + "type": "vcs", + "url": "https://code.zeptotech.net/Nest/Configuration" + } + ], + "require": { + "php": "^8.3", + "ext-pdo": "*", + "nest/core": "dev-main", + "nest/events": "dev-main", + "nest/types": "dev-main", + "nest/cli": "1.x-dev", + "nest/model": "dev-main", + "nesbot/carbon": "^3.0", + "symfony/uid": "^7.1" + }, + "suggest": { + "nest/configuration": "dev-main" + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..d426741 --- /dev/null +++ b/composer.lock @@ -0,0 +1,928 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "23ba0fcb363fbba990800f53ca21803c", + "packages": [ + { + "name": "carbonphp/carbon-doctrine-types", + "version": "3.2.0", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon-doctrine-types.git", + "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon-doctrine-types/zipball/18ba5ddfec8976260ead6e866180bd5d2f71aa1d", + "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "conflict": { + "doctrine/dbal": "<4.0.0 || >=5.0.0" + }, + "require-dev": { + "doctrine/dbal": "^4.0.0", + "nesbot/carbon": "^2.71.0 || ^3.0.0", + "phpunit/phpunit": "^10.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Carbon\\Doctrine\\": "src/Carbon/Doctrine/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KyleKatarn", + "email": "kylekatarnls@gmail.com" + } + ], + "description": "Types to use Carbon in Doctrine", + "keywords": [ + "carbon", + "date", + "datetime", + "doctrine", + "time" + ], + "support": { + "issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues", + "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.0" + }, + "funding": [ + { + "url": "https://github.com/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon", + "type": "open_collective" + }, + { + "url": "https://tidelift.com/funding/github/packagist/nesbot/carbon", + "type": "tidelift" + } + ], + "time": "2024-02-09T16:56:22+00:00" + }, + { + "name": "nesbot/carbon", + "version": "3.8.2", + "source": { + "type": "git", + "url": "https://github.com/briannesbitt/Carbon.git", + "reference": "e1268cdbc486d97ce23fef2c666dc3c6b6de9947" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/briannesbitt/Carbon/zipball/e1268cdbc486d97ce23fef2c666dc3c6b6de9947", + "reference": "e1268cdbc486d97ce23fef2c666dc3c6b6de9947", + "shasum": "" + }, + "require": { + "carbonphp/carbon-doctrine-types": "<100.0", + "ext-json": "*", + "php": "^8.1", + "psr/clock": "^1.0", + "symfony/clock": "^6.3 || ^7.0", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation": "^4.4.18 || ^5.2.1|| ^6.0 || ^7.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "require-dev": { + "doctrine/dbal": "^3.6.3 || ^4.0", + "doctrine/orm": "^2.15.2 || ^3.0", + "friendsofphp/php-cs-fixer": "^3.57.2", + "kylekatarnls/multi-tester": "^2.5.3", + "ondrejmirtes/better-reflection": "^6.25.0.4", + "phpmd/phpmd": "^2.15.0", + "phpstan/extension-installer": "^1.3.1", + "phpstan/phpstan": "^1.11.2", + "phpunit/phpunit": "^10.5.20", + "squizlabs/php_codesniffer": "^3.9.0" + }, + "bin": [ + "bin/carbon" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev", + "dev-2.x": "2.x-dev" + }, + "laravel": { + "providers": [ + "Carbon\\Laravel\\ServiceProvider" + ] + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + } + }, + "autoload": { + "psr-4": { + "Carbon\\": "src/Carbon/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Nesbitt", + "email": "brian@nesbot.com", + "homepage": "https://markido.com" + }, + { + "name": "kylekatarnls", + "homepage": "https://github.com/kylekatarnls" + } + ], + "description": "An API extension for DateTime that supports 281 different languages.", + "homepage": "https://carbon.nesbot.com", + "keywords": [ + "date", + "datetime", + "time" + ], + "support": { + "docs": "https://carbon.nesbot.com/docs", + "issues": "https://github.com/briannesbitt/Carbon/issues", + "source": "https://github.com/briannesbitt/Carbon" + }, + "funding": [ + { + "url": "https://github.com/sponsors/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon#sponsor", + "type": "opencollective" + }, + { + "url": "https://tidelift.com/subscription/pkg/packagist-nesbot-carbon?utm_source=packagist-nesbot-carbon&utm_medium=referral&utm_campaign=readme", + "type": "tidelift" + } + ], + "time": "2024-11-07T17:46:48+00:00" + }, + { + "name": "nest/cli", + "version": "dev-main", + "source": { + "type": "git", + "url": "https://code.zeptotech.net/Nest/Cli", + "reference": "79669be111783f9968f0a0ba77eba63a6495be96" + }, + "require": { + "nest/core": "dev-main", + "php": "^8.3" + }, + "default-branch": true, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1-dev" + } + }, + "autoload": { + "psr-4": { + "Nest\\Cli\\": "src/" + } + }, + "authors": [ + { + "name": "Madeorsk", + "email": "madeorsk@protonmail.com" + } + ], + "description": "Nest CLI service and engine.", + "time": "2024-11-08T14:56:14+00:00" + }, + { + "name": "nest/core", + "version": "dev-main", + "source": { + "type": "git", + "url": "https://code.zeptotech.net/Nest/Core", + "reference": "22296b8d7c8b528089189a8d9500395424cb6468" + }, + "require": { + "php": "^8.3" + }, + "default-branch": true, + "type": "library", + "autoload": { + "psr-4": { + "Nest\\": "src/" + }, + "files": [ + "src/Utils/Array.php", + "src/Utils/Paths.php", + "src/Utils/Reflection.php", + "src/Utils/String.php" + ] + }, + "authors": [ + { + "name": "Madeorsk", + "email": "madeorsk@protonmail.com" + } + ], + "description": "Nest framework core.", + "time": "2024-11-08T11:12:38+00:00" + }, + { + "name": "nest/events", + "version": "dev-main", + "source": { + "type": "git", + "url": "https://code.zeptotech.net/Nest/Events", + "reference": "fcb3e114a7f9bdbcfd90f9283dd2c2588da09590" + }, + "require": { + "nest/core": "dev-main", + "php": "^8.3" + }, + "default-branch": true, + "type": "library", + "autoload": { + "psr-4": { + "Nest\\Events\\": "src/" + } + }, + "authors": [ + { + "name": "Madeorsk", + "email": "madeorsk@protonmail.com" + } + ], + "description": "Nest events implementation.", + "time": "2024-11-08T14:59:15+00:00" + }, + { + "name": "nest/types", + "version": "dev-main", + "source": { + "type": "git", + "url": "https://code.zeptotech.net/Nest/Types", + "reference": "b18a91301c76bd8e85a1c5de2f8960108aaa55ec" + }, + "require": { + "nesbot/carbon": "^3.8", + "nest/core": "dev-main", + "php": "^8.3" + }, + "default-branch": true, + "type": "library", + "autoload": { + "psr-4": { + "Nest\\Types\\": "src/" + } + }, + "authors": [ + { + "name": "Madeorsk", + "email": "madeorsk@protonmail.com" + } + ], + "description": "Nest types implementation.", + "time": "2024-11-08T15:30:45+00:00" + }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, + { + "name": "symfony/clock", + "version": "v7.1.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "97bebc53548684c17ed696bc8af016880f0f098d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/97bebc53548684c17ed696bc8af016880f0f098d", + "reference": "97bebc53548684c17ed696bc8af016880f0f098d", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/clock": "^1.0", + "symfony/polyfill-php83": "^1.28" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Decouples applications from the system clock", + "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], + "support": { + "source": "https://github.com/symfony/clock/tree/v7.1.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:20:29+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.31.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341", + "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-php83", + "version": "v1.31.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "2fb86d65e2d424369ad2905e83b236a8805ba491" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/2fb86d65e2d424369ad2905e83b236a8805ba491", + "reference": "2fb86d65e2d424369ad2905e83b236a8805ba491", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/tree/v1.31.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-uuid", + "version": "v1.31.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-uuid.git", + "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/21533be36c24be3f4b1669c4725c7d1d2bab4ae2", + "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-uuid": "*" + }, + "suggest": { + "ext-uuid": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Uuid\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for uuid functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.31.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/translation", + "version": "v7.1.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation.git", + "reference": "b9f72ab14efdb6b772f85041fa12f820dee8d55f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation/zipball/b9f72ab14efdb6b772f85041fa12f820dee8d55f", + "reference": "b9f72ab14efdb6b772f85041fa12f820dee8d55f", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-mbstring": "~1.0", + "symfony/translation-contracts": "^2.5|^3.0" + }, + "conflict": { + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<6.4", + "symfony/service-contracts": "<2.5", + "symfony/twig-bundle": "<6.4", + "symfony/yaml": "<6.4" + }, + "provide": { + "symfony/translation-implementation": "2.3|3.0" + }, + "require-dev": { + "nikic/php-parser": "^4.18|^5.0", + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0", + "symfony/console": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/finder": "^6.4|^7.0", + "symfony/http-client-contracts": "^2.5|^3.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/intl": "^6.4|^7.0", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/routing": "^6.4|^7.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to internationalize your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/translation/tree/v7.1.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-28T12:35:13+00:00" + }, + { + "name": "symfony/translation-contracts", + "version": "v3.5.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "b9d2189887bb6b2e0367a9fc7136c5239ab9b05a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/b9d2189887bb6b2e0367a9fc7136c5239ab9b05a", + "reference": "b9d2189887bb6b2e0367a9fc7136c5239ab9b05a", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.5-dev" + }, + "thanks": { + "name": "symfony/contracts", + "url": "https://github.com/symfony/contracts" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/translation-contracts/tree/v3.5.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-04-18T09:32:20+00:00" + }, + { + "name": "symfony/uid", + "version": "v7.1.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/uid.git", + "reference": "65befb3bb2d503bbffbd08c815aa38b472999917" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/uid/zipball/65befb3bb2d503bbffbd08c815aa38b472999917", + "reference": "65befb3bb2d503bbffbd08c815aa38b472999917", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-uuid": "^1.15" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Uid\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to generate and represent UIDs", + "homepage": "https://symfony.com", + "keywords": [ + "UID", + "ulid", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/uid/tree/v7.1.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:20:29+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": { + "nest/cli": 20, + "nest/core": 20, + "nest/events": 20, + "nest/types": 20 + }, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": "^8.3", + "ext-pdo": "*" + }, + "platform-dev": {}, + "plugin-api-version": "2.6.0" +} diff --git a/src/Cli/Migrations/MigrateCommand.php b/src/Cli/Migrations/MigrateCommand.php new file mode 100644 index 0000000..c36686d --- /dev/null +++ b/src/Cli/Migrations/MigrateCommand.php @@ -0,0 +1,43 @@ +migrations()->onMigrationStart = function (Migration $migration) { + Out::print("Executing migration V{$migration->getVersion()} ".Out::BOLD_ATTRIBUTE."{$migration->getName()}".Out::RESET_ATTRIBUTES); + echo " ".Out::ITALIC_ATTRIBUTE.$migration->getDescription().Out::RESET_ATTRIBUTES."\n\n"; + }; + Application::get()->migrations()->onMigrationEnd = function (Migration $migration) { + echo "\n"; + Out::print("V{$migration->getVersion()}_{$migration->getName()} finished."); + echo "\n"; + }; + + // Execute all remaining migrations. + Application::get()->migrations()->load()->migrate(); + + Out::success("Migrations executed successfully."); + } +} diff --git a/src/Cli/Migrations/MigrateOneCommand.php b/src/Cli/Migrations/MigrateOneCommand.php new file mode 100644 index 0000000..3ba9e75 --- /dev/null +++ b/src/Cli/Migrations/MigrateOneCommand.php @@ -0,0 +1,43 @@ +migrations()->onMigrationStart = function (Migration $migration) { + Out::print("Executing migration V{$migration->getVersion()} ".Out::BOLD_ATTRIBUTE."{$migration->getName()}".Out::RESET_ATTRIBUTES); + echo " ".Out::ITALIC_ATTRIBUTE.$migration->getDescription().Out::RESET_ATTRIBUTES."\n\n"; + }; + Application::get()->migrations()->onMigrationEnd = function (Migration $migration) { + echo "\n"; + Out::print("V{$migration->getVersion()}_{$migration->getName()} finished."); + echo "\n"; + }; + + // Try to execute given migration. + Application::get()->migrations()->load()->migrateOne($migrationId); + + Out::success("Success."); + } +} diff --git a/src/Cli/Migrations/MigrationsCommands.php b/src/Cli/Migrations/MigrationsCommands.php new file mode 100644 index 0000000..9c0dfc1 --- /dev/null +++ b/src/Cli/Migrations/MigrationsCommands.php @@ -0,0 +1,47 @@ +command("migrations") + ->description("Migrations manager.") + ->subcommands([ + "migrate" => fn (CommandDefinition $subcommand) => $subcommand + ->description("Execute all remaining migrations.") + ->handler(MigrateCommand::class) + , + "migrateOne" => fn (CommandDefinition $subcommand) => $subcommand + ->description("Perform one migration.") + ->parameter("migrationId", fn (ParameterDefinition $parameter) => $parameter->description("Migration ID of migration to execute.")) + ->handler(MigrateOneCommand::class) + , + "rollback" => fn (CommandDefinition $subcommand) => $subcommand + ->description("Rollback latest migration.") + ->handler(RollbackCommand::class) + , + "new" => fn (CommandDefinition $subcommand) => $subcommand + ->description("Generate a migration with the given name.") + ->parameter("name", fn (ParameterDefinition $parameter) => $parameter->description("Name of the new migration.")) + ->handler(NewCommand::class) + , + ]); + } +} diff --git a/src/Cli/Migrations/NewCommand.php b/src/Cli/Migrations/NewCommand.php new file mode 100644 index 0000000..6968490 --- /dev/null +++ b/src/Cli/Migrations/NewCommand.php @@ -0,0 +1,18 @@ +migrations()->newMigration($name); + + Out::success("Migration successfully created."); + } +} diff --git a/src/Cli/Migrations/RollbackCommand.php b/src/Cli/Migrations/RollbackCommand.php new file mode 100644 index 0000000..60e5dc1 --- /dev/null +++ b/src/Cli/Migrations/RollbackCommand.php @@ -0,0 +1,33 @@ +migrations()->onRollbackStart = function (Migration $migration) { + Out::print("Starting to roll back V{$migration->getVersion()} ".Out::BOLD_ATTRIBUTE."{$migration->getName()}".Out::RESET_ATTRIBUTES); + echo " ".Out::ITALIC_ATTRIBUTE.$migration->getDescription().Out::RESET_ATTRIBUTES."\n\n"; + }; + + // Rollback latest migration. + $migration = Application::get()->migrations()->load()->rollbackLatest(); + + if (!empty($migration)) + Out::success("Rolled back V{$migration->getVersion()} {$migration->getName()}"); + else + Out::warning("Nothing to rollback."); + } +} diff --git a/src/Configuration/DatabaseFactory.php b/src/Configuration/DatabaseFactory.php new file mode 100644 index 0000000..5e9d7e0 --- /dev/null +++ b/src/Configuration/DatabaseFactory.php @@ -0,0 +1,51 @@ +configuration[$configKey])) + throw new MissingRequiredConfigurationValueException($this->databaseIdentifier, static::class, $configKey); + + return $this->configuration[$configKey]; + } + + /** + * Get an optional configuration value. + * @param string $configKey Configuration key. + * @param mixed|null $default Default configuration value. + * @return mixed Configuration value. + */ + public function getOptionalConfig(string $configKey, mixed $default = null): mixed + { + return $this->configuration[$configKey] ?? $default; + } + + /** + * Make the database object from its configuration array. + * @return Database The created database. + * @throws MissingRequiredConfigurationValueException + */ + public abstract function make(): Database; +} diff --git a/src/Configuration/DatabasesArrayConfiguration.php b/src/Configuration/DatabasesArrayConfiguration.php new file mode 100644 index 0000000..0fda6fd --- /dev/null +++ b/src/Configuration/DatabasesArrayConfiguration.php @@ -0,0 +1,40 @@ +application->configuration()->getValue($this->configurationKey) as $identifier => $configuration) + { + if (empty($configuration["type"])) + // Undefined database type, throwing an exception. + throw new UndefinedDatabaseTypeException($identifier); + + // Create the factory with the given configuration. + $factories[$identifier] = new $configuration["type"]($identifier, $configuration); + } + + return $factories; // Return loaded factories. + } +} diff --git a/src/Configuration/DatabasesConfiguration.php b/src/Configuration/DatabasesConfiguration.php new file mode 100644 index 0000000..88bc8f0 --- /dev/null +++ b/src/Configuration/DatabasesConfiguration.php @@ -0,0 +1,14 @@ + Database factories, indexed by databases identifiers. + */ + public abstract function getFactories(): array; +} diff --git a/src/Database.php b/src/Database.php new file mode 100644 index 0000000..2059cb5 --- /dev/null +++ b/src/Database.php @@ -0,0 +1,144 @@ +query($table); + } + + + /** + * Get the current transaction, if there is one. + * @return Transaction|null + */ + public function getCurrentTransaction(): ?Transaction + { + return $this->transaction; + } + + /** + * Called when the state of a transaction changed (inactive -> active or active -> inactive). + * @param Transaction $transaction + * @return void + * @throws NotCurrentTransactionException + */ + public function onTransactionStateChanged(Transaction $transaction): void + { + if ($transaction->isActive()) + { // Set the newly active transaction as the current one. + + // Its parent should be the current transaction, if it exists. + if (!empty($transaction->getParent()) && $transaction->getParent()->getUuid() != $this->transaction->getUuid()) + throw new NotCurrentTransactionException($this->transaction, $transaction->getParent()); + + $this->transaction = $transaction; + } + else + { // The transaction has been terminated, it should be the current one. + if (!empty($this->transaction) && $this->transaction->getUuid() != $transaction->getUuid()) + throw new NotCurrentTransactionException($this->transaction, $transaction); + + $this->transaction = $transaction->getParent(); + } + } + + /** + * Start a new transaction (in the current one, if there is one). + * @return Transaction The new transaction. + * @throws NotCurrentTransactionException + */ + public function startTransaction(): Transaction + { + // Create the transaction as a child of the existing one, if there is one. + $newTransaction = new Transaction($this, $this->transaction); + if (!empty($this->transaction)) + // Set the new transaction as child of the current one. + $this->transaction->setChild($newTransaction); + + $newTransaction->start(); // Start the new transaction. + + return $newTransaction; // Return the new transaction. + } + + /** + * Start a new transaction and execute the function in parameter. + * The transaction will be committed / rolled back automatically at the end of the function. + * A rollback happen if any exception is thrown in the function. + * @param callable(Transaction): mixed $function The function to execute in a transaction. + * @return mixed Function result. + * @throws Throwable + */ + public function transaction(callable $function): mixed + { + // Start a new transaction. + $transaction = $this->startTransaction(); + + try + { // Trying to run the function in the started transaction. + $result = $function($transaction); + + // The function finished successfully, commit the transaction. + $transaction->commit(); + + // Return result of the function. + return $result; + } + catch (Throwable $throwable) + { // An exception has been thrown, rolling back the transaction and throw it again. + $transaction->rollback(); + throw $throwable; + } + } +} diff --git a/src/DatabaseAdapter.php b/src/DatabaseAdapter.php new file mode 100644 index 0000000..dcef0cf --- /dev/null +++ b/src/DatabaseAdapter.php @@ -0,0 +1,199 @@ + $rows Rows values. + * @param bool $returning True to return inserted objects. + * @return object[]|bool Inserted rows objects if returning is true, true otherwise. + */ + public abstract function insert(string $tableName, array $columns, array $rows, bool $returning = false): array|bool; + + /** + * Update rows of a table. + * @param string $tableName The table to update. + * @param array $set Set values in the rows. + * @param ConditionBuilder[] $wheres Conditions applied to rows for update. + * @param bool $returning True to return updated objects. + * @return object[]|bool Updated rows objects if returning is true, true otherwise. + * @throws MissingConditionValueException + */ + public abstract function update(string $tableName, array $set, array $wheres, bool $returning = false): array|bool; + + /** + * Delete rows from a table. + * @param string $tableName The table in which to delete rows. + * @param array $wheres Conditions applied to rows for deletion. + * @return void + * @throws MissingConditionValueException + */ + public abstract function delete(string $tableName, array $wheres): void; +} diff --git a/src/Databases.php b/src/Databases.php new file mode 100644 index 0000000..d861c64 --- /dev/null +++ b/src/Databases.php @@ -0,0 +1,57 @@ + + */ + private array $databases; + + /** + * @param Application $application Application of the databases. + * @param DatabasesConfiguration $configuration Configuration of the databases. + * @throws MissingRequiredConfigurationValueException + */ + public function __construct(protected Application $application, protected DatabasesConfiguration $configuration) + { + $this->load(); + } + + /** + * Load databases from their factories. + * @return void + * @throws MissingRequiredConfigurationValueException + */ + public function load(): void + { + foreach ($this->configuration->getFactories() as $identifier => $databaseFactory) + { // Load all databases from their factories. + $this->databases[$identifier] = $databaseFactory->make(); + } + } + + /** + * @param string $identifier Database identifier. + * @return Database Database corresponding to the given identifier. + * @throws UnknownDatabaseException + */ + public function db(string $identifier): Database + { + if (empty($this->databases[$identifier])) + // Cannot find a database with this identifier, throwing an exception. + throw new UnknownDatabaseException($identifier); + + return $this->databases[$identifier]; // Return loaded database. + } +} diff --git a/src/DatabasesService.php b/src/DatabasesService.php new file mode 100644 index 0000000..6633e81 --- /dev/null +++ b/src/DatabasesService.php @@ -0,0 +1,39 @@ +databases = new Databases($this, $this->getServiceConfiguration(DatabasesConfiguration::class)); + } + + /** + * Databases service. + * @return Databases The databases manager. + */ + public function databases(): Databases + { + return $this->databases; + } +} diff --git a/src/Events/PdoDatabaseAfterConnectionEvent.php b/src/Events/PdoDatabaseAfterConnectionEvent.php new file mode 100644 index 0000000..4c13483 --- /dev/null +++ b/src/Events/PdoDatabaseAfterConnectionEvent.php @@ -0,0 +1,13 @@ +databaseIdentifier: cannot find required configuration $this->configKey for factory $this->factoryClass.", $code, $previous); + } +} diff --git a/src/Exceptions/Configuration/UndefinedDatabaseTypeException.php b/src/Exceptions/Configuration/UndefinedDatabaseTypeException.php new file mode 100644 index 0000000..ada2c85 --- /dev/null +++ b/src/Exceptions/Configuration/UndefinedDatabaseTypeException.php @@ -0,0 +1,22 @@ +migration->getName()}.", $code, $previous); + } +} diff --git a/src/Exceptions/Migrations/MigrationNotFoundException.php b/src/Exceptions/Migrations/MigrationNotFoundException.php new file mode 100644 index 0000000..9e29746 --- /dev/null +++ b/src/Exceptions/Migrations/MigrationNotFoundException.php @@ -0,0 +1,22 @@ +id not found.", $code, $previous); + } +} diff --git a/src/Exceptions/Migrations/UndefinedNewColumnTypeException.php b/src/Exceptions/Migrations/UndefinedNewColumnTypeException.php new file mode 100644 index 0000000..f621035 --- /dev/null +++ b/src/Exceptions/Migrations/UndefinedNewColumnTypeException.php @@ -0,0 +1,23 @@ +tableName\".\"$this->columnName\".", $code, $previous); + } +} diff --git a/src/Exceptions/NotCurrentTransactionException.php b/src/Exceptions/NotCurrentTransactionException.php new file mode 100644 index 0000000..749550c --- /dev/null +++ b/src/Exceptions/NotCurrentTransactionException.php @@ -0,0 +1,26 @@ +currentTransaction->getUuid().") can be altered. Cannot alter ".$this->alteredTransaction->getUuid().".", + $code, $previous + ); + } +} diff --git a/src/Exceptions/Query/MissingConditionValueException.php b/src/Exceptions/Query/MissingConditionValueException.php new file mode 100644 index 0000000..42a16e5 --- /dev/null +++ b/src/Exceptions/Query/MissingConditionValueException.php @@ -0,0 +1,21 @@ +incompleteSql", $code, $previous); + } +} diff --git a/src/Exceptions/Query/NoPrimaryFieldException.php b/src/Exceptions/Query/NoPrimaryFieldException.php new file mode 100644 index 0000000..e46b3e8 --- /dev/null +++ b/src/Exceptions/Query/NoPrimaryFieldException.php @@ -0,0 +1,22 @@ +entity).", cannot apply WHERE clause on it.", $code, $previous); + } +} diff --git a/src/Exceptions/Query/QueryBuilderException.php b/src/Exceptions/Query/QueryBuilderException.php new file mode 100644 index 0000000..19fedae --- /dev/null +++ b/src/Exceptions/Query/QueryBuilderException.php @@ -0,0 +1,9 @@ +identifier.", $code, $previous); + } +} diff --git a/src/Migrations/Configuration/MigrationsConfiguration.php b/src/Migrations/Configuration/MigrationsConfiguration.php new file mode 100644 index 0000000..49261a9 --- /dev/null +++ b/src/Migrations/Configuration/MigrationsConfiguration.php @@ -0,0 +1,47 @@ +getMigrationsPath(), "Prepare"); + } + + /** + * Get database to use for migrations table. + * @return string Database identifier. + */ + public function getMigrationsDatabase(): string + { + return "default"; + } + + /** + * @return string Done migrations table name. + */ + public function getMigrationsTable(): string + { + return "_migrations"; + } +} diff --git a/src/Migrations/Configuration/MigrationsConfigurator.php b/src/Migrations/Configuration/MigrationsConfigurator.php new file mode 100644 index 0000000..06313d0 --- /dev/null +++ b/src/Migrations/Configuration/MigrationsConfigurator.php @@ -0,0 +1,106 @@ +migrationsNamespace = $migrationsNamespace; + return $this; + } + + /** + * @inheritDoc + */ + #[\Override] public function getMigrationsNamespace(): string + { + return $this->migrationsNamespace; + } + + /** + * @param string $migrationsPath Database migrations path. + * @return $this + */ + public function setMigrationsPath(string $migrationsPath): static + { + $this->migrationsPath = $migrationsPath; + return $this; + } + + /** + * @inheritDoc + */ + #[\Override] public function getMigrationsPath(): string + { + return $this->migrationsPath; + } + + /** + * @param string $preparationsPath Database preparations path. + * @return $this + */ + public function setPreparationsPath(string $preparationsPath): static + { + $this->preparationsPath = $preparationsPath; + return $this; + } + + /** + * @inheritDoc + */ + #[\Override] public function getPreparationsPath(): string + { + return $this->preparationsPath ?? parent::getPreparationsPath(); + } + + /** + * Set database to use for migrations table. + * @param string $databaseIdentifier Database identifier. + * @return $this + */ + public function setMigrationsDatabase(string $databaseIdentifier): static + { + $this->migrationsDatabase = $databaseIdentifier; + return $this; + } + + /** + * @inheritDoc + */ + public function getMigrationsDatabase(): string + { + return $this->migrationsDatabase ?? parent::getMigrationsDatabase(); + } + + /** + * @param string $migrationsTable Done migrations table name. + * @return $this + */ + public function setMigrationsTable(string $migrationsTable): static + { + $this->migrationsTable = $migrationsTable; + return $this; + } + + /** + * @inheritDoc + */ + #[\Override] public function getMigrationsTable(): string + { + return $this->migrationsTable ?? parent::getMigrationsTable(); + } +} diff --git a/src/Migrations/Diff/Table.php b/src/Migrations/Diff/Table.php new file mode 100644 index 0000000..893d295 --- /dev/null +++ b/src/Migrations/Diff/Table.php @@ -0,0 +1,137 @@ +database; + } + + /** + * Get table name. + * @return string + */ + public function getTableName(): string + { + return $this->tableName; + } + + /** + * Create the table. + * @param bool $ifNotExists True to try to add if table does not exist, will just pass if it exists. + * @return $this + */ + public function create(bool $ifNotExists = false): static + { + $this->getDatabase()->getQueriesAdapter()->createTable($this->getTableName(), $ifNotExists); + return $this; + } + + /** + * Create the table, if it does not exists. + * @return $this + */ + public function createIfNotExists(): static + { + return $this->create(true); + } + + /** + * Drop the table. + * @param bool $ifExists True to try to drop if table exists, will just pass if it does not exist. + * @return $this + */ + public function drop(bool $ifExists = false): static + { + $this->getDatabase()->getQueriesAdapter()->dropTable($this->getTableName(), $ifExists); + return $this; + } + + /** + * Drop the table, if it exists. + * @return $this + */ + public function dropIfExists(): static + { + return $this->drop(true); + } + + /** + * Rename the table. + * @param string $newName New name of the table. + * @return void + */ + public function rename(string $newName): void + { + $this->getDatabase()->getQueriesAdapter()->renameTable($this->getTableName(), $newName); + } + + + /** + * Get or create a column for the current table. + * @param string $columnName Column name. + * @return TableColumn The table column. + */ + public function column(string $columnName): TableColumn + { + return new TableColumn($this, $columnName); + } + + /** + * Make a foreign key creator for the current table. + * @return TableForeignKey The table foreign key creator. + */ + public function foreignKey(): TableForeignKey + { + return new TableForeignKey($this); + } + + /** + * Get or create an index for the current table. + * @param string $indexName Index name. + * @return TableIndex The table index. + */ + public function index(string $indexName): TableIndex + { + return new TableIndex($this, $indexName); + } + + /** + * Drop a constraint with the given name. + * @param string $constraintName The constraint to drop. + * @param bool $ifExists True to try to drop if constraint exists, will just pass if it does not exist. + * @return void + */ + public function dropConstraint(string $constraintName, bool $ifExists = false): void + { + $this->getDatabase()->getQueriesAdapter()->dropConstraint($this->getTableName(), $constraintName, $ifExists); + } + + /** + * Drop a constraint with the given name if it exists. + * @param string $constraintName The constraint to drop. + * @return void + */ + public function dropConstraintIfExists(string $constraintName): void + { + $this->dropConstraint($constraintName, true); + } +} diff --git a/src/Migrations/Diff/TableColumn.php b/src/Migrations/Diff/TableColumn.php new file mode 100644 index 0000000..1893a01 --- /dev/null +++ b/src/Migrations/Diff/TableColumn.php @@ -0,0 +1,188 @@ +type = (string) ($type instanceof BackedEnum ? $type->value : $type); + return $this; + } + + /** + * Set default column value. + * @param Raw|string|int|float|null $value Default column value. + * @return $this + */ + public function default(Raw|string|int|float|null $value): static + { + $this->default = $value; + return $this; + } + + /** + * Set default column value to NOW. + * @return $this + */ + public function defaultNow(): static + { + return $this->default(new Raw("NOW()")); + } + + /** + * Set nullable for the current column. + * @param bool $nullable True if the column is nullable, false otherwise. + * @return $this + */ + public function nullable(bool $nullable = true): static + { + $this->nullable = $nullable; + return $this; + } + + /** + * Current column is a primary key. + * @return $this + */ + public function primary(): static + { + // Set the current column as a primary key. + $this->primary = true; + return $this; + } + + /** + * Index the current column. + * @param bool $unique True to ensure a unique value for each row, false otherwise. + * @param string|null $method Custom index method (raw SQL!). + * @return $this + */ + public function index(bool $unique = false, ?string $method = null): static + { + // Initialize the column index. + $this->index = new TableColumnIndex(); + $this->index->unique = $unique; + if (!empty($method)) $this->index->method = $method; + return $this; + } + + /** + * Unique index the current column. + * @param string|null $method Custom index method (raw SQL!). + * @return $this + */ + public function unique(?string $method = null): static + { + // Initialize the column index. + return $this->index(true, $method); + } + + /** + * Add the current column to the table. + * @param bool $ifNotExists True to try to add if column does not exist, will just pass if it exists. + * @return void + * @throws UndefinedNewColumnTypeException + */ + public function add(bool $ifNotExists = false): void + { + if (empty($this->type)) + // Throw an exception if no type is provided. + throw new UndefinedNewColumnTypeException($this->table->getTableName(), $this->name); + + // Add table column. + $this->table->getDatabase()->getQueriesAdapter()->addTableColumn($this, $ifNotExists); + + // Set column index, if it's defined. + if (!empty($this->index)) + $this->table->getDatabase()->getQueriesAdapter()->setTableColumnIndex($this); + } + + /** + * Rename the column. + * @param string $newName New name of the column. + * @return void + */ + public function rename(string $newName): void + { + // Rename table column. + $this->table->getDatabase()->getQueriesAdapter()->renameTableColumn($this, $newName); + } + + /** + * Alter the current table column. + * @return void + */ + public function alter(): void + { + // Alter table column. + $this->table->getDatabase()->getQueriesAdapter()->alterTableColumn($this); + + // Set column index, if it's defined. + if (!empty($this->index)) + $this->table->getDatabase()->getQueriesAdapter()->setTableColumnIndex($this); + } + + /** + * Drop the current column from the table. + * @param bool $ifExists True to try to drop if column exists, will just pass if it does not exist. + * @return void + */ + public function drop(bool $ifExists = false): void + { + // Drop table column. + $this->table->getDatabase()->getQueriesAdapter()->dropTableColumn($this->table->getTableName(), $this->name, $ifExists); + } +} diff --git a/src/Migrations/Diff/TableColumnIndex.php b/src/Migrations/Diff/TableColumnIndex.php new file mode 100644 index 0000000..7b00b35 --- /dev/null +++ b/src/Migrations/Diff/TableColumnIndex.php @@ -0,0 +1,21 @@ +columns, ...$columns); + return $this; + } + + /** + * Set referenced table and columns.. + * @param string $referencedTable Referenced table name. + * @param string[] $referencedColumns Referenced columns in table. + * @return $this + */ + public function references(string $referencedTable, string ...$referencedColumns): static + { + $this->referencedTable = $referencedTable; + $this->referencedColumns = $referencedColumns; + + return $this; + } + + /** + * Set action on delete. + * @param string $referentialAction Raw action. + * @return $this + */ + public function onDelete(string $referentialAction): static + { + $this->onDelete = $referentialAction; + return $this; + } + + /** + * Set action on update. + * @param string $referentialAction Raw action. + * @return $this + */ + public function onUpdate(string $referentialAction): static + { + $this->onUpdate = $referentialAction; + return $this; + } + + /** + * Create the defined foreign key. + * @return void + */ + public function create(): void + { + $this->table->getDatabase()->getQueriesAdapter()->createForeignKey($this); + } +} diff --git a/src/Migrations/Diff/TableIndex.php b/src/Migrations/Diff/TableIndex.php new file mode 100644 index 0000000..162d3e8 --- /dev/null +++ b/src/Migrations/Diff/TableIndex.php @@ -0,0 +1,167 @@ +columns, ...$columns); + return $this; + } + + /** + * Set a raw expression to index. + * @param string $rawExpression Raw expression to index. + * @return $this + */ + public function expression(string $rawExpression): static + { + $this->rawExpression = $rawExpression; + return $this; + } + + /** + * Set that indexed values must be uniques. + * @param bool $unique True to set unique, false to set non-unique. + * @return $this + */ + public function unique(bool $unique = true): static + { + $this->unique = $unique; + return $this; + } + + /** + * Set the method to used to index. + * @param string|null $method Index method to use. + * @return $this + */ + public function using(?string $method): static + { + $this->method = $method; + return $this; + } + + /** + * Ascending sort order. + * @return $this + */ + public function asc(): static + { + $this->order = "ASC"; + return $this; + } + /** + * Descending sort order. + * @return $this + */ + public function desc(): static + { + $this->order = "DESC"; + return $this; + } + + /** + * Nulls sort before non-nulls. + * @return $this + */ + public function nullsFirst(): static + { + $this->nulls = "NULLS FIRST"; + return $this; + } + /** + * Nulls sort after non-nulls. + * @return $this + */ + public function nullsLast(): static + { + $this->nulls = "NULLS LAST"; + return $this; + } + + /** + * Create index. + * @param bool $ifNotExists True to try to add if index does not exist, will just pass if it exists. + * @return void + */ + public function create(bool $ifNotExists = false): void + { + $this->table->getDatabase()->getQueriesAdapter()->createIndex($this, $ifNotExists); + } + + /** + * Rename index. + * @param string $newName The new name of the index. + * @param bool $ifExists True to try to rename if index exists, will just pass if it does not exist. + * @return void + */ + public function renameTo(string $newName, bool $ifExists = false): void + { + $this->table->getDatabase()->getQueriesAdapter()->renameIndex($this->name, $newName, $ifExists); + } + + /** + * Drop index. + * @param bool $ifExists True to try to drop if index exists, will just pass if it does not exist. + * @return void + */ + public function drop(bool $ifExists = false): void + { + $this->table->getDatabase()->getQueriesAdapter()->dropIndex($this, $ifExists); + } +} diff --git a/src/Migrations/Migration.php b/src/Migrations/Migration.php new file mode 100644 index 0000000..d7ed328 --- /dev/null +++ b/src/Migrations/Migration.php @@ -0,0 +1,67 @@ +version; + } + /** + * @return string The database migration name. + */ + public function getName(): string + { + return $this->name; + } + + /** + * Describes operations of the current database migration. + * @return string Description of database migration. + */ + public abstract function getDescription(): string; + + /** + * Do the migration. + * @return void + */ + public abstract function up(): void; + + /** + * Undo the migration. + * @return void + */ + public abstract function down(): void; + + + /** + * Create a new table diff manager. + * @param string $tableName Table name to alter. + * @return Table Table diff manager. + */ + protected function table(string $tableName): Table + { + return new Table($this->database, $tableName); + } +} diff --git a/src/Migrations/Migrations.php b/src/Migrations/Migrations.php new file mode 100644 index 0000000..18b89d2 --- /dev/null +++ b/src/Migrations/Migrations.php @@ -0,0 +1,572 @@ + + */ + private array $migrations = []; + + /** + * The preparations scripts array. + * @var string[] + */ + private array $preparations = []; + + /** + * Migration start event. + * @var callable(Migration): void|null + */ + public $onMigrationStart = null; + + /** + * Migration end event. + * @var callable(Migration): void|null + */ + public $onMigrationEnd = null; + + /** + * Rollback start event. + * @var callable(Migration): void|null + */ + public $onRollbackStart = null; + + /** + * Rollback end event. + * @var callable(Migration): void|null + */ + public $onRollbackEnd = null; + + /** + * @param Application $application The application. + * @param MigrationsConfiguration $configuration The migrations configuration. + */ + public function __construct(protected Application $application, protected MigrationsConfiguration $configuration) + { + } + + /** + * Initial load of the migrations system. + * @return $this + * @throws IncompatibleTypeException + * @throws MissingConditionValueException + * @throws UnknownDatabaseException + * @throws UndefinedNewColumnTypeException + */ + public function load(): static + { + // Get migrations database. + $this->database = $this->application->databases()->db($this->configuration->getMigrationsDatabase()); + + // Initial load of database migrations and preparations. + $this->prepareMigrationsTable(); + $this->loadMigrations(); + $this->loadPreparations(); + + return $this; + } + + /** + * Try to register a new migration file. + * @param string $migrationFile The migration file name. + * @param string $subdirectory Subdirectory where the migration file has been found. + * @return void + */ + protected function registerMigration(string $migrationFile, string $subdirectory): void + { + if (preg_match("/^V([0-9]+)_([^.]+)\\.php$/", $migrationFile, $matches)) + { // The filename matches migration name format. + $version = $matches[1]; + $name = $matches[2]; + + $this->migrations[$version] = [ + "version" => $version, + "name" => $name, + "migrated" => false, // Already run migrations are found by using findMigrated after migration registration. + "entity" => null, // Migration entity in database, when it's migrated. + "package" => $subdirectory, + ]; + } + } + + /** + * Recursively load migrations in the given path with subdirectory. + * @param string $migrationsPath The path where to find migrations. + * @param string $subdirectory The subdirectory / subpackage of registered migration files. + * @return void + */ + private function _loadMigrations(string $migrationsPath, string $subdirectory = ""): void + { + // Get the list of files in the migrations directory. + $migrationFiles = scandir($migrationsPath); + + foreach ($migrationFiles as $migrationFile) + { // Try to register each migration file. + $migrationFileFullPath = path_join($migrationsPath, $migrationFile); + if (is_dir($migrationFileFullPath) && $migrationFile != "." && $migrationFile != "..") + { // If it is a directory, we try to read it recursively. + $this->_loadMigrations($migrationFileFullPath, !empty($subdirectory) ? path_join($subdirectory, $migrationFile) : $migrationFile); + } + else + { // It is a file, we try to register it as a migration file. + $this->registerMigration($migrationFile, $subdirectory); + } + } + } + + /** + * Load all existing migrations. + * @return void + * @throws IncompatibleTypeException + * @throws MissingConditionValueException + */ + protected function loadMigrations(): void + { + // Reset migrations list. + $this->migrations = []; + + // Load migrations in the configured migrations path. + $this->_loadMigrations($this->configuration->getMigrationsPath()); + + // Sort migrations by lexicographic order of version identifiers. + ksort($this->migrations); + + // Find done migrations. + $this->findMigrated(); + } + + /** + * Load all preparations scripts. + * @return void + */ + protected function loadPreparations(): void + { + // Reset preparations scripts list. + $this->preparations = []; + + if (file_exists($this->configuration->getPreparationsPath())) + { // If there is a preparation folder. + + // Get all preparations scripts files. + $this->preparations = scandir($this->configuration->getPreparationsPath()); + + // Filter non-SQL files. + $this->preparations = array_filter($this->preparations, fn ($preparationScript) => ( + // Only keep files ending with ".sql". + is_file($preparationScript) && str_ends_with($preparationScript, ".sql") + )); + + // Sort in lexicographic order. + sort($this->preparations); + } + } + + /** + * Prepare migrations table: create it if it doesn't exists. + * @return void + * @throws UndefinedNewColumnTypeException + */ + protected function prepareMigrationsTable(): void + { + $table = new Table($this->database, $this->configuration->getMigrationsTable()); + $table->createIfNotExists(); + $table->column("id")->type(Type::Varchar)->primary()->add(true); + $table->column("name")->type(Type::Varchar)->index()->add(true); + $table->column("created_at")->type(new Timestamp(true))->defaultNow()->add(true); + } + + /** + * Get a migration model for the current migrations. + * @return \Nest\Database\Migrations\Model\Migration + */ + protected function getMigrationModel(): \Nest\Database\Migrations\Model\Migration + { + // Create an anonymous class for the current migrations. + $migrationModel = new class extends \Nest\Database\Migrations\Model\Migration + { + /** + * Migrations table name. + * @var string + */ + public static string $migrationsTableName; + + /** + * @inheritDoc + */ + public function definition(EntityBlueprint $blueprint): EntityBlueprint + { + if (!empty(static::$migrationsTableName)) + // Set migrations table name. + $blueprint->setTable(static::$migrationsTableName); + + return parent::definition($blueprint); + } + }; + + // Set migrations table name. + // $migrationModel cannot be used as is: the table name has been defined after the first definition. + $migrationModel::$migrationsTableName = $this->configuration->getMigrationsTable(); + + return $migrationModel->new(); // Return migrations model base instance. + } + + /** + * Find done migrations. + * @return void + * @throws IncompatibleTypeException + * @throws MissingConditionValueException + * @throws UnknownDatabaseException + * @throws InvalidTypeException + * @throws UndefinedRelationException + * @throws UnhandledPropertyTypeException + */ + protected function findMigrated(): void + { + /** + * Get migrated migrations. + * @var \Nest\Database\Migrations\Model\Migration[] $migrations + */ + $migrations = $this->getMigrationModel()->query()->get(); + + foreach ($migrations as $migration) + { // Set each migration as migrated. + $this->migrations[$migration->id]["migrated"] = true; + $this->migrations[$migration->id]["entity"] = $migration; + } + } + + /** + * Mark the given migration as migrated. + * @param string $migrationId The migration version identifier. + * @return void + * @throws IncompatibleTypeException + * @throws MissingConditionValueException + * @throws UnknownDatabaseException + * @throws MissingRequiredFieldException + */ + public function markMigrated(string $migrationId): void + { + // Mark migration as migrated. + $this->migrations[$migrationId]["migrated"] = true; + + // Create a migration and save it as migrated. + $this->migrations[$migrationId]["entity"] = $this->getMigrationModel()->new(); + $this->migrations[$migrationId]["entity"]->id = $migrationId; + $this->migrations[$migrationId]["entity"]->name = $this->migrations[$migrationId]["name"]; + $this->migrations[$migrationId]["entity"]->save(); + } + /** + * Mark the given migration as NOT migrated. + * @param string $migrationId The migration version identifier. + * @return void + */ + public function markNotMigrated(string $migrationId): void + { + $this->migrations[$migrationId]["entity"]->delete(); + $this->migrations[$migrationId]["migrated"] = false; + } + + /** + * Get a new version for a new database migration. + * @return string The version of a new migration. + */ + protected function getNewVersion(): string + { + // Generate a new version from current date and time. + return (new DateTime())->format("YmdHis"); + } + + /** + * Generate a new migration with the given name. + * @param string $migrationName The migration name. + * @return void + */ + public function newMigration(string $migrationName): void + { + // Database migration filename. + $filename = path_join($this->configuration->getMigrationsPath(), "V".($version = $this->getNewVersion()))."_{$migrationName}.php"; + // Generate new migration content. + file_put_contents($filename, <<configuration->getMigrationsNamespace()}; + +use Nest\Database\Migrations\Migration; + +final class V{$version}_{$migrationName} extends Migration +{ + /** + * @inheritDoc + */ + #[\Override] public function getDescription(): string + { return ""; } + + /** + * @inheritDoc + */ + public function up(): void + {} + + /** + * @inheritDoc + */ + public function down(): void + { + throw new \Nest\Exceptions\Database\Migrations\CannotRollbackException(\$this); + } +} +EOD +); + } + + /** + * Get migrations versions list. + * @return string[] + */ + public function getMigrations(): array + { + return array_keys($this->migrations); + } + + /** + * Construct a new migration instance from its version identifier. + * @param array $migrationData Migration data associative array. + * @return Migration The migration instance. + */ + protected function newMigrationInstance(array $migrationData): Migration + { + // Get migration full class name. + $fullClassName = "\\".$this->configuration->getMigrationsNamespace() + // Add subpackage if there is one. + .(!empty($migrationData["package"]) ? "\\".$migrationData["package"] : "") + // Add migration class name. + ."\\V{$migrationData["version"]}_{$migrationData["name"]}"; + + // Instantiate migration. + return new $fullClassName($this->database, $migrationData["version"], $migrationData["name"]); + } + + /** + * Execute a migration. + * @param string $migrationId The migration version identifier. + * @return Migration The executed migration instance. + * @throws IncompatibleTypeException + * @throws MigrationNotFoundException + * @throws MissingConditionValueException + * @throws MissingRequiredFieldException + * @throws UnknownDatabaseException + * @throws Throwable + */ + public function migrateOne(string $migrationId): Migration + { + if (empty($this->migrations[$migrationId])) + // Migration not found. + throw new MigrationNotFoundException($migrationId); + + // Migrate in a transaction. + return $this->database->transaction(function () use ($migrationId) { + // Get the migration instance. + $migration = $this->newMigrationInstance($this->migrations[$migrationId]); + + if (!empty($this->onMigrationStart)) + // Fire migration start event, if it is defined. + ($this->onMigrationStart)($migration); + + // Execute migration. + $migration->up(); + + // Mark the migration as migrated. + $this->markMigrated($migrationId); + + if (!empty($this->onMigrationEnd)) + // Fire migration end event, if it is defined. + ($this->onMigrationEnd)($migration); + + // Return executed migration. + return $migration; + }); + } + + /** + * Execute all unapplied migrations. + * @return Migration[] The applied migrations list. + * @throws IncompatibleTypeException + * @throws MigrationNotFoundException + * @throws MissingConditionValueException + * @throws MissingRequiredFieldException + * @throws UnknownDatabaseException + * @throws Throwable + */ + public function migrate(): array + { + // Migrate in a transaction. + return $this->database->transaction(function () { + // Initialize the executed migrations. + $migrated = []; + + // Clear preparations before executing migrations. + $this->clearPreparations(); + + foreach ($this->migrations as $version => $migration) + { // For each migration, if it is not executed, we execute it. + if (!$migration["migrated"]) + { // If the migration isn't done, execute it. + $migrated[$version] = $this->migrateOne($version); + } + } + + // Do database preparations after migrations. + $this->prepare(); + + return $migrated; // Return executed migrations list. + }); + } + + /** + * Rollback a migration. + * @param string $migrationId The migration version identifier. + * @return Migration The rolled back migration. + * @throws MigrationNotFoundException + * @throws Throwable + */ + public function rollbackOne(string $migrationId): Migration + { + if (empty($this->migrations[$migrationId])) + // Migration not found. + throw new MigrationNotFoundException($migrationId); + + // Rollback in a transaction. + return $this->database->transaction(function () use ($migrationId) { + // Clear preparations before rolling back. + $this->clearPreparations(); + + // Get the migration instance. + $migration = $this->newMigrationInstance($this->migrations[$migrationId]); + + if (!empty($this->onRollbackStart)) + // Fire rollback start event, if it is defined. + ($this->onRollbackStart)($migration); + + // Rollback migration. + $migration->down(); + + // Mark the migration as not migrated. + $this->markNotMigrated($migrationId); + + // Do database preparations after rollback. + $this->prepare(); + + if (!empty($this->onRollbackEnd)) + // Fire rollback start event, if it is defined. + ($this->onRollbackEnd)($migration); + + return $migration; // Return rolled back migration. + }); + } + + /** + * Rollback the latest migrated migration + * @return Migration|null The rolled back migration, if there is one. + * @throws MigrationNotFoundException + * @throws Throwable + */ + public function rollbackLatest(): ?Migration + { + // Get latest migration ID. + $latestMigration = null; + foreach (array_reverse($this->migrations, true) as $migrationId => $migration) + { // Exploring migrations in reverse order (the most recent first). + if ($migration["migrated"]) + { // The first migrated migration is taken as the latest one. + $latestMigration = $migrationId; + break; + } + } + + if (!empty($latestMigration)) + // Rollback the latest migrated migration. + return $this->rollbackOne($latestMigration); + else + // No latest migration, do nothing and return NULL. + return null; + } + + /** + * The preparations clear scripts filename regex. + */ + const string PREPARATIONS_CLEAR_SCRIPT_REGEX = "/^0+_(.*)$/"; + + /** + * Clear database preparations. + * Database preparations are cleared before migrations and should drop all functions and views used by tables that can + * be changed by migrations. + * @return void + */ + public function clearPreparations(): void + { + foreach ($this->preparations as $preparationScript) + { // Execute all preparations clear scripts. + if (preg_match(static::PREPARATIONS_CLEAR_SCRIPT_REGEX, $preparationScript)) + // Only execute files with 00_ prefix. + $this->execPreparation($preparationScript); + } + } + + /** + * Do database preparations. + * Database preparations are executed after migrations and can create functions and views based on tables. + * @return void + */ + public function prepare(): void + { + foreach ($this->preparations as $preparationScript) + { // Execute all preparations that are not clear scripts. + if (!preg_match(static::PREPARATIONS_CLEAR_SCRIPT_REGEX, $preparationScript)) + // Only execute files that aren't preparations clear scripts. + $this->execPreparation($preparationScript); + } + } + + /** + * Execute a given preparation script. + * @param string $preparationScript The preparation script to execute. + * @return void + */ + private function execPreparation(string $preparationScript): void + { + // Read preparation script and execute it. + $this->database->execute(file_get_contents($preparationScript)); + } +} diff --git a/src/Migrations/MigrationsService.php b/src/Migrations/MigrationsService.php new file mode 100644 index 0000000..a61dcb8 --- /dev/null +++ b/src/Migrations/MigrationsService.php @@ -0,0 +1,42 @@ +migrations = new Migrations($this, $this->getServiceConfiguration(MigrationsConfiguration::class)); + } + + /** + * Database migrations service. + * @return Migrations The database migrations manager. + */ + public function migrations(): Migrations + { + return $this->migrations; + } +} diff --git a/src/Migrations/Model/Migration.php b/src/Migrations/Model/Migration.php new file mode 100644 index 0000000..76302bf --- /dev/null +++ b/src/Migrations/Model/Migration.php @@ -0,0 +1,44 @@ +field("id", StringType::class)->primary(); + $blueprint->field("name", StringType::class)->index(); + $blueprint->createdAt(); + + return $blueprint; + } +} diff --git a/src/PdoDatabase.php b/src/PdoDatabase.php new file mode 100644 index 0000000..465f9c0 --- /dev/null +++ b/src/PdoDatabase.php @@ -0,0 +1,111 @@ + true, + PDO::ERRMODE_EXCEPTION => true, + ]; + } + + /** + * @inheritDoc + */ + #[\Override] public function connect(): void + { + $this->getEventsManager()->fire(new PdoDatabaseBeforeConnectionEvent($this)); + + // Connect to the database using PDO. + $this->pdo = new PDO($this->getDsn(), $this->getUsername(), $this->getPassword(), $this->getPdoOptions()); + + $this->getEventsManager()->fire(new PdoDatabaseAfterConnectionEvent($this, $this->pdo)); + } + + /** + * @inheritDoc + */ + #[\Override] public function execute(string $statement, array $bindings = []): array + { + // Prepare the statement. + $statement = $this->getConnection()->prepare($statement); + foreach ($bindings as $param => $value) + { // Assign bindings to the statement. + $statement->bindValue(is_int($param) ? $param + 1 : $param, $value); + } + + // Exception is thrown in case of failure. + $statement->execute(); + + // Fetching results. + $result = []; + while ($obj = $statement->fetchObject()) + { // Fetch next object while there is one. + $result[] = $obj; + } + + // Return results. + return $result; + } + + /** + * Check if the connection to the database is active. + * @return bool True if the connection to the database is active, false otherwise. + */ + public function isConnected(): bool + { + return !empty($this->pdo); + } + + /** + * Get the current connection to the database. + * @return PDO + */ + public function getConnection(): PDO + { + if (!$this->isConnected()) + // If we are not connected, trying to connect to the database. + $this->connect(); + + return $this->pdo; + } +} diff --git a/src/PostgreSql/Columns/Bit.php b/src/PostgreSql/Columns/Bit.php new file mode 100644 index 0000000..f229db7 --- /dev/null +++ b/src/PostgreSql/Columns/Bit.php @@ -0,0 +1,22 @@ +size)"; + } +} diff --git a/src/PostgreSql/Columns/Char.php b/src/PostgreSql/Columns/Char.php new file mode 100644 index 0000000..1b6115e --- /dev/null +++ b/src/PostgreSql/Columns/Char.php @@ -0,0 +1,22 @@ +size)"; + } +} diff --git a/src/PostgreSql/Columns/Numeric.php b/src/PostgreSql/Columns/Numeric.php new file mode 100644 index 0000000..917c1da --- /dev/null +++ b/src/PostgreSql/Columns/Numeric.php @@ -0,0 +1,24 @@ +precision, $this->scale)"; + } +} diff --git a/src/PostgreSql/Columns/Time.php b/src/PostgreSql/Columns/Time.php new file mode 100644 index 0000000..1fa9d58 --- /dev/null +++ b/src/PostgreSql/Columns/Time.php @@ -0,0 +1,22 @@ +timezone ? " with time zone" : ""); + } +} diff --git a/src/PostgreSql/Columns/Timestamp.php b/src/PostgreSql/Columns/Timestamp.php new file mode 100644 index 0000000..073ba3f --- /dev/null +++ b/src/PostgreSql/Columns/Timestamp.php @@ -0,0 +1,22 @@ +timezone ? " with time zone" : ""); + } +} diff --git a/src/PostgreSql/Columns/Type.php b/src/PostgreSql/Columns/Type.php new file mode 100644 index 0000000..2041bb7 --- /dev/null +++ b/src/PostgreSql/Columns/Type.php @@ -0,0 +1,62 @@ +size)"; + } +} diff --git a/src/PostgreSql/Columns/Varchar.php b/src/PostgreSql/Columns/Varchar.php new file mode 100644 index 0000000..971c726 --- /dev/null +++ b/src/PostgreSql/Columns/Varchar.php @@ -0,0 +1,22 @@ +size)"; + } +} diff --git a/src/PostgreSql/PostgreSql.php b/src/PostgreSql/PostgreSql.php new file mode 100644 index 0000000..e9da756 --- /dev/null +++ b/src/PostgreSql/PostgreSql.php @@ -0,0 +1,23 @@ +getRequiredConfig("host"), + $this->getRequiredConfig("database"), + $this->getRequiredConfig("username"), + $this->getRequiredConfig("password"), + $this->getOptionalConfig("port", 5432), + ); + } +} diff --git a/src/PostgreSql/PostgreSqlAdapter.php b/src/PostgreSql/PostgreSqlAdapter.php new file mode 100644 index 0000000..6ca328a --- /dev/null +++ b/src/PostgreSql/PostgreSqlAdapter.php @@ -0,0 +1,501 @@ +database->execute("BEGIN;"); + } + + /** + * @inheritDoc + */ + #[\Override] public function rollbackTransaction(): void + { + $this->database->execute("ROLLBACK;"); + } + + /** + * @inheritDoc + */ + #[\Override] public function commitTransaction(): void + { + $this->database->execute("COMMIT;"); + } + + /** + * @inheritDoc + */ + #[Override] public function createTable(string $tableName, bool $ifNotExists = false): void + { + $this->database->execute("CREATE TABLE ".($ifNotExists ? "IF NOT EXISTS " : "").format_object_name($tableName)."();"); + } + + /** + * @inheritDoc + */ + #[\Override] public function dropTable(string $tableName, bool $ifExists = false): void + { + $this->database->execute("DROP TABLE ".($ifExists ? "IF EXISTS " : "").format_object_name($tableName).";"); + } + + /** + * @inheritDoc + */ + #[\Override] public function renameTable(string $tableName, string $newTableName): void + { + $this->database->execute("ALTER TABLE ".format_object_name($tableName)." RENAME TO ".format_object_name($newTableName).";"); + } + + /** + * @inheritDoc + */ + #[\Override] public function addTableColumn(TableColumn $tableColumn, bool $ifNotExists = false): void + { + // SQL base query. + $sql = "ADD COLUMN".($ifNotExists ? " IF NOT EXISTS" : "")." ".format_object_name($tableColumn->name)." $tableColumn->type"; + + // Set nullable or not. + if (!empty($tableColumn->nullable)) + $sql .= " NULL"; + else + $sql .= " NOT NULL"; + + // Set default value if there is one. + if (isset($tableColumn->default)) + $sql .= " DEFAULT ".((string) $tableColumn->default); + + if (!empty($tableColumn->primary)) + $sql .= " PRIMARY KEY"; + + // Execute add table column. + $this->database->execute("ALTER TABLE ".format_object_name($tableColumn->table->getTableName())." {$sql};"); + } + + /** + * @inheritDoc + */ + #[\Override] public function setTableColumnIndex(TableColumn $tableColumn): void + { + // Create index name. + $indexName = $tableColumn->table->getTableName()."_".$tableColumn->name."_index"; + + // Drop existing index, if it exists. + $this->database->execute("DROP INDEX IF EXISTS {$indexName};"); + + // SQL base query. + $sql = "CREATE ".($tableColumn->index->unique ? "UNIQUE " : "")."INDEX $indexName ON ".format_object_name($tableColumn->table->getTableName()); + + // Set index method if specified. + if (!empty($tableColumn->index->method)) + $sql .= " USING ({$tableColumn->index->method})"; + + // Set column name in new index. + $sql .= " ({$tableColumn->name})"; + + // Execute add column index. + $this->database->execute("$sql;"); + } + + /** + * @inheritDoc + */ + #[\Override] public function renameTableColumn(TableColumn $tableColumn, string $newName): void + { + $this->database->execute("ALTER TABLE ".format_object_name($tableColumn->table->getTableName()). + " RENAME COLUMN ".format_object_name($tableColumn->name). + " TO ".format_object_name($newName)); + } + + /** + * @inheritDoc + */ + #[\Override] public function alterTableColumn(TableColumn $tableColumn): void + { + // SQL base query. + $sql = "ALTER COLUMN ".format_object_name($tableColumn->name); + + if (!empty($tableColumn->type)) + $sql .= " TYPE $tableColumn->type"; + + // Set nullable or not. + if (isset($tableColumn->nullable)) + { + if ($tableColumn->nullable) + $sql .= " DROP NOT NULL"; + else + $sql .= " SET NOT NULL"; + } + + // Set default value if there is one. + if (isset($tableColumn->default)) + $sql .= " SET DEFAULT ".((string) $tableColumn->default); + + // Execute add table column. + $this->database->execute("ALTER TABLE ".format_object_name($tableColumn->table->getTableName())." {$sql};"); + + if (!empty($tableColumn->primary)) + // Set the column as a primary key. + $this->database->execute("ALTER TABLE ".format_object_name($tableColumn->table->getTableName())." ADD PRIMARY KEY (".format_object_name($tableColumn->name).");"); + } + + /** + * @inheritDoc + */ + #[\Override] public function dropTableColumn(string $tableName, string $columnName, bool $ifExists = false): void + { + // SQL base query. + $sql = "DROP COLUMN ".($ifExists ? "IF EXISTS " : "").format_object_name($columnName); + + // Execute drop table column. + $this->database->execute("ALTER TABLE ".format_object_name($tableName)." {$sql};"); + } + + /** + * @inheritDoc + */ + #[\Override] public function dropConstraint(string $tableName, string $constraintName, bool $ifExists = false): void + { + // SQL base query. + $sql = "DROP CONSTRAINT ".($ifExists ? "IF EXISTS " : "").format_object_name($constraintName); + + // Execute drop table constraint. + $this->database->execute("ALTER TABLE ".format_object_name($tableName)." {$sql};"); + } + + /** + * @inheritDoc + */ + #[\Override] public function createForeignKey(TableForeignKey $foreignKey): void + { + // Format columns list. + $columns = implode(", ", array_map(fn ($column) => format_object_name($column), $foreignKey->columns)); + + // Format referenced columns list. + $referencedColumns = implode(", ", array_map(fn ($column) => format_object_name($column), $foreignKey->referencedColumns)); + + // Base SQL. + $sql = "ADD FOREIGN KEY ($columns) REFERENCES ".format_object_name($foreignKey->referencedTable)."($referencedColumns)"; + + // Set ON DELETE and ON UPDATE actions. + if (!empty($foreignKey->onDelete)) + $sql .= " ON DELETE {$foreignKey->onDelete}"; + if (!empty($foreignKey->onUpdate)) + $sql .= " ON UPDATE {$foreignKey->onUpdate}"; + + // Execute add foreign key constraint. + $this->database->execute("ALTER TABLE ".format_object_name($foreignKey->table->getTableName())." {$sql};"); + } + + /** + * @inheritDoc + */ + #[\Override] public function createIndex(TableIndex $index, bool $ifNotExists = false): void + { + // Base SQL. + $sql = "CREATE (UNIQUE) INDEX".($ifNotExists ? " IF NOT EXISTS" : "")." $index->name ON ".format_object_name($index->table->getTableName()); + + if (!empty($index->method)) + $sql .= " USING $index->method"; + + $sql .= "("; + + if (!empty($index->rawExpression)) + $sql .= "($index->rawExpression)"; + else + { // Format indexed columns. + // Format columns list. + $columns = implode(", ", array_map(fn ($column) => format_object_name($column), $index->columns)); + $sql .= "($columns)"; + } + + // Set index order, if defined. + if (!empty($index->order)) + $sql .= " $index->order"; + if (!empty($index->nulls)) + $sql .= " $index->nulls"; + + $sql .= ")"; + + // Execute index creation. + $this->database->execute("$sql;"); + } + + /** + * @inheritDoc + */ + #[\Override] public function renameIndex(string $indexName, string $newName, bool $ifExists = false): void + { + // Execute index rename. + $this->database->execute("ALTER INDEX".($ifExists ? "IF EXISTS " : "")." ".format_object_name($indexName). + " RENAME TO ".format_object_name($newName).";"); + } + + /** + * @inheritDoc + */ + #[\Override] public function dropIndex(TableIndex $index, bool $ifExists = false): void + { + // Execute index drop. + $this->database->execute("DROP INDEX".($ifExists ? "IF EXISTS " : "")." ".format_object_name($index->name).";"); + } + + /** + * Build JOIN clauses from join builders. + * @param JoinBuilder[] $joins Joins to build. + * @return array{string, array} The built SQL with its bindings, if there are some. Bindings can be empty or non-existent. + * @throws MissingConditionValueException + */ + protected function buildJoins(array $joins): array + { + // Return empty join clauses if there are no conditions. + if (empty($joins)) return ["", []]; + + $sql = ""; + $bindings = []; + + // Build all join clauses. + foreach ($joins as $join) + { // For each join clause, build its conditions and append the built bindings. + [$onSql, $onBindings] = $this->buildWhere($join->getConditions(), "ON"); + $sql .= "$join->type JOIN \"$join->table\" $onSql "; + array_push($bindings, ...$onBindings); + } + + // Return all joins clauses SQL and bindings. + return [$sql, $bindings]; + } + + /** + * Build WHERE clause from conditions builders. + * @param ConditionBuilder[] $wheres Conditions to build. + * @param string $keyword The keyword to use. "WHERE" by default, but can be replaced by "ON" for JOIN conditions. + * @return array{string, array} The built SQL with its bindings, if there are some. Bindings can be empty or non-existent. + * @throws MissingConditionValueException + */ + protected function buildWhere(array $wheres, string $keyword = "WHERE"): array + { + // Return empty where clause if there are no conditions. + if (empty($wheres)) return ["", []]; + + // Build all conditions. + $conditions = array_map(fn (ConditionBuilder $condition) => $condition->toSql(), $wheres); + // Join all conditions with AND. + $sql = implode("AND", array_column($conditions, 0)); + // Join all values bindings. + $bindings = array_merge(...array_column($conditions, 1)); + + // Return built WHERE clause. + return ["$keyword $sql", $bindings]; + } + + /** + * @inheritDoc + */ + #[\Override] public function buildSelect(string $tableName, array $selected, array $joins, array $wheres, ?int $limit = null): Raw + { + $bindings = []; // Initialize empty bindings. + + // SQL base query: select the table columns. + $sql = "SELECT ".implode(", ", array_map(function (Raw|string|array $select) use (&$bindings) { + if ($select instanceof Raw) + { // Raw selection. + // Add its bindings to the list. + array_push($bindings, ...$select->bindings); + // Return raw SQL. + return (string) $select; + } + else + // Format selection and return its SQL. + return format_object_name((string) $select); + }, $selected)); + + // Append FROM clause. + $sql .= " FROM ".format_object_name($tableName); + + // Append JOIN clauses. + [$joinsSql, $joinsBindings] = $this->buildJoins($joins); + if (!empty($joinsSql)) + { + $sql .= " $joinsSql"; + array_push($bindings, ...$joinsBindings); + } + + // Append WHERE clause. + [$whereSql, $whereBindings] = $this->buildWhere($wheres); + if (!empty($whereSql)) + { + $sql .= " $whereSql"; + array_push($bindings, ...$whereBindings); + } + + // Append LIMIT clause. + if (!is_null($limit)) + $sql .= " LIMIT $limit"; + + // Return raw SQL with its bindings. + return new Raw($sql, $bindings); + } + + /** + * @inheritDoc + */ + #[\Override] public function select(string $tableName, array $selected, array $joins, array $wheres, ?int $limit = null): array + { + // Build SELECT query. + $raw = $this->buildSelect($tableName, $selected, $joins, $wheres, $limit); + + // Execute built query and return result. + return $this->database->execute($raw->sql, $raw->bindings); + } + + /** + * @inheritDoc + */ + #[\Override] public function insert(string $tableName, array $columns, array $rows, bool $returning = false): array|bool + { + // SQL base query: insert into the table columns. + $sql = "INSERT INTO ".format_object_name($tableName). + " (".implode(", ", array_map(fn (string $column) => format_object_name($column), $columns)).")"; + + // Add values (with bindings). + $sql .= " VALUES "; + $rowsValues = []; + $bindings = []; + foreach ($rows as $row) + { // Create a new VALUES tuple for each row. + // Initialize row parts of the current row. + $rowParts = []; + $rowBindings = []; + + foreach ($row as $value) + { // For each row value, add a row part corresponding to its type. + if ($value instanceof Raw) + // Add a new raw row part. + $rowParts[] = (string) $value; + else + { // Add a new row part, with its binding. + $rowParts[] = "?"; + $rowBindings[] = $value; + } + } + + // Append current row tuple to values, with its bindings. + $rowsValues[] = "(".implode(", ", $rowParts).")"; + array_push($bindings, ...$rowBindings); + } + + // Build values SQL and append it to the query. + $sql .= implode(", ", $rowsValues); + + if ($returning) + // Inserted rows shall be returned, indicating it in the query. + $sql .= "RETURNING *"; + + // SQL query end. + $sql .= ";"; + + // Execute INSERT query. + $result = $this->database->execute($sql, $bindings); + + if ($returning) + // Returning inserted rows. + return $result; + else + // INSERT executed successfully. + return true; + } + + /** + * @inheritDoc + */ + #[\Override] public function update(string $tableName, array $set, array $wheres, bool $returning = false): array|bool + { + // SQL base query: update the given table. + $sql = "UPDATE ".format_object_name($tableName); + $bindings = []; // Initialize empty bindings. + + // Initialize SET clause. + $sql .= " SET "; + $setParts = []; + foreach ($set as $columnName => $value) + { // For each set value, add a set part corresponding to its type. + $currentSet = format_object_name($columnName)." = "; + if ($value instanceof Raw) + // Add a new raw set part. + $setParts[] = $currentSet.((string) $value); + else + { // Add a new set part, with its binding. + $setParts[] = $currentSet."?"; + $bindings[] = $value; + } + } + + // Append SET columns. + $sql .= implode(", ", $setParts); + + // Append WHERE clause. + [$whereSql, $whereBindings] = $this->buildWhere($wheres); + if (!empty($whereSql)) + { + $sql .= " $whereSql"; + array_push($bindings, ...$whereBindings); + } + + if ($returning) + // Inserted rows shall be returned, indicating it in the query. + $sql .= "RETURNING *"; + + // SQL query end. + $sql .= ";"; + + // Execute UPDATE query. + $result = $this->database->execute($sql, $bindings); + + if ($returning) + // Returning updated rows. + return $result; + else + // UPDATE executed successfully. + return true; + } + + /** + * @inheritDoc + */ + #[\Override] public function delete(string $tableName, array $wheres): void + { + // SQL base query: delete from the given table. + $sql = "DELETE FROM ".format_object_name($tableName); + + // Append WHERE clause. + [$whereSql, $bindings] = $this->buildWhere($wheres); + if (!empty($whereSql)) + $sql .= " $whereSql"; + + // SQL query end. + $sql .= ";"; + + // Execute DELETE query. + $this->database->execute($sql, $bindings); + } +} diff --git a/src/PostgreSql/PostgreSqlDatabase.php b/src/PostgreSql/PostgreSqlDatabase.php new file mode 100644 index 0000000..8808a70 --- /dev/null +++ b/src/PostgreSql/PostgreSqlDatabase.php @@ -0,0 +1,51 @@ +host};port={$this->port};dbname={$this->database}"; + } + + /** + * @inheritDoc + */ + #[\Override] protected function getUsername(): string + { + return $this->username; + } + + /** + * @inheritDoc + */ + #[\Override] protected function getPassword(): string + { + return $this->password; + } +} diff --git a/src/Query/DeleteQuery.php b/src/Query/DeleteQuery.php new file mode 100644 index 0000000..027baa7 --- /dev/null +++ b/src/Query/DeleteQuery.php @@ -0,0 +1,43 @@ +database; + } + + /** + * Execute DELETE query. + * @return void + * @throws MissingConditionValueException + */ + public function execute(): void + { + // Execute built query and return result. + $this->database->getQueriesAdapter()->delete($this->table, $this->wheres); + } +} diff --git a/src/Query/InsertQuery.php b/src/Query/InsertQuery.php new file mode 100644 index 0000000..f12eeb6 --- /dev/null +++ b/src/Query/InsertQuery.php @@ -0,0 +1,75 @@ +> + */ + protected array $values = []; + + /** + * Create a new INSERT query. + * @param Database $database The database on which to execute the query. + * @param string $table Base table of the INSERT query. + */ + public function __construct(protected Database $database, protected string $table) + {} + + /** + * Reset values to insert. + * @return $this + */ + public function resetValues(): static + { + $this->values = []; + return $this; + } + + /** + * Add values to insert. + * @param array ...$row Each parameter is an associative array that represents one row data. + * @return $this + */ + public function values(array ...$row): static + { + // Append all rows to values array. + array_push($this->values, ...$row); + return $this; + } + + /** + * Execute insert query. + * @param bool $returning True to return inserted objects. + * @return object[]|bool Inserted objects if returning is true, true otherwise. + */ + public function execute(bool $returning = false): array|bool + { + // Find all inserted columns. + $columns = array_unique_quick(array_merge(...array_map(fn (array $row) => array_keys($row), $this->values))); + + // Prepare rows to insert (fill rows with DEFAULT values, if some of them are missing something). + $rows = array_map(function (array $rowValues) use ($columns) { + // Initialize current row. + $row = []; + + foreach ($columns as $column) + { // Get value for each column: from row values if it is defined, use DEFAULT otherwise. + $row[$column] = $rowValues[$column] ?? new Raw("DEFAULT"); + } + + return $row; // Return full row ready to insert. + }, $this->values); + + // Perform insert. + return $this->database->getQueriesAdapter()->insert($this->table, $columns, $rows, $returning); + } +} diff --git a/src/Query/Join/HasJoin.php b/src/Query/Join/HasJoin.php new file mode 100644 index 0000000..d6162ef --- /dev/null +++ b/src/Query/Join/HasJoin.php @@ -0,0 +1,78 @@ +joins = []; + return $this; + } + + /** + * Create a new join clause. + * @param string $type Type of the join clause. + * @param string $table Joined table. + * @return JoinBuilder New join clause builder. + */ + public function join(string $type, string $table): JoinBuilder + { + $join = new JoinBuilder($this, $type, $table); + $this->joins[] = $join; + return $join; + } + + /** + * Create a new inner join clause. + * @param string $table Joined table. + * @return JoinBuilder New join clause builder. + */ + public function innerJoin(string $table): JoinBuilder + { + return $this->join(JoinBuilder::INNER, $table); + } + + /** + * Create a new outer join clause. + * @param string $table Joined table. + * @return JoinBuilder New join clause builder. + */ + public function outerJoin(string $table): JoinBuilder + { + return $this->join(JoinBuilder::OUTER, $table); + } + + /** + * Create a new left join clause. + * @param string $table Joined table. + * @return JoinBuilder New join clause builder. + */ + public function leftJoin(string $table): JoinBuilder + { + return $this->join(JoinBuilder::LEFT, $table); + } + + /** + * Create a new right join clause. + * @param string $table Joined table. + * @return JoinBuilder New join clause builder. + */ + public function rightJoin(string $table): JoinBuilder + { + return $this->join(JoinBuilder::RIGHT, $table); + } +} diff --git a/src/Query/Join/JoinBuilder.php b/src/Query/Join/JoinBuilder.php new file mode 100644 index 0000000..8965a0f --- /dev/null +++ b/src/Query/Join/JoinBuilder.php @@ -0,0 +1,75 @@ +on = []; + return $this; + } + + /** + * Add a ON condition. + * @param string|callable $column The column of the condition, or a condition builder callable. + * @param string $operator The operator of the condition (or the value column if no value is passed). + * @param string|null $valueColumn The value column of the condition. + * @return mixed The query. + */ + public function on(string|callable $column, string $operator, string $valueColumn = null): mixed + { + if (is_callable($column)) + { // Callable condition builder. + $this->on[] = $column(new ConditionBuilder()); + } + else + { // Simple condition, registering it. + if (!empty($operator) && empty($valueColumn)) + { // If there are 2 parameters, considering the second one as the value, with default operator. + $valueColumn = $operator; + // Default operator: "=". + $operator = "="; + } + + // Create the simple condition. + $this->on[] = (new ConditionBuilder())->column($column)->operator($operator)->column($valueColumn); + } + + return $this->query; + } + + /** + * Get join conditions. + * @return ConditionBuilder[] ON conditions of the JOIN clause. + */ + public function getConditions(): array + { + return $this->on; + } +} diff --git a/src/Query/QueryBuilder.php b/src/Query/QueryBuilder.php new file mode 100644 index 0000000..6bdab6a --- /dev/null +++ b/src/Query/QueryBuilder.php @@ -0,0 +1,92 @@ +setTable($table); + } + + /** + * Get the database on which to execute the query. + * @return Database + */ + public function getDatabase(): Database + { + return $this->database; + } + + /** + * Set the table to use in the query. + * @param string $table The table to use in the query. + * @return $this + */ + public function setTable(string $table): static + { + $this->table = $table; + return $this; + } + + /** + * Get the table to use in the query. + * @return string + */ + public function getTable(): string + { + return $this->table; + } + + /** + * Create a new SELECT query. + * @return SelectQuery A select query. + */ + public function newSelect(): SelectQuery + { + return new SelectQuery($this->getDatabase(), $this->getTable()); + } + + /** + * Create a new INSERT query. + * @return InsertQuery An INSERT query. + */ + public function newInsert(): InsertQuery + { + return new InsertQuery($this->getDatabase(), $this->getTable()); + } + + /** + * Create a new UPDATE query. + * @return UpdateQuery An UPDATE query. + */ + public function newUpdate(): UpdateQuery + { + return new UpdateQuery($this->getDatabase(), $this->getTable()); + } + + /** + * Create a new DELETE query. + * @return DeleteQuery A DELETE query. + */ + public function newDelete(): DeleteQuery + { + return new DeleteQuery($this->getDatabase(), $this->getTable()); + } +} diff --git a/src/Query/Raw.php b/src/Query/Raw.php new file mode 100644 index 0000000..ebe7d2f --- /dev/null +++ b/src/Query/Raw.php @@ -0,0 +1,23 @@ +sql; + } +} diff --git a/src/Query/SelectQuery.php b/src/Query/SelectQuery.php new file mode 100644 index 0000000..2681d54 --- /dev/null +++ b/src/Query/SelectQuery.php @@ -0,0 +1,135 @@ +database; + } + + /** + * Get retrieved table name. + * @return string Selected table name. + */ + public function getTableName(): string + { + return $this->table; + } + + /** + * Reset selected columns. + * @return $this + */ + public function resetSelect(): static + { + $this->selected = []; + return $this; + } + + /** + * Add selected columns. + * @param string|Raw ...$selectedColumns Selected columns. + * @return $this + */ + public function select(string|Raw ...$selectedColumns): static + { + // Append SELECTed columns. + array_push($this->selected, ...$selectedColumns); + return $this; + } + + /** + * Add raw selected SQL. + * @param string ...$rawSelect Raw selected SQL. + * @return $this + */ + public function selectRaw(string ...$rawSelect): static + { + // Append raw SQL SELECT. + array_push($this->selected, ...array_map(fn (string $rawSql) => new Raw($rawSql), $rawSelect)); + return $this; + } + + /** + * Set the new limit of results. + * @param int|null $limit Limit of retrieved results. NULL to remove the limit. + * @return $this + */ + public function limit(?int $limit): static + { + $this->limit = $limit; + return $this; + } + + /** + * Build SELECT query. + * @return Raw SQL and its bindings. + * @throws MissingConditionValueException + */ + public function build(): Raw + { + // Build SELECT query and return its result. + return $this->database->getQueriesAdapter()->buildSelect($this->table, + // Select all columns by default. + empty($this->selected) ? [new Raw("*")] : $this->selected, + $this->joins, + $this->wheres, + $this->limit, + ); + } + + /** + * Execute SELECT query. + * @return object[] SELECT query result. + * @throws MissingConditionValueException + */ + public function execute(): array + { + // Execute built query and return result. + return $this->database->getQueriesAdapter()->select($this->table, + // Select all columns by default. + empty($this->selected) ? [new Raw("*")] : $this->selected, + $this->joins, + $this->wheres, + $this->limit, + ); + } +} diff --git a/src/Query/UpdateQuery.php b/src/Query/UpdateQuery.php new file mode 100644 index 0000000..a1046e7 --- /dev/null +++ b/src/Query/UpdateQuery.php @@ -0,0 +1,80 @@ + + */ + protected array $set = []; + + /** + * Create a new UPDATE query. + * @param Database $database The database on which to execute the query. + * @param string $table Base table of the UPDATE query. + */ + public function __construct(protected Database $database, protected string $table) + {} + + /** + * Get the database on which to execute the query. + * @return Database + */ + public function getDatabase(): Database + { + return $this->database; + } + + /** + * Reset set values. + * @return $this + */ + public function resetSet(): static + { + $this->set = []; + return $this; + } + + /** + * Add set columns values. + * @param string|array $column The name of the column in column mode, or an associative array of all set columns with their corresponding value. + * @param Raw|string|int|float|null $value The value to set, when setting in column mode (with a column as first parameter). + * @return $this + */ + public function set(string|array $column, Raw|string|int|float|null $value = null): static + { + if (is_array($column)) + { // The first parameter is an array, adding set values in array mode. + $this->set = array_merge($this->set, $column); + } + else + { // The first parameter is a string, adding set value in column mode. + $this->set[$column] = $value; + } + + return $this; + } + + /** + * Execute update query. + * @param bool $returning True to return inserted objects. + * @return object[]|bool Updated objects if returning is true, true otherwise. + * @throws MissingConditionValueException + */ + public function execute(bool $returning = false): array|bool + { + // Execute built query and return result. + return $this->database->getQueriesAdapter()->update($this->table, $this->set, $this->wheres, $returning); + } +} diff --git a/src/Query/Where/ConditionBuilder.php b/src/Query/Where/ConditionBuilder.php new file mode 100644 index 0000000..08d7f67 --- /dev/null +++ b/src/Query/Where/ConditionBuilder.php @@ -0,0 +1,215 @@ +column) || !empty($this->value)) + $this->column = $column; + else + $this->valueColumn = $column; + + return $this; + } + + /** + * Set operator of the condition. + * @param string $operator Operator to use. + * @return $this + */ + public function operator(string $operator): static + { + $this->operator = $operator; + return $this; + } + + /** + * Set value of the condition. + * @param mixed $value Value to use. + * @return $this + */ + public function value(mixed $value): static + { + $this->value = $value; + return $this; + } + + /** + * Set "=" operator. + * @return $this + */ + public function equals(): static + { + return $this->operator("="); + } + /** + * Set "LIKE" operator. + * @return $this + */ + public function like(): static + { + return $this->operator("LIKE"); + } + /** + * Set "EXISTS" operator. + * @return $this + */ + public function exists(): static + { + return $this->operator("EXISTS"); + } + + /** + * Append and conditions. + * @param callable ...$conditionBuilder And conditions builders. + * @return $this + */ + public function and(callable ...$conditionBuilder): static + { + foreach ($conditionBuilder as $conditionBuilderCallable) + { + $this->ands[] = $conditionBuilderCallable(new ConditionBuilder()); + } + + return $this; + } + + /** + * Append or conditions. + * @param callable ...$conditionBuilder Or conditions builders. + * @return $this + */ + public function or(callable ...$conditionBuilder): static + { + foreach ($conditionBuilder as $conditionBuilderCallable) + { + $this->ors[] = $conditionBuilderCallable(new ConditionBuilder()); + } + + return $this; + } + + + + /** + * Build SQL AND subcondition. + * @param ConditionBuilder $condition The subcondition builder. + * @return array{string, string[]} The built SQL with its bindings, if there are some. Bindings can be empty or non-existent. + * @throws MissingConditionValueException + */ + private function buildAndSql(ConditionBuilder $condition): array + { + [$sql, $bindings] = $condition->toSql(); + return ["AND $sql", $bindings]; + } + /** + * Build SQL OR subcondition. + * @param ConditionBuilder $condition The subcondition builder. + * @return array{string, string[]} The built SQL with its bindings, if there are some. Bindings can be empty or non-existent. + * @throws MissingConditionValueException + */ + private function buildOrSql(ConditionBuilder $condition): array + { + [$sql, $bindings] = $condition->toSql(); + return ["OR $sql", $bindings]; + } + + /** + * Build SQL of the condition. + * @return array{string, string[]} The built SQL with its bindings, if there are some. Bindings can be empty or non-existent. + * @throws MissingConditionValueException + */ + public function toSql(): array + { + // Build subconditions. + $subconditions = [ + ...array_map( + fn (ConditionBuilder $condition) => $this->buildAndSql($condition), + $this->ands, + ), ...array_map( + fn (ConditionBuilder $condition) => $this->buildOrSql($condition), + $this->ors, + ) + ]; + + // SQL to append after current condition. + $appendSql = ""; + // Values bindings to append after current condition. + $appendBindings = []; + foreach ($subconditions as [$sql, $bindings]) + { // Append current subcondition to SQL. + $appendSql .= $sql; + // Append current subcondition bindings. + array_push($appendBindings, ...$bindings); + } + + if (!empty($this->column)) + { // If a column is defined, build the condition on it. + if (($this->operator ?? "=") === "EXISTS" || (empty($this->operator) && empty($this->value) && empty($this->valueColumn))) + // "EXISTS" operator, or no operator nor value are defined. + return ["(EXISTS ".format_object_name($this->column)." $appendSql)", $appendBindings]; + else + { + if (!empty($this->value)) + { // Condition value is a bound value. + if (is_array($this->value)) + { + $valueSql = "(".implode(",", array_fill(0, count($this->value), "?")).")"; + $bindings = $this->value; + } + else + { + $valueSql = "?"; + $bindings = [$this->value]; + } + } + elseif (!empty($this->valueColumn)) + { // Condition value is an SQL column. + $valueSql = format_object_name($this->valueColumn); + $bindings = []; + } + else + // A condition value is missing. + throw new MissingConditionValueException("(".format_object_name($this->column)." $this->operator [...MISSING_CONDITION_VALUE...])"); + + // Default operator is "=". + $operator = $this->operator ?? "="; + + return ["(".format_object_name($this->column)." $operator $valueSql $appendSql)", [...$bindings, ...$appendBindings]]; + } + } + else + { // No column defined, just return subconditions, if there are some. + if (str_starts_with($appendSql, "OR")) $appendSql = trim(substr($appendSql, 2)); + if (str_starts_with($appendSql, "AND")) $appendSql = trim(substr($appendSql, 3)); + return ["($appendSql)", $appendBindings]; + } + } +} diff --git a/src/Query/Where/HasWhere.php b/src/Query/Where/HasWhere.php new file mode 100644 index 0000000..343e192 --- /dev/null +++ b/src/Query/Where/HasWhere.php @@ -0,0 +1,154 @@ +wheres = []; + return $this; + } + + /** + * Add a where condition. + * @param string|callable $column The column of the condition, or a condition builder callable. + * @param mixed|null $operator The operator of the condition (or the value if no value is passed). + * @param mixed|null $value The value of the condition. + * @return $this + */ + public function where(string|callable $column, mixed $operator = null, mixed $value = null): static + { + if (is_callable($column)) + { // Callable condition builder. + $this->wheres[] = $column(new ConditionBuilder()); + } + else + { // Simple condition, registering it. + if (!empty($operator) && empty($value)) + { // If there are 2 parameters, considering the second one as the value, with default operator. + $value = $operator; + $operator = null; + } + + if (!empty($value)) + { // A value is defined. + // Default operator: "=". + if (empty($operator)) $operator = "="; + + // Create the simple condition. + $this->wheres[] = (new ConditionBuilder())->column($column)->operator($operator)->value($value); + } + else + { // No value is given, considering a simple existence check. + $this->whereExists($column); + } + } + + return $this; + } + + /** + * Add a where IN condition. + * @param string $column The column of the condition. + * @param array $values The values of the condition. + * @return $this + */ + public function whereIn(string $column, array $values): static + { + return $this->where($column, "IN", $values); + } + + /** + * Add a where condition on a column as a value. + * @param string $column The column of the condition. + * @param string $operator The operator of the condition (or the value column if no value is passed). + * @param string|null $valueColumn The value column of the condition. + * @return $this + */ + public function whereColumn(string $column, string $operator, ?string $valueColumn = null): static + { + if (!empty($operator) && empty($valueColumn)) + { // If there are 2 parameters, considering the second one as the value, with default operator. + $valueColumn = $operator; + $operator = "="; + } + + // Create the simple condition. + $this->wheres[] = (new ConditionBuilder())->column($column)->operator($operator)->column($valueColumn); + + return $this; + } + + /** + * Add a where exists condition. + * @param string $column The column to check for existence. + * @return $this + */ + public function whereExists(string $column): static + { + // Create an existence check. + $this->wheres[] = (new ConditionBuilder())->column($column)->exists(); + return $this; + } + + /** + * Add condition to find given entities. + * @param Entity|Entities $entities Entity or entities to find. + * @return $this + * @throws NoPrimaryFieldException + */ + public function whereKeyOf(Entity|Entities $entities): static + { + // Normalize parameter to a simple entities array. + if ($entities instanceof Entities) + $entitiesArray = $entities->get(); + else + $entitiesArray = [$entities]; + + // Initialize entities conditions. + $entitiesConditions = []; + foreach ($entitiesArray as $entity) + { // Add condition for each entity to find. + // Get current entity primary fields and normalize it to an array. + $primaryFields = $entity->getPrimaryFields(); + if (!is_array($primaryFields)) $primaryFields = [$primaryFields]; + + if (empty($primaryFields)) + // If there are no primary fields, thrown an exception to ensure not to do something on EVERY rows. + throw new NoPrimaryFieldException($entity); + + // Create conditions for current entity. + $entityConditions = []; + foreach ($primaryFields as $primaryField) + // Create a condition builder for each field. + $entityConditions[] = fn (ConditionBuilder $condition) => + $condition->column($primaryField)->equals()->value($entity->$primaryField); + + // Add the full entity condition to the entities conditions array. + $entitiesConditions[] = fn (ConditionBuilder $condition) => $condition->and(...$entityConditions); + } + + // Add condition to match every entities. + return $this->where(fn (ConditionBuilder $condition) => ( + $condition->or(...$entitiesConditions) + )); + } +} diff --git a/src/Transactions/Transaction.php b/src/Transactions/Transaction.php new file mode 100644 index 0000000..48a1d23 --- /dev/null +++ b/src/Transactions/Transaction.php @@ -0,0 +1,139 @@ +uuid = Uuid::v4()->toString(); + } + + /** + * Get UUID of the transaction. + * @return string + */ + public function getUuid(): string + { + return $this->uuid; + } + + /** + * Get the parent transaction. + * @return Transaction|null + */ + public function getParent(): ?Transaction + { + return $this->parentTransaction; + } + + /** + * Set the current child transaction of this transaction. + * @param Transaction|null $transaction The child transaction. NULL if there is no child transaction. + * @return void + */ + public function setChild(?Transaction $transaction): void + { + $this->childTransaction = $transaction; + } + + /** + * Start the transaction. + * @return void + * @throws NotCurrentTransactionException + */ + public function start(): void + { + // Transaction activation state changed. + $this->active = true; + $this->database->onTransactionStateChanged($this); + + // Start the transaction. + $this->database->getQueriesAdapter()->newTransaction(); + } + + /** + * Commit the transaction. + * @return void + * @throws NotCurrentTransactionException + */ + public function commit(): void + { + if (!$this->isActive()) + // Do not commit a non-active transaction. + return; + + if (!empty($this->childTransaction)) + // First commit the child transaction, if there is one. + $this->childTransaction->commit(); + + // Transaction activation state changed. + $this->active = false; + $this->database->onTransactionStateChanged($this); + + // Commit the current transaction. + $this->database->getQueriesAdapter()->commitTransaction(); + } + + /** + * Rollback the transaction. + * @return void + * @throws NotCurrentTransactionException + */ + public function rollback(): void + { + if (!$this->isActive()) + // Do not rollback a non-active transaction. + return; + + if (!empty($this->childTransaction)) + // First rollback the child transaction, if there is one. + $this->childTransaction->rollback(); + + // Transaction activation state changed. + $this->active = false; + $this->database->onTransactionStateChanged($this); + + // Rollback the current transaction. + $this->database->getQueriesAdapter()->rollbackTransaction(); + } + + /** + * Determine if the transaction is still active (not committed nor rolled back). + * @return bool + */ + public function isActive(): bool + { + return $this->active; + } +}