From 85f2f29b8101f1b8dcfa8add150f333e75cef2a9 Mon Sep 17 00:00:00 2001 From: augushong Date: Wed, 20 Sep 2023 15:35:20 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dseeder=E9=94=99=E8=AF=AF?= =?UTF-8?q?=EF=BC=9B=E5=88=A0=E9=99=A4=E7=AC=AC=E4=B8=89=E6=96=B9=E7=9A=84?= =?UTF-8?q?think=E8=BF=81=E7=A7=BB=E5=B7=A5=E5=85=B7=EF=BC=8C=E5=9C=A8exte?= =?UTF-8?q?nd=E4=B8=AD=E9=87=8D=E6=96=B0=E5=AE=9E=E7=8E=B0=EF=BC=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/service.php | 3 + composer.json | 4 +- composer.lock | 59 +- database/seeds/InitBaseAdminData.php | 10 +- extend/phinx/Config/Config.php | 553 ++++++ extend/phinx/Config/ConfigInterface.php | 171 ++ extend/phinx/Config/FeatureFlags.php | 41 + .../phinx/Config/NamespaceAwareInterface.php | 33 + extend/phinx/Config/NamespaceAwareTrait.php | 74 + extend/phinx/Db/Action/Action.php | 38 + extend/phinx/Db/Action/AddColumn.php | 62 + extend/phinx/Db/Action/AddForeignKey.php | 78 + extend/phinx/Db/Action/AddIndex.php | 67 + extend/phinx/Db/Action/ChangeColumn.php | 87 + extend/phinx/Db/Action/ChangeComment.php | 42 + extend/phinx/Db/Action/ChangePrimaryKey.php | 42 + extend/phinx/Db/Action/CreateTable.php | 12 + extend/phinx/Db/Action/DropForeignKey.php | 68 + extend/phinx/Db/Action/DropIndex.php | 75 + extend/phinx/Db/Action/DropTable.php | 12 + extend/phinx/Db/Action/RemoveColumn.php | 59 + extend/phinx/Db/Action/RenameColumn.php | 79 + extend/phinx/Db/Action/RenameTable.php | 42 + extend/phinx/Db/Adapter/AbstractAdapter.php | 412 ++++ extend/phinx/Db/Adapter/AdapterFactory.php | 172 ++ extend/phinx/Db/Adapter/AdapterInterface.php | 505 +++++ extend/phinx/Db/Adapter/AdapterWrapper.php | 487 +++++ .../Db/Adapter/DirectActionInterface.php | 139 ++ extend/phinx/Db/Adapter/MysqlAdapter.php | 1548 +++++++++++++++ extend/phinx/Db/Adapter/PdoAdapter.php | 1003 ++++++++++ extend/phinx/Db/Adapter/PostgresAdapter.php | 1600 +++++++++++++++ extend/phinx/Db/Adapter/ProxyAdapter.php | 129 ++ extend/phinx/Db/Adapter/SQLiteAdapter.php | 1767 +++++++++++++++++ extend/phinx/Db/Adapter/SqlServerAdapter.php | 1377 +++++++++++++ .../phinx/Db/Adapter/TablePrefixAdapter.php | 494 +++++ .../phinx/Db/Adapter/TimedOutputAdapter.php | 423 ++++ .../UnsupportedColumnTypeException.php | 19 + extend/phinx/Db/Adapter/WrapperInterface.php | 39 + extend/phinx/Db/Plan/AlterTable.php | 72 + extend/phinx/Db/Plan/Intent.php | 55 + extend/phinx/Db/Plan/NewTable.php | 101 + extend/phinx/Db/Plan/Plan.php | 492 +++++ .../phinx/Db/Plan/Solver/ActionSplitter.php | 103 + extend/phinx/Db/Table.php | 721 +++++++ extend/phinx/Db/Table/Column.php | 801 ++++++++ extend/phinx/Db/Table/ForeignKey.php | 237 +++ extend/phinx/Db/Table/Index.php | 227 +++ extend/phinx/Db/Table/Table.php | 84 + extend/phinx/Db/Util/AlterInstructions.php | 122 ++ extend/phinx/LICENSE | 9 + extend/phinx/Migration/AbstractMigration.php | 338 ++++ .../Migration/AbstractTemplateCreation.php | 74 + extend/phinx/Migration/CreationInterface.php | 69 + .../IrreversibleMigrationException.php | 20 + extend/phinx/Migration/Manager.php | 1141 +++++++++++ .../phinx/Migration/Manager/Environment.php | 398 ++++ .../Migration.change.template.php.dist | 23 + .../Migration.up_down.template.php.dist | 17 + extend/phinx/Migration/MigrationInterface.php | 269 +++ extend/phinx/README.md | 143 ++ extend/phinx/Seed/AbstractSeed.php | 222 +++ extend/phinx/Seed/Seed.template.php.dist | 20 + extend/phinx/Seed/SeedInterface.php | 188 ++ extend/phinx/Util/Expression.php | 41 + extend/phinx/Util/Literal.php | 41 + extend/phinx/Util/Util.php | 361 ++++ extend/think/migration/Command.php | 97 + extend/think/migration/Creator.php | 77 + extend/think/migration/Factory.php | 313 +++ extend/think/migration/FactoryBuilder.php | 437 ++++ extend/think/migration/Migrator.php | 27 + extend/think/migration/NullOutput.php | 13 + extend/think/migration/Seeder.php | 24 + extend/think/migration/Service.php | 51 + extend/think/migration/UsePhinx.php | 116 ++ extend/think/migration/command/Migrate.php | 153 ++ extend/think/migration/command/Seed.php | 78 + .../migration/command/factory/Create.php | 82 + .../migration/command/migrate/Breakpoint.php | 92 + .../migration/command/migrate/Create.php | 55 + .../migration/command/migrate/Rollback.php | 146 ++ .../think/migration/command/migrate/Run.php | 140 ++ .../migration/command/migrate/Status.php | 126 ++ .../think/migration/command/seed/Create.php | 83 + extend/think/migration/command/seed/Run.php | 107 + .../migration/command/stubs/factory.stub | 11 + .../migration/command/stubs/migrate.stub | 33 + .../think/migration/command/stubs/seed.stub | 19 + extend/think/migration/db/Column.php | 171 ++ extend/think/migration/db/Table.php | 159 ++ extend/think/migration/helper.php | 40 + 91 files changed, 20798 insertions(+), 66 deletions(-) create mode 100644 extend/phinx/Config/Config.php create mode 100644 extend/phinx/Config/ConfigInterface.php create mode 100644 extend/phinx/Config/FeatureFlags.php create mode 100644 extend/phinx/Config/NamespaceAwareInterface.php create mode 100644 extend/phinx/Config/NamespaceAwareTrait.php create mode 100644 extend/phinx/Db/Action/Action.php create mode 100644 extend/phinx/Db/Action/AddColumn.php create mode 100644 extend/phinx/Db/Action/AddForeignKey.php create mode 100644 extend/phinx/Db/Action/AddIndex.php create mode 100644 extend/phinx/Db/Action/ChangeColumn.php create mode 100644 extend/phinx/Db/Action/ChangeComment.php create mode 100644 extend/phinx/Db/Action/ChangePrimaryKey.php create mode 100644 extend/phinx/Db/Action/CreateTable.php create mode 100644 extend/phinx/Db/Action/DropForeignKey.php create mode 100644 extend/phinx/Db/Action/DropIndex.php create mode 100644 extend/phinx/Db/Action/DropTable.php create mode 100644 extend/phinx/Db/Action/RemoveColumn.php create mode 100644 extend/phinx/Db/Action/RenameColumn.php create mode 100644 extend/phinx/Db/Action/RenameTable.php create mode 100644 extend/phinx/Db/Adapter/AbstractAdapter.php create mode 100644 extend/phinx/Db/Adapter/AdapterFactory.php create mode 100644 extend/phinx/Db/Adapter/AdapterInterface.php create mode 100644 extend/phinx/Db/Adapter/AdapterWrapper.php create mode 100644 extend/phinx/Db/Adapter/DirectActionInterface.php create mode 100644 extend/phinx/Db/Adapter/MysqlAdapter.php create mode 100644 extend/phinx/Db/Adapter/PdoAdapter.php create mode 100644 extend/phinx/Db/Adapter/PostgresAdapter.php create mode 100644 extend/phinx/Db/Adapter/ProxyAdapter.php create mode 100644 extend/phinx/Db/Adapter/SQLiteAdapter.php create mode 100644 extend/phinx/Db/Adapter/SqlServerAdapter.php create mode 100644 extend/phinx/Db/Adapter/TablePrefixAdapter.php create mode 100644 extend/phinx/Db/Adapter/TimedOutputAdapter.php create mode 100644 extend/phinx/Db/Adapter/UnsupportedColumnTypeException.php create mode 100644 extend/phinx/Db/Adapter/WrapperInterface.php create mode 100644 extend/phinx/Db/Plan/AlterTable.php create mode 100644 extend/phinx/Db/Plan/Intent.php create mode 100644 extend/phinx/Db/Plan/NewTable.php create mode 100644 extend/phinx/Db/Plan/Plan.php create mode 100644 extend/phinx/Db/Plan/Solver/ActionSplitter.php create mode 100644 extend/phinx/Db/Table.php create mode 100644 extend/phinx/Db/Table/Column.php create mode 100644 extend/phinx/Db/Table/ForeignKey.php create mode 100644 extend/phinx/Db/Table/Index.php create mode 100644 extend/phinx/Db/Table/Table.php create mode 100644 extend/phinx/Db/Util/AlterInstructions.php create mode 100644 extend/phinx/LICENSE create mode 100644 extend/phinx/Migration/AbstractMigration.php create mode 100644 extend/phinx/Migration/AbstractTemplateCreation.php create mode 100644 extend/phinx/Migration/CreationInterface.php create mode 100644 extend/phinx/Migration/IrreversibleMigrationException.php create mode 100644 extend/phinx/Migration/Manager.php create mode 100644 extend/phinx/Migration/Manager/Environment.php create mode 100644 extend/phinx/Migration/Migration.change.template.php.dist create mode 100644 extend/phinx/Migration/Migration.up_down.template.php.dist create mode 100644 extend/phinx/Migration/MigrationInterface.php create mode 100644 extend/phinx/README.md create mode 100644 extend/phinx/Seed/AbstractSeed.php create mode 100644 extend/phinx/Seed/Seed.template.php.dist create mode 100644 extend/phinx/Seed/SeedInterface.php create mode 100644 extend/phinx/Util/Expression.php create mode 100644 extend/phinx/Util/Literal.php create mode 100644 extend/phinx/Util/Util.php create mode 100644 extend/think/migration/Command.php create mode 100644 extend/think/migration/Creator.php create mode 100644 extend/think/migration/Factory.php create mode 100644 extend/think/migration/FactoryBuilder.php create mode 100644 extend/think/migration/Migrator.php create mode 100644 extend/think/migration/NullOutput.php create mode 100644 extend/think/migration/Seeder.php create mode 100644 extend/think/migration/Service.php create mode 100644 extend/think/migration/UsePhinx.php create mode 100644 extend/think/migration/command/Migrate.php create mode 100644 extend/think/migration/command/Seed.php create mode 100644 extend/think/migration/command/factory/Create.php create mode 100644 extend/think/migration/command/migrate/Breakpoint.php create mode 100644 extend/think/migration/command/migrate/Create.php create mode 100644 extend/think/migration/command/migrate/Rollback.php create mode 100644 extend/think/migration/command/migrate/Run.php create mode 100644 extend/think/migration/command/migrate/Status.php create mode 100644 extend/think/migration/command/seed/Create.php create mode 100644 extend/think/migration/command/seed/Run.php create mode 100644 extend/think/migration/command/stubs/factory.stub create mode 100644 extend/think/migration/command/stubs/migrate.stub create mode 100644 extend/think/migration/command/stubs/seed.stub create mode 100644 extend/think/migration/db/Column.php create mode 100644 extend/think/migration/db/Table.php create mode 100644 extend/think/migration/helper.php diff --git a/app/service.php b/app/service.php index 7e066b8..cd91d6d 100644 --- a/app/service.php +++ b/app/service.php @@ -2,9 +2,12 @@ declare(strict_types=1); +use think\migration\Service; + $service_default = [ 0 => 'think\\captcha\\CaptchaService', 1 => 'think\\app\\Service', + 2 => Service::class, ]; $service_common = include_once __DIR__ . '/common/app/service.php'; diff --git a/composer.json b/composer.json index 811149f..74e1b39 100644 --- a/composer.json +++ b/composer.json @@ -24,7 +24,6 @@ "topthink/think-multi-app": "^1.0", "topthink/think-view": "^2.0", "topthink/think-captcha": "^3.0", - "topthink/think-migration": "^3.0", "guzzlehttp/guzzle": "^7.4", "phpoffice/phpspreadsheet": "^1.22", "doctrine/annotations": "^1.13", @@ -40,7 +39,8 @@ }, "autoload": { "psr-4": { - "app\\": "app" + "app\\": "app", + "Phinx\\": "extend/phinx" }, "psr-0": { "": "extend/" diff --git a/composer.lock b/composer.lock index fe196ec..cb07b6d 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "abd6c2dce1ad5e333fe3406707a11947", + "content-hash": "eabe0953a10d42f429663eb3c42834a5", "packages": [ { "name": "aliyuncs/oss-sdk-php", @@ -2315,63 +2315,6 @@ }, "time": "2021-12-15T04:27:55+00:00" }, - { - "name": "topthink/think-migration", - "version": "v3.1.1", - "source": { - "type": "git", - "url": "https://github.com/top-think/think-migration.git", - "reference": "22c44058e1454f3af1d346e7f6524fbe654de7fb" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/top-think/think-migration/zipball/22c44058e1454f3af1d346e7f6524fbe654de7fb", - "reference": "22c44058e1454f3af1d346e7f6524fbe654de7fb", - "shasum": "" - }, - "require": { - "php": ">=7.2", - "topthink/framework": "^6.0 || ^8.0", - "topthink/think-helper": "^3.0.3" - }, - "require-dev": { - "composer/composer": "^2.5.8", - "fzaninotto/faker": "^1.8", - "robmorgan/phinx": "^0.13.4" - }, - "suggest": { - "fzaninotto/faker": "Required to use the factory builder (^1.8)." - }, - "type": "library", - "extra": { - "think": { - "services": [ - "think\\migration\\Service" - ] - } - }, - "autoload": { - "psr-4": { - "Phinx\\": "phinx", - "think\\migration\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "Apache-2.0" - ], - "authors": [ - { - "name": "yunwuxin", - "email": "448901948@qq.com" - } - ], - "support": { - "issues": "https://github.com/top-think/think-migration/issues", - "source": "https://github.com/top-think/think-migration/tree/v3.1.1" - }, - "time": "2023-09-14T05:51:31+00:00" - }, { "name": "topthink/think-multi-app", "version": "v1.0.17", diff --git a/database/seeds/InitBaseAdminData.php b/database/seeds/InitBaseAdminData.php index 1d45697..c2e415d 100644 --- a/database/seeds/InitBaseAdminData.php +++ b/database/seeds/InitBaseAdminData.php @@ -1,9 +1,8 @@ getOutput(); TriggerService::updateSysconfig(); @@ -26,9 +24,9 @@ class InitBaseAdminData extends Seeder $install_lock = sysconfig('base_admin_install', true, 0); if ($install_lock == 1) { - $output->writeln('系统已初始化,跳过当前程序'); - return false; + + return; } $model_config = SystemConfig::where('group', 'system') diff --git a/extend/phinx/Config/Config.php b/extend/phinx/Config/Config.php new file mode 100644 index 0000000..91cfbd4 --- /dev/null +++ b/extend/phinx/Config/Config.php @@ -0,0 +1,553 @@ +configFilePath = $configFilePath; + $this->values = $this->replaceTokens($configArray); + + if (isset($this->values['feature_flags'])) { + FeatureFlags::setFlagsFromConfig($this->values['feature_flags']); + } + } + + /** + * Create a new instance of the config class using a Yaml file path. + * + * @param string $configFilePath Path to the Yaml File + * @throws \RuntimeException + * @return \Phinx\Config\ConfigInterface + */ + public static function fromYaml(string $configFilePath): ConfigInterface + { + if (!class_exists('Symfony\\Component\\Yaml\\Yaml', true)) { + // @codeCoverageIgnoreStart + throw new RuntimeException('Missing yaml parser, symfony/yaml package is not installed.'); + // @codeCoverageIgnoreEnd + } + + $configFile = file_get_contents($configFilePath); + $configArray = Yaml::parse($configFile); + + if (!is_array($configArray)) { + throw new RuntimeException(sprintf( + 'File \'%s\' must be valid YAML', + $configFilePath + )); + } + + return new static($configArray, $configFilePath); + } + + /** + * Create a new instance of the config class using a JSON file path. + * + * @param string $configFilePath Path to the JSON File + * @throws \RuntimeException + * @return \Phinx\Config\ConfigInterface + */ + public static function fromJson(string $configFilePath): ConfigInterface + { + if (!function_exists('json_decode')) { + // @codeCoverageIgnoreStart + throw new RuntimeException('Need to install JSON PHP extension to use JSON config'); + // @codeCoverageIgnoreEnd + } + + $configArray = json_decode(file_get_contents($configFilePath), true); + if (!is_array($configArray)) { + throw new RuntimeException(sprintf( + 'File \'%s\' must be valid JSON', + $configFilePath + )); + } + + return new static($configArray, $configFilePath); + } + + /** + * Create a new instance of the config class using a PHP file path. + * + * @param string $configFilePath Path to the PHP File + * @throws \RuntimeException + * @return \Phinx\Config\ConfigInterface + */ + public static function fromPhp(string $configFilePath): ConfigInterface + { + ob_start(); + /** @noinspection PhpIncludeInspection */ + $configArray = include $configFilePath; + + // Hide console output + ob_end_clean(); + + if (!is_array($configArray)) { + throw new RuntimeException(sprintf( + 'PHP file \'%s\' must return an array', + $configFilePath + )); + } + + return new static($configArray, $configFilePath); + } + + /** + * @inheritDoc + */ + public function getEnvironments(): ?array + { + if (isset($this->values['environments'])) { + $environments = []; + foreach ($this->values['environments'] as $key => $value) { + if (is_array($value)) { + $environments[$key] = $value; + } + } + + return $environments; + } + + return null; + } + + /** + * @inheritDoc + */ + public function getEnvironment(string $name): ?array + { + $environments = $this->getEnvironments(); + + if (isset($environments[$name])) { + if ( + isset($this->values['environments']['default_migration_table']) + && !isset($environments[$name]['migration_table']) + ) { + $environments[$name]['migration_table'] = + $this->values['environments']['default_migration_table']; + } + + if ( + isset($environments[$name]['adapter']) + && $environments[$name]['adapter'] === 'sqlite' + && !empty($environments[$name]['memory']) + ) { + $environments[$name]['name'] = SQLiteAdapter::MEMORY; + } + + return $this->parseAgnosticDsn($environments[$name]); + } + + return null; + } + + /** + * @inheritDoc + */ + public function hasEnvironment(string $name): bool + { + return $this->getEnvironment($name) !== null; + } + + /** + * @inheritDoc + */ + public function getDefaultEnvironment(): string + { + // The $PHINX_ENVIRONMENT variable overrides all other default settings + $env = getenv('PHINX_ENVIRONMENT'); + if (!empty($env)) { + if ($this->hasEnvironment($env)) { + return $env; + } + + throw new RuntimeException(sprintf( + 'The environment configuration (read from $PHINX_ENVIRONMENT) for \'%s\' is missing', + $env + )); + } + + // deprecated: to be removed 0.13 + if (isset($this->values['environments']['default_database'])) { + trigger_error('default_database in the config has been deprecated since 0.12, use default_environment instead.', E_USER_DEPRECATED); + $this->values['environments']['default_environment'] = $this->values['environments']['default_database']; + } + + // if the user has configured a default environment then use it, + // providing it actually exists! + if (isset($this->values['environments']['default_environment'])) { + if ($this->hasEnvironment($this->values['environments']['default_environment'])) { + return $this->values['environments']['default_environment']; + } + + throw new RuntimeException(sprintf( + 'The environment configuration for \'%s\' is missing', + $this->values['environments']['default_environment'] + )); + } + + // else default to the first available one + if (is_array($this->getEnvironments()) && count($this->getEnvironments()) > 0) { + $names = array_keys($this->getEnvironments()); + + return $names[0]; + } + + throw new RuntimeException('Could not find a default environment'); + } + + /** + * @inheritDoc + */ + public function getAlias($alias): ?string + { + return !empty($this->values['aliases'][$alias]) ? $this->values['aliases'][$alias] : null; + } + + /** + * @inheritDoc + */ + public function getAliases(): array + { + return !empty($this->values['aliases']) ? $this->values['aliases'] : []; + } + + /** + * @inheritDoc + */ + public function getConfigFilePath(): ?string + { + return $this->configFilePath; + } + + /** + * @inheritDoc + * @throws \UnexpectedValueException + */ + public function getMigrationPaths(): array + { + if (!isset($this->values['paths']['migrations'])) { + throw new UnexpectedValueException('Migrations path missing from config file'); + } + + if (is_string($this->values['paths']['migrations'])) { + $this->values['paths']['migrations'] = [$this->values['paths']['migrations']]; + } + + return $this->values['paths']['migrations']; + } + + /** + * @inheritDoc + * @throws \UnexpectedValueException + */ + public function getSeedPaths(): array + { + if (!isset($this->values['paths']['seeds'])) { + throw new UnexpectedValueException('Seeds path missing from config file'); + } + + if (is_string($this->values['paths']['seeds'])) { + $this->values['paths']['seeds'] = [$this->values['paths']['seeds']]; + } + + return $this->values['paths']['seeds']; + } + + /** + * @inheritdoc + */ + public function getMigrationBaseClassName(bool $dropNamespace = true): string + { + $className = !isset($this->values['migration_base_class']) ? 'Phinx\Migration\AbstractMigration' : $this->values['migration_base_class']; + + return $dropNamespace ? (substr(strrchr($className, '\\'), 1) ?: $className) : $className; + } + + /** + * @inheritdoc + */ + public function getSeedBaseClassName(bool $dropNamespace = true): string + { + $className = !isset($this->values['seed_base_class']) ? 'Phinx\Seed\AbstractSeed' : $this->values['seed_base_class']; + + return $dropNamespace ? substr(strrchr($className, '\\'), 1) : $className; + } + + /** + * @inheritdoc + */ + public function getTemplateFile() + { + if (!isset($this->values['templates']['file'])) { + return false; + } + + return $this->values['templates']['file']; + } + + /** + * @inheritdoc + */ + public function getTemplateClass() + { + if (!isset($this->values['templates']['class'])) { + return false; + } + + return $this->values['templates']['class']; + } + + /** + * @inheritdoc + */ + public function getTemplateStyle(): string + { + if (!isset($this->values['templates']['style'])) { + return self::TEMPLATE_STYLE_CHANGE; + } + + return $this->values['templates']['style'] === self::TEMPLATE_STYLE_UP_DOWN ? self::TEMPLATE_STYLE_UP_DOWN : self::TEMPLATE_STYLE_CHANGE; + } + + /** + * @inheritdoc + */ + public function getDataDomain(): array + { + if (!isset($this->values['data_domain'])) { + return []; + } + + return $this->values['data_domain']; + } + + /** + * @inheritDoc + */ + public function getContainer(): ?ContainerInterface + { + if (!isset($this->values['container'])) { + return null; + } + + return $this->values['container']; + } + + /** + * @inheritdoc + */ + public function getVersionOrder(): string + { + if (!isset($this->values['version_order'])) { + return self::VERSION_ORDER_CREATION_TIME; + } + + return $this->values['version_order']; + } + + /** + * @inheritdoc + */ + public function isVersionOrderCreationTime(): bool + { + $versionOrder = $this->getVersionOrder(); + + return $versionOrder == self::VERSION_ORDER_CREATION_TIME; + } + + /** + * @inheritdoc + */ + public function getBootstrapFile() + { + if (!isset($this->values['paths']['bootstrap'])) { + return false; + } + + return $this->values['paths']['bootstrap']; + } + + /** + * Replace tokens in the specified array. + * + * @param array $arr Array to replace + * @return array + */ + protected function replaceTokens(array $arr): array + { + // Get environment variables + // Depending on configuration of server / OS and variables_order directive, + // environment variables either end up in $_SERVER (most likely) or $_ENV, + // so we search through both + $tokens = []; + foreach (array_merge($_ENV, $_SERVER) as $varname => $varvalue) { + if (strpos($varname, 'PHINX_') === 0) { + $tokens['%%' . $varname . '%%'] = $varvalue; + } + } + + // Phinx defined tokens (override env tokens) + $tokens['%%PHINX_CONFIG_PATH%%'] = $this->getConfigFilePath(); + $tokens['%%PHINX_CONFIG_DIR%%'] = $this->getConfigFilePath() !== null ? dirname($this->getConfigFilePath()) : ''; + + // Recurse the array and replace tokens + return $this->recurseArrayForTokens($arr, $tokens); + } + + /** + * Recurse an array for the specified tokens and replace them. + * + * @param array $arr Array to recurse + * @param string[] $tokens Array of tokens to search for + * @return array + */ + protected function recurseArrayForTokens(array $arr, array $tokens): array + { + $out = []; + foreach ($arr as $name => $value) { + if (is_array($value)) { + $out[$name] = $this->recurseArrayForTokens($value, $tokens); + continue; + } + if (is_string($value)) { + foreach ($tokens as $token => $tval) { + $value = str_replace($token, $tval ?? '', $value); + } + $out[$name] = $value; + continue; + } + $out[$name] = $value; + } + + return $out; + } + + /** + * Parse a database-agnostic DSN into individual options. + * + * @param array $options Options + * @return array + */ + protected function parseAgnosticDsn(array $options): array + { + $parsed = Util::parseDsn($options['dsn'] ?? ''); + if ($parsed) { + unset($options['dsn']); + } + + $options += $parsed; + + return $options; + } + + /** + * {@inheritDoc} + * + * @param mixed $id ID + * @param mixed $value Value + * @return void + */ + public function offsetSet($id, $value): void + { + $this->values[$id] = $value; + } + + /** + * {@inheritDoc} + * + * @param mixed $id ID + * @throws \InvalidArgumentException + * @return mixed + */ + #[\ReturnTypeWillChange] + public function offsetGet($id) + { + if (!array_key_exists($id, $this->values)) { + throw new InvalidArgumentException(sprintf('Identifier "%s" is not defined.', $id)); + } + + return $this->values[$id] instanceof Closure ? $this->values[$id]($this) : $this->values[$id]; + } + + /** + * {@inheritDoc} + * + * @param mixed $id ID + * @return bool + */ + public function offsetExists($id): bool + { + return isset($this->values[$id]); + } + + /** + * {@inheritDoc} + * + * @param mixed $id ID + * @return void + */ + public function offsetUnset($id): void + { + unset($this->values[$id]); + } + + /** + * @inheritdoc + */ + public function getSeedTemplateFile(): ?string + { + return $this->values['templates']['seedFile'] ?? null; + } +} diff --git a/extend/phinx/Config/ConfigInterface.php b/extend/phinx/Config/ConfigInterface.php new file mode 100644 index 0000000..3bd57f2 --- /dev/null +++ b/extend/phinx/Config/ConfigInterface.php @@ -0,0 +1,171 @@ +null if no environments exist. + * + * @return array|null + */ + public function getEnvironments(): ?array; + + /** + * Returns the configuration for a given environment. + * + * This method returns null if the specified environment + * doesn't exist. + * + * @param string $name Environment Name + * @return array|null + */ + public function getEnvironment(string $name): ?array; + + /** + * Does the specified environment exist in the configuration file? + * + * @param string $name Environment Name + * @return bool + */ + public function hasEnvironment(string $name): bool; + + /** + * Gets the default environment name. + * + * @throws \RuntimeException + * @return string + */ + public function getDefaultEnvironment(): string; + + /** + * Get the aliased value from a supplied alias. + * + * @param string $alias Alias + * @return string|null + */ + public function getAlias(string $alias): ?string; + + /** + * Get all the aliased values. + * + * @return string[] + */ + public function getAliases(): array; + + /** + * Gets the config file path. + * + * @return string|null + */ + public function getConfigFilePath(): ?string; + + /** + * Gets the paths to search for migration files. + * + * @return string[] + */ + public function getMigrationPaths(): array; + + /** + * Gets the paths to search for seed files. + * + * @return string[] + */ + public function getSeedPaths(): array; + + /** + * Get the template file name. + * + * @return string|false + */ + public function getTemplateFile(); + + /** + * Get the template class name. + * + * @return string|false + */ + public function getTemplateClass(); + + /** + * Get the template style to use, either change or up_down. + * + * @return string + */ + public function getTemplateStyle(): string; + + /** + * Get the user-provided container for instantiating seeds + * + * @return \Psr\Container\ContainerInterface|null + */ + public function getContainer(): ?ContainerInterface; + + /** + * Get the data domain array. + * + * @return array + */ + public function getDataDomain(): array; + + /** + * Get the version order. + * + * @return string + */ + public function getVersionOrder(): string; + + /** + * Is version order creation time? + * + * @return bool + */ + public function isVersionOrderCreationTime(): bool; + + /** + * Get the bootstrap file path + * + * @return string|false + */ + public function getBootstrapFile(); + + /** + * Gets the base class name for migrations. + * + * @param bool $dropNamespace Return the base migration class name without the namespace. + * @return string + */ + public function getMigrationBaseClassName(bool $dropNamespace = true): string; + + /** + * Gets the base class name for seeders. + * + * @param bool $dropNamespace Return the base seeder class name without the namespace. + * @return string + */ + public function getSeedBaseClassName(bool $dropNamespace = true): string; + + /** + * Get the seeder template file name or null if not set. + * + * @return string|null + */ + public function getSeedTemplateFile(): ?string; +} diff --git a/extend/phinx/Config/FeatureFlags.php b/extend/phinx/Config/FeatureFlags.php new file mode 100644 index 0000000..dded535 --- /dev/null +++ b/extend/phinx/Config/FeatureFlags.php @@ -0,0 +1,41 @@ +getMigrationPaths(); + + return $this->searchNamespace($path, $paths); + } + + /** + * Get Seed Namespace associated with path. + * + * @param string $path Path + * @return string|null + */ + public function getSeedNamespaceByPath(string $path): ?string + { + $paths = $this->getSeedPaths(); + + return $this->searchNamespace($path, $paths); + } +} diff --git a/extend/phinx/Db/Action/Action.php b/extend/phinx/Db/Action/Action.php new file mode 100644 index 0000000..90c3ae4 --- /dev/null +++ b/extend/phinx/Db/Action/Action.php @@ -0,0 +1,38 @@ +table = $table; + } + + /** + * The table this action will be applied to + * + * @return \Phinx\Db\Table\Table + */ + public function getTable(): Table + { + return $this->table; + } +} diff --git a/extend/phinx/Db/Action/AddColumn.php b/extend/phinx/Db/Action/AddColumn.php new file mode 100644 index 0000000..299c927 --- /dev/null +++ b/extend/phinx/Db/Action/AddColumn.php @@ -0,0 +1,62 @@ +column = $column; + } + + /** + * Returns a new AddColumn object after assembling the given commands + * + * @param \Phinx\Db\Table\Table $table The table to add the column to + * @param string $columnName The column name + * @param string|\Phinx\Util\Literal $type The column type + * @param array $options The column options + * @return static + */ + public static function build(Table $table, string $columnName, $type = null, array $options = []) + { + $column = new Column(); + $column->setName($columnName); + $column->setType($type); + $column->setOptions($options); // map options to column methods + + return new static($table, $column); + } + + /** + * Returns the column to be added + * + * @return \Phinx\Db\Table\Column + */ + public function getColumn(): Column + { + return $this->column; + } +} diff --git a/extend/phinx/Db/Action/AddForeignKey.php b/extend/phinx/Db/Action/AddForeignKey.php new file mode 100644 index 0000000..9121cfe --- /dev/null +++ b/extend/phinx/Db/Action/AddForeignKey.php @@ -0,0 +1,78 @@ +foreignKey = $fk; + } + + /** + * Creates a new AddForeignKey object after building the foreign key with + * the passed attributes + * + * @param \Phinx\Db\Table\Table $table The table object to add the foreign key to + * @param string|string[] $columns The columns for the foreign key + * @param \Phinx\Db\Table\Table|string $referencedTable The table the foreign key references + * @param string|string[] $referencedColumns The columns in the referenced table + * @param array $options Extra options for the foreign key + * @param string|null $name The name of the foreign key + * @return static + */ + public static function build(Table $table, $columns, $referencedTable, $referencedColumns = ['id'], array $options = [], ?string $name = null) + { + if (is_string($referencedColumns)) { + $referencedColumns = [$referencedColumns]; // str to array + } + + if (is_string($referencedTable)) { + $referencedTable = new Table($referencedTable); + } + + $fk = new ForeignKey(); + $fk->setReferencedTable($referencedTable) + ->setColumns($columns) + ->setReferencedColumns($referencedColumns) + ->setOptions($options); + + if ($name !== null) { + $fk->setConstraint($name); + } + + return new static($table, $fk); + } + + /** + * Returns the foreign key to be added + * + * @return \Phinx\Db\Table\ForeignKey + */ + public function getForeignKey(): ForeignKey + { + return $this->foreignKey; + } +} diff --git a/extend/phinx/Db/Action/AddIndex.php b/extend/phinx/Db/Action/AddIndex.php new file mode 100644 index 0000000..aac22cb --- /dev/null +++ b/extend/phinx/Db/Action/AddIndex.php @@ -0,0 +1,67 @@ +index = $index; + } + + /** + * Creates a new AddIndex object after building the index object with the + * provided arguments + * + * @param \Phinx\Db\Table\Table $table The table to add the index to + * @param string|string[]|\Phinx\Db\Table\Index $columns The columns to index + * @param array $options Additional options for the index creation + * @return static + */ + public static function build(Table $table, $columns, array $options = []) + { + // create a new index object if strings or an array of strings were supplied + $index = $columns; + + if (!$columns instanceof Index) { + $index = new Index(); + + $index->setColumns($columns); + $index->setOptions($options); + } + + return new static($table, $index); + } + + /** + * Returns the index to be added + * + * @return \Phinx\Db\Table\Index + */ + public function getIndex(): Index + { + return $this->index; + } +} diff --git a/extend/phinx/Db/Action/ChangeColumn.php b/extend/phinx/Db/Action/ChangeColumn.php new file mode 100644 index 0000000..143baf9 --- /dev/null +++ b/extend/phinx/Db/Action/ChangeColumn.php @@ -0,0 +1,87 @@ +columnName = $columnName; + $this->column = $column; + + // if the name was omitted use the existing column name + if ($column->getName() === null || strlen($column->getName()) === 0) { + $column->setName($columnName); + } + } + + /** + * Creates a new ChangeColumn object after building the column definition + * out of the provided arguments + * + * @param \Phinx\Db\Table\Table $table The table to alter + * @param string $columnName The name of the column to change + * @param string|\Phinx\Db\Table\Column|\Phinx\Util\Literal $type The type of the column + * @param array $options Additional options for the column + * @return static + */ + public static function build(Table $table, string $columnName, $type = null, array $options = []) + { + $column = new Column(); + $column->setName($columnName); + $column->setType($type); + $column->setOptions($options); // map options to column methods + + return new static($table, $columnName, $column); + } + + /** + * Returns the name of the column to change + * + * @return string + */ + public function getColumnName(): string + { + return $this->columnName; + } + + /** + * Returns the column definition + * + * @return \Phinx\Db\Table\Column + */ + public function getColumn(): Column + { + return $this->column; + } +} diff --git a/extend/phinx/Db/Action/ChangeComment.php b/extend/phinx/Db/Action/ChangeComment.php new file mode 100644 index 0000000..5604530 --- /dev/null +++ b/extend/phinx/Db/Action/ChangeComment.php @@ -0,0 +1,42 @@ +newComment = $newComment; + } + + /** + * Return the new comment for the table + * + * @return string|null + */ + public function getNewComment(): ?string + { + return $this->newComment; + } +} diff --git a/extend/phinx/Db/Action/ChangePrimaryKey.php b/extend/phinx/Db/Action/ChangePrimaryKey.php new file mode 100644 index 0000000..bb220fd --- /dev/null +++ b/extend/phinx/Db/Action/ChangePrimaryKey.php @@ -0,0 +1,42 @@ +newColumns = $newColumns; + } + + /** + * Return the new columns for the primary key + * + * @return string|string[]|null + */ + public function getNewColumns() + { + return $this->newColumns; + } +} diff --git a/extend/phinx/Db/Action/CreateTable.php b/extend/phinx/Db/Action/CreateTable.php new file mode 100644 index 0000000..8f50afa --- /dev/null +++ b/extend/phinx/Db/Action/CreateTable.php @@ -0,0 +1,12 @@ +foreignKey = $foreignKey; + } + + /** + * Creates a new DropForeignKey object after building the ForeignKey + * definition out of the passed arguments. + * + * @param \Phinx\Db\Table\Table $table The table to delete the foreign key from + * @param string|string[] $columns The columns participating in the foreign key + * @param string|null $constraint The constraint name + * @return static + */ + public static function build(Table $table, $columns, ?string $constraint = null) + { + if (is_string($columns)) { + $columns = [$columns]; + } + + $foreignKey = new ForeignKey(); + $foreignKey->setColumns($columns); + + if ($constraint) { + $foreignKey->setConstraint($constraint); + } + + return new static($table, $foreignKey); + } + + /** + * Returns the foreign key to remove + * + * @return \Phinx\Db\Table\ForeignKey + */ + public function getForeignKey(): ForeignKey + { + return $this->foreignKey; + } +} diff --git a/extend/phinx/Db/Action/DropIndex.php b/extend/phinx/Db/Action/DropIndex.php new file mode 100644 index 0000000..f2b6e21 --- /dev/null +++ b/extend/phinx/Db/Action/DropIndex.php @@ -0,0 +1,75 @@ +index = $index; + } + + /** + * Creates a new DropIndex object after assembling the passed + * arguments. + * + * @param \Phinx\Db\Table\Table $table The table where the index is + * @param string[] $columns the indexed columns + * @return static + */ + public static function build(Table $table, array $columns = []) + { + $index = new Index(); + $index->setColumns($columns); + + return new static($table, $index); + } + + /** + * Creates a new DropIndex when the name of the index to drop + * is known. + * + * @param \Phinx\Db\Table\Table $table The table where the index is + * @param string $name The name of the index + * @return static + */ + public static function buildFromName(Table $table, string $name) + { + $index = new Index(); + $index->setName($name); + + return new static($table, $index); + } + + /** + * Returns the index to be dropped + * + * @return \Phinx\Db\Table\Index + */ + public function getIndex(): Index + { + return $this->index; + } +} diff --git a/extend/phinx/Db/Action/DropTable.php b/extend/phinx/Db/Action/DropTable.php new file mode 100644 index 0000000..6343d0b --- /dev/null +++ b/extend/phinx/Db/Action/DropTable.php @@ -0,0 +1,12 @@ +column = $column; + } + + /** + * Creates a new RemoveColumn object after assembling the + * passed arguments. + * + * @param \Phinx\Db\Table\Table $table The table where the column is + * @param string $columnName The name of the column to drop + * @return static + */ + public static function build(Table $table, string $columnName) + { + $column = new Column(); + $column->setName($columnName); + + return new static($table, $column); + } + + /** + * Returns the column to be dropped + * + * @return \Phinx\Db\Table\Column + */ + public function getColumn(): Column + { + return $this->column; + } +} diff --git a/extend/phinx/Db/Action/RenameColumn.php b/extend/phinx/Db/Action/RenameColumn.php new file mode 100644 index 0000000..0f18395 --- /dev/null +++ b/extend/phinx/Db/Action/RenameColumn.php @@ -0,0 +1,79 @@ +newName = $newName; + $this->column = $column; + } + + /** + * Creates a new RenameColumn object after building the passed + * arguments + * + * @param \Phinx\Db\Table\Table $table The table where the column is + * @param string $columnName The name of the column to be changed + * @param string $newName The new name for the column + * @return static + */ + public static function build(Table $table, string $columnName, string $newName) + { + $column = new Column(); + $column->setName($columnName); + + return new static($table, $column, $newName); + } + + /** + * Returns the column to be changed + * + * @return \Phinx\Db\Table\Column + */ + public function getColumn(): Column + { + return $this->column; + } + + /** + * Returns the new name for the column + * + * @return string + */ + public function getNewName(): string + { + return $this->newName; + } +} diff --git a/extend/phinx/Db/Action/RenameTable.php b/extend/phinx/Db/Action/RenameTable.php new file mode 100644 index 0000000..dd9652d --- /dev/null +++ b/extend/phinx/Db/Action/RenameTable.php @@ -0,0 +1,42 @@ +newName = $newName; + } + + /** + * Return the new name for the table + * + * @return string + */ + public function getNewName(): string + { + return $this->newName; + } +} diff --git a/extend/phinx/Db/Adapter/AbstractAdapter.php b/extend/phinx/Db/Adapter/AbstractAdapter.php new file mode 100644 index 0000000..720fa8c --- /dev/null +++ b/extend/phinx/Db/Adapter/AbstractAdapter.php @@ -0,0 +1,412 @@ + + */ + protected $options = []; + + /** + * @var \think\console\Input|null + */ + protected $input; + + /** + * @var \think\console\Output + */ + protected $output; + + /** + * @var string[] + */ + protected $createdTables = []; + + /** + * @var string + */ + protected $schemaTableName = 'phinxlog'; + + /** + * @var array + */ + protected $dataDomain = []; + + /** + * Class Constructor. + * + * @param array $options Options + * @param \think\console\Input|null $input Input Interface + * @param \think\console\Output|null $output Output Interface + */ + public function __construct(array $options, ?InputInterface $input = null, ?OutputInterface $output = null) + { + $this->setOptions($options); + if ($input !== null) { + $this->setInput($input); + } + if ($output !== null) { + $this->setOutput($output); + } + } + + /** + * @inheritDoc + */ + public function setOptions(array $options): AdapterInterface + { + $this->options = $options; + + if (isset($options['default_migration_table'])) { + trigger_error('The default_migration_table setting for adapter has been deprecated since 0.13.0. Use `migration_table` instead.', E_USER_DEPRECATED); + if (!isset($options['migration_table'])) { + $options['migration_table'] = $options['default_migration_table']; + } + } + + if (isset($options['migration_table'])) { + $this->setSchemaTableName($options['migration_table']); + } + + if (isset($options['data_domain'])) { + $this->setDataDomain($options['data_domain']); + } + + return $this; + } + + /** + * @inheritDoc + */ + public function getOptions(): array + { + return $this->options; + } + + /** + * @inheritDoc + */ + public function hasOption(string $name): bool + { + return isset($this->options[$name]); + } + + /** + * @inheritDoc + */ + public function getOption(string $name) + { + if (!$this->hasOption($name)) { + return null; + } + + return $this->options[$name]; + } + + /** + * @inheritDoc + */ + public function setInput(InputInterface $input): AdapterInterface + { + $this->input = $input; + + return $this; + } + + /** + * @inheritDoc + */ + public function getInput(): ?InputInterface + { + return $this->input; + } + + /** + * @inheritDoc + */ + public function setOutput(OutputInterface $output): AdapterInterface + { + $this->output = $output; + + return $this; + } + + /** + * @inheritDoc + */ + public function getOutput(): OutputInterface + { + if ($this->output === null) { + $output = new NullOutput(); + $this->setOutput($output); + } + + return $this->output; + } + + /** + * @inheritDoc + * @return array + */ + public function getVersions(): array + { + $rows = $this->getVersionLog(); + + return array_keys($rows); + } + + /** + * Gets the schema table name. + * + * @return string + */ + public function getSchemaTableName(): string + { + return $this->schemaTableName; + } + + /** + * Sets the schema table name. + * + * @param string $schemaTableName Schema Table Name + * @return $this + */ + public function setSchemaTableName(string $schemaTableName) + { + $this->schemaTableName = $schemaTableName; + + return $this; + } + + /** + * Gets the data domain. + * + * @return array + */ + public function getDataDomain(): array + { + return $this->dataDomain; + } + + /** + * Sets the data domain. + * + * @param array $dataDomain Array for the data domain + * @return $this + */ + public function setDataDomain(array $dataDomain) + { + $this->dataDomain = []; + + // Iterate over data domain field definitions and perform initial and + // simple normalization. We make sure the definition as a base 'type' + // and it is compatible with the base Phinx types. + foreach ($dataDomain as $type => $options) { + if (!isset($options['type'])) { + throw new \InvalidArgumentException(sprintf( + 'You must specify a type for data domain type "%s".', + $type + )); + } + + // Replace type if it's the name of a Phinx constant + if (defined('static::' . $options['type'])) { + $options['type'] = constant('static::' . $options['type']); + } + + if (!in_array($options['type'], $this->getColumnTypes(), true)) { + throw new \InvalidArgumentException(sprintf( + 'An invalid column type "%s" was specified for data domain type "%s".', + $options['type'], + $type + )); + } + + $internal_type = $options['type']; + unset($options['type']); + + // Do a simple replacement for the 'length' / 'limit' option and + // detect hinting values for 'limit'. + if (isset($options['length'])) { + $options['limit'] = $options['length']; + unset($options['length']); + } + + if (isset($options['limit']) && !is_numeric($options['limit'])) { + if (!defined('static::' . $options['limit'])) { + throw new \InvalidArgumentException(sprintf( + 'An invalid limit value "%s" was specified for data domain type "%s".', + $options['limit'], + $type + )); + } + + $options['limit'] = constant('static::' . $options['limit']); + } + + // Save the data domain types in a more suitable format + $this->dataDomain[$type] = [ + 'type' => $internal_type, + 'options' => $options, + ]; + } + + return $this; + } + + /** + * @inheritdoc + */ + public function getColumnForType(string $columnName, string $type, array $options): Column + { + $column = new Column(); + $column->setName($columnName); + + if (array_key_exists($type, $this->getDataDomain())) { + $column->setType($this->dataDomain[$type]['type']); + $column->setOptions($this->dataDomain[$type]['options']); + } else { + $column->setType($type); + } + + $column->setOptions($options); + + return $column; + } + + /** + * @inheritDoc + * @throws \InvalidArgumentException + * @return void + */ + public function createSchemaTable(): void + { + try { + $options = [ + 'id' => false, + 'primary_key' => 'version', + ]; + + $table = new Table($this->getSchemaTableName(), $options, $this); + $table->addColumn('version', 'biginteger', ['null' => false]) + ->addColumn('migration_name', 'string', ['limit' => 100, 'default' => null, 'null' => true]) + ->addColumn('start_time', 'timestamp', ['default' => null, 'null' => true]) + ->addColumn('end_time', 'timestamp', ['default' => null, 'null' => true]) + ->addColumn('breakpoint', 'boolean', ['default' => false, 'null' => false]) + ->save(); + } catch (Exception $exception) { + throw new InvalidArgumentException( + 'There was a problem creating the schema table: ' . $exception->getMessage(), + (int)$exception->getCode(), + $exception + ); + } + } + + /** + * @inheritDoc + */ + public function getAdapterType(): string + { + return $this->getOption('adapter'); + } + + /** + * @inheritDoc + */ + public function isValidColumnType(Column $column): bool + { + return $column->getType() instanceof Literal || in_array($column->getType(), $this->getColumnTypes(), true); + } + + /** + * Determines if instead of executing queries a dump to standard output is needed + * + * @return bool + */ + public function isDryRunEnabled(): bool + { + /** @var \think\console\Input|null $input */ + $input = $this->getInput(); + + return $input && $input->hasOption('dry-run') ? (bool)$input->getOption('dry-run') : false; + } + + /** + * Adds user-created tables (e.g. not phinxlog) to a cached list + * + * @param string $tableName The name of the table + * @return void + */ + protected function addCreatedTable(string $tableName): void + { + $tableName = $this->quoteTableName($tableName); + if (substr_compare($tableName, 'phinxlog', -strlen('phinxlog')) !== 0) { + $this->createdTables[] = $tableName; + } + } + + /** + * Updates the name of the cached table + * + * @param string $tableName Original name of the table + * @param string $newTableName New name of the table + * @return void + */ + protected function updateCreatedTableName(string $tableName, string $newTableName): void + { + $tableName = $this->quoteTableName($tableName); + $newTableName = $this->quoteTableName($newTableName); + $key = array_search($tableName, $this->createdTables, true); + if ($key !== false) { + $this->createdTables[$key] = $newTableName; + } + } + + /** + * Removes table from the cached created list + * + * @param string $tableName The name of the table + * @return void + */ + protected function removeCreatedTable(string $tableName): void + { + $tableName = $this->quoteTableName($tableName); + $key = array_search($tableName, $this->createdTables, true); + if ($key !== false) { + unset($this->createdTables[$key]); + } + } + + /** + * Check if the table is in the cached list of created tables + * + * @param string $tableName The name of the table + * @return bool + */ + protected function hasCreatedTable(string $tableName): bool + { + $tableName = $this->quoteTableName($tableName); + + return in_array($tableName, $this->createdTables, true); + } +} diff --git a/extend/phinx/Db/Adapter/AdapterFactory.php b/extend/phinx/Db/Adapter/AdapterFactory.php new file mode 100644 index 0000000..3843691 --- /dev/null +++ b/extend/phinx/Db/Adapter/AdapterFactory.php @@ -0,0 +1,172 @@ + + */ +class AdapterFactory +{ + /** + * @var \Phinx\Db\Adapter\AdapterFactory|null + */ + protected static $instance; + + /** + * Get the factory singleton instance. + * + * @return \Phinx\Db\Adapter\AdapterFactory + */ + public static function instance() + { + if (!static::$instance) { + static::$instance = new static(); + } + + return static::$instance; + } + + /** + * Class map of database adapters, indexed by PDO::ATTR_DRIVER_NAME. + * + * @var array + * @phpstan-var array> + */ + protected $adapters = [ + 'mysql' => 'Phinx\Db\Adapter\MysqlAdapter', + 'pgsql' => 'Phinx\Db\Adapter\PostgresAdapter', + 'sqlite' => 'Phinx\Db\Adapter\SQLiteAdapter', + 'sqlsrv' => 'Phinx\Db\Adapter\SqlServerAdapter', + ]; + + /** + * Class map of adapters wrappers, indexed by name. + * + * @var array + */ + protected $wrappers = [ + 'prefix' => 'Phinx\Db\Adapter\TablePrefixAdapter', + 'proxy' => 'Phinx\Db\Adapter\ProxyAdapter', + 'timed' => 'Phinx\Db\Adapter\TimedOutputAdapter', + ]; + + /** + * Register an adapter class with a given name. + * + * @param string $name Name + * @param object|string $class Class + * @throws \RuntimeException + * @return $this + */ + public function registerAdapter(string $name, $class) + { + if (!is_subclass_of($class, 'Phinx\Db\Adapter\AdapterInterface')) { + throw new RuntimeException(sprintf( + 'Adapter class "%s" must implement Phinx\\Db\\Adapter\\AdapterInterface', + is_string($class) ? $class : get_class($class) + )); + } + $this->adapters[$name] = $class; + + return $this; + } + + /** + * Get an adapter class by name. + * + * @param string $name Name + * @throws \RuntimeException + * @return object|string + * @phpstan-return object|class-string<\Phinx\Db\Adapter\AdapterInterface> + */ + protected function getClass(string $name) + { + if (empty($this->adapters[$name])) { + throw new RuntimeException(sprintf( + 'Adapter "%s" has not been registered', + $name + )); + } + + return $this->adapters[$name]; + } + + /** + * Get an adapter instance by name. + * + * @param string $name Name + * @param array $options Options + * @return \Phinx\Db\Adapter\AdapterInterface + */ + public function getAdapter(string $name, array $options): AdapterInterface + { + $class = $this->getClass($name); + + return new $class($options); + } + + /** + * Add or replace a wrapper with a fully qualified class name. + * + * @param string $name Name + * @param object|string $class Class + * @throws \RuntimeException + * @return $this + */ + public function registerWrapper(string $name, $class) + { + if (!is_subclass_of($class, 'Phinx\Db\Adapter\WrapperInterface')) { + throw new RuntimeException(sprintf( + 'Wrapper class "%s" must be implement Phinx\\Db\\Adapter\\WrapperInterface', + is_string($class) ? $class : get_class($class) + )); + } + $this->wrappers[$name] = $class; + + return $this; + } + + /** + * Get a wrapper class by name. + * + * @param string $name Name + * @throws \RuntimeException + * @return \Phinx\Db\Adapter\WrapperInterface|string + */ + protected function getWrapperClass(string $name) + { + if (empty($this->wrappers[$name])) { + throw new RuntimeException(sprintf( + 'Wrapper "%s" has not been registered', + $name + )); + } + + return $this->wrappers[$name]; + } + + /** + * Get a wrapper instance by name. + * + * @param string $name Name + * @param \Phinx\Db\Adapter\AdapterInterface $adapter Adapter + * @return \Phinx\Db\Adapter\AdapterWrapper + */ + public function getWrapper(string $name, AdapterInterface $adapter): AdapterWrapper + { + $class = $this->getWrapperClass($name); + + return new $class($adapter); + } +} diff --git a/extend/phinx/Db/Adapter/AdapterInterface.php b/extend/phinx/Db/Adapter/AdapterInterface.php new file mode 100644 index 0000000..a59ec27 --- /dev/null +++ b/extend/phinx/Db/Adapter/AdapterInterface.php @@ -0,0 +1,505 @@ + + * @method \PDO getConnection() + */ +interface AdapterInterface +{ + public const PHINX_TYPE_STRING = 'string'; + public const PHINX_TYPE_CHAR = 'char'; + public const PHINX_TYPE_TEXT = 'text'; + public const PHINX_TYPE_INTEGER = 'integer'; + public const PHINX_TYPE_TINY_INTEGER = 'tinyinteger'; + public const PHINX_TYPE_SMALL_INTEGER = 'smallinteger'; + public const PHINX_TYPE_BIG_INTEGER = 'biginteger'; + public const PHINX_TYPE_BIT = 'bit'; + public const PHINX_TYPE_FLOAT = 'float'; + public const PHINX_TYPE_DECIMAL = 'decimal'; + public const PHINX_TYPE_DOUBLE = 'double'; + public const PHINX_TYPE_DATETIME = 'datetime'; + public const PHINX_TYPE_TIMESTAMP = 'timestamp'; + public const PHINX_TYPE_TIME = 'time'; + public const PHINX_TYPE_DATE = 'date'; + public const PHINX_TYPE_BINARY = 'binary'; + public const PHINX_TYPE_VARBINARY = 'varbinary'; + public const PHINX_TYPE_BINARYUUID = 'binaryuuid'; + public const PHINX_TYPE_BLOB = 'blob'; + public const PHINX_TYPE_TINYBLOB = 'tinyblob'; // Specific to Mysql. + public const PHINX_TYPE_MEDIUMBLOB = 'mediumblob'; // Specific to Mysql + public const PHINX_TYPE_LONGBLOB = 'longblob'; // Specific to Mysql + public const PHINX_TYPE_BOOLEAN = 'boolean'; + public const PHINX_TYPE_JSON = 'json'; + public const PHINX_TYPE_JSONB = 'jsonb'; + public const PHINX_TYPE_UUID = 'uuid'; + public const PHINX_TYPE_FILESTREAM = 'filestream'; + + // Geospatial database types + public const PHINX_TYPE_GEOMETRY = 'geometry'; + public const PHINX_TYPE_GEOGRAPHY = 'geography'; + public const PHINX_TYPE_POINT = 'point'; + public const PHINX_TYPE_LINESTRING = 'linestring'; + public const PHINX_TYPE_POLYGON = 'polygon'; + + public const PHINX_TYPES_GEOSPATIAL = [ + self::PHINX_TYPE_GEOMETRY, + self::PHINX_TYPE_POINT, + self::PHINX_TYPE_LINESTRING, + self::PHINX_TYPE_POLYGON, + ]; + + // only for mysql so far + public const PHINX_TYPE_MEDIUM_INTEGER = 'mediuminteger'; + public const PHINX_TYPE_ENUM = 'enum'; + public const PHINX_TYPE_SET = 'set'; + public const PHINX_TYPE_YEAR = 'year'; + + // only for postgresql so far + public const PHINX_TYPE_CIDR = 'cidr'; + public const PHINX_TYPE_INET = 'inet'; + public const PHINX_TYPE_MACADDR = 'macaddr'; + public const PHINX_TYPE_INTERVAL = 'interval'; + + /** + * Get all migrated version numbers. + * + * @return array + */ + public function getVersions(): array; + + /** + * Get all migration log entries, indexed by version creation time and sorted ascendingly by the configuration's + * version order option + * + * @return array + */ + public function getVersionLog(): array; + + /** + * Set adapter configuration options. + * + * @param array $options Options + * @return $this + */ + public function setOptions(array $options); + + /** + * Get all adapter options. + * + * @return array + */ + public function getOptions(): array; + + /** + * Check if an option has been set. + * + * @param string $name Name + * @return bool + */ + public function hasOption(string $name): bool; + + /** + * Get a single adapter option, or null if the option does not exist. + * + * @param string $name Name + * @return mixed + */ + public function getOption(string $name); + + /** + * Sets the console input. + * + * @param \think\console\Input $input Input + * @return $this + */ + public function setInput(InputInterface $input); + + /** + * Gets the console input. + * + * @return \think\console\Input|null + */ + public function getInput(): ?InputInterface; + + /** + * Sets the console output. + * + * @param \think\console\Output $output Output + * @return $this + */ + public function setOutput(OutputInterface $output); + + /** + * Gets the console output. + * + * @return \think\console\Output + */ + public function getOutput(): OutputInterface; + + /** + * Returns a new Phinx\Db\Table\Column using the existent data domain. + * + * @param string $columnName The desired column name + * @param string $type The type for the column. Can be a data domain type. + * @param array $options Options array + * @return \Phinx\Db\Table\Column + */ + public function getColumnForType(string $columnName, string $type, array $options): Column; + + /** + * Records a migration being run. + * + * @param \Phinx\Migration\MigrationInterface $migration Migration + * @param string $direction Direction + * @param string $startTime Start Time + * @param string $endTime End Time + * @return $this + */ + public function migrated(MigrationInterface $migration, string $direction, string $startTime, string $endTime); + + /** + * Toggle a migration breakpoint. + * + * @param \Phinx\Migration\MigrationInterface $migration Migration + * @return $this + */ + public function toggleBreakpoint(MigrationInterface $migration); + + /** + * Reset all migration breakpoints. + * + * @return int The number of breakpoints reset + */ + public function resetAllBreakpoints(): int; + + /** + * Set a migration breakpoint. + * + * @param \Phinx\Migration\MigrationInterface $migration The migration target for the breakpoint set + * @return $this + */ + public function setBreakpoint(MigrationInterface $migration); + + /** + * Unset a migration breakpoint. + * + * @param \Phinx\Migration\MigrationInterface $migration The migration target for the breakpoint unset + * @return $this + */ + public function unsetBreakpoint(MigrationInterface $migration); + + /** + * Creates the schema table. + * + * @return void + */ + public function createSchemaTable(): void; + + /** + * Returns the adapter type. + * + * @return string + */ + public function getAdapterType(): string; + + /** + * Initializes the database connection. + * + * @throws \RuntimeException When the requested database driver is not installed. + * @return void + */ + public function connect(): void; + + /** + * Closes the database connection. + * + * @return void + */ + public function disconnect(): void; + + /** + * Does the adapter support transactions? + * + * @return bool + */ + public function hasTransactions(): bool; + + /** + * Begin a transaction. + * + * @return void + */ + public function beginTransaction(): void; + + /** + * Commit a transaction. + * + * @return void + */ + public function commitTransaction(): void; + + /** + * Rollback a transaction. + * + * @return void + */ + public function rollbackTransaction(): void; + + /** + * Executes a SQL statement and returns the number of affected rows. + * + * @param string $sql SQL + * @param array $params parameters to use for prepared query + * @return int + */ + public function execute(string $sql, array $params = []): int; + + /** + * Executes a list of migration actions for the given table + * + * @param \Phinx\Db\Table\Table $table The table to execute the actions for + * @param \Phinx\Db\Action\Action[] $actions The table to execute the actions for + * @return void + */ + public function executeActions(Table $table, array $actions): void; + + /** + * Returns a new Query object + * + * @return \Cake\Database\Query + */ + public function getQueryBuilder(): Query; + + /** + * Executes a SQL statement. + * + * The return type depends on the underlying adapter being used. + * + * @param string $sql SQL + * @param array $params parameters to use for prepared query + * @return mixed + */ + public function query(string $sql, array $params = []); + + /** + * Executes a query and returns only one row as an array. + * + * @param string $sql SQL + * @return array|false + */ + public function fetchRow(string $sql); + + /** + * Executes a query and returns an array of rows. + * + * @param string $sql SQL + * @return array + */ + public function fetchAll(string $sql): array; + + /** + * Inserts data into a table. + * + * @param \Phinx\Db\Table\Table $table Table where to insert data + * @param array $row Row + * @return void + */ + public function insert(Table $table, array $row): void; + + /** + * Inserts data into a table in a bulk. + * + * @param \Phinx\Db\Table\Table $table Table where to insert data + * @param array $rows Rows + * @return void + */ + public function bulkinsert(Table $table, array $rows): void; + + /** + * Quotes a table name for use in a query. + * + * @param string $tableName Table name + * @return string + */ + public function quoteTableName(string $tableName): string; + + /** + * Quotes a column name for use in a query. + * + * @param string $columnName Table name + * @return string + */ + public function quoteColumnName(string $columnName): string; + + /** + * Checks to see if a table exists. + * + * @param string $tableName Table name + * @return bool + */ + public function hasTable(string $tableName): bool; + + /** + * Creates the specified database table. + * + * @param \Phinx\Db\Table\Table $table Table + * @param \Phinx\Db\Table\Column[] $columns List of columns in the table + * @param \Phinx\Db\Table\Index[] $indexes List of indexes for the table + * @return void + */ + public function createTable(Table $table, array $columns = [], array $indexes = []): void; + + /** + * Truncates the specified table + * + * @param string $tableName Table name + * @return void + */ + public function truncateTable(string $tableName): void; + + /** + * Returns table columns + * + * @param string $tableName Table name + * @return \Phinx\Db\Table\Column[] + */ + public function getColumns(string $tableName): array; + + /** + * Checks to see if a column exists. + * + * @param string $tableName Table name + * @param string $columnName Column name + * @return bool + */ + public function hasColumn(string $tableName, string $columnName): bool; + + /** + * Checks to see if an index exists. + * + * @param string $tableName Table name + * @param string|string[] $columns Column(s) + * @return bool + */ + public function hasIndex(string $tableName, $columns): bool; + + /** + * Checks to see if an index specified by name exists. + * + * @param string $tableName Table name + * @param string $indexName Index name + * @return bool + */ + public function hasIndexByName(string $tableName, string $indexName): bool; + + /** + * Checks to see if the specified primary key exists. + * + * @param string $tableName Table name + * @param string|string[] $columns Column(s) + * @param string|null $constraint Constraint name + * @return bool + */ + public function hasPrimaryKey(string $tableName, $columns, ?string $constraint = null): bool; + + /** + * Checks to see if a foreign key exists. + * + * @param string $tableName Table name + * @param string|string[] $columns Column(s) + * @param string|null $constraint Constraint name + * @return bool + */ + public function hasForeignKey(string $tableName, $columns, ?string $constraint = null): bool; + + /** + * Returns an array of the supported Phinx column types. + * + * @return string[] + */ + public function getColumnTypes(): array; + + /** + * Checks that the given column is of a supported type. + * + * @param \Phinx\Db\Table\Column $column Column + * @return bool + */ + public function isValidColumnType(Column $column): bool; + + /** + * Converts the Phinx logical type to the adapter's SQL type. + * + * @param \Phinx\Util\Literal|string $type Type + * @param int|null $limit Limit + * @return array + */ + public function getSqlType($type, ?int $limit = null): array; + + /** + * Creates a new database. + * + * @param string $name Database Name + * @param array $options Options + * @return void + */ + public function createDatabase(string $name, array $options = []): void; + + /** + * Checks to see if a database exists. + * + * @param string $name Database Name + * @return bool + */ + public function hasDatabase(string $name): bool; + + /** + * Drops the specified database. + * + * @param string $name Database Name + * @return void + */ + public function dropDatabase(string $name): void; + + /** + * Creates the specified schema or throws an exception + * if there is no support for it. + * + * @param string $schemaName Schema Name + * @return void + */ + public function createSchema(string $schemaName = 'public'): void; + + /** + * Drops the specified schema table or throws an exception + * if there is no support for it. + * + * @param string $schemaName Schema name + * @return void + */ + public function dropSchema(string $schemaName): void; + + /** + * Cast a value to a boolean appropriate for the adapter. + * + * @param mixed $value The value to be cast + * @return mixed + */ + public function castToBool($value); +} diff --git a/extend/phinx/Db/Adapter/AdapterWrapper.php b/extend/phinx/Db/Adapter/AdapterWrapper.php new file mode 100644 index 0000000..153a3a7 --- /dev/null +++ b/extend/phinx/Db/Adapter/AdapterWrapper.php @@ -0,0 +1,487 @@ + + */ +abstract class AdapterWrapper implements AdapterInterface, WrapperInterface +{ + /** + * @var \Phinx\Db\Adapter\AdapterInterface + */ + protected $adapter; + + /** + * @inheritDoc + */ + public function __construct(AdapterInterface $adapter) + { + $this->setAdapter($adapter); + } + + /** + * @inheritDoc + */ + public function setAdapter(AdapterInterface $adapter): AdapterInterface + { + $this->adapter = $adapter; + + return $this; + } + + /** + * @inheritDoc + */ + public function getAdapter(): AdapterInterface + { + return $this->adapter; + } + + /** + * @inheritDoc + */ + public function setOptions(array $options): AdapterInterface + { + $this->adapter->setOptions($options); + + return $this; + } + + /** + * @inheritDoc + */ + public function getOptions(): array + { + return $this->adapter->getOptions(); + } + + /** + * @inheritDoc + */ + public function hasOption(string $name): bool + { + return $this->adapter->hasOption($name); + } + + /** + * @inheritDoc + */ + public function getOption(string $name) + { + return $this->adapter->getOption($name); + } + + /** + * @inheritDoc + */ + public function setInput(InputInterface $input): AdapterInterface + { + $this->adapter->setInput($input); + + return $this; + } + + /** + * @inheritDoc + */ + public function getInput(): InputInterface + { + return $this->adapter->getInput(); + } + + /** + * @inheritDoc + */ + public function setOutput(OutputInterface $output): AdapterInterface + { + $this->adapter->setOutput($output); + + return $this; + } + + /** + * @inheritDoc + */ + public function getOutput(): OutputInterface + { + return $this->adapter->getOutput(); + } + + /** + * @inheritDoc + */ + public function getColumnForType(string $columnName, string $type, array $options): Column + { + return $this->adapter->getColumnForType($columnName, $type, $options); + } + + /** + * @inheritDoc + */ + public function connect(): void + { + $this->getAdapter()->connect(); + } + + /** + * @inheritDoc + */ + public function disconnect(): void + { + $this->getAdapter()->disconnect(); + } + + /** + * @inheritDoc + */ + public function execute(string $sql, array $params = []): int + { + return $this->getAdapter()->execute($sql, $params); + } + + /** + * @inheritDoc + */ + public function query(string $sql, array $params = []) + { + return $this->getAdapter()->query($sql, $params); + } + + /** + * @inheritDoc + */ + public function insert(Table $table, array $row): void + { + $this->getAdapter()->insert($table, $row); + } + + /** + * @inheritDoc + */ + public function bulkinsert(Table $table, array $rows): void + { + $this->getAdapter()->bulkinsert($table, $rows); + } + + /** + * @inheritDoc + */ + public function fetchRow(string $sql) + { + return $this->getAdapter()->fetchRow($sql); + } + + /** + * @inheritDoc + */ + public function fetchAll(string $sql): array + { + return $this->getAdapter()->fetchAll($sql); + } + + /** + * @inheritDoc + */ + public function getVersions(): array + { + return $this->getAdapter()->getVersions(); + } + + /** + * @inheritDoc + */ + public function getVersionLog(): array + { + return $this->getAdapter()->getVersionLog(); + } + + /** + * @inheritDoc + */ + public function migrated(MigrationInterface $migration, string $direction, string $startTime, string $endTime): AdapterInterface + { + $this->getAdapter()->migrated($migration, $direction, $startTime, $endTime); + + return $this; + } + + /** + * @inheritDoc + */ + public function toggleBreakpoint(MigrationInterface $migration): AdapterInterface + { + $this->getAdapter()->toggleBreakpoint($migration); + + return $this; + } + + /** + * @inheritDoc + */ + public function resetAllBreakpoints(): int + { + return $this->getAdapter()->resetAllBreakpoints(); + } + + /** + * @inheritDoc + */ + public function setBreakpoint(MigrationInterface $migration): AdapterInterface + { + $this->getAdapter()->setBreakpoint($migration); + + return $this; + } + + /** + * @inheritDoc + */ + public function unsetBreakpoint(MigrationInterface $migration): AdapterInterface + { + $this->getAdapter()->unsetBreakpoint($migration); + + return $this; + } + + /** + * @inheritDoc + */ + public function createSchemaTable(): void + { + $this->getAdapter()->createSchemaTable(); + } + + /** + * @inheritDoc + */ + public function getColumnTypes(): array + { + return $this->getAdapter()->getColumnTypes(); + } + + /** + * @inheritDoc + */ + public function isValidColumnType(Column $column): bool + { + return $this->getAdapter()->isValidColumnType($column); + } + + /** + * @inheritDoc + */ + public function hasTransactions(): bool + { + return $this->getAdapter()->hasTransactions(); + } + + /** + * @inheritDoc + */ + public function beginTransaction(): void + { + $this->getAdapter()->beginTransaction(); + } + + /** + * @inheritDoc + */ + public function commitTransaction(): void + { + $this->getAdapter()->commitTransaction(); + } + + /** + * @inheritDoc + */ + public function rollbackTransaction(): void + { + $this->getAdapter()->rollbackTransaction(); + } + + /** + * @inheritDoc + */ + public function quoteTableName(string $tableName): string + { + return $this->getAdapter()->quoteTableName($tableName); + } + + /** + * @inheritDoc + */ + public function quoteColumnName(string $columnName): string + { + return $this->getAdapter()->quoteColumnName($columnName); + } + + /** + * @inheritDoc + */ + public function hasTable(string $tableName): bool + { + return $this->getAdapter()->hasTable($tableName); + } + + /** + * @inheritDoc + */ + public function createTable(Table $table, array $columns = [], array $indexes = []): void + { + $this->getAdapter()->createTable($table, $columns, $indexes); + } + + /** + * @inheritDoc + */ + public function getColumns(string $tableName): array + { + return $this->getAdapter()->getColumns($tableName); + } + + /** + * @inheritDoc + */ + public function hasColumn(string $tableName, string $columnName): bool + { + return $this->getAdapter()->hasColumn($tableName, $columnName); + } + + /** + * @inheritDoc + */ + public function hasIndex(string $tableName, $columns): bool + { + return $this->getAdapter()->hasIndex($tableName, $columns); + } + + /** + * @inheritDoc + */ + public function hasIndexByName(string $tableName, string $indexName): bool + { + return $this->getAdapter()->hasIndexByName($tableName, $indexName); + } + + /** + * @inheritDoc + */ + public function hasPrimaryKey(string $tableName, $columns, ?string $constraint = null): bool + { + return $this->getAdapter()->hasPrimaryKey($tableName, $columns, $constraint); + } + + /** + * @inheritDoc + */ + public function hasForeignKey(string $tableName, $columns, ?string $constraint = null): bool + { + return $this->getAdapter()->hasForeignKey($tableName, $columns, $constraint); + } + + /** + * @inheritDoc + */ + public function getSqlType($type, ?int $limit = null): array + { + return $this->getAdapter()->getSqlType($type, $limit); + } + + /** + * @inheritDoc + */ + public function createDatabase(string $name, array $options = []): void + { + $this->getAdapter()->createDatabase($name, $options); + } + + /** + * @inheritDoc + */ + public function hasDatabase(string $name): bool + { + return $this->getAdapter()->hasDatabase($name); + } + + /** + * @inheritDoc + */ + public function dropDatabase(string $name): void + { + $this->getAdapter()->dropDatabase($name); + } + + /** + * @inheritDoc + */ + public function createSchema(string $schemaName = 'public'): void + { + $this->getAdapter()->createSchema($schemaName); + } + + /** + * @inheritDoc + */ + public function dropSchema(string $schemaName): void + { + $this->getAdapter()->dropSchema($schemaName); + } + + /** + * @inheritDoc + */ + public function truncateTable(string $tableName): void + { + $this->getAdapter()->truncateTable($tableName); + } + + /** + * @inheritDoc + */ + public function castToBool($value) + { + return $this->getAdapter()->castToBool($value); + } + + /** + * @return \PDO + */ + public function getConnection() + { + return $this->getAdapter()->getConnection(); + } + + /** + * @inheritDoc + */ + public function executeActions(Table $table, array $actions): void + { + $this->getAdapter()->executeActions($table, $actions); + } + + /** + * @inheritDoc + */ + public function getQueryBuilder(): Query + { + return $this->getAdapter()->getQueryBuilder(); + } +} diff --git a/extend/phinx/Db/Adapter/DirectActionInterface.php b/extend/phinx/Db/Adapter/DirectActionInterface.php new file mode 100644 index 0000000..64b30f6 --- /dev/null +++ b/extend/phinx/Db/Adapter/DirectActionInterface.php @@ -0,0 +1,139 @@ + + */ +class MysqlAdapter extends PdoAdapter +{ + /** + * @var string[] + */ + protected static $specificColumnTypes = [ + self::PHINX_TYPE_ENUM, + self::PHINX_TYPE_SET, + self::PHINX_TYPE_YEAR, + self::PHINX_TYPE_JSON, + self::PHINX_TYPE_BINARYUUID, + self::PHINX_TYPE_TINYBLOB, + self::PHINX_TYPE_MEDIUMBLOB, + self::PHINX_TYPE_LONGBLOB, + self::PHINX_TYPE_MEDIUM_INTEGER, + ]; + + /** + * @var bool[] + */ + protected $signedColumnTypes = [ + self::PHINX_TYPE_INTEGER => true, + self::PHINX_TYPE_TINY_INTEGER => true, + self::PHINX_TYPE_SMALL_INTEGER => true, + self::PHINX_TYPE_MEDIUM_INTEGER => true, + self::PHINX_TYPE_BIG_INTEGER => true, + self::PHINX_TYPE_FLOAT => true, + self::PHINX_TYPE_DECIMAL => true, + self::PHINX_TYPE_DOUBLE => true, + self::PHINX_TYPE_BOOLEAN => true, + ]; + + // These constants roughly correspond to the maximum allowed value for each field, + // except for the `_LONG` and `_BIG` variants, which are maxed at 32-bit + // PHP_INT_MAX value. The `INT_REGULAR` field is just arbitrarily half of INT_BIG + // as its actual value is its regular value is larger than PHP_INT_MAX. We do this + // to keep consistent the type hints for getSqlType and Column::$limit being integers. + public const TEXT_TINY = 255; + public const TEXT_SMALL = 255; /* deprecated, alias of TEXT_TINY */ + public const TEXT_REGULAR = 65535; + public const TEXT_MEDIUM = 16777215; + public const TEXT_LONG = 2147483647; + + // According to https://dev.mysql.com/doc/refman/5.0/en/blob.html BLOB sizes are the same as TEXT + public const BLOB_TINY = 255; + public const BLOB_SMALL = 255; /* deprecated, alias of BLOB_TINY */ + public const BLOB_REGULAR = 65535; + public const BLOB_MEDIUM = 16777215; + public const BLOB_LONG = 2147483647; + + public const INT_TINY = 255; + public const INT_SMALL = 65535; + public const INT_MEDIUM = 16777215; + public const INT_REGULAR = 1073741823; + public const INT_BIG = 2147483647; + + public const INT_DISPLAY_TINY = 4; + public const INT_DISPLAY_SMALL = 6; + public const INT_DISPLAY_MEDIUM = 8; + public const INT_DISPLAY_REGULAR = 11; + public const INT_DISPLAY_BIG = 20; + + public const BIT = 64; + + public const TYPE_YEAR = 'year'; + + public const FIRST = 'FIRST'; + + /** + * {@inheritDoc} + * + * @throws \RuntimeException + * @throws \InvalidArgumentException + * @return void + */ + public function connect(): void + { + if ($this->connection === null) { + if (!class_exists('PDO') || !in_array('mysql', PDO::getAvailableDrivers(), true)) { + // @codeCoverageIgnoreStart + throw new RuntimeException('You need to enable the PDO_Mysql extension for Phinx to run properly.'); + // @codeCoverageIgnoreEnd + } + + $options = $this->getOptions(); + + $dsn = 'mysql:'; + + if (!empty($options['unix_socket'])) { + // use socket connection + $dsn .= 'unix_socket=' . $options['unix_socket']; + } else { + // use network connection + $dsn .= 'host=' . $options['host']; + if (!empty($options['port'])) { + $dsn .= ';port=' . $options['port']; + } + } + + $dsn .= ';dbname=' . $options['name']; + + // charset support + if (!empty($options['charset'])) { + $dsn .= ';charset=' . $options['charset']; + } + + $driverOptions = []; + + // use custom data fetch mode + if (!empty($options['fetch_mode'])) { + $driverOptions[PDO::ATTR_DEFAULT_FETCH_MODE] = constant('\PDO::FETCH_' . strtoupper($options['fetch_mode'])); + } + + // pass \PDO::ATTR_PERSISTENT to driver options instead of useless setting it after instantiation + if (isset($options['attr_persistent'])) { + $driverOptions[PDO::ATTR_PERSISTENT] = $options['attr_persistent']; + } + + // support arbitrary \PDO::MYSQL_ATTR_* driver options and pass them to PDO + // https://php.net/manual/en/ref.pdo-mysql.php#pdo-mysql.constants + foreach ($options as $key => $option) { + if (strpos($key, 'mysql_attr_') === 0) { + $pdoConstant = '\PDO::' . strtoupper($key); + if (!defined($pdoConstant)) { + throw new \UnexpectedValueException('Invalid PDO attribute: ' . $key . ' (' . $pdoConstant . ')'); + } + $driverOptions[constant($pdoConstant)] = $option; + } + } + + $db = $this->createPdoConnection($dsn, $options['user'] ?? null, $options['pass'] ?? null, $driverOptions); + + $this->setConnection($db); + } + } + + /** + * @inheritDoc + */ + public function disconnect(): void + { + $this->connection = null; + } + + /** + * @inheritDoc + */ + public function hasTransactions(): bool + { + return true; + } + + /** + * @inheritDoc + */ + public function beginTransaction(): void + { + $this->execute('START TRANSACTION'); + } + + /** + * @inheritDoc + */ + public function commitTransaction(): void + { + $this->execute('COMMIT'); + } + + /** + * @inheritDoc + */ + public function rollbackTransaction(): void + { + $this->execute('ROLLBACK'); + } + + /** + * @inheritDoc + */ + public function quoteTableName(string $tableName): string + { + return str_replace('.', '`.`', $this->quoteColumnName($tableName)); + } + + /** + * @inheritDoc + */ + public function quoteColumnName(string $columnName): string + { + return '`' . str_replace('`', '``', $columnName) . '`'; + } + + /** + * @inheritDoc + */ + public function hasTable(string $tableName): bool + { + if ($this->hasCreatedTable($tableName)) { + return true; + } + + if (strpos($tableName, '.') !== false) { + [$schema, $table] = explode('.', $tableName); + $exists = $this->hasTableWithSchema($schema, $table); + // Only break here on success, because it is possible for table names to contain a dot. + if ($exists) { + return true; + } + } + + $options = $this->getOptions(); + + return $this->hasTableWithSchema($options['name'], $tableName); + } + + /** + * @param string $schema The table schema + * @param string $tableName The table name + * @return bool + */ + protected function hasTableWithSchema(string $schema, string $tableName): bool + { + $result = $this->fetchRow(sprintf( + "SELECT TABLE_NAME + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = '%s' AND TABLE_NAME = '%s'", + $schema, + $tableName + )); + + return !empty($result); + } + + /** + * @inheritDoc + */ + public function createTable(Table $table, array $columns = [], array $indexes = []): void + { + // This method is based on the MySQL docs here: https://dev.mysql.com/doc/refman/5.1/en/create-index.html + $defaultOptions = [ + 'engine' => 'InnoDB', + 'collation' => 'utf8mb4_unicode_ci', + ]; + + $options = array_merge( + $defaultOptions, + array_intersect_key($this->getOptions(), $defaultOptions), + $table->getOptions() + ); + + // Add the default primary key + if (!isset($options['id']) || (isset($options['id']) && $options['id'] === true)) { + $options['id'] = 'id'; + } + + if (isset($options['id']) && is_string($options['id'])) { + // Handle id => "field_name" to support AUTO_INCREMENT + $column = new Column(); + $column->setName($options['id']) + ->setType('integer') + ->setOptions([ + 'signed' => $options['signed'] ?? !FeatureFlags::$unsignedPrimaryKeys, + 'identity' => true, + ]); + + if (isset($options['limit'])) { + $column->setLimit($options['limit']); + } + + array_unshift($columns, $column); + if (isset($options['primary_key']) && (array)$options['id'] !== (array)$options['primary_key']) { + throw new InvalidArgumentException('You cannot enable an auto incrementing ID field and a primary key'); + } + $options['primary_key'] = $options['id']; + } + + // open: process table options like collation etc + + // process table engine (default to InnoDB) + $optionsStr = 'ENGINE = InnoDB'; + if (isset($options['engine'])) { + $optionsStr = sprintf('ENGINE = %s', $options['engine']); + } + + // process table collation + if (isset($options['collation'])) { + $charset = explode('_', $options['collation']); + $optionsStr .= sprintf(' CHARACTER SET %s', $charset[0]); + $optionsStr .= sprintf(' COLLATE %s', $options['collation']); + } + + // set the table comment + if (isset($options['comment'])) { + $optionsStr .= sprintf(' COMMENT=%s ', $this->getConnection()->quote($options['comment'])); + } + + // set the table row format + if (isset($options['row_format'])) { + $optionsStr .= sprintf(' ROW_FORMAT=%s ', $options['row_format']); + } + + $sql = 'CREATE TABLE '; + $sql .= $this->quoteTableName($table->getName()) . ' ('; + foreach ($columns as $column) { + $sql .= $this->quoteColumnName($column->getName()) . ' ' . $this->getColumnSqlDefinition($column) . ', '; + } + + // set the primary key(s) + if (isset($options['primary_key'])) { + $sql = rtrim($sql); + $sql .= ' PRIMARY KEY ('; + if (is_string($options['primary_key'])) { // handle primary_key => 'id' + $sql .= $this->quoteColumnName($options['primary_key']); + } elseif (is_array($options['primary_key'])) { // handle primary_key => array('tag_id', 'resource_id') + $sql .= implode(',', array_map([$this, 'quoteColumnName'], (array)$options['primary_key'])); + } + $sql .= ')'; + } else { + $sql = substr(rtrim($sql), 0, -1); // no primary keys + } + + // set the indexes + foreach ($indexes as $index) { + $sql .= ', ' . $this->getIndexSqlDefinition($index); + } + + $sql .= ') ' . $optionsStr; + $sql = rtrim($sql); + + // execute the sql + $this->execute($sql); + + $this->addCreatedTable($table->getName()); + } + + /** + * {@inheritDoc} + * + * @throws \InvalidArgumentException + */ + protected function getChangePrimaryKeyInstructions(Table $table, $newColumns): AlterInstructions + { + $instructions = new AlterInstructions(); + + // Drop the existing primary key + $primaryKey = $this->getPrimaryKey($table->getName()); + if (!empty($primaryKey['columns'])) { + $instructions->addAlter('DROP PRIMARY KEY'); + } + + // Add the primary key(s) + if (!empty($newColumns)) { + $sql = 'ADD PRIMARY KEY ('; + if (is_string($newColumns)) { // handle primary_key => 'id' + $sql .= $this->quoteColumnName($newColumns); + } elseif (is_array($newColumns)) { // handle primary_key => array('tag_id', 'resource_id') + $sql .= implode(',', array_map([$this, 'quoteColumnName'], $newColumns)); + } else { + throw new InvalidArgumentException(sprintf( + 'Invalid value for primary key: %s', + json_encode($newColumns) + )); + } + $sql .= ')'; + $instructions->addAlter($sql); + } + + return $instructions; + } + + /** + * @inheritDoc + */ + protected function getChangeCommentInstructions(Table $table, ?string $newComment): AlterInstructions + { + $instructions = new AlterInstructions(); + + // passing 'null' is to remove table comment + $newComment = $newComment ?? ''; + $sql = sprintf(' COMMENT=%s ', $this->getConnection()->quote($newComment)); + $instructions->addAlter($sql); + + return $instructions; + } + + /** + * @inheritDoc + */ + protected function getRenameTableInstructions(string $tableName, string $newTableName): AlterInstructions + { + $this->updateCreatedTableName($tableName, $newTableName); + $sql = sprintf( + 'RENAME TABLE %s TO %s', + $this->quoteTableName($tableName), + $this->quoteTableName($newTableName) + ); + + return new AlterInstructions([], [$sql]); + } + + /** + * @inheritDoc + */ + protected function getDropTableInstructions(string $tableName): AlterInstructions + { + $this->removeCreatedTable($tableName); + $sql = sprintf('DROP TABLE %s', $this->quoteTableName($tableName)); + + return new AlterInstructions([], [$sql]); + } + + /** + * @inheritDoc + */ + public function truncateTable(string $tableName): void + { + $sql = sprintf( + 'TRUNCATE TABLE %s', + $this->quoteTableName($tableName) + ); + + $this->execute($sql); + } + + /** + * @inheritDoc + */ + public function getColumns(string $tableName): array + { + $columns = []; + $rows = $this->fetchAll(sprintf('SHOW COLUMNS FROM %s', $this->quoteTableName($tableName))); + foreach ($rows as $columnInfo) { + $phinxType = $this->getPhinxType($columnInfo['Type']); + + $column = new Column(); + $column->setName($columnInfo['Field']) + ->setNull($columnInfo['Null'] !== 'NO') + ->setType($phinxType['name']) + ->setSigned(strpos($columnInfo['Type'], 'unsigned') === false) + ->setLimit($phinxType['limit']) + ->setScale($phinxType['scale']); + + if ($columnInfo['Extra'] === 'auto_increment') { + $column->setIdentity(true); + } + + if (isset($phinxType['values'])) { + $column->setValues($phinxType['values']); + } + + $default = $columnInfo['Default']; + if ( + is_string($default) && + in_array( + $column->getType(), + array_merge( + static::PHINX_TYPES_GEOSPATIAL, + [static::PHINX_TYPE_BLOB, static::PHINX_TYPE_JSON, static::PHINX_TYPE_TEXT] + ) + ) + ) { + // The default that comes back from MySQL for these types prefixes the collation type and + // surrounds the value with escaped single quotes, for example "_utf8mbf4\'abc\'", and so + // this converts that then down to the default value of "abc" to correspond to what the user + // would have specified in a migration. + $default = preg_replace("/^_(?:[a-zA-Z0-9]+?)\\\'(.*)\\\'$/", '\1', $default); + } + $column->setDefault($default); + + $columns[] = $column; + } + + return $columns; + } + + /** + * @inheritDoc + */ + public function hasColumn(string $tableName, string $columnName): bool + { + $rows = $this->fetchAll(sprintf('SHOW COLUMNS FROM %s', $this->quoteTableName($tableName))); + foreach ($rows as $column) { + if (strcasecmp($column['Field'], $columnName) === 0) { + return true; + } + } + + return false; + } + + /** + * @inheritDoc + */ + protected function getAddColumnInstructions(Table $table, Column $column): AlterInstructions + { + $alter = sprintf( + 'ADD %s %s', + $this->quoteColumnName($column->getName()), + $this->getColumnSqlDefinition($column) + ); + + $alter .= $this->afterClause($column); + + return new AlterInstructions([$alter]); + } + + /** + * Exposes the MySQL syntax to arrange a column `FIRST`. + * + * @param \Phinx\Db\Table\Column $column The column being altered. + * @return string The appropriate SQL fragment. + */ + protected function afterClause(Column $column): string + { + $after = $column->getAfter(); + if (empty($after)) { + return ''; + } + + if ($after === self::FIRST) { + return ' FIRST'; + } + + return ' AFTER ' . $this->quoteColumnName($after); + } + + /** + * {@inheritDoc} + * + * @throws \InvalidArgumentException + */ + protected function getRenameColumnInstructions(string $tableName, string $columnName, string $newColumnName): AlterInstructions + { + $rows = $this->fetchAll(sprintf('SHOW FULL COLUMNS FROM %s', $this->quoteTableName($tableName))); + + foreach ($rows as $row) { + if (strcasecmp($row['Field'], $columnName) === 0) { + $null = $row['Null'] === 'NO' ? 'NOT NULL' : 'NULL'; + $comment = isset($row['Comment']) ? ' COMMENT ' . '\'' . addslashes($row['Comment']) . '\'' : ''; + $extra = ' ' . strtoupper($row['Extra']); + if (($row['Default'] !== null)) { + $extra .= $this->getDefaultValueDefinition($row['Default']); + } + $definition = $row['Type'] . ' ' . $null . $extra . $comment; + + $alter = sprintf( + 'CHANGE COLUMN %s %s %s', + $this->quoteColumnName($columnName), + $this->quoteColumnName($newColumnName), + $definition + ); + + return new AlterInstructions([$alter]); + } + } + + throw new InvalidArgumentException(sprintf( + "The specified column doesn't exist: " . + $columnName + )); + } + + /** + * @inheritDoc + */ + protected function getChangeColumnInstructions(string $tableName, string $columnName, Column $newColumn): AlterInstructions + { + $alter = sprintf( + 'CHANGE %s %s %s%s', + $this->quoteColumnName($columnName), + $this->quoteColumnName($newColumn->getName()), + $this->getColumnSqlDefinition($newColumn), + $this->afterClause($newColumn) + ); + + return new AlterInstructions([$alter]); + } + + /** + * @inheritDoc + */ + protected function getDropColumnInstructions(string $tableName, string $columnName): AlterInstructions + { + $alter = sprintf('DROP COLUMN %s', $this->quoteColumnName($columnName)); + + return new AlterInstructions([$alter]); + } + + /** + * Get an array of indexes from a particular table. + * + * @param string $tableName Table name + * @return array + */ + protected function getIndexes(string $tableName): array + { + $indexes = []; + $rows = $this->fetchAll(sprintf('SHOW INDEXES FROM %s', $this->quoteTableName($tableName))); + foreach ($rows as $row) { + if (!isset($indexes[$row['Key_name']])) { + $indexes[$row['Key_name']] = ['columns' => []]; + } + $indexes[$row['Key_name']]['columns'][] = strtolower($row['Column_name']); + } + + return $indexes; + } + + /** + * @inheritDoc + */ + public function hasIndex(string $tableName, $columns): bool + { + if (is_string($columns)) { + $columns = [$columns]; // str to array + } + + $columns = array_map('strtolower', $columns); + $indexes = $this->getIndexes($tableName); + + foreach ($indexes as $index) { + if ($columns == $index['columns']) { + return true; + } + } + + return false; + } + + /** + * @inheritDoc + */ + public function hasIndexByName(string $tableName, string $indexName): bool + { + $indexes = $this->getIndexes($tableName); + + foreach ($indexes as $name => $index) { + if ($name === $indexName) { + return true; + } + } + + return false; + } + + /** + * @inheritDoc + */ + protected function getAddIndexInstructions(Table $table, Index $index): AlterInstructions + { + $instructions = new AlterInstructions(); + + if ($index->getType() === Index::FULLTEXT) { + // Must be executed separately + // SQLSTATE[HY000]: General error: 1795 InnoDB presently supports one FULLTEXT index creation at a time + $alter = sprintf( + 'ALTER TABLE %s ADD %s', + $this->quoteTableName($table->getName()), + $this->getIndexSqlDefinition($index) + ); + + $instructions->addPostStep($alter); + } else { + $alter = sprintf( + 'ADD %s', + $this->getIndexSqlDefinition($index) + ); + + $instructions->addAlter($alter); + } + + return $instructions; + } + + /** + * {@inheritDoc} + * + * @throws \InvalidArgumentException + */ + protected function getDropIndexByColumnsInstructions(string $tableName, $columns): AlterInstructions + { + if (is_string($columns)) { + $columns = [$columns]; // str to array + } + + $indexes = $this->getIndexes($tableName); + $columns = array_map('strtolower', $columns); + + foreach ($indexes as $indexName => $index) { + if ($columns == $index['columns']) { + return new AlterInstructions([sprintf( + 'DROP INDEX %s', + $this->quoteColumnName($indexName) + )]); + } + } + + throw new InvalidArgumentException(sprintf( + "The specified index on columns '%s' does not exist", + implode(',', $columns) + )); + } + + /** + * {@inheritDoc} + * + * @throws \InvalidArgumentException + */ + protected function getDropIndexByNameInstructions(string $tableName, $indexName): AlterInstructions + { + $indexes = $this->getIndexes($tableName); + + foreach ($indexes as $name => $index) { + if ($name === $indexName) { + return new AlterInstructions([sprintf( + 'DROP INDEX %s', + $this->quoteColumnName($indexName) + )]); + } + } + + throw new InvalidArgumentException(sprintf( + "The specified index name '%s' does not exist", + $indexName + )); + } + + /** + * @inheritDoc + */ + public function hasPrimaryKey(string $tableName, $columns, ?string $constraint = null): bool + { + $primaryKey = $this->getPrimaryKey($tableName); + + if (empty($primaryKey['constraint'])) { + return false; + } + + if ($constraint) { + return $primaryKey['constraint'] === $constraint; + } else { + if (is_string($columns)) { + $columns = [$columns]; // str to array + } + $missingColumns = array_diff($columns, $primaryKey['columns']); + + return empty($missingColumns); + } + } + + /** + * Get the primary key from a particular table. + * + * @param string $tableName Table name + * @return array + */ + public function getPrimaryKey(string $tableName): array + { + $options = $this->getOptions(); + $rows = $this->fetchAll(sprintf( + "SELECT + k.CONSTRAINT_NAME, + k.COLUMN_NAME + FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS t + JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE k + USING(CONSTRAINT_NAME,TABLE_SCHEMA,TABLE_NAME) + WHERE t.CONSTRAINT_TYPE='PRIMARY KEY' + AND t.TABLE_SCHEMA='%s' + AND t.TABLE_NAME='%s'", + $options['name'], + $tableName + )); + + $primaryKey = [ + 'columns' => [], + ]; + foreach ($rows as $row) { + $primaryKey['constraint'] = $row['CONSTRAINT_NAME']; + $primaryKey['columns'][] = $row['COLUMN_NAME']; + } + + return $primaryKey; + } + + /** + * @inheritDoc + */ + public function hasForeignKey(string $tableName, $columns, ?string $constraint = null): bool + { + if (is_string($columns)) { + $columns = [$columns]; // str to array + } + $foreignKeys = $this->getForeignKeys($tableName); + if ($constraint) { + if (isset($foreignKeys[$constraint])) { + return !empty($foreignKeys[$constraint]); + } + + return false; + } + + foreach ($foreignKeys as $key) { + if ($columns == $key['columns']) { + return true; + } + } + + return false; + } + + /** + * Get an array of foreign keys from a particular table. + * + * @param string $tableName Table name + * @return array + */ + protected function getForeignKeys(string $tableName): array + { + if (strpos($tableName, '.') !== false) { + [$schema, $tableName] = explode('.', $tableName); + } + + $foreignKeys = []; + $rows = $this->fetchAll(sprintf( + "SELECT + CONSTRAINT_NAME, + CONCAT(TABLE_SCHEMA, '.', TABLE_NAME) AS TABLE_NAME, + COLUMN_NAME, + CONCAT(REFERENCED_TABLE_SCHEMA, '.', REFERENCED_TABLE_NAME) AS REFERENCED_TABLE_NAME, + REFERENCED_COLUMN_NAME + FROM information_schema.KEY_COLUMN_USAGE + WHERE REFERENCED_TABLE_NAME IS NOT NULL + AND TABLE_SCHEMA = %s + AND TABLE_NAME = '%s' + ORDER BY POSITION_IN_UNIQUE_CONSTRAINT", + empty($schema) ? 'DATABASE()' : "'$schema'", + $tableName + )); + foreach ($rows as $row) { + $foreignKeys[$row['CONSTRAINT_NAME']]['table'] = $row['TABLE_NAME']; + $foreignKeys[$row['CONSTRAINT_NAME']]['columns'][] = $row['COLUMN_NAME']; + $foreignKeys[$row['CONSTRAINT_NAME']]['referenced_table'] = $row['REFERENCED_TABLE_NAME']; + $foreignKeys[$row['CONSTRAINT_NAME']]['referenced_columns'][] = $row['REFERENCED_COLUMN_NAME']; + } + + return $foreignKeys; + } + + /** + * @inheritDoc + */ + protected function getAddForeignKeyInstructions(Table $table, ForeignKey $foreignKey): AlterInstructions + { + $alter = sprintf( + 'ADD %s', + $this->getForeignKeySqlDefinition($foreignKey) + ); + + return new AlterInstructions([$alter]); + } + + /** + * @inheritDoc + */ + protected function getDropForeignKeyInstructions(string $tableName, string $constraint): AlterInstructions + { + $alter = sprintf( + 'DROP FOREIGN KEY %s', + $constraint + ); + + return new AlterInstructions([$alter]); + } + + /** + * {@inheritDoc} + * + * @throws \InvalidArgumentException + */ + protected function getDropForeignKeyByColumnsInstructions(string $tableName, array $columns): AlterInstructions + { + $instructions = new AlterInstructions(); + + foreach ($columns as $column) { + $rows = $this->fetchAll(sprintf( + "SELECT + CONSTRAINT_NAME + FROM information_schema.KEY_COLUMN_USAGE + WHERE REFERENCED_TABLE_SCHEMA = DATABASE() + AND REFERENCED_TABLE_NAME IS NOT NULL + AND TABLE_NAME = '%s' + AND COLUMN_NAME = '%s' + ORDER BY POSITION_IN_UNIQUE_CONSTRAINT", + $tableName, + $column + )); + + foreach ($rows as $row) { + $instructions->merge($this->getDropForeignKeyInstructions($tableName, $row['CONSTRAINT_NAME'])); + } + } + + if (empty($instructions->getAlterParts())) { + throw new InvalidArgumentException(sprintf( + "Not foreign key on columns '%s' exist", + implode(',', $columns) + )); + } + + return $instructions; + } + + /** + * {@inheritDoc} + * + * @throws \Phinx\Db\Adapter\UnsupportedColumnTypeException + */ + public function getSqlType($type, ?int $limit = null): array + { + switch ($type) { + case static::PHINX_TYPE_FLOAT: + case static::PHINX_TYPE_DOUBLE: + case static::PHINX_TYPE_DECIMAL: + case static::PHINX_TYPE_DATE: + case static::PHINX_TYPE_ENUM: + case static::PHINX_TYPE_SET: + case static::PHINX_TYPE_JSON: + // Geospatial database types + case static::PHINX_TYPE_GEOMETRY: + case static::PHINX_TYPE_POINT: + case static::PHINX_TYPE_LINESTRING: + case static::PHINX_TYPE_POLYGON: + return ['name' => $type]; + case static::PHINX_TYPE_DATETIME: + case static::PHINX_TYPE_TIMESTAMP: + case static::PHINX_TYPE_TIME: + return ['name' => $type, 'limit' => $limit]; + case static::PHINX_TYPE_STRING: + return ['name' => 'varchar', 'limit' => $limit ?: 255]; + case static::PHINX_TYPE_CHAR: + return ['name' => 'char', 'limit' => $limit ?: 255]; + case static::PHINX_TYPE_TEXT: + if ($limit) { + $sizes = [ + // Order matters! Size must always be tested from longest to shortest! + 'longtext' => static::TEXT_LONG, + 'mediumtext' => static::TEXT_MEDIUM, + 'text' => static::TEXT_REGULAR, + 'tinytext' => static::TEXT_SMALL, + ]; + foreach ($sizes as $name => $length) { + if ($limit >= $length) { + return ['name' => $name]; + } + } + } + + return ['name' => 'text']; + case static::PHINX_TYPE_BINARY: + if ($limit === null) { + $limit = 255; + } + + if ($limit > 255) { + return $this->getSqlType(static::PHINX_TYPE_BLOB, $limit); + } + + return ['name' => 'binary', 'limit' => $limit]; + case static::PHINX_TYPE_BINARYUUID: + return ['name' => 'binary', 'limit' => 16]; + case static::PHINX_TYPE_VARBINARY: + if ($limit === null) { + $limit = 255; + } + + if ($limit > 255) { + return $this->getSqlType(static::PHINX_TYPE_BLOB, $limit); + } + + return ['name' => 'varbinary', 'limit' => $limit]; + case static::PHINX_TYPE_BLOB: + if ($limit !== null) { + // Rework this part as the choosen types were always UNDER the required length + $sizes = [ + 'tinyblob' => static::BLOB_SMALL, + 'blob' => static::BLOB_REGULAR, + 'mediumblob' => static::BLOB_MEDIUM, + ]; + + foreach ($sizes as $name => $length) { + if ($limit <= $length) { + return ['name' => $name]; + } + } + + // For more length requirement, the longblob is used + return ['name' => 'longblob']; + } + + // If not limit is provided, fallback on blob + return ['name' => 'blob']; + case static::PHINX_TYPE_TINYBLOB: + // Automatically reprocess blob type to ensure that correct blob subtype is selected given provided limit + return $this->getSqlType(static::PHINX_TYPE_BLOB, $limit ?: static::BLOB_TINY); + case static::PHINX_TYPE_MEDIUMBLOB: + // Automatically reprocess blob type to ensure that correct blob subtype is selected given provided limit + return $this->getSqlType(static::PHINX_TYPE_BLOB, $limit ?: static::BLOB_MEDIUM); + case static::PHINX_TYPE_LONGBLOB: + // Automatically reprocess blob type to ensure that correct blob subtype is selected given provided limit + return $this->getSqlType(static::PHINX_TYPE_BLOB, $limit ?: static::BLOB_LONG); + case static::PHINX_TYPE_BIT: + return ['name' => 'bit', 'limit' => $limit ?: 64]; + case static::PHINX_TYPE_BIG_INTEGER: + if ($limit === static::INT_BIG) { + $limit = static::INT_DISPLAY_BIG; + } + + return ['name' => 'bigint', 'limit' => $limit ?: 20]; + case static::PHINX_TYPE_MEDIUM_INTEGER: + if ($limit === static::INT_MEDIUM) { + $limit = static::INT_DISPLAY_MEDIUM; + } + + return ['name' => 'mediumint', 'limit' => $limit ?: 8]; + case static::PHINX_TYPE_SMALL_INTEGER: + if ($limit === static::INT_SMALL) { + $limit = static::INT_DISPLAY_SMALL; + } + + return ['name' => 'smallint', 'limit' => $limit ?: 6]; + case static::PHINX_TYPE_TINY_INTEGER: + if ($limit === static::INT_TINY) { + $limit = static::INT_DISPLAY_TINY; + } + + return ['name' => 'tinyint', 'limit' => $limit ?: 4]; + case static::PHINX_TYPE_INTEGER: + if ($limit && $limit >= static::INT_TINY) { + $sizes = [ + // Order matters! Size must always be tested from longest to shortest! + 'bigint' => static::INT_BIG, + 'int' => static::INT_REGULAR, + 'mediumint' => static::INT_MEDIUM, + 'smallint' => static::INT_SMALL, + 'tinyint' => static::INT_TINY, + ]; + $limits = [ + 'tinyint' => static::INT_DISPLAY_TINY, + 'smallint' => static::INT_DISPLAY_SMALL, + 'mediumint' => static::INT_DISPLAY_MEDIUM, + 'int' => static::INT_DISPLAY_REGULAR, + 'bigint' => static::INT_DISPLAY_BIG, + ]; + foreach ($sizes as $name => $length) { + if ($limit >= $length) { + $def = ['name' => $name]; + if (isset($limits[$name])) { + $def['limit'] = $limits[$name]; + } + + return $def; + } + } + } elseif (!$limit) { + $limit = static::INT_DISPLAY_REGULAR; + } + + return ['name' => 'int', 'limit' => $limit]; + case static::PHINX_TYPE_BOOLEAN: + return ['name' => 'tinyint', 'limit' => 1]; + case static::PHINX_TYPE_UUID: + return ['name' => 'char', 'limit' => 36]; + case static::PHINX_TYPE_YEAR: + if (!$limit || in_array($limit, [2, 4])) { + $limit = 4; + } + + return ['name' => 'year', 'limit' => $limit]; + default: + throw new UnsupportedColumnTypeException('Column type "' . $type . '" is not supported by MySQL.'); + } + } + + /** + * Returns Phinx type by SQL type + * + * @internal param string $sqlType SQL type + * @param string $sqlTypeDef SQL Type definition + * @throws \Phinx\Db\Adapter\UnsupportedColumnTypeException + * @return array Phinx type + */ + public function getPhinxType($sqlTypeDef) + { + $matches = []; + if (!preg_match('/^([\w]+)(\(([\d]+)*(,([\d]+))*\))*(.+)*$/', $sqlTypeDef, $matches)) { + throw new UnsupportedColumnTypeException('Column type "' . $sqlTypeDef . '" is not supported by MySQL.'); + } + + $limit = null; + $scale = null; + $type = $matches[1]; + if (count($matches) > 2) { + $limit = $matches[3] ? (int)$matches[3] : null; + } + if (count($matches) > 4) { + $scale = (int)$matches[5]; + } + if ($type === 'tinyint' && $limit === 1) { + $type = static::PHINX_TYPE_BOOLEAN; + $limit = null; + } + switch ($type) { + case 'varchar': + $type = static::PHINX_TYPE_STRING; + if ($limit === 255) { + $limit = null; + } + break; + case 'char': + $type = static::PHINX_TYPE_CHAR; + if ($limit === 255) { + $limit = null; + } + if ($limit === 36) { + $type = static::PHINX_TYPE_UUID; + } + break; + case 'tinyint': + $type = static::PHINX_TYPE_TINY_INTEGER; + break; + case 'smallint': + $type = static::PHINX_TYPE_SMALL_INTEGER; + break; + case 'mediumint': + $type = static::PHINX_TYPE_MEDIUM_INTEGER; + break; + case 'int': + $type = static::PHINX_TYPE_INTEGER; + break; + case 'bigint': + $type = static::PHINX_TYPE_BIG_INTEGER; + break; + case 'bit': + $type = static::PHINX_TYPE_BIT; + if ($limit === 64) { + $limit = null; + } + break; + case 'blob': + $type = static::PHINX_TYPE_BLOB; + $limit = static::BLOB_REGULAR; + break; + case 'tinyblob': + $type = static::PHINX_TYPE_TINYBLOB; + $limit = static::BLOB_TINY; + break; + case 'mediumblob': + $type = static::PHINX_TYPE_MEDIUMBLOB; + $limit = static::BLOB_MEDIUM; + break; + case 'longblob': + $type = static::PHINX_TYPE_LONGBLOB; + $limit = static::BLOB_LONG; + break; + case 'tinytext': + $type = static::PHINX_TYPE_TEXT; + $limit = static::TEXT_TINY; + break; + case 'mediumtext': + $type = static::PHINX_TYPE_TEXT; + $limit = static::TEXT_MEDIUM; + break; + case 'longtext': + $type = static::PHINX_TYPE_TEXT; + $limit = static::TEXT_LONG; + break; + case 'binary': + if ($limit === null) { + $limit = 255; + } + + if ($limit > 255) { + $type = static::PHINX_TYPE_BLOB; + break; + } + + if ($limit === 16) { + $type = static::PHINX_TYPE_BINARYUUID; + } + break; + } + + try { + // Call this to check if parsed type is supported. + $this->getSqlType($type, $limit); + } catch (UnsupportedColumnTypeException $e) { + $type = Literal::from($type); + } + + $phinxType = [ + 'name' => $type, + 'limit' => $limit, + 'scale' => $scale, + ]; + + if ($type === static::PHINX_TYPE_ENUM || $type === static::PHINX_TYPE_SET) { + $values = trim($matches[6], '()'); + $phinxType['values'] = []; + $opened = false; + $escaped = false; + $wasEscaped = false; + $value = ''; + $valuesLength = strlen($values); + for ($i = 0; $i < $valuesLength; $i++) { + $char = $values[$i]; + if ($char === "'" && !$opened) { + $opened = true; + } elseif ( + !$escaped + && ($i + 1) < $valuesLength + && ( + $char === "'" && $values[$i + 1] === "'" + || $char === '\\' && $values[$i + 1] === '\\' + ) + ) { + $escaped = true; + } elseif ($char === "'" && $opened && !$escaped) { + $phinxType['values'][] = $value; + $value = ''; + $opened = false; + } elseif (($char === "'" || $char === '\\') && $opened && $escaped) { + $value .= $char; + $escaped = false; + $wasEscaped = true; + } elseif ($opened) { + if ($values[$i - 1] === '\\' && !$wasEscaped) { + if ($char === 'n') { + $char = "\n"; + } elseif ($char === 'r') { + $char = "\r"; + } elseif ($char === 't') { + $char = "\t"; + } + if ($values[$i] !== $char) { + $value = substr($value, 0, strlen($value) - 1); + } + } + $value .= $char; + $wasEscaped = false; + } + } + } + + return $phinxType; + } + + /** + * @inheritDoc + */ + public function createDatabase(string $name, array $options = []): void + { + $charset = $options['charset'] ?? 'utf8'; + + if (isset($options['collation'])) { + $this->execute(sprintf( + 'CREATE DATABASE `%s` DEFAULT CHARACTER SET `%s` COLLATE `%s`', + $name, + $charset, + $options['collation'] + )); + } else { + $this->execute(sprintf('CREATE DATABASE `%s` DEFAULT CHARACTER SET `%s`', $name, $charset)); + } + } + + /** + * @inheritDoc + */ + public function hasDatabase(string $name): bool + { + $rows = $this->fetchAll( + sprintf( + 'SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = \'%s\'', + $name + ) + ); + + foreach ($rows as $row) { + if (!empty($row)) { + return true; + } + } + + return false; + } + + /** + * @inheritDoc + */ + public function dropDatabase(string $name): void + { + $this->execute(sprintf('DROP DATABASE IF EXISTS `%s`', $name)); + $this->createdTables = []; + } + + /** + * Gets the MySQL Column Definition for a Column object. + * + * @param \Phinx\Db\Table\Column $column Column + * @return string + */ + protected function getColumnSqlDefinition(Column $column): string + { + if ($column->getType() instanceof Literal) { + $def = (string)$column->getType(); + } else { + $sqlType = $this->getSqlType($column->getType(), $column->getLimit()); + $def = strtoupper($sqlType['name']); + } + if ($column->getPrecision() && $column->getScale()) { + $def .= '(' . $column->getPrecision() . ',' . $column->getScale() . ')'; + } elseif (isset($sqlType['limit'])) { + $def .= '(' . $sqlType['limit'] . ')'; + } + + $values = $column->getValues(); + if ($values && is_array($values)) { + $def .= '(' . implode(', ', array_map(function ($value) { + // we special case NULL as it's not actually allowed an enum value, + // and we want MySQL to issue an error on the create statement, but + // quote coerces it to an empty string, which will not error + return $value === null ? 'NULL' : $this->getConnection()->quote($value); + }, $values)) . ')'; + } + + $def .= $column->getEncoding() ? ' CHARACTER SET ' . $column->getEncoding() : ''; + $def .= $column->getCollation() ? ' COLLATE ' . $column->getCollation() : ''; + $def .= !$column->isSigned() && isset($this->signedColumnTypes[$column->getType()]) ? ' unsigned' : ''; + $def .= $column->isNull() ? ' NULL' : ' NOT NULL'; + + if ( + version_compare($this->getAttribute(\PDO::ATTR_SERVER_VERSION), '8', '>=') + && in_array($column->getType(), static::PHINX_TYPES_GEOSPATIAL) + && !is_null($column->getSrid()) + ) { + $def .= " SRID {$column->getSrid()}"; + } + + $def .= $column->isIdentity() ? ' AUTO_INCREMENT' : ''; + + $default = $column->getDefault(); + // MySQL 8 supports setting default for the following tested types, but only if they are "cast as expressions" + if ( + version_compare($this->getAttribute(\PDO::ATTR_SERVER_VERSION), '8', '>=') && + is_string($default) && + in_array( + $column->getType(), + array_merge( + static::PHINX_TYPES_GEOSPATIAL, + [static::PHINX_TYPE_BLOB, static::PHINX_TYPE_JSON, static::PHINX_TYPE_TEXT] + ) + ) + ) { + $default = Literal::from('(' . $this->getConnection()->quote($column->getDefault()) . ')'); + } + $def .= $this->getDefaultValueDefinition($default, $column->getType()); + + if ($column->getComment()) { + $def .= ' COMMENT ' . $this->getConnection()->quote($column->getComment()); + } + + if ($column->getUpdate()) { + $def .= ' ON UPDATE ' . $column->getUpdate(); + } + + return $def; + } + + /** + * Gets the MySQL Index Definition for an Index object. + * + * @param \Phinx\Db\Table\Index $index Index + * @return string + */ + protected function getIndexSqlDefinition(Index $index): string + { + $def = ''; + $limit = ''; + + if ($index->getType() === Index::UNIQUE) { + $def .= ' UNIQUE'; + } + + if ($index->getType() === Index::FULLTEXT) { + $def .= ' FULLTEXT'; + } + + $def .= ' KEY'; + + if (is_string($index->getName())) { + $def .= ' `' . $index->getName() . '`'; + } + + $columnNames = $index->getColumns(); + $order = $index->getOrder() ?? []; + $columnNames = array_map(function ($columnName) use ($order) { + $ret = '`' . $columnName . '`'; + if (isset($order[$columnName])) { + $ret .= ' ' . $order[$columnName]; + } + + return $ret; + }, $columnNames); + + if (!is_array($index->getLimit())) { + if ($index->getLimit()) { + $limit = '(' . $index->getLimit() . ')'; + } + $def .= ' (' . implode(',', $columnNames) . $limit . ')'; + } else { + $columns = $index->getColumns(); + $limits = $index->getLimit(); + $def .= ' ('; + foreach ($columns as $column) { + $limit = !isset($limits[$column]) || $limits[$column] <= 0 ? '' : '(' . $limits[$column] . ')'; + $columnSort = isset($order[$column]) ?? ''; + $def .= '`' . $column . '`' . $limit . ' ' . $columnSort . ', '; + } + $def = rtrim($def, ', '); + $def .= ' )'; + } + + return $def; + } + + /** + * Gets the MySQL Foreign Key Definition for an ForeignKey object. + * + * @param \Phinx\Db\Table\ForeignKey $foreignKey Foreign key + * @return string + */ + protected function getForeignKeySqlDefinition(ForeignKey $foreignKey): string + { + $def = ''; + if ($foreignKey->getConstraint()) { + $def .= ' CONSTRAINT ' . $this->quoteColumnName($foreignKey->getConstraint()); + } + $columnNames = []; + foreach ($foreignKey->getColumns() as $column) { + $columnNames[] = $this->quoteColumnName($column); + } + $def .= ' FOREIGN KEY (' . implode(',', $columnNames) . ')'; + $refColumnNames = []; + foreach ($foreignKey->getReferencedColumns() as $column) { + $refColumnNames[] = $this->quoteColumnName($column); + } + $def .= ' REFERENCES ' . $this->quoteTableName($foreignKey->getReferencedTable()->getName()) . ' (' . implode(',', $refColumnNames) . ')'; + if ($foreignKey->getOnDelete()) { + $def .= ' ON DELETE ' . $foreignKey->getOnDelete(); + } + if ($foreignKey->getOnUpdate()) { + $def .= ' ON UPDATE ' . $foreignKey->getOnUpdate(); + } + + return $def; + } + + /** + * Describes a database table. This is a MySQL adapter specific method. + * + * @param string $tableName Table name + * @return array + */ + public function describeTable(string $tableName): array + { + $options = $this->getOptions(); + + // mysql specific + $sql = sprintf( + "SELECT * + FROM information_schema.tables + WHERE table_schema = '%s' + AND table_name = '%s'", + $options['name'], + $tableName + ); + + $table = $this->fetchRow($sql); + + return $table !== false ? $table : []; + } + + /** + * Returns MySQL column types (inherited and MySQL specified). + * + * @return string[] + */ + public function getColumnTypes(): array + { + return array_merge(parent::getColumnTypes(), static::$specificColumnTypes); + } + + /** + * @inheritDoc + */ + public function getDecoratedConnection(): Connection + { + $options = $this->getOptions(); + $options = [ + 'username' => $options['user'] ?? null, + 'password' => $options['pass'] ?? null, + 'database' => $options['name'], + 'quoteIdentifiers' => true, + ] + $options; + + $driver = new MysqlDriver($options); + $driver->setConnection($this->connection); + + return new Connection(['driver' => $driver] + $options); + } +} diff --git a/extend/phinx/Db/Adapter/PdoAdapter.php b/extend/phinx/Db/Adapter/PdoAdapter.php new file mode 100644 index 0000000..592297c --- /dev/null +++ b/extend/phinx/Db/Adapter/PdoAdapter.php @@ -0,0 +1,1003 @@ + + */ +abstract class PdoAdapter extends AbstractAdapter implements DirectActionInterface +{ + /** + * @var \PDO|null + */ + protected $connection; + + /** + * Writes a message to stdout if verbose output is on + * + * @param string $message The message to show + * @return void + */ + protected function verboseLog($message): void + { + if ( + !$this->isDryRunEnabled() && + $this->getOutput()->getVerbosity() < OutputInterface::VERBOSITY_VERY_VERBOSE + ) { + return; + } + + $this->getOutput()->writeln($message); + } + + /** + * Create PDO connection + * + * @param string $dsn Connection string + * @param string|null $username Database username + * @param string|null $password Database password + * @param array $options Connection options + * @return \PDO + */ + protected function createPdoConnection(string $dsn, ?string $username = null, ?string $password = null, array $options = []): PDO + { + $adapterOptions = $this->getOptions() + [ + 'attr_errmode' => PDO::ERRMODE_EXCEPTION, + ]; + + try { + $db = new PDO($dsn, $username, $password, $options); + + foreach ($adapterOptions as $key => $option) { + if (strpos($key, 'attr_') === 0) { + $pdoConstant = '\PDO::' . strtoupper($key); + if (!defined($pdoConstant)) { + throw new \UnexpectedValueException('Invalid PDO attribute: ' . $key . ' (' . $pdoConstant . ')'); + } + $db->setAttribute(constant($pdoConstant), $option); + } + } + } catch (PDOException $e) { + throw new InvalidArgumentException(sprintf( + 'There was a problem connecting to the database: %s', + $e->getMessage() + ), 0, $e); + } + + return $db; + } + + /** + * @inheritDoc + */ + public function setOptions(array $options): AdapterInterface + { + parent::setOptions($options); + + if (isset($options['connection'])) { + $this->setConnection($options['connection']); + } + + return $this; + } + + /** + * Sets the database connection. + * + * @param \PDO $connection Connection + * @return \Phinx\Db\Adapter\AdapterInterface + */ + public function setConnection(PDO $connection): AdapterInterface + { + $this->connection = $connection; + + // Create the schema table if it doesn't already exist + if (!$this->hasTable($this->getSchemaTableName())) { + $this->createSchemaTable(); + } else { + $table = new DbTable($this->getSchemaTableName(), [], $this); + if (!$table->hasColumn('migration_name')) { + $table + ->addColumn( + 'migration_name', + 'string', + ['limit' => 100, 'after' => 'version', 'default' => null, 'null' => true] + ) + ->save(); + } + if (!$table->hasColumn('breakpoint')) { + $table + ->addColumn('breakpoint', 'boolean', ['default' => false, 'null' => false]) + ->save(); + } + } + + return $this; + } + + /** + * Gets the database connection + * + * @return \PDO + */ + public function getConnection(): PDO + { + if ($this->connection === null) { + $this->connect(); + } + + return $this->connection; + } + + /** + * @inheritDoc + */ + abstract public function connect(): void; + + /** + * @inheritDoc + */ + abstract public function disconnect(): void; + + /** + * @inheritDoc + */ + public function execute(string $sql, array $params = []): int + { + $sql = rtrim($sql, "; \t\n\r\0\x0B") . ';'; + $this->verboseLog($sql); + + if ($this->isDryRunEnabled()) { + return 0; + } + + if (empty($params)) { + return $this->getConnection()->exec($sql); + } + + $stmt = $this->getConnection()->prepare($sql); + $result = $stmt->execute($params); + + return $result ? $stmt->rowCount() : $result; + } + + /** + * Returns the Cake\Database connection object using the same underlying + * PDO object as this connection. + * + * @return \Cake\Database\Connection + */ + abstract public function getDecoratedConnection(): Connection; + + /** + * @inheritDoc + */ + public function getQueryBuilder(): Query + { + return $this->getDecoratedConnection()->newQuery(); + } + + /** + * Executes a query and returns PDOStatement. + * + * @param string $sql SQL + * @return \PDOStatement|false + */ + public function query(string $sql, array $params = []) + { + if (empty($params)) { + return $this->getConnection()->query($sql); + } + $stmt = $this->getConnection()->prepare($sql); + $result = $stmt->execute($params); + + return $result ? $stmt : false; + } + + /** + * @inheritDoc + */ + public function fetchRow(string $sql) + { + return $this->query($sql)->fetch(); + } + + /** + * @inheritDoc + */ + public function fetchAll(string $sql): array + { + return $this->query($sql)->fetchAll(); + } + + /** + * @inheritDoc + */ + public function insert(Table $table, array $row): void + { + $sql = sprintf( + 'INSERT INTO %s ', + $this->quoteTableName($table->getName()) + ); + $columns = array_keys($row); + $sql .= '(' . implode(', ', array_map([$this, 'quoteColumnName'], $columns)) . ')'; + + foreach ($row as $column => $value) { + if (is_bool($value)) { + $row[$column] = $this->castToBool($value); + } + } + + if ($this->isDryRunEnabled()) { + $sql .= ' VALUES (' . implode(', ', array_map([$this, 'quoteValue'], $row)) . ');'; + $this->output->writeln($sql); + } else { + $sql .= ' VALUES (' . implode(', ', array_fill(0, count($columns), '?')) . ')'; + $stmt = $this->getConnection()->prepare($sql); + $stmt->execute(array_values($row)); + } + } + + /** + * Quotes a database value. + * + * @param mixed $value The value to quote + * @return mixed + */ + protected function quoteValue($value) + { + if (is_numeric($value)) { + return $value; + } + + if ($value === null) { + return 'null'; + } + + return $this->getConnection()->quote($value); + } + + /** + * Quotes a database string. + * + * @param string $value The string to quote + * @return string + */ + protected function quoteString(string $value): string + { + return $this->getConnection()->quote($value); + } + + /** + * @inheritDoc + */ + public function bulkinsert(Table $table, array $rows): void + { + $sql = sprintf( + 'INSERT INTO %s ', + $this->quoteTableName($table->getName()) + ); + $current = current($rows); + $keys = array_keys($current); + $sql .= '(' . implode(', ', array_map([$this, 'quoteColumnName'], $keys)) . ') VALUES '; + + if ($this->isDryRunEnabled()) { + $values = array_map(function ($row) { + return '(' . implode(', ', array_map([$this, 'quoteValue'], $row)) . ')'; + }, $rows); + $sql .= implode(', ', $values) . ';'; + $this->output->writeln($sql); + } else { + $count_keys = count($keys); + $query = '(' . implode(', ', array_fill(0, $count_keys, '?')) . ')'; + $count_vars = count($rows); + $queries = array_fill(0, $count_vars, $query); + $sql .= implode(',', $queries); + $stmt = $this->getConnection()->prepare($sql); + $vals = []; + + foreach ($rows as $row) { + foreach ($row as $v) { + if (is_bool($v)) { + $vals[] = $this->castToBool($v); + } else { + $vals[] = $v; + } + } + } + + $stmt->execute($vals); + } + } + + /** + * @inheritDoc + */ + public function getVersions(): array + { + $rows = $this->getVersionLog(); + + return array_keys($rows); + } + + /** + * {@inheritDoc} + * + * @throws \RuntimeException + */ + public function getVersionLog(): array + { + $result = []; + + switch ($this->options['version_order']) { + case Config::VERSION_ORDER_CREATION_TIME: + $orderBy = 'version ASC'; + break; + case Config::VERSION_ORDER_EXECUTION_TIME: + $orderBy = 'start_time ASC, version ASC'; + break; + default: + throw new RuntimeException('Invalid version_order configuration option'); + } + + // This will throw an exception if doing a --dry-run without any migrations as phinxlog + // does not exist, so in that case, we can just expect to trivially return empty set + try { + $rows = $this->fetchAll(sprintf('SELECT * FROM %s ORDER BY %s', $this->quoteTableName($this->getSchemaTableName()), $orderBy)); + } catch (PDOException $e) { + if (!$this->isDryRunEnabled()) { + throw $e; + } + $rows = []; + } + + foreach ($rows as $version) { + $result[(int)$version['version']] = $version; + } + + return $result; + } + + /** + * @inheritDoc + */ + public function migrated(MigrationInterface $migration, string $direction, string $startTime, string $endTime): AdapterInterface + { + if (strcasecmp($direction, MigrationInterface::UP) === 0) { + // up + $sql = sprintf( + "INSERT INTO %s (%s, %s, %s, %s, %s) VALUES ('%s', '%s', '%s', '%s', %s);", + $this->quoteTableName($this->getSchemaTableName()), + $this->quoteColumnName('version'), + $this->quoteColumnName('migration_name'), + $this->quoteColumnName('start_time'), + $this->quoteColumnName('end_time'), + $this->quoteColumnName('breakpoint'), + $migration->getVersion(), + substr($migration->getName(), 0, 100), + $startTime, + $endTime, + $this->castToBool(false) + ); + + $this->execute($sql); + } else { + // down + $sql = sprintf( + "DELETE FROM %s WHERE %s = '%s'", + $this->quoteTableName($this->getSchemaTableName()), + $this->quoteColumnName('version'), + $migration->getVersion() + ); + + $this->execute($sql); + } + + return $this; + } + + /** + * @inheritDoc + */ + public function toggleBreakpoint(MigrationInterface $migration): AdapterInterface + { + $this->query( + sprintf( + 'UPDATE %1$s SET %2$s = CASE %2$s WHEN %3$s THEN %4$s ELSE %3$s END, %7$s = %7$s WHERE %5$s = \'%6$s\';', + $this->quoteTableName($this->getSchemaTableName()), + $this->quoteColumnName('breakpoint'), + $this->castToBool(true), + $this->castToBool(false), + $this->quoteColumnName('version'), + $migration->getVersion(), + $this->quoteColumnName('start_time') + ) + ); + + return $this; + } + + /** + * @inheritDoc + */ + public function resetAllBreakpoints(): int + { + return $this->execute( + sprintf( + 'UPDATE %1$s SET %2$s = %3$s, %4$s = %4$s WHERE %2$s <> %3$s;', + $this->quoteTableName($this->getSchemaTableName()), + $this->quoteColumnName('breakpoint'), + $this->castToBool(false), + $this->quoteColumnName('start_time') + ) + ); + } + + /** + * @inheritDoc + */ + public function setBreakpoint(MigrationInterface $migration): AdapterInterface + { + $this->markBreakpoint($migration, true); + + return $this; + } + + /** + * @inheritDoc + */ + public function unsetBreakpoint(MigrationInterface $migration): AdapterInterface + { + $this->markBreakpoint($migration, false); + + return $this; + } + + /** + * Mark a migration breakpoint. + * + * @param \Phinx\Migration\MigrationInterface $migration The migration target for the breakpoint + * @param bool $state The required state of the breakpoint + * @return \Phinx\Db\Adapter\AdapterInterface + */ + protected function markBreakpoint(MigrationInterface $migration, bool $state): AdapterInterface + { + $this->query( + sprintf( + 'UPDATE %1$s SET %2$s = %3$s, %4$s = %4$s WHERE %5$s = \'%6$s\';', + $this->quoteTableName($this->getSchemaTableName()), + $this->quoteColumnName('breakpoint'), + $this->castToBool($state), + $this->quoteColumnName('start_time'), + $this->quoteColumnName('version'), + $migration->getVersion() + ) + ); + + return $this; + } + + /** + * {@inheritDoc} + * + * @throws \BadMethodCallException + * @return void + */ + public function createSchema(string $schemaName = 'public'): void + { + throw new BadMethodCallException('Creating a schema is not supported'); + } + + /** + * {@inheritDoc} + * + * @throws \BadMethodCallException + * @return void + */ + public function dropSchema(string $name): void + { + throw new BadMethodCallException('Dropping a schema is not supported'); + } + + /** + * @inheritDoc + */ + public function getColumnTypes(): array + { + return [ + 'string', + 'char', + 'text', + 'tinyinteger', + 'smallinteger', + 'integer', + 'biginteger', + 'bit', + 'float', + 'decimal', + 'double', + 'datetime', + 'timestamp', + 'time', + 'date', + 'blob', + 'binary', + 'varbinary', + 'boolean', + 'uuid', + // Geospatial data types + 'geometry', + 'point', + 'linestring', + 'polygon', + ]; + } + + /** + * @inheritDoc + */ + public function castToBool($value) + { + return (bool)$value ? 1 : 0; + } + + /** + * Retrieve a database connection attribute + * + * @see https://php.net/manual/en/pdo.getattribute.php + * @param int $attribute One of the PDO::ATTR_* constants + * @return mixed + */ + public function getAttribute(int $attribute) + { + return $this->connection->getAttribute($attribute); + } + + /** + * Get the definition for a `DEFAULT` statement. + * + * @param mixed $default Default value + * @param string|null $columnType column type added + * @return string + */ + protected function getDefaultValueDefinition($default, ?string $columnType = null): string + { + if ($default instanceof Literal) { + $default = (string)$default; + } elseif (is_string($default) && strpos($default, 'CURRENT_TIMESTAMP') !== 0) { + // Ensure a defaults of CURRENT_TIMESTAMP(3) is not quoted. + $default = $this->getConnection()->quote($default); + } elseif (is_bool($default)) { + $default = $this->castToBool($default); + } elseif ($default !== null && $columnType === static::PHINX_TYPE_BOOLEAN) { + $default = $this->castToBool((bool)$default); + } + + return isset($default) ? " DEFAULT $default" : ''; + } + + /** + * Executes all the ALTER TABLE instructions passed for the given table + * + * @param string $tableName The table name to use in the ALTER statement + * @param \Phinx\Db\Util\AlterInstructions $instructions The object containing the alter sequence + * @return void + */ + protected function executeAlterSteps(string $tableName, AlterInstructions $instructions): void + { + $alter = sprintf('ALTER TABLE %s %%s', $this->quoteTableName($tableName)); + $instructions->execute($alter, [$this, 'execute']); + } + + /** + * @inheritDoc + */ + public function addColumn(Table $table, Column $column): void + { + $instructions = $this->getAddColumnInstructions($table, $column); + $this->executeAlterSteps($table->getName(), $instructions); + } + + /** + * Returns the instructions to add the specified column to a database table. + * + * @param \Phinx\Db\Table\Table $table Table + * @param \Phinx\Db\Table\Column $column Column + * @return \Phinx\Db\Util\AlterInstructions + */ + abstract protected function getAddColumnInstructions(Table $table, Column $column): AlterInstructions; + + /** + * @inheritdoc + */ + public function renameColumn(string $tableName, string $columnName, string $newColumnName): void + { + $instructions = $this->getRenameColumnInstructions($tableName, $columnName, $newColumnName); + $this->executeAlterSteps($tableName, $instructions); + } + + /** + * Returns the instructions to rename the specified column. + * + * @param string $tableName Table name + * @param string $columnName Column Name + * @param string $newColumnName New Column Name + * @return \Phinx\Db\Util\AlterInstructions + */ + abstract protected function getRenameColumnInstructions(string $tableName, string $columnName, string $newColumnName): AlterInstructions; + + /** + * @inheritdoc + */ + public function changeColumn(string $tableName, string $columnName, Column $newColumn): void + { + $instructions = $this->getChangeColumnInstructions($tableName, $columnName, $newColumn); + $this->executeAlterSteps($tableName, $instructions); + } + + /** + * Returns the instructions to change a table column type. + * + * @param string $tableName Table name + * @param string $columnName Column Name + * @param \Phinx\Db\Table\Column $newColumn New Column + * @return \Phinx\Db\Util\AlterInstructions + */ + abstract protected function getChangeColumnInstructions(string $tableName, string $columnName, Column $newColumn): AlterInstructions; + + /** + * @inheritdoc + */ + public function dropColumn(string $tableName, string $columnName): void + { + $instructions = $this->getDropColumnInstructions($tableName, $columnName); + $this->executeAlterSteps($tableName, $instructions); + } + + /** + * Returns the instructions to drop the specified column. + * + * @param string $tableName Table name + * @param string $columnName Column Name + * @return \Phinx\Db\Util\AlterInstructions + */ + abstract protected function getDropColumnInstructions(string $tableName, string $columnName): AlterInstructions; + + /** + * @inheritdoc + */ + public function addIndex(Table $table, Index $index): void + { + $instructions = $this->getAddIndexInstructions($table, $index); + $this->executeAlterSteps($table->getName(), $instructions); + } + + /** + * Returns the instructions to add the specified index to a database table. + * + * @param \Phinx\Db\Table\Table $table Table + * @param \Phinx\Db\Table\Index $index Index + * @return \Phinx\Db\Util\AlterInstructions + */ + abstract protected function getAddIndexInstructions(Table $table, Index $index): AlterInstructions; + + /** + * @inheritdoc + */ + public function dropIndex(string $tableName, $columns): void + { + $instructions = $this->getDropIndexByColumnsInstructions($tableName, $columns); + $this->executeAlterSteps($tableName, $instructions); + } + + /** + * Returns the instructions to drop the specified index from a database table. + * + * @param string $tableName The name of of the table where the index is + * @param string|string[] $columns Column(s) + * @return \Phinx\Db\Util\AlterInstructions + */ + abstract protected function getDropIndexByColumnsInstructions(string $tableName, $columns): AlterInstructions; + + /** + * @inheritdoc + */ + public function dropIndexByName(string $tableName, string $indexName): void + { + $instructions = $this->getDropIndexByNameInstructions($tableName, $indexName); + $this->executeAlterSteps($tableName, $instructions); + } + + /** + * Returns the instructions to drop the index specified by name from a database table. + * + * @param string $tableName The table name whe the index is + * @param string $indexName The name of the index + * @return \Phinx\Db\Util\AlterInstructions + */ + abstract protected function getDropIndexByNameInstructions(string $tableName, string $indexName): AlterInstructions; + + /** + * @inheritdoc + */ + public function addForeignKey(Table $table, ForeignKey $foreignKey): void + { + $instructions = $this->getAddForeignKeyInstructions($table, $foreignKey); + $this->executeAlterSteps($table->getName(), $instructions); + } + + /** + * Returns the instructions to adds the specified foreign key to a database table. + * + * @param \Phinx\Db\Table\Table $table The table to add the constraint to + * @param \Phinx\Db\Table\ForeignKey $foreignKey The foreign key to add + * @return \Phinx\Db\Util\AlterInstructions + */ + abstract protected function getAddForeignKeyInstructions(Table $table, ForeignKey $foreignKey): AlterInstructions; + + /** + * @inheritDoc + */ + public function dropForeignKey(string $tableName, array $columns, ?string $constraint = null): void + { + if ($constraint) { + $instructions = $this->getDropForeignKeyInstructions($tableName, $constraint); + } else { + $instructions = $this->getDropForeignKeyByColumnsInstructions($tableName, $columns); + } + + $this->executeAlterSteps($tableName, $instructions); + } + + /** + * Returns the instructions to drop the specified foreign key from a database table. + * + * @param string $tableName The table where the foreign key constraint is + * @param string $constraint Constraint name + * @return \Phinx\Db\Util\AlterInstructions + */ + abstract protected function getDropForeignKeyInstructions(string $tableName, string $constraint): AlterInstructions; + + /** + * Returns the instructions to drop the specified foreign key from a database table. + * + * @param string $tableName The table where the foreign key constraint is + * @param string[] $columns The list of column names + * @return \Phinx\Db\Util\AlterInstructions + */ + abstract protected function getDropForeignKeyByColumnsInstructions(string $tableName, array $columns): AlterInstructions; + + /** + * @inheritdoc + */ + public function dropTable(string $tableName): void + { + $instructions = $this->getDropTableInstructions($tableName); + $this->executeAlterSteps($tableName, $instructions); + } + + /** + * Returns the instructions to drop the specified database table. + * + * @param string $tableName Table name + * @return \Phinx\Db\Util\AlterInstructions + */ + abstract protected function getDropTableInstructions(string $tableName): AlterInstructions; + + /** + * @inheritdoc + */ + public function renameTable(string $tableName, string $newTableName): void + { + $instructions = $this->getRenameTableInstructions($tableName, $newTableName); + $this->executeAlterSteps($tableName, $instructions); + } + + /** + * Returns the instructions to rename the specified database table. + * + * @param string $tableName Table name + * @param string $newTableName New Name + * @return \Phinx\Db\Util\AlterInstructions + */ + abstract protected function getRenameTableInstructions(string $tableName, string $newTableName): AlterInstructions; + + /** + * @inheritdoc + */ + public function changePrimaryKey(Table $table, $newColumns): void + { + $instructions = $this->getChangePrimaryKeyInstructions($table, $newColumns); + $this->executeAlterSteps($table->getName(), $instructions); + } + + /** + * Returns the instructions to change the primary key for the specified database table. + * + * @param \Phinx\Db\Table\Table $table Table + * @param string|string[]|null $newColumns Column name(s) to belong to the primary key, or null to drop the key + * @return \Phinx\Db\Util\AlterInstructions + */ + abstract protected function getChangePrimaryKeyInstructions(Table $table, $newColumns): AlterInstructions; + + /** + * @inheritdoc + */ + public function changeComment(Table $table, $newComment): void + { + $instructions = $this->getChangeCommentInstructions($table, $newComment); + $this->executeAlterSteps($table->getName(), $instructions); + } + + /** + * Returns the instruction to change the comment for the specified database table. + * + * @param \Phinx\Db\Table\Table $table Table + * @param string|null $newComment New comment string, or null to drop the comment + * @return \Phinx\Db\Util\AlterInstructions + */ + abstract protected function getChangeCommentInstructions(Table $table, ?string $newComment): AlterInstructions; + + /** + * {@inheritDoc} + * + * @throws \InvalidArgumentException + * @return void + */ + public function executeActions(Table $table, array $actions): void + { + $instructions = new AlterInstructions(); + + foreach ($actions as $action) { + switch (true) { + case $action instanceof AddColumn: + /** @var \Phinx\Db\Action\AddColumn $action */ + $instructions->merge($this->getAddColumnInstructions($table, $action->getColumn())); + break; + + case $action instanceof AddIndex: + /** @var \Phinx\Db\Action\AddIndex $action */ + $instructions->merge($this->getAddIndexInstructions($table, $action->getIndex())); + break; + + case $action instanceof AddForeignKey: + /** @var \Phinx\Db\Action\AddForeignKey $action */ + $instructions->merge($this->getAddForeignKeyInstructions($table, $action->getForeignKey())); + break; + + case $action instanceof ChangeColumn: + /** @var \Phinx\Db\Action\ChangeColumn $action */ + $instructions->merge($this->getChangeColumnInstructions( + $table->getName(), + $action->getColumnName(), + $action->getColumn() + )); + break; + + case $action instanceof DropForeignKey && !$action->getForeignKey()->getConstraint(): + /** @var \Phinx\Db\Action\DropForeignKey $action */ + $instructions->merge($this->getDropForeignKeyByColumnsInstructions( + $table->getName(), + $action->getForeignKey()->getColumns() + )); + break; + + case $action instanceof DropForeignKey && $action->getForeignKey()->getConstraint(): + /** @var \Phinx\Db\Action\DropForeignKey $action */ + $instructions->merge($this->getDropForeignKeyInstructions( + $table->getName(), + $action->getForeignKey()->getConstraint() + )); + break; + + case $action instanceof DropIndex && $action->getIndex()->getName() !== null: + /** @var \Phinx\Db\Action\DropIndex $action */ + $instructions->merge($this->getDropIndexByNameInstructions( + $table->getName(), + $action->getIndex()->getName() + )); + break; + + case $action instanceof DropIndex && $action->getIndex()->getName() == null: + /** @var \Phinx\Db\Action\DropIndex $action */ + $instructions->merge($this->getDropIndexByColumnsInstructions( + $table->getName(), + $action->getIndex()->getColumns() + )); + break; + + case $action instanceof DropTable: + /** @var \Phinx\Db\Action\DropTable $action */ + $instructions->merge($this->getDropTableInstructions( + $table->getName() + )); + break; + + case $action instanceof RemoveColumn: + /** @var \Phinx\Db\Action\RemoveColumn $action */ + $instructions->merge($this->getDropColumnInstructions( + $table->getName(), + $action->getColumn()->getName() + )); + break; + + case $action instanceof RenameColumn: + /** @var \Phinx\Db\Action\RenameColumn $action */ + $instructions->merge($this->getRenameColumnInstructions( + $table->getName(), + $action->getColumn()->getName(), + $action->getNewName() + )); + break; + + case $action instanceof RenameTable: + /** @var \Phinx\Db\Action\RenameTable $action */ + $instructions->merge($this->getRenameTableInstructions( + $table->getName(), + $action->getNewName() + )); + break; + + case $action instanceof ChangePrimaryKey: + /** @var \Phinx\Db\Action\ChangePrimaryKey $action */ + $instructions->merge($this->getChangePrimaryKeyInstructions( + $table, + $action->getNewColumns() + )); + break; + + case $action instanceof ChangeComment: + /** @var \Phinx\Db\Action\ChangeComment $action */ + $instructions->merge($this->getChangeCommentInstructions( + $table, + $action->getNewComment() + )); + break; + + default: + throw new InvalidArgumentException( + sprintf("Don't know how to execute action: '%s'", get_class($action)) + ); + } + } + + $this->executeAlterSteps($table->getName(), $instructions); + } +} diff --git a/extend/phinx/Db/Adapter/PostgresAdapter.php b/extend/phinx/Db/Adapter/PostgresAdapter.php new file mode 100644 index 0000000..1d17e5a --- /dev/null +++ b/extend/phinx/Db/Adapter/PostgresAdapter.php @@ -0,0 +1,1600 @@ += 10.0) + * + * @var bool + */ + protected $useIdentity; + + /** + * {@inheritDoc} + * + * @throws \RuntimeException + * @throws \InvalidArgumentException + * @return void + */ + public function connect(): void + { + if ($this->connection === null) { + if (!class_exists('PDO') || !in_array('pgsql', PDO::getAvailableDrivers(), true)) { + // @codeCoverageIgnoreStart + throw new RuntimeException('You need to enable the PDO_Pgsql extension for Phinx to run properly.'); + // @codeCoverageIgnoreEnd + } + + $options = $this->getOptions(); + $dsn = 'pgsql:dbname=' . $options['name']; + + if (isset($options['host'])) { + $dsn .= ';host=' . $options['host']; + } + + // if custom port is specified use it + if (isset($options['port'])) { + $dsn .= ';port=' . $options['port']; + } + + $driverOptions = []; + + // use custom data fetch mode + if (!empty($options['fetch_mode'])) { + $driverOptions[PDO::ATTR_DEFAULT_FETCH_MODE] = + constant('\PDO::FETCH_' . strtoupper($options['fetch_mode'])); + } + + // pass \PDO::ATTR_PERSISTENT to driver options instead of useless setting it after instantiation + if (isset($options['attr_persistent'])) { + $driverOptions[PDO::ATTR_PERSISTENT] = $options['attr_persistent']; + } + + $db = $this->createPdoConnection($dsn, $options['user'] ?? null, $options['pass'] ?? null, $driverOptions); + + try { + if (isset($options['schema'])) { + $db->exec('SET search_path TO ' . $this->quoteSchemaName($options['schema'])); + } + } catch (PDOException $exception) { + throw new InvalidArgumentException( + sprintf('Schema does not exists: %s', $options['schema']), + 0, + $exception + ); + } + + $this->useIdentity = (float)$db->getAttribute(PDO::ATTR_SERVER_VERSION) >= 10; + + $this->setConnection($db); + } + } + + /** + * @inheritDoc + */ + public function disconnect(): void + { + $this->connection = null; + } + + /** + * @inheritDoc + */ + public function hasTransactions(): bool + { + return true; + } + + /** + * @inheritDoc + */ + public function beginTransaction(): void + { + $this->execute('BEGIN'); + } + + /** + * @inheritDoc + */ + public function commitTransaction(): void + { + $this->execute('COMMIT'); + } + + /** + * @inheritDoc + */ + public function rollbackTransaction(): void + { + $this->execute('ROLLBACK'); + } + + /** + * Quotes a schema name for use in a query. + * + * @param string $schemaName Schema Name + * @return string + */ + public function quoteSchemaName(string $schemaName): string + { + return $this->quoteColumnName($schemaName); + } + + /** + * @inheritDoc + */ + public function quoteTableName(string $tableName): string + { + $parts = $this->getSchemaName($tableName); + + return $this->quoteSchemaName($parts['schema']) . '.' . $this->quoteColumnName($parts['table']); + } + + /** + * @inheritDoc + */ + public function quoteColumnName(string $columnName): string + { + return '"' . $columnName . '"'; + } + + /** + * @inheritDoc + */ + public function hasTable(string $tableName): bool + { + if ($this->hasCreatedTable($tableName)) { + return true; + } + + $parts = $this->getSchemaName($tableName); + $result = $this->getConnection()->query( + sprintf( + 'SELECT * + FROM information_schema.tables + WHERE table_schema = %s + AND table_name = %s', + $this->getConnection()->quote($parts['schema']), + $this->getConnection()->quote($parts['table']) + ) + ); + + return $result->rowCount() === 1; + } + + /** + * @inheritDoc + */ + public function createTable(Table $table, array $columns = [], array $indexes = []): void + { + $queries = []; + + $options = $table->getOptions(); + $parts = $this->getSchemaName($table->getName()); + + // Add the default primary key + if (!isset($options['id']) || (isset($options['id']) && $options['id'] === true)) { + $options['id'] = 'id'; + } + + if (isset($options['id']) && is_string($options['id'])) { + // Handle id => "field_name" to support AUTO_INCREMENT + $column = new Column(); + $column->setName($options['id']) + ->setType('integer') + ->setOptions(['identity' => true]); + + array_unshift($columns, $column); + if (isset($options['primary_key']) && (array)$options['id'] !== (array)$options['primary_key']) { + throw new InvalidArgumentException('You cannot enable an auto incrementing ID field and a primary key'); + } + $options['primary_key'] = $options['id']; + } + + // TODO - process table options like collation etc + $sql = 'CREATE TABLE '; + $sql .= $this->quoteTableName($table->getName()) . ' ('; + + $this->columnsWithComments = []; + foreach ($columns as $column) { + $sql .= $this->quoteColumnName($column->getName()) . ' ' . $this->getColumnSqlDefinition($column); + if ($this->useIdentity && $column->getIdentity() && $column->getGenerated() !== null) { + $sql .= sprintf(' GENERATED %s AS IDENTITY', $column->getGenerated()); + } + $sql .= ', '; + + // set column comments, if needed + if ($column->getComment()) { + $this->columnsWithComments[] = $column; + } + } + + // set the primary key(s) + if (isset($options['primary_key'])) { + $sql = rtrim($sql); + $sql .= sprintf(' CONSTRAINT %s PRIMARY KEY (', $this->quoteColumnName($parts['table'] . '_pkey')); + if (is_string($options['primary_key'])) { // handle primary_key => 'id' + $sql .= $this->quoteColumnName($options['primary_key']); + } elseif (is_array($options['primary_key'])) { // handle primary_key => array('tag_id', 'resource_id') + $sql .= implode(',', array_map([$this, 'quoteColumnName'], $options['primary_key'])); + } + $sql .= ')'; + } else { + $sql = rtrim($sql, ', '); // no primary keys + } + + $sql .= ')'; + $queries[] = $sql; + + // process column comments + if (!empty($this->columnsWithComments)) { + foreach ($this->columnsWithComments as $column) { + $queries[] = $this->getColumnCommentSqlDefinition($column, $table->getName()); + } + } + + // set the indexes + if (!empty($indexes)) { + foreach ($indexes as $index) { + $queries[] = $this->getIndexSqlDefinition($index, $table->getName()); + } + } + + // process table comments + if (isset($options['comment'])) { + $queries[] = sprintf( + 'COMMENT ON TABLE %s IS %s', + $this->quoteTableName($table->getName()), + $this->getConnection()->quote($options['comment']) + ); + } + + foreach ($queries as $query) { + $this->execute($query); + } + + $this->addCreatedTable($table->getName()); + } + + /** + * {@inheritDoc} + * + * @throws \InvalidArgumentException + */ + protected function getChangePrimaryKeyInstructions(Table $table, $newColumns): AlterInstructions + { + $parts = $this->getSchemaName($table->getName()); + + $instructions = new AlterInstructions(); + + // Drop the existing primary key + $primaryKey = $this->getPrimaryKey($table->getName()); + if (!empty($primaryKey['constraint'])) { + $sql = sprintf( + 'DROP CONSTRAINT %s', + $this->quoteColumnName($primaryKey['constraint']) + ); + $instructions->addAlter($sql); + } + + // Add the new primary key + if (!empty($newColumns)) { + $sql = sprintf( + 'ADD CONSTRAINT %s PRIMARY KEY (', + $this->quoteColumnName($parts['table'] . '_pkey') + ); + if (is_string($newColumns)) { // handle primary_key => 'id' + $sql .= $this->quoteColumnName($newColumns); + } elseif (is_array($newColumns)) { // handle primary_key => array('tag_id', 'resource_id') + $sql .= implode(',', array_map([$this, 'quoteColumnName'], $newColumns)); + } else { + throw new InvalidArgumentException(sprintf( + 'Invalid value for primary key: %s', + json_encode($newColumns) + )); + } + $sql .= ')'; + $instructions->addAlter($sql); + } + + return $instructions; + } + + /** + * @inheritDoc + */ + protected function getChangeCommentInstructions(Table $table, ?string $newComment): AlterInstructions + { + $instructions = new AlterInstructions(); + + // passing 'null' is to remove table comment + $newComment = $newComment !== null + ? $this->getConnection()->quote($newComment) + : 'NULL'; + $sql = sprintf( + 'COMMENT ON TABLE %s IS %s', + $this->quoteTableName($table->getName()), + $newComment + ); + $instructions->addPostStep($sql); + + return $instructions; + } + + /** + * @inheritDoc + */ + protected function getRenameTableInstructions(string $tableName, string $newTableName): AlterInstructions + { + $this->updateCreatedTableName($tableName, $newTableName); + $sql = sprintf( + 'ALTER TABLE %s RENAME TO %s', + $this->quoteTableName($tableName), + $this->quoteColumnName($newTableName) + ); + + return new AlterInstructions([], [$sql]); + } + + /** + * @inheritDoc + */ + protected function getDropTableInstructions(string $tableName): AlterInstructions + { + $this->removeCreatedTable($tableName); + $sql = sprintf('DROP TABLE %s', $this->quoteTableName($tableName)); + + return new AlterInstructions([], [$sql]); + } + + /** + * @inheritDoc + */ + public function truncateTable(string $tableName): void + { + $sql = sprintf( + 'TRUNCATE TABLE %s RESTART IDENTITY', + $this->quoteTableName($tableName) + ); + + $this->execute($sql); + } + + /** + * @inheritDoc + */ + public function getColumns(string $tableName): array + { + $parts = $this->getSchemaName($tableName); + $columns = []; + $sql = sprintf( + 'SELECT column_name, data_type, udt_name, is_identity, is_nullable, + column_default, character_maximum_length, numeric_precision, numeric_scale, + datetime_precision + %s + FROM information_schema.columns + WHERE table_schema = %s AND table_name = %s + ORDER BY ordinal_position', + $this->useIdentity ? ', identity_generation' : '', + $this->getConnection()->quote($parts['schema']), + $this->getConnection()->quote($parts['table']) + ); + $columnsInfo = $this->fetchAll($sql); + foreach ($columnsInfo as $columnInfo) { + $isUserDefined = strtoupper(trim($columnInfo['data_type'])) === 'USER-DEFINED'; + + if ($isUserDefined) { + $columnType = Literal::from($columnInfo['udt_name']); + } else { + $columnType = $this->getPhinxType($columnInfo['data_type']); + } + + // If the default value begins with a ' or looks like a function mark it as literal + if (isset($columnInfo['column_default'][0]) && $columnInfo['column_default'][0] === "'") { + if (preg_match('/^\'(.*)\'::[^:]+$/', $columnInfo['column_default'], $match)) { + // '' and \' are replaced with a single ' + $columnDefault = preg_replace('/[\'\\\\]\'/', "'", $match[1]); + } else { + $columnDefault = Literal::from($columnInfo['column_default']); + } + } elseif ( + $columnInfo['column_default'] !== null && + preg_match('/^\D[a-z_\d]*\(.*\)$/', $columnInfo['column_default']) + ) { + $columnDefault = Literal::from($columnInfo['column_default']); + } else { + $columnDefault = $columnInfo['column_default']; + } + + $column = new Column(); + + $column->setName($columnInfo['column_name']) + ->setType($columnType) + ->setNull($columnInfo['is_nullable'] === 'YES') + ->setDefault($columnDefault) + ->setIdentity($columnInfo['is_identity'] === 'YES') + ->setScale($columnInfo['numeric_scale']); + + if ($this->useIdentity) { + $column->setGenerated($columnInfo['identity_generation']); + } + + if (preg_match('/\bwith time zone$/', $columnInfo['data_type'])) { + $column->setTimezone(true); + } + + if (isset($columnInfo['character_maximum_length'])) { + $column->setLimit($columnInfo['character_maximum_length']); + } + + if (in_array($columnType, [static::PHINX_TYPE_TIME, static::PHINX_TYPE_DATETIME], true)) { + $column->setPrecision($columnInfo['datetime_precision']); + } elseif ( + !in_array($columnType, [ + self::PHINX_TYPE_SMALL_INTEGER, + self::PHINX_TYPE_INTEGER, + self::PHINX_TYPE_BIG_INTEGER, + ], true) + ) { + $column->setPrecision($columnInfo['numeric_precision']); + } + $columns[] = $column; + } + + return $columns; + } + + /** + * @inheritDoc + */ + public function hasColumn(string $tableName, string $columnName): bool + { + $parts = $this->getSchemaName($tableName); + $sql = sprintf( + 'SELECT count(*) + FROM information_schema.columns + WHERE table_schema = %s AND table_name = %s AND column_name = %s', + $this->getConnection()->quote($parts['schema']), + $this->getConnection()->quote($parts['table']), + $this->getConnection()->quote($columnName) + ); + + $result = $this->fetchRow($sql); + + return $result['count'] > 0; + } + + /** + * @inheritDoc + */ + protected function getAddColumnInstructions(Table $table, Column $column): AlterInstructions + { + $instructions = new AlterInstructions(); + $instructions->addAlter(sprintf( + 'ADD %s %s %s', + $this->quoteColumnName($column->getName()), + $this->getColumnSqlDefinition($column), + $column->isIdentity() && $column->getGenerated() !== null && $this->useIdentity ? + sprintf('GENERATED %s AS IDENTITY', $column->getGenerated()) : '' + )); + + if ($column->getComment()) { + $instructions->addPostStep($this->getColumnCommentSqlDefinition($column, $table->getName())); + } + + return $instructions; + } + + /** + * {@inheritDoc} + * + * @throws \InvalidArgumentException + */ + protected function getRenameColumnInstructions( + string $tableName, + string $columnName, + string $newColumnName + ): AlterInstructions { + $parts = $this->getSchemaName($tableName); + $sql = sprintf( + 'SELECT CASE WHEN COUNT(*) > 0 THEN 1 ELSE 0 END AS column_exists + FROM information_schema.columns + WHERE table_schema = %s AND table_name = %s AND column_name = %s', + $this->getConnection()->quote($parts['schema']), + $this->getConnection()->quote($parts['table']), + $this->getConnection()->quote($columnName) + ); + + $result = $this->fetchRow($sql); + if (!(bool)$result['column_exists']) { + throw new InvalidArgumentException("The specified column does not exist: $columnName"); + } + + $instructions = new AlterInstructions(); + $instructions->addPostStep( + sprintf( + 'ALTER TABLE %s RENAME COLUMN %s TO %s', + $this->quoteTableName($tableName), + $this->quoteColumnName($columnName), + $this->quoteColumnName($newColumnName) + ) + ); + + return $instructions; + } + + /** + * @inheritDoc + */ + protected function getChangeColumnInstructions( + string $tableName, + string $columnName, + Column $newColumn + ): AlterInstructions { + $quotedColumnName = $this->quoteColumnName($columnName); + $instructions = new AlterInstructions(); + if ($newColumn->getType() === 'boolean') { + $sql = sprintf('ALTER COLUMN %s DROP DEFAULT', $quotedColumnName); + $instructions->addAlter($sql); + } + $sql = sprintf( + 'ALTER COLUMN %s TYPE %s', + $quotedColumnName, + $this->getColumnSqlDefinition($newColumn) + ); + if (in_array($newColumn->getType(), ['smallinteger', 'integer', 'biginteger'], true)) { + $sql .= sprintf( + ' USING (%s::bigint)', + $quotedColumnName + ); + } + if ($newColumn->getType() === 'uuid') { + $sql .= sprintf( + ' USING (%s::uuid)', + $quotedColumnName + ); + } + //NULL and DEFAULT cannot be set while changing column type + $sql = preg_replace('/ NOT NULL/', '', $sql); + $sql = preg_replace('/ NULL/', '', $sql); + //If it is set, DEFAULT is the last definition + $sql = preg_replace('/DEFAULT .*/', '', $sql); + if ($newColumn->getType() === 'boolean') { + $sql .= sprintf( + ' USING (CASE WHEN %s IS NULL THEN NULL WHEN %s::int=0 THEN FALSE ELSE TRUE END)', + $quotedColumnName, + $quotedColumnName + ); + } + $instructions->addAlter($sql); + + $column = $this->getColumn($tableName, $columnName); + + if ($this->useIdentity) { + // process identity + $sql = sprintf( + 'ALTER COLUMN %s', + $quotedColumnName + ); + if ($newColumn->isIdentity() && $newColumn->getGenerated() !== null) { + if ($column->isIdentity()) { + $sql .= sprintf(' SET GENERATED %s', $newColumn->getGenerated()); + } else { + $sql .= sprintf(' ADD GENERATED %s AS IDENTITY', $newColumn->getGenerated()); + } + } else { + $sql .= ' DROP IDENTITY IF EXISTS'; + } + $instructions->addAlter($sql); + } + + // process null + $sql = sprintf( + 'ALTER COLUMN %s', + $quotedColumnName + ); + + if (!$newColumn->getIdentity() && !$column->getIdentity() && $newColumn->isNull()) { + $sql .= ' DROP NOT NULL'; + } else { + $sql .= ' SET NOT NULL'; + } + + $instructions->addAlter($sql); + + if ($newColumn->getDefault() !== null) { + $instructions->addAlter(sprintf( + 'ALTER COLUMN %s SET %s', + $quotedColumnName, + $this->getDefaultValueDefinition($newColumn->getDefault(), $newColumn->getType()) + )); + } elseif (!$newColumn->getIdentity()) { + //drop default + $instructions->addAlter(sprintf( + 'ALTER COLUMN %s DROP DEFAULT', + $quotedColumnName + )); + } + + // rename column + if ($columnName !== $newColumn->getName()) { + $instructions->addPostStep(sprintf( + 'ALTER TABLE %s RENAME COLUMN %s TO %s', + $this->quoteTableName($tableName), + $quotedColumnName, + $this->quoteColumnName($newColumn->getName()) + )); + } + + // change column comment if needed + if ($newColumn->getComment()) { + $instructions->addPostStep($this->getColumnCommentSqlDefinition($newColumn, $tableName)); + } + + return $instructions; + } + + /** + * @param string $tableName Table name + * @param string $columnName Column name + * @return ?\Phinx\Db\Table\Column + */ + protected function getColumn(string $tableName, string $columnName): ?Column + { + $columns = $this->getColumns($tableName); + foreach ($columns as $column) { + if ($column->getName() === $columnName) { + return $column; + } + } + + return null; + } + + /** + * @inheritDoc + */ + protected function getDropColumnInstructions(string $tableName, string $columnName): AlterInstructions + { + $alter = sprintf( + 'DROP COLUMN %s', + $this->quoteColumnName($columnName) + ); + + return new AlterInstructions([$alter]); + } + + /** + * Get an array of indexes from a particular table. + * + * @param string $tableName Table name + * @return array + */ + protected function getIndexes($tableName) + { + $parts = $this->getSchemaName($tableName); + + $indexes = []; + $sql = sprintf( + "SELECT + i.relname AS index_name, + a.attname AS column_name + FROM + pg_class t, + pg_class i, + pg_index ix, + pg_attribute a, + pg_namespace nsp + WHERE + t.oid = ix.indrelid + AND i.oid = ix.indexrelid + AND a.attrelid = t.oid + AND a.attnum = ANY(ix.indkey) + AND t.relnamespace = nsp.oid + AND nsp.nspname = %s + AND t.relkind = 'r' + AND t.relname = %s + ORDER BY + t.relname, + i.relname", + $this->getConnection()->quote($parts['schema']), + $this->getConnection()->quote($parts['table']) + ); + $rows = $this->fetchAll($sql); + foreach ($rows as $row) { + if (!isset($indexes[$row['index_name']])) { + $indexes[$row['index_name']] = ['columns' => []]; + } + $indexes[$row['index_name']]['columns'][] = $row['column_name']; + } + + return $indexes; + } + + /** + * @inheritDoc + */ + public function hasIndex(string $tableName, $columns): bool + { + if (is_string($columns)) { + $columns = [$columns]; + } + $indexes = $this->getIndexes($tableName); + foreach ($indexes as $index) { + if (array_diff($index['columns'], $columns) === array_diff($columns, $index['columns'])) { + return true; + } + } + + return false; + } + + /** + * @inheritDoc + */ + public function hasIndexByName(string $tableName, string $indexName): bool + { + $indexes = $this->getIndexes($tableName); + foreach ($indexes as $name => $index) { + if ($name === $indexName) { + return true; + } + } + + return false; + } + + /** + * @inheritDoc + */ + protected function getAddIndexInstructions(Table $table, Index $index): AlterInstructions + { + $instructions = new AlterInstructions(); + $instructions->addPostStep($this->getIndexSqlDefinition($index, $table->getName())); + + return $instructions; + } + + /** + * {@inheritDoc} + * + * @throws \InvalidArgumentException + */ + protected function getDropIndexByColumnsInstructions(string $tableName, $columns): AlterInstructions + { + $parts = $this->getSchemaName($tableName); + + if (is_string($columns)) { + $columns = [$columns]; // str to array + } + + $indexes = $this->getIndexes($tableName); + foreach ($indexes as $indexName => $index) { + $a = array_diff($columns, $index['columns']); + if (empty($a)) { + return new AlterInstructions([], [sprintf( + 'DROP INDEX IF EXISTS %s', + '"' . ($parts['schema'] . '".' . $this->quoteColumnName($indexName)) + )]); + } + } + + throw new InvalidArgumentException(sprintf( + "The specified index on columns '%s' does not exist", + implode(',', $columns) + )); + } + + /** + * @inheritDoc + */ + protected function getDropIndexByNameInstructions(string $tableName, string $indexName): AlterInstructions + { + $parts = $this->getSchemaName($tableName); + + $sql = sprintf( + 'DROP INDEX IF EXISTS %s', + '"' . ($parts['schema'] . '".' . $this->quoteColumnName($indexName)) + ); + + return new AlterInstructions([], [$sql]); + } + + /** + * @inheritDoc + */ + public function hasPrimaryKey(string $tableName, $columns, ?string $constraint = null): bool + { + $primaryKey = $this->getPrimaryKey($tableName); + + if (empty($primaryKey)) { + return false; + } + + if ($constraint) { + return $primaryKey['constraint'] === $constraint; + } else { + if (is_string($columns)) { + $columns = [$columns]; // str to array + } + $missingColumns = array_diff($columns, $primaryKey['columns']); + + return empty($missingColumns); + } + } + + /** + * Get the primary key from a particular table. + * + * @param string $tableName Table name + * @return array + */ + public function getPrimaryKey(string $tableName): array + { + $parts = $this->getSchemaName($tableName); + $rows = $this->fetchAll(sprintf( + "SELECT + tc.constraint_name, + kcu.column_name + FROM information_schema.table_constraints AS tc + JOIN information_schema.key_column_usage AS kcu + ON tc.constraint_name = kcu.constraint_name + WHERE constraint_type = 'PRIMARY KEY' + AND tc.table_schema = %s + AND tc.table_name = %s + ORDER BY kcu.position_in_unique_constraint", + $this->getConnection()->quote($parts['schema']), + $this->getConnection()->quote($parts['table']) + )); + + $primaryKey = [ + 'columns' => [], + ]; + foreach ($rows as $row) { + $primaryKey['constraint'] = $row['constraint_name']; + $primaryKey['columns'][] = $row['column_name']; + } + + return $primaryKey; + } + + /** + * @inheritDoc + */ + public function hasForeignKey(string $tableName, $columns, ?string $constraint = null): bool + { + if (is_string($columns)) { + $columns = [$columns]; // str to array + } + $foreignKeys = $this->getForeignKeys($tableName); + if ($constraint) { + if (isset($foreignKeys[$constraint])) { + return !empty($foreignKeys[$constraint]); + } + + return false; + } + + foreach ($foreignKeys as $key) { + $a = array_diff($columns, $key['columns']); + if (empty($a)) { + return true; + } + } + + return false; + } + + /** + * Get an array of foreign keys from a particular table. + * + * @param string $tableName Table name + * @return array + */ + protected function getForeignKeys(string $tableName): array + { + $parts = $this->getSchemaName($tableName); + $foreignKeys = []; + $rows = $this->fetchAll(sprintf( + "SELECT + tc.constraint_name, + tc.table_name, kcu.column_name, + ccu.table_name AS referenced_table_name, + ccu.column_name AS referenced_column_name + FROM + information_schema.table_constraints AS tc + JOIN information_schema.key_column_usage AS kcu ON tc.constraint_name = kcu.constraint_name + JOIN information_schema.constraint_column_usage AS ccu ON ccu.constraint_name = tc.constraint_name + WHERE constraint_type = 'FOREIGN KEY' AND tc.table_schema = %s AND tc.table_name = %s + ORDER BY kcu.position_in_unique_constraint", + $this->getConnection()->quote($parts['schema']), + $this->getConnection()->quote($parts['table']) + )); + foreach ($rows as $row) { + $foreignKeys[$row['constraint_name']]['table'] = $row['table_name']; + $foreignKeys[$row['constraint_name']]['columns'][] = $row['column_name']; + $foreignKeys[$row['constraint_name']]['referenced_table'] = $row['referenced_table_name']; + $foreignKeys[$row['constraint_name']]['referenced_columns'][] = $row['referenced_column_name']; + } + + return $foreignKeys; + } + + /** + * @inheritDoc + */ + protected function getAddForeignKeyInstructions(Table $table, ForeignKey $foreignKey): AlterInstructions + { + $alter = sprintf( + 'ADD %s', + $this->getForeignKeySqlDefinition($foreignKey, $table->getName()) + ); + + return new AlterInstructions([$alter]); + } + + /** + * @inheritDoc + */ + protected function getDropForeignKeyInstructions($tableName, $constraint): AlterInstructions + { + $alter = sprintf( + 'DROP CONSTRAINT %s', + $this->quoteColumnName($constraint) + ); + + return new AlterInstructions([$alter]); + } + + /** + * @inheritDoc + */ + protected function getDropForeignKeyByColumnsInstructions(string $tableName, array $columns): AlterInstructions + { + $instructions = new AlterInstructions(); + + $parts = $this->getSchemaName($tableName); + $sql = 'SELECT c.CONSTRAINT_NAME + FROM ( + SELECT CONSTRAINT_NAME, array_agg(COLUMN_NAME::varchar) as columns + FROM information_schema.KEY_COLUMN_USAGE + WHERE TABLE_SCHEMA = %s + AND TABLE_NAME IS NOT NULL + AND TABLE_NAME = %s + AND POSITION_IN_UNIQUE_CONSTRAINT IS NOT NULL + GROUP BY CONSTRAINT_NAME + ) c + WHERE + ARRAY[%s]::varchar[] <@ c.columns AND + ARRAY[%s]::varchar[] @> c.columns'; + + $array = []; + foreach ($columns as $col) { + $array[] = "'$col'"; + } + + $rows = $this->fetchAll(sprintf( + $sql, + $this->getConnection()->quote($parts['schema']), + $this->getConnection()->quote($parts['table']), + implode(',', $array), + implode(',', $array) + )); + + foreach ($rows as $row) { + $newInstr = $this->getDropForeignKeyInstructions($tableName, $row['constraint_name']); + $instructions->merge($newInstr); + } + + return $instructions; + } + + /** + * {@inheritDoc} + * + * @throws \Phinx\Db\Adapter\UnsupportedColumnTypeException + */ + public function getSqlType($type, ?int $limit = null): array + { + switch ($type) { + case static::PHINX_TYPE_TEXT: + case static::PHINX_TYPE_TIME: + case static::PHINX_TYPE_DATE: + case static::PHINX_TYPE_BOOLEAN: + case static::PHINX_TYPE_JSON: + case static::PHINX_TYPE_JSONB: + case static::PHINX_TYPE_UUID: + case static::PHINX_TYPE_CIDR: + case static::PHINX_TYPE_INET: + case static::PHINX_TYPE_MACADDR: + case static::PHINX_TYPE_TIMESTAMP: + case static::PHINX_TYPE_INTEGER: + return ['name' => $type]; + case static::PHINX_TYPE_TINY_INTEGER: + return ['name' => 'smallint']; + case static::PHINX_TYPE_SMALL_INTEGER: + return ['name' => 'smallint']; + case static::PHINX_TYPE_DECIMAL: + return ['name' => $type, 'precision' => 18, 'scale' => 0]; + case static::PHINX_TYPE_DOUBLE: + return ['name' => 'double precision']; + case static::PHINX_TYPE_STRING: + return ['name' => 'character varying', 'limit' => 255]; + case static::PHINX_TYPE_CHAR: + return ['name' => 'character', 'limit' => 255]; + case static::PHINX_TYPE_BIG_INTEGER: + return ['name' => 'bigint']; + case static::PHINX_TYPE_FLOAT: + return ['name' => 'real']; + case static::PHINX_TYPE_DATETIME: + return ['name' => 'timestamp']; + case static::PHINX_TYPE_BINARYUUID: + return ['name' => 'uuid']; + case static::PHINX_TYPE_BLOB: + case static::PHINX_TYPE_BINARY: + return ['name' => 'bytea']; + case static::PHINX_TYPE_INTERVAL: + return ['name' => 'interval']; + // Geospatial database types + // Spatial storage in Postgres is done via the PostGIS extension, + // which enables the use of the "geography" type in combination + // with SRID 4326. + case static::PHINX_TYPE_GEOMETRY: + return ['name' => 'geography', 'type' => 'geometry', 'srid' => 4326]; + case static::PHINX_TYPE_POINT: + return ['name' => 'geography', 'type' => 'point', 'srid' => 4326]; + case static::PHINX_TYPE_LINESTRING: + return ['name' => 'geography', 'type' => 'linestring', 'srid' => 4326]; + case static::PHINX_TYPE_POLYGON: + return ['name' => 'geography', 'type' => 'polygon', 'srid' => 4326]; + default: + if ($this->isArrayType($type)) { + return ['name' => $type]; + } + // Return array type + throw new UnsupportedColumnTypeException('Column type `' . $type . '` is not supported by Postgresql.'); + } + } + + /** + * Returns Phinx type by SQL type + * + * @param string $sqlType SQL type + * @throws \Phinx\Db\Adapter\UnsupportedColumnTypeException + * @return string Phinx type + */ + public function getPhinxType(string $sqlType): string + { + switch ($sqlType) { + case 'character varying': + case 'varchar': + return static::PHINX_TYPE_STRING; + case 'character': + case 'char': + return static::PHINX_TYPE_CHAR; + case 'text': + return static::PHINX_TYPE_TEXT; + case 'json': + return static::PHINX_TYPE_JSON; + case 'jsonb': + return static::PHINX_TYPE_JSONB; + case 'smallint': + return static::PHINX_TYPE_SMALL_INTEGER; + case 'int': + case 'int4': + case 'integer': + return static::PHINX_TYPE_INTEGER; + case 'decimal': + case 'numeric': + return static::PHINX_TYPE_DECIMAL; + case 'bigint': + case 'int8': + return static::PHINX_TYPE_BIG_INTEGER; + case 'real': + case 'float4': + return static::PHINX_TYPE_FLOAT; + case 'double precision': + return static::PHINX_TYPE_DOUBLE; + case 'bytea': + return static::PHINX_TYPE_BINARY; + case 'interval': + return static::PHINX_TYPE_INTERVAL; + case 'time': + case 'timetz': + case 'time with time zone': + case 'time without time zone': + return static::PHINX_TYPE_TIME; + case 'date': + return static::PHINX_TYPE_DATE; + case 'timestamp': + case 'timestamptz': + case 'timestamp with time zone': + case 'timestamp without time zone': + return static::PHINX_TYPE_DATETIME; + case 'bool': + case 'boolean': + return static::PHINX_TYPE_BOOLEAN; + case 'uuid': + return static::PHINX_TYPE_UUID; + case 'cidr': + return static::PHINX_TYPE_CIDR; + case 'inet': + return static::PHINX_TYPE_INET; + case 'macaddr': + return static::PHINX_TYPE_MACADDR; + default: + throw new UnsupportedColumnTypeException( + 'Column type `' . $sqlType . '` is not supported by Postgresql.' + ); + } + } + + /** + * @inheritDoc + */ + public function createDatabase(string $name, array $options = []): void + { + $charset = $options['charset'] ?? 'utf8'; + $this->execute(sprintf("CREATE DATABASE %s WITH ENCODING = '%s'", $name, $charset)); + } + + /** + * @inheritDoc + */ + public function hasDatabase(string $name): bool + { + $sql = sprintf("SELECT count(*) FROM pg_database WHERE datname = '%s'", $name); + $result = $this->fetchRow($sql); + + return $result['count'] > 0; + } + + /** + * @inheritDoc + */ + public function dropDatabase($name): void + { + $this->disconnect(); + $this->execute(sprintf('DROP DATABASE IF EXISTS %s', $name)); + $this->createdTables = []; + $this->connect(); + } + + /** + * Gets the PostgreSQL Column Definition for a Column object. + * + * @param \Phinx\Db\Table\Column $column Column + * @return string + */ + protected function getColumnSqlDefinition(Column $column): string + { + $buffer = []; + + if ($column->isIdentity() && (!$this->useIdentity || $column->getGenerated() === null)) { + if ($column->getType() === 'smallinteger') { + $buffer[] = 'SMALLSERIAL'; + } elseif ($column->getType() === 'biginteger') { + $buffer[] = 'BIGSERIAL'; + } else { + $buffer[] = 'SERIAL'; + } + } elseif ($column->getType() instanceof Literal) { + $buffer[] = (string)$column->getType(); + } else { + $sqlType = $this->getSqlType($column->getType(), $column->getLimit()); + $buffer[] = strtoupper($sqlType['name']); + + // integers cant have limits in postgres + if ($sqlType['name'] === static::PHINX_TYPE_DECIMAL && ($column->getPrecision() || $column->getScale())) { + $buffer[] = sprintf( + '(%s, %s)', + $column->getPrecision() ?: $sqlType['precision'], + $column->getScale() ?: $sqlType['scale'] + ); + } elseif ($sqlType['name'] === self::PHINX_TYPE_GEOMETRY) { + // geography type must be written with geometry type and srid, like this: geography(POLYGON,4326) + $buffer[] = sprintf( + '(%s,%s)', + strtoupper($sqlType['type']), + $column->getSrid() ?: $sqlType['srid'] + ); + } elseif (in_array($sqlType['name'], [self::PHINX_TYPE_TIME, self::PHINX_TYPE_TIMESTAMP], true)) { + if (is_numeric($column->getPrecision())) { + $buffer[] = sprintf('(%s)', $column->getPrecision()); + } + + if ($column->isTimezone()) { + $buffer[] = strtoupper('with time zone'); + } + } elseif ( + !in_array($column->getType(), [ + self::PHINX_TYPE_TINY_INTEGER, + self::PHINX_TYPE_SMALL_INTEGER, + self::PHINX_TYPE_INTEGER, + self::PHINX_TYPE_BIG_INTEGER, + self::PHINX_TYPE_BOOLEAN, + self::PHINX_TYPE_TEXT, + self::PHINX_TYPE_BINARY, + ], true) + ) { + if ($column->getLimit() || isset($sqlType['limit'])) { + $buffer[] = sprintf('(%s)', $column->getLimit() ?: $sqlType['limit']); + } + } + } + + $buffer[] = $column->isNull() ? 'NULL' : 'NOT NULL'; + + if ($column->getDefault() !== null) { + $buffer[] = $this->getDefaultValueDefinition($column->getDefault(), $column->getType()); + } + + return implode(' ', $buffer); + } + + /** + * Gets the PostgreSQL Column Comment Definition for a column object. + * + * @param \Phinx\Db\Table\Column $column Column + * @param string $tableName Table name + * @return string + */ + protected function getColumnCommentSqlDefinition(Column $column, string $tableName): string + { + // passing 'null' is to remove column comment + $comment = strcasecmp($column->getComment(), 'NULL') !== 0 + ? $this->getConnection()->quote($column->getComment()) + : 'NULL'; + + return sprintf( + 'COMMENT ON COLUMN %s.%s IS %s;', + $this->quoteTableName($tableName), + $this->quoteColumnName($column->getName()), + $comment + ); + } + + /** + * Gets the PostgreSQL Index Definition for an Index object. + * + * @param \Phinx\Db\Table\Index $index Index + * @param string $tableName Table name + * @return string + */ + protected function getIndexSqlDefinition(Index $index, string $tableName): string + { + $parts = $this->getSchemaName($tableName); + $columnNames = $index->getColumns(); + + if (is_string($index->getName())) { + $indexName = $index->getName(); + } else { + $indexName = sprintf('%s_%s', $parts['table'], implode('_', $columnNames)); + } + + $order = $index->getOrder() ?? []; + $columnNames = array_map(function ($columnName) use ($order) { + $ret = '"' . $columnName . '"'; + if (isset($order[$columnName])) { + $ret .= ' ' . $order[$columnName]; + } + + return $ret; + }, $columnNames); + + $includedColumns = $index->getInclude() ? sprintf('INCLUDE ("%s")', implode('","', $index->getInclude())) : ''; + + $createIndexSentence = 'CREATE %s INDEX %s ON %s '; + if ($index->getType() === self::GIN_INDEX_TYPE) { + $createIndexSentence .= ' USING ' . $index->getType() . '(%s) %s;'; + } else { + $createIndexSentence .= '(%s) %s;'; + } + + return sprintf( + $createIndexSentence, + ($index->getType() === Index::UNIQUE ? 'UNIQUE' : ''), + $this->quoteColumnName($indexName), + $this->quoteTableName($tableName), + implode(',', $columnNames), + $includedColumns + ); + } + + /** + * Gets the MySQL Foreign Key Definition for an ForeignKey object. + * + * @param \Phinx\Db\Table\ForeignKey $foreignKey Foreign key + * @param string $tableName Table name + * @return string + */ + protected function getForeignKeySqlDefinition(ForeignKey $foreignKey, string $tableName): string + { + $parts = $this->getSchemaName($tableName); + + $constraintName = $foreignKey->getConstraint() ?: ( + $parts['table'] . '_' . implode('_', $foreignKey->getColumns()) . '_fkey' + ); + $def = ' CONSTRAINT ' . $this->quoteColumnName($constraintName) . + ' FOREIGN KEY ("' . implode('", "', $foreignKey->getColumns()) . '")' . + " REFERENCES {$this->quoteTableName($foreignKey->getReferencedTable()->getName())} (\"" . + implode('", "', $foreignKey->getReferencedColumns()) . '")'; + if ($foreignKey->getOnDelete()) { + $def .= " ON DELETE {$foreignKey->getOnDelete()}"; + } + if ($foreignKey->getOnUpdate()) { + $def .= " ON UPDATE {$foreignKey->getOnUpdate()}"; + } + + return $def; + } + + /** + * @inheritDoc + */ + public function createSchemaTable(): void + { + // Create the public/custom schema if it doesn't already exist + if ($this->hasSchema($this->getGlobalSchemaName()) === false) { + $this->createSchema($this->getGlobalSchemaName()); + } + + $this->setSearchPath(); + + parent::createSchemaTable(); + } + + /** + * @inheritDoc + */ + public function getVersions(): array + { + $this->setSearchPath(); + + return parent::getVersions(); + } + + /** + * @inheritDoc + */ + public function getVersionLog(): array + { + $this->setSearchPath(); + + return parent::getVersionLog(); + } + + /** + * Creates the specified schema. + * + * @param string $schemaName Schema Name + * @return void + */ + public function createSchema(string $schemaName = 'public'): void + { + // from postgres 9.3 we can use "CREATE SCHEMA IF NOT EXISTS schema_name" + $sql = sprintf('CREATE SCHEMA IF NOT EXISTS %s', $this->quoteSchemaName($schemaName)); + $this->execute($sql); + } + + /** + * Checks to see if a schema exists. + * + * @param string $schemaName Schema Name + * @return bool + */ + public function hasSchema(string $schemaName): bool + { + $sql = sprintf( + 'SELECT count(*) + FROM pg_namespace + WHERE nspname = %s', + $this->getConnection()->quote($schemaName) + ); + $result = $this->fetchRow($sql); + + return $result['count'] > 0; + } + + /** + * Drops the specified schema table. + * + * @param string $schemaName Schema name + * @return void + */ + public function dropSchema(string $schemaName): void + { + $sql = sprintf('DROP SCHEMA IF EXISTS %s CASCADE', $this->quoteSchemaName($schemaName)); + $this->execute($sql); + + foreach ($this->createdTables as $idx => $createdTable) { + if ($this->getSchemaName($createdTable)['schema'] === $this->quoteSchemaName($schemaName)) { + unset($this->createdTables[$idx]); + } + } + } + + /** + * Drops all schemas. + * + * @return void + */ + public function dropAllSchemas(): void + { + foreach ($this->getAllSchemas() as $schema) { + $this->dropSchema($schema); + } + } + + /** + * Returns schemas. + * + * @return array + */ + public function getAllSchemas(): array + { + $sql = "SELECT schema_name + FROM information_schema.schemata + WHERE schema_name <> 'information_schema' AND schema_name !~ '^pg_'"; + $items = $this->fetchAll($sql); + $schemaNames = []; + foreach ($items as $item) { + $schemaNames[] = $item['schema_name']; + } + + return $schemaNames; + } + + /** + * @inheritDoc + */ + public function getColumnTypes(): array + { + return array_merge(parent::getColumnTypes(), static::$specificColumnTypes); + } + + /** + * @inheritDoc + */ + public function isValidColumnType(Column $column): bool + { + // If not a standard column type, maybe it is array type? + return parent::isValidColumnType($column) || $this->isArrayType($column->getType()); + } + + /** + * Check if the given column is an array of a valid type. + * + * @param string|\Phinx\Util\Literal $columnType Column type + * @return bool + */ + protected function isArrayType($columnType): bool + { + if (!preg_match('/^([a-z]+)(?:\[\]){1,}$/', $columnType, $matches)) { + return false; + } + + $baseType = $matches[1]; + + return in_array($baseType, $this->getColumnTypes(), true); + } + + /** + * @param string $tableName Table name + * @return array + */ + protected function getSchemaName(string $tableName): array + { + $schema = $this->getGlobalSchemaName(); + $table = $tableName; + if (strpos($tableName, '.') !== false) { + [$schema, $table] = explode('.', $tableName); + } + + return [ + 'schema' => $schema, + 'table' => $table, + ]; + } + + /** + * Gets the schema name. + * + * @return string + */ + protected function getGlobalSchemaName(): string + { + $options = $this->getOptions(); + + return empty($options['schema']) ? 'public' : $options['schema']; + } + + /** + * @inheritDoc + */ + public function castToBool($value) + { + return (bool)$value ? 'TRUE' : 'FALSE'; + } + + /** + * @inheritDoc + */ + public function getDecoratedConnection(): Connection + { + $options = $this->getOptions(); + $options = [ + 'username' => $options['user'] ?? null, + 'password' => $options['pass'] ?? null, + 'database' => $options['name'], + 'quoteIdentifiers' => true, + ] + $options; + + $driver = new PostgresDriver($options); + + $driver->setConnection($this->connection); + + return new Connection(['driver' => $driver] + $options); + } + + /** + * Sets search path of schemas to look through for a table + * + * @return void + */ + public function setSearchPath(): void + { + $this->execute( + sprintf( + 'SET search_path TO %s,"$user",public', + $this->quoteSchemaName($this->getGlobalSchemaName()) + ) + ); + } +} diff --git a/extend/phinx/Db/Adapter/ProxyAdapter.php b/extend/phinx/Db/Adapter/ProxyAdapter.php new file mode 100644 index 0000000..979700c --- /dev/null +++ b/extend/phinx/Db/Adapter/ProxyAdapter.php @@ -0,0 +1,129 @@ + + */ +class ProxyAdapter extends AdapterWrapper +{ + /** + * @var \Phinx\Db\Action\Action[] + */ + protected $commands = []; + + /** + * @inheritDoc + */ + public function getAdapterType(): string + { + return 'ProxyAdapter'; + } + + /** + * @inheritDoc + */ + public function createTable(Table $table, array $columns = [], array $indexes = []): void + { + $this->commands[] = new CreateTable($table); + } + + /** + * @inheritDoc + */ + public function executeActions(Table $table, array $actions): void + { + $this->commands = array_merge($this->commands, $actions); + } + + /** + * Gets an array of the recorded commands in reverse. + * + * @throws \Phinx\Migration\IrreversibleMigrationException if a command cannot be reversed. + * @return \Phinx\Db\Plan\Intent + */ + public function getInvertedCommands(): Intent + { + $inverted = new Intent(); + + foreach (array_reverse($this->commands) as $command) { + switch (true) { + case $command instanceof CreateTable: + /** @var \Phinx\Db\Action\CreateTable $command */ + $inverted->addAction(new DropTable($command->getTable())); + break; + + case $command instanceof RenameTable: + /** @var \Phinx\Db\Action\RenameTable $command */ + $inverted->addAction(new RenameTable(new Table($command->getNewName()), $command->getTable()->getName())); + break; + + case $command instanceof AddColumn: + /** @var \Phinx\Db\Action\AddColumn $command */ + $inverted->addAction(new RemoveColumn($command->getTable(), $command->getColumn())); + break; + + case $command instanceof RenameColumn: + /** @var \Phinx\Db\Action\RenameColumn $command */ + $column = clone $command->getColumn(); + $name = $column->getName(); + $column->setName($command->getNewName()); + $inverted->addAction(new RenameColumn($command->getTable(), $column, $name)); + break; + + case $command instanceof AddIndex: + /** @var \Phinx\Db\Action\AddIndex $command */ + $inverted->addAction(new DropIndex($command->getTable(), $command->getIndex())); + break; + + case $command instanceof AddForeignKey: + /** @var \Phinx\Db\Action\AddForeignKey $command */ + $inverted->addAction(new DropForeignKey($command->getTable(), $command->getForeignKey())); + break; + + default: + throw new IrreversibleMigrationException(sprintf( + 'Cannot reverse a "%s" command', + get_class($command) + )); + } + } + + return $inverted; + } + + /** + * Execute the recorded commands in reverse. + * + * @return void + */ + public function executeInvertedCommands(): void + { + $plan = new Plan($this->getInvertedCommands()); + $plan->executeInverse($this->getAdapter()); + } +} diff --git a/extend/phinx/Db/Adapter/SQLiteAdapter.php b/extend/phinx/Db/Adapter/SQLiteAdapter.php new file mode 100644 index 0000000..f249071 --- /dev/null +++ b/extend/phinx/Db/Adapter/SQLiteAdapter.php @@ -0,0 +1,1767 @@ + + * @author Richard McIntyre + */ +class SQLiteAdapter extends PdoAdapter +{ + public const MEMORY = ':memory:'; + + /** + * List of supported Phinx column types with their SQL equivalents + * some types have an affinity appended to ensure they do not receive NUMERIC affinity + * + * @var string[] + */ + protected static $supportedColumnTypes = [ + self::PHINX_TYPE_BIG_INTEGER => 'biginteger', + self::PHINX_TYPE_BINARY => 'binary_blob', + self::PHINX_TYPE_BINARYUUID => 'binary_blob', + self::PHINX_TYPE_BLOB => 'blob', + self::PHINX_TYPE_BOOLEAN => 'boolean_integer', + self::PHINX_TYPE_CHAR => 'char', + self::PHINX_TYPE_DATE => 'date_text', + self::PHINX_TYPE_DATETIME => 'datetime_text', + self::PHINX_TYPE_DECIMAL => 'decimal', + self::PHINX_TYPE_DOUBLE => 'double', + self::PHINX_TYPE_FLOAT => 'float', + self::PHINX_TYPE_INTEGER => 'integer', + self::PHINX_TYPE_JSON => 'json_text', + self::PHINX_TYPE_JSONB => 'jsonb_text', + self::PHINX_TYPE_SMALL_INTEGER => 'smallinteger', + self::PHINX_TYPE_STRING => 'varchar', + self::PHINX_TYPE_TEXT => 'text', + self::PHINX_TYPE_TIME => 'time_text', + self::PHINX_TYPE_TIMESTAMP => 'timestamp_text', + self::PHINX_TYPE_TINY_INTEGER => 'tinyinteger', + self::PHINX_TYPE_UUID => 'uuid_text', + self::PHINX_TYPE_VARBINARY => 'varbinary_blob', + ]; + + /** + * List of aliases of supported column types + * + * @var string[] + */ + protected static $supportedColumnTypeAliases = [ + 'varchar' => self::PHINX_TYPE_STRING, + 'tinyint' => self::PHINX_TYPE_TINY_INTEGER, + 'tinyinteger' => self::PHINX_TYPE_TINY_INTEGER, + 'smallint' => self::PHINX_TYPE_SMALL_INTEGER, + 'int' => self::PHINX_TYPE_INTEGER, + 'mediumint' => self::PHINX_TYPE_INTEGER, + 'mediuminteger' => self::PHINX_TYPE_INTEGER, + 'bigint' => self::PHINX_TYPE_BIG_INTEGER, + 'tinytext' => self::PHINX_TYPE_TEXT, + 'mediumtext' => self::PHINX_TYPE_TEXT, + 'longtext' => self::PHINX_TYPE_TEXT, + 'tinyblob' => self::PHINX_TYPE_BLOB, + 'mediumblob' => self::PHINX_TYPE_BLOB, + 'longblob' => self::PHINX_TYPE_BLOB, + 'real' => self::PHINX_TYPE_FLOAT, + ]; + + /** + * List of known but unsupported Phinx column types + * + * @var string[] + */ + protected static $unsupportedColumnTypes = [ + self::PHINX_TYPE_BIT, + self::PHINX_TYPE_CIDR, + self::PHINX_TYPE_ENUM, + self::PHINX_TYPE_FILESTREAM, + self::PHINX_TYPE_GEOMETRY, + self::PHINX_TYPE_INET, + self::PHINX_TYPE_INTERVAL, + self::PHINX_TYPE_LINESTRING, + self::PHINX_TYPE_MACADDR, + self::PHINX_TYPE_POINT, + self::PHINX_TYPE_POLYGON, + self::PHINX_TYPE_SET, + ]; + + /** + * @var string[] + */ + protected $definitionsWithLimits = [ + 'CHAR', + 'CHARACTER', + 'VARCHAR', + 'VARYING CHARACTER', + 'NCHAR', + 'NATIVE CHARACTER', + 'NVARCHAR', + ]; + + /** + * @var string + */ + protected $suffix = '.sqlite3'; + + /** + * Indicates whether the database library version is at least the specified version + * + * @param string $ver The version to check against e.g. '3.28.0' + * @return bool + */ + public function databaseVersionAtLeast($ver): bool + { + $actual = $this->query('SELECT sqlite_version()')->fetchColumn(); + + return version_compare($actual, $ver, '>='); + } + + /** + * {@inheritDoc} + * + * @throws \RuntimeException + * @throws \InvalidArgumentException + * @return void + */ + public function connect(): void + { + if ($this->connection === null) { + if (!class_exists('PDO') || !in_array('sqlite', PDO::getAvailableDrivers(), true)) { + // @codeCoverageIgnoreStart + throw new RuntimeException('You need to enable the PDO_SQLITE extension for Phinx to run properly.'); + // @codeCoverageIgnoreEnd + } + + $options = $this->getOptions(); + + if (PHP_VERSION_ID < 80100 && (!empty($options['mode']) || !empty($options['cache']))) { + throw new RuntimeException('SQLite URI support requires PHP 8.1.'); + } elseif ((!empty($options['mode']) || !empty($options['cache'])) && !empty($options['memory'])) { + throw new RuntimeException('Memory must not be set when cache or mode are.'); + } elseif (PHP_VERSION_ID >= 80100 && (!empty($options['mode']) || !empty($options['cache']))) { + $params = []; + if (!empty($options['cache'])) { + $params[] = 'cache=' . $options['cache']; + } + if (!empty($options['mode'])) { + $params[] = 'mode=' . $options['mode']; + } + $dsn = 'sqlite:file:' . ($options['name'] ?? '') . '?' . implode('&', $params); + } else { + // use a memory database if the option was specified + if (!empty($options['memory']) || $options['name'] === static::MEMORY) { + $dsn = 'sqlite:' . static::MEMORY; + } else { + $dsn = 'sqlite:' . $options['name'] . $this->suffix; + } + } + + $driverOptions = []; + + // use custom data fetch mode + if (!empty($options['fetch_mode'])) { + $driverOptions[PDO::ATTR_DEFAULT_FETCH_MODE] = constant('\PDO::FETCH_' . strtoupper($options['fetch_mode'])); + } + + // pass \PDO::ATTR_PERSISTENT to driver options instead of useless setting it after instantiation + if (isset($options['attr_persistent'])) { + $driverOptions[PDO::ATTR_PERSISTENT] = $options['attr_persistent']; + } + + $db = $this->createPdoConnection($dsn, null, null, $driverOptions); + + $this->setConnection($db); + } + } + + /** + * @inheritDoc + */ + public function setOptions(array $options): AdapterInterface + { + parent::setOptions($options); + + if (isset($options['suffix'])) { + $this->suffix = $options['suffix']; + } + //don't "fix" the file extension if it is blank, some people + //might want a SQLITE db file with absolutely no extension. + if ($this->suffix !== '' && strpos($this->suffix, '.') !== 0) { + $this->suffix = '.' . $this->suffix; + } + + return $this; + } + + /** + * @inheritDoc + */ + public function disconnect(): void + { + $this->connection = null; + } + + /** + * @inheritDoc + */ + public function hasTransactions(): bool + { + return true; + } + + /** + * @inheritDoc + */ + public function beginTransaction(): void + { + $this->getConnection()->beginTransaction(); + } + + /** + * @inheritDoc + */ + public function commitTransaction(): void + { + $this->getConnection()->commit(); + } + + /** + * @inheritDoc + */ + public function rollbackTransaction(): void + { + $this->getConnection()->rollBack(); + } + + /** + * @inheritDoc + */ + public function quoteTableName($tableName): string + { + return str_replace('.', '`.`', $this->quoteColumnName($tableName)); + } + + /** + * @inheritDoc + */ + public function quoteColumnName($columnName): string + { + return '`' . str_replace('`', '``', $columnName) . '`'; + } + + /** + * @param string $tableName Table name + * @param bool $quoted Whether to return the schema name and table name escaped and quoted. If quoted, the schema (if any) will also be appended with a dot + * @return array + */ + protected function getSchemaName(string $tableName, bool $quoted = false): array + { + if (preg_match("/.\.([^\.]+)$/", $tableName, $match)) { + $table = $match[1]; + $schema = substr($tableName, 0, strlen($tableName) - strlen($match[0]) + 1); + $result = ['schema' => $schema, 'table' => $table]; + } else { + $result = ['schema' => '', 'table' => $tableName]; + } + + if ($quoted) { + $result['schema'] = $result['schema'] !== '' ? $this->quoteColumnName($result['schema']) . '.' : ''; + $result['table'] = $this->quoteColumnName($result['table']); + } + + return $result; + } + + /** + * Retrieves information about a given table from one of the SQLite pragmas + * + * @param string $tableName The table to query + * @param string $pragma The pragma to query + * @return array + */ + protected function getTableInfo(string $tableName, string $pragma = 'table_info'): array + { + $info = $this->getSchemaName($tableName, true); + + return $this->fetchAll(sprintf('PRAGMA %s%s(%s)', $info['schema'], $pragma, $info['table'])); + } + + /** + * Searches through all available schemata to find a table and returns an array + * containing the bare schema name and whether the table exists at all. + * If no schema was specified and the table does not exist the "main" schema is returned + * + * @param string $tableName The name of the table to find + * @return array + */ + protected function resolveTable(string $tableName): array + { + $info = $this->getSchemaName($tableName); + if ($info['schema'] === '') { + // if no schema is specified we search all schemata + $rows = $this->fetchAll('PRAGMA database_list;'); + // the temp schema is always first to be searched + $schemata = ['temp']; + foreach ($rows as $row) { + if (strtolower($row['name']) !== 'temp') { + $schemata[] = $row['name']; + } + } + $defaultSchema = 'main'; + } else { + // otherwise we search just the specified schema + $schemata = (array)$info['schema']; + $defaultSchema = $info['schema']; + } + + $table = strtolower($info['table']); + foreach ($schemata as $schema) { + if (strtolower($schema) === 'temp') { + $master = 'sqlite_temp_master'; + } else { + $master = sprintf('%s.%s', $this->quoteColumnName($schema), 'sqlite_master'); + } + try { + $rows = $this->fetchAll(sprintf("SELECT name FROM %s WHERE type='table' AND lower(name) = %s", $master, $this->quoteString($table))); + } catch (PDOException $e) { + // an exception can occur if the schema part of the table refers to a database which is not attached + break; + } + + // this somewhat pedantic check with strtolower is performed because the SQL lower function may be redefined, + // and can act on all Unicode characters if the ICU extension is loaded, while SQL identifiers are only case-insensitive for ASCII + foreach ($rows as $row) { + if (strtolower($row['name']) === $table) { + return ['schema' => $schema, 'table' => $row['name'], 'exists' => true]; + } + } + } + + return ['schema' => $defaultSchema, 'table' => $info['table'], 'exists' => false]; + } + + /** + * @inheritDoc + */ + public function hasTable(string $tableName): bool + { + return $this->hasCreatedTable($tableName) || $this->resolveTable($tableName)['exists']; + } + + /** + * @inheritDoc + */ + public function createTable(Table $table, array $columns = [], array $indexes = []): void + { + // Add the default primary key + $options = $table->getOptions(); + if (!isset($options['id']) || (isset($options['id']) && $options['id'] === true)) { + $options['id'] = 'id'; + } + + if (isset($options['id']) && is_string($options['id'])) { + // Handle id => "field_name" to support AUTO_INCREMENT + $column = new Column(); + $column->setName($options['id']) + ->setType('integer') + ->setOptions(['identity' => true]); + + array_unshift($columns, $column); + } + + $sql = 'CREATE TABLE '; + $sql .= $this->quoteTableName($table->getName()) . ' ('; + foreach ($columns as $column) { + $sql .= $this->quoteColumnName($column->getName()) . ' ' . $this->getColumnSqlDefinition($column) . ', '; + + if (isset($options['primary_key']) && $column->getIdentity()) { + //remove column from the primary key array as it is already defined as an autoincrement + //primary id + $identityColumnIndex = array_search($column->getName(), $options['primary_key'], true); + if ($identityColumnIndex !== false) { + unset($options['primary_key'][$identityColumnIndex]); + + if (empty($options['primary_key'])) { + //The last primary key has been removed + unset($options['primary_key']); + } + } + } + } + + // set the primary key(s) + if (isset($options['primary_key'])) { + $sql = rtrim($sql); + $sql .= ' PRIMARY KEY ('; + if (is_string($options['primary_key'])) { // handle primary_key => 'id' + $sql .= $this->quoteColumnName($options['primary_key']); + } elseif (is_array($options['primary_key'])) { // handle primary_key => array('tag_id', 'resource_id') + $sql .= implode(',', array_map([$this, 'quoteColumnName'], $options['primary_key'])); + } + $sql .= ')'; + } else { + $sql = substr(rtrim($sql), 0, -1); // no primary keys + } + + $sql = rtrim($sql) . ');'; + // execute the sql + $this->execute($sql); + + foreach ($indexes as $index) { + $this->addIndex($table, $index); + } + + $this->addCreatedTable($table->getName()); + } + + /** + * {@inheritDoc} + * + * @throws \InvalidArgumentException + */ + protected function getChangePrimaryKeyInstructions(Table $table, $newColumns): AlterInstructions + { + $instructions = new AlterInstructions(); + + // Drop the existing primary key + $primaryKey = $this->getPrimaryKey($table->getName()); + if (!empty($primaryKey)) { + $instructions->merge( + // FIXME: array access is a hack to make this incomplete implementation work with a correct getPrimaryKey implementation + $this->getDropPrimaryKeyInstructions($table, $primaryKey[0], false) + ); + } + + // Add the primary key(s) + if (!empty($newColumns)) { + if (!is_string($newColumns)) { + throw new InvalidArgumentException(sprintf( + 'Invalid value for primary key: %s', + json_encode($newColumns) + )); + } + + $instructions->merge( + $this->getAddPrimaryKeyInstructions($table, $newColumns) + ); + } + + return $instructions; + } + + /** + * {@inheritDoc} + * + * SQLiteAdapter does not implement this functionality, and so will always throw an exception if used. + * + * @throws \BadMethodCallException + */ + protected function getChangeCommentInstructions(Table $table, $newComment): AlterInstructions + { + throw new BadMethodCallException('SQLite does not have table comments'); + } + + /** + * @inheritDoc + */ + protected function getRenameTableInstructions(string $tableName, string $newTableName): AlterInstructions + { + $this->updateCreatedTableName($tableName, $newTableName); + $sql = sprintf( + 'ALTER TABLE %s RENAME TO %s', + $this->quoteTableName($tableName), + $this->quoteTableName($newTableName) + ); + + return new AlterInstructions([], [$sql]); + } + + /** + * @inheritDoc + */ + protected function getDropTableInstructions(string $tableName): AlterInstructions + { + $this->removeCreatedTable($tableName); + $sql = sprintf('DROP TABLE %s', $this->quoteTableName($tableName)); + + return new AlterInstructions([], [$sql]); + } + + /** + * @inheritDoc + */ + public function truncateTable(string $tableName): void + { + $info = $this->resolveTable($tableName); + // first try deleting the rows + $this->execute(sprintf( + 'DELETE FROM %s.%s', + $this->quoteColumnName($info['schema']), + $this->quoteColumnName($info['table']) + )); + + // assuming no error occurred, reset the autoincrement (if any) + if ($this->hasTable($info['schema'] . '.sqlite_sequence')) { + $this->execute(sprintf( + 'DELETE FROM %s.%s where name = %s', + $this->quoteColumnName($info['schema']), + 'sqlite_sequence', + $this->quoteString($info['table']) + )); + } + } + + /** + * Parses a default-value expression to yield either a Literal representing + * a string value, a string representing an expression, or some other scalar + * + * @param mixed $default The default-value expression to interpret + * @param string $columnType The Phinx type of the column + * @return mixed + */ + protected function parseDefaultValue($default, string $columnType) + { + if ($default === null) { + return null; + } + + // split the input into tokens + $trimChars = " \t\n\r\0\x0B"; + $pattern = <<getTableInfo($tableName) as $col) { + $type = strtolower($col['type']); + if ($col['pk'] > 1) { + // the table has a composite primary key + return null; + } elseif ($col['pk'] == 0) { + // the column is not a primary key column and is thus not relevant + continue; + } elseif ($type !== 'integer') { + // if the primary key's type is not exactly INTEGER, it cannot be a row ID alias + return null; + } else { + // the column is a candidate for a row ID alias + $result = $col['name']; + } + } + // if there is no suitable PK column, stop now + if ($result === null) { + return null; + } + // make sure the table does not have a PK-origin autoindex + // such an autoindex would indicate either that the primary key was specified as descending, or that this is a WITHOUT ROWID table + foreach ($this->getTableInfo($tableName, 'index_list') as $idx) { + if ($idx['origin'] === 'pk') { + return null; + } + } + + return $result; + } + + /** + * @inheritDoc + */ + public function getColumns(string $tableName): array + { + $columns = []; + + $rows = $this->getTableInfo($tableName); + $identity = $this->resolveIdentity($tableName); + + foreach ($rows as $columnInfo) { + $column = new Column(); + $type = $this->getPhinxType($columnInfo['type']); + $default = $this->parseDefaultValue($columnInfo['dflt_value'], $type['name']); + + $column->setName($columnInfo['name']) + // SQLite on PHP 8.1 returns int for notnull, older versions return a string + ->setNull((int)$columnInfo['notnull'] !== 1) + ->setDefault($default) + ->setType($type['name']) + ->setLimit($type['limit']) + ->setScale($type['scale']) + ->setIdentity($columnInfo['name'] === $identity); + + $columns[] = $column; + } + + return $columns; + } + + /** + * @inheritDoc + */ + public function hasColumn(string $tableName, string $columnName): bool + { + $rows = $this->getTableInfo($tableName); + foreach ($rows as $column) { + if (strcasecmp($column['name'], $columnName) === 0) { + return true; + } + } + + return false; + } + + /** + * @inheritDoc + */ + protected function getAddColumnInstructions(Table $table, Column $column): AlterInstructions + { + $tableName = $table->getName(); + + $instructions = $this->beginAlterByCopyTable($tableName); + + $instructions->addPostStep(function ($state) use ($tableName, $column) { + // we use the final column to anchor our regex to insert the new column, + // as the alternative is unwinding all possible table constraints which + // gets messy quickly with CHECK constraints. + $columns = $this->getColumns($tableName); + if (!$columns) { + return $state; + } + $finalColumnName = end($columns)->getName(); + $sql = preg_replace( + sprintf( + "/(%s(?:\/\*.*?\*\/|\([^)]+\)|'[^']*?'|[^,])+)([,)])/", + $this->quoteColumnName($finalColumnName) + ), + sprintf( + '$1, %s %s$2', + $this->quoteColumnName($column->getName()), + $this->getColumnSqlDefinition($column) + ), + $state['createSQL'], + 1 + ); + $this->execute($sql); + + return $state; + }); + + $instructions->addPostStep(function ($state) use ($tableName) { + $newState = $this->calculateNewTableColumns($tableName, false, false); + + return $newState + $state; + }); + + return $this->copyAndDropTmpTable($instructions, $tableName); + } + + /** + * Returns the original CREATE statement for the give table + * + * @param string $tableName The table name to get the create statement for + * @return string + */ + protected function getDeclaringSql(string $tableName): string + { + $rows = $this->fetchAll("SELECT * FROM sqlite_master WHERE `type` = 'table'"); + + $sql = ''; + foreach ($rows as $table) { + if ($table['tbl_name'] === $tableName) { + $sql = $table['sql']; + } + } + + $columnsInfo = $this->getTableInfo($tableName); + + foreach ($columnsInfo as $column) { + $columnName = $column['name']; + $columnNamePattern = "\"$columnName\"|`$columnName`|\\[$columnName\\]|$columnName"; + $columnNamePattern = "#([\(,]+\\s*)($columnNamePattern)(\\s)#iU"; + + $sql = preg_replace($columnNamePattern, "$1`$columnName`$3", $sql); + } + + $tableNamePattern = "\"$tableName\"|`$tableName`|\\[$tableName\\]|$tableName"; + $tableNamePattern = "#^(CREATE TABLE)\s*($tableNamePattern)\s*(\()#Ui"; + + $sql = preg_replace($tableNamePattern, "$1 `$tableName` $3", $sql, 1); + + return $sql; + } + + /** + * Returns the original CREATE statement for the give index + * + * @param string $tableName The table name to get the create statement for + * @param string $indexName The table index + * @return string + */ + protected function getDeclaringIndexSql(string $tableName, string $indexName): string + { + $rows = $this->fetchAll("SELECT * FROM sqlite_master WHERE `type` = 'index'"); + + $sql = ''; + foreach ($rows as $table) { + if ($table['tbl_name'] === $tableName && $table['name'] === $indexName) { + $sql = $table['sql'] . '; '; + } + } + + return $sql; + } + + /** + * Copies all the data from a tmp table to another table + * + * @param string $tableName The table name to copy the data to + * @param string $tmpTableName The tmp table name where the data is stored + * @param string[] $writeColumns The list of columns in the target table + * @param string[] $selectColumns The list of columns in the tmp table + * @return void + */ + protected function copyDataToNewTable(string $tableName, string $tmpTableName, array $writeColumns, array $selectColumns): void + { + $sql = sprintf( + 'INSERT INTO %s(%s) SELECT %s FROM %s', + $this->quoteTableName($tableName), + implode(', ', $writeColumns), + implode(', ', $selectColumns), + $this->quoteTableName($tmpTableName) + ); + $this->execute($sql); + } + + /** + * Modifies the passed instructions to copy all data from the table into + * the provided tmp table and then drops the table and rename tmp table. + * + * @param \Phinx\Db\Util\AlterInstructions $instructions The instructions to modify + * @param string $tableName The table name to copy the data to + * @param bool $validateForeignKeys Whether to validate foreign keys after the copy and drop operations. Note that + * enabling this option only has an effect when the `foreign_keys` PRAGMA is set to `ON`! + * @return \Phinx\Db\Util\AlterInstructions + */ + protected function copyAndDropTmpTable( + AlterInstructions $instructions, + string $tableName, + bool $validateForeignKeys = true + ): AlterInstructions { + $instructions->addPostStep(function ($state) use ($tableName, $validateForeignKeys) { + $this->copyDataToNewTable( + $state['tmpTableName'], + $tableName, + $state['writeColumns'], + $state['selectColumns'] + ); + + $rows = $this->fetchAll( + sprintf( + " + SELECT * + FROM sqlite_master + WHERE + (`type` = 'index' OR `type` = 'trigger') + AND tbl_name = %s + AND sql IS NOT NULL + ", + $this->quoteValue($tableName) + ) + ); + + $foreignKeysEnabled = (bool)$this->fetchRow('PRAGMA foreign_keys')['foreign_keys']; + if ($foreignKeysEnabled) { + $this->execute('PRAGMA foreign_keys = OFF'); + } + $this->execute(sprintf('DROP TABLE %s', $this->quoteTableName($tableName))); + if ($foreignKeysEnabled) { + $this->execute('PRAGMA foreign_keys = ON'); + } + + $this->execute(sprintf( + 'ALTER TABLE %s RENAME TO %s', + $this->quoteTableName($state['tmpTableName']), + $this->quoteTableName($tableName) + )); + + foreach ($rows as $row) { + $this->execute($row['sql']); + } + + if ( + $foreignKeysEnabled && + $validateForeignKeys + ) { + $this->validateForeignKeys($tableName); + } + + return $state; + }); + + return $instructions; + } + + /** + * Validates the foreign key constraints of the given table, and of those + * tables whose constraints are targeting it. + * + * @param string $tableName The name of the table for which to check constraints. + * @return void + * @throws \RuntimeException In case of a foreign key constraint violation. + */ + protected function validateForeignKeys(string $tableName): void + { + $tablesToCheck = [ + $tableName, + ]; + + $otherTables = $this + ->query( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name != ?", + [$tableName] + ) + ->fetchAll(); + + foreach ($otherTables as $otherTable) { + $foreignKeyList = $this->getTableInfo($otherTable['name'], 'foreign_key_list'); + foreach ($foreignKeyList as $foreignKey) { + if (strcasecmp($foreignKey['table'], $tableName) === 0) { + $tablesToCheck[] = $otherTable['name']; + break; + } + } + } + + $tablesToCheck = array_unique(array_map('strtolower', $tablesToCheck)); + + foreach ($tablesToCheck as $tableToCheck) { + $schema = $this->getSchemaName($tableToCheck, true)['schema']; + + $stmt = $this->query( + sprintf('PRAGMA %sforeign_key_check(%s)', $schema, $this->quoteTableName($tableToCheck)) + ); + $row = $stmt->fetch(); + $stmt->closeCursor(); + + if (is_array($row)) { + throw new RuntimeException(sprintf( + 'Integrity constraint violation: FOREIGN KEY constraint on `%s` failed.', + $tableToCheck + )); + } + } + } + + /** + * Returns the columns and type to use when copying a table to another in the process + * of altering a table + * + * @param string $tableName The table to modify + * @param string|false $columnName The column name that is about to change + * @param string|false $newColumnName Optionally the new name for the column + * @throws \InvalidArgumentException + * @return array + */ + protected function calculateNewTableColumns(string $tableName, $columnName, $newColumnName): array + { + $columns = $this->fetchAll(sprintf('pragma table_info(%s)', $this->quoteTableName($tableName))); + $selectColumns = []; + $writeColumns = []; + $columnType = null; + $found = false; + + foreach ($columns as $column) { + $selectName = $column['name']; + $writeName = $selectName; + + if ($selectName === $columnName) { + $writeName = $newColumnName; + $found = true; + $columnType = $column['type']; + $selectName = $newColumnName === false ? $newColumnName : $selectName; + } + + $selectColumns[] = $selectName; + $writeColumns[] = $writeName; + } + + $selectColumns = array_filter($selectColumns, 'strlen'); + $writeColumns = array_filter($writeColumns, 'strlen'); + $selectColumns = array_map([$this, 'quoteColumnName'], $selectColumns); + $writeColumns = array_map([$this, 'quoteColumnName'], $writeColumns); + + if ($columnName && !$found) { + throw new InvalidArgumentException(sprintf( + 'The specified column doesn\'t exist: ' . $columnName + )); + } + + return compact('writeColumns', 'selectColumns', 'columnType'); + } + + /** + * Returns the initial instructions to alter a table using the + * create-copy-drop strategy + * + * @param string $tableName The table to modify + * @return \Phinx\Db\Util\AlterInstructions + */ + protected function beginAlterByCopyTable(string $tableName): AlterInstructions + { + $instructions = new AlterInstructions(); + $instructions->addPostStep(function ($state) use ($tableName) { + $tmpTableName = "tmp_{$tableName}"; + $createSQL = $this->getDeclaringSql($tableName); + + // Table name in SQLite can be hilarious inside declaring SQL: + // - tableName + // - `tableName` + // - "tableName" + // - [this is a valid table name too!] + // - etc. + // Just remove all characters before first "(" and build them again + $createSQL = preg_replace( + "/^CREATE TABLE .* \(/Ui", + '', + $createSQL + ); + + $createSQL = "CREATE TABLE {$this->quoteTableName($tmpTableName)} ({$createSQL}"; + + return compact('createSQL', 'tmpTableName') + $state; + }); + + return $instructions; + } + + /** + * @inheritDoc + */ + protected function getRenameColumnInstructions(string $tableName, string $columnName, string $newColumnName): AlterInstructions + { + $instructions = $this->beginAlterByCopyTable($tableName); + + $instructions->addPostStep(function ($state) use ($columnName, $newColumnName) { + $sql = str_replace( + $this->quoteColumnName($columnName), + $this->quoteColumnName($newColumnName), + $state['createSQL'] + ); + $this->execute($sql); + + return $state; + }); + + $instructions->addPostStep(function ($state) use ($columnName, $newColumnName, $tableName) { + $newState = $this->calculateNewTableColumns($tableName, $columnName, $newColumnName); + + return $newState + $state; + }); + + return $this->copyAndDropTmpTable($instructions, $tableName); + } + + /** + * @inheritDoc + */ + protected function getChangeColumnInstructions(string $tableName, string $columnName, Column $newColumn): AlterInstructions + { + $instructions = $this->beginAlterByCopyTable($tableName); + + $newColumnName = $newColumn->getName(); + $instructions->addPostStep(function ($state) use ($columnName, $newColumn) { + $sql = preg_replace( + sprintf("/%s(?:\/\*.*?\*\/|\([^)]+\)|'[^']*?'|[^,])+([,)])/", $this->quoteColumnName($columnName)), + sprintf('%s %s$1', $this->quoteColumnName($newColumn->getName()), $this->getColumnSqlDefinition($newColumn)), + $state['createSQL'], + 1 + ); + $this->execute($sql); + + return $state; + }); + + $instructions->addPostStep(function ($state) use ($columnName, $newColumnName, $tableName) { + $newState = $this->calculateNewTableColumns($tableName, $columnName, $newColumnName); + + return $newState + $state; + }); + + return $this->copyAndDropTmpTable($instructions, $tableName); + } + + /** + * @inheritDoc + */ + protected function getDropColumnInstructions(string $tableName, string $columnName): AlterInstructions + { + $instructions = $this->beginAlterByCopyTable($tableName); + + $instructions->addPostStep(function ($state) use ($tableName, $columnName) { + $newState = $this->calculateNewTableColumns($tableName, $columnName, false); + + return $newState + $state; + }); + + $instructions->addPostStep(function ($state) use ($columnName) { + $sql = preg_replace( + sprintf("/%s\s%s.*(,\s(?!')|\)$)/U", preg_quote($this->quoteColumnName($columnName)), preg_quote($state['columnType'])), + '', + $state['createSQL'] + ); + + if (substr($sql, -2) === ', ') { + $sql = substr($sql, 0, -2) . ')'; + } + + $this->execute($sql); + + return $state; + }); + + return $this->copyAndDropTmpTable($instructions, $tableName); + } + + /** + * Get an array of indexes from a particular table. + * + * @param string $tableName Table name + * @return array + */ + protected function getIndexes(string $tableName): array + { + $indexes = []; + $schema = $this->getSchemaName($tableName, true)['schema']; + $indexList = $this->getTableInfo($tableName, 'index_list'); + + foreach ($indexList as $index) { + $indexData = $this->fetchAll(sprintf('pragma %sindex_info(%s)', $schema, $this->quoteColumnName($index['name']))); + $cols = []; + foreach ($indexData as $indexItem) { + $cols[] = $indexItem['name']; + } + $indexes[$index['name']] = $cols; + } + + return $indexes; + } + + /** + * Finds the names of a table's indexes matching the supplied columns + * + * @param string $tableName The table to which the index belongs + * @param string|string[] $columns The columns of the index + * @return array + */ + protected function resolveIndex(string $tableName, $columns): array + { + $columns = array_map('strtolower', (array)$columns); + $indexes = $this->getIndexes($tableName); + $matches = []; + + foreach ($indexes as $name => $index) { + $indexCols = array_map('strtolower', $index); + if ($columns == $indexCols) { + $matches[] = $name; + } + } + + return $matches; + } + + /** + * @inheritDoc + */ + public function hasIndex(string $tableName, $columns): bool + { + return (bool)$this->resolveIndex($tableName, $columns); + } + + /** + * @inheritDoc + */ + public function hasIndexByName(string $tableName, string $indexName): bool + { + $indexName = strtolower($indexName); + $indexes = $this->getIndexes($tableName); + + foreach (array_keys($indexes) as $index) { + if ($indexName === strtolower($index)) { + return true; + } + } + + return false; + } + + /** + * @inheritDoc + */ + protected function getAddIndexInstructions(Table $table, Index $index): AlterInstructions + { + $indexColumnArray = []; + foreach ($index->getColumns() as $column) { + $indexColumnArray[] = sprintf('`%s` ASC', $column); + } + $indexColumns = implode(',', $indexColumnArray); + $sql = sprintf( + 'CREATE %s ON %s (%s)', + $this->getIndexSqlDefinition($table, $index), + $this->quoteTableName($table->getName()), + $indexColumns + ); + + return new AlterInstructions([], [$sql]); + } + + /** + * @inheritDoc + */ + protected function getDropIndexByColumnsInstructions(string $tableName, $columns): AlterInstructions + { + $instructions = new AlterInstructions(); + $indexNames = $this->resolveIndex($tableName, $columns); + $schema = $this->getSchemaName($tableName, true)['schema']; + foreach ($indexNames as $indexName) { + if (strpos($indexName, 'sqlite_autoindex_') !== 0) { + $instructions->addPostStep(sprintf( + 'DROP INDEX %s%s', + $schema, + $this->quoteColumnName($indexName) + )); + } + } + + return $instructions; + } + + /** + * @inheritDoc + */ + protected function getDropIndexByNameInstructions(string $tableName, string $indexName): AlterInstructions + { + $instructions = new AlterInstructions(); + $indexName = strtolower($indexName); + $indexes = $this->getIndexes($tableName); + + $found = false; + foreach (array_keys($indexes) as $index) { + if ($indexName === strtolower($index)) { + $found = true; + break; + } + } + + if ($found) { + $schema = $this->getSchemaName($tableName, true)['schema']; + $instructions->addPostStep(sprintf( + 'DROP INDEX %s%s', + $schema, + $this->quoteColumnName($indexName) + )); + } + + return $instructions; + } + + /** + * {@inheritDoc} + * + * @throws \InvalidArgumentException + */ + public function hasPrimaryKey(string $tableName, $columns, ?string $constraint = null): bool + { + if ($constraint !== null) { + throw new InvalidArgumentException('SQLite does not support named constraints.'); + } + + $columns = array_map('strtolower', (array)$columns); + $primaryKey = array_map('strtolower', $this->getPrimaryKey($tableName)); + + if (array_diff($primaryKey, $columns) || array_diff($columns, $primaryKey)) { + return false; + } + + return true; + } + + /** + * Get the primary key from a particular table. + * + * @param string $tableName Table name + * @return string[] + */ + protected function getPrimaryKey(string $tableName): array + { + $primaryKey = []; + + $rows = $this->getTableInfo($tableName); + + foreach ($rows as $row) { + if ($row['pk'] > 0) { + $primaryKey[$row['pk'] - 1] = $row['name']; + } + } + + return $primaryKey; + } + + /** + * @inheritDoc + */ + public function hasForeignKey(string $tableName, $columns, ?string $constraint = null): bool + { + if ($constraint !== null) { + return preg_match( + "/,?\sCONSTRAINT\s" . preg_quote($this->quoteColumnName($constraint)) . ' FOREIGN KEY/', + $this->getDeclaringSql($tableName) + ) === 1; + } + + $columns = array_map('strtolower', (array)$columns); + $foreignKeys = $this->getForeignKeys($tableName); + + foreach ($foreignKeys as $key) { + $key = array_map('strtolower', $key); + if (array_diff($key, $columns) || array_diff($columns, $key)) { + continue; + } + + return true; + } + + return false; + } + + /** + * Get an array of foreign keys from a particular table. + * + * @param string $tableName Table name + * @return array + */ + protected function getForeignKeys(string $tableName): array + { + $foreignKeys = []; + + $rows = $this->getTableInfo($tableName, 'foreign_key_list'); + + foreach ($rows as $row) { + if (!isset($foreignKeys[$row['id']])) { + $foreignKeys[$row['id']] = []; + } + $foreignKeys[$row['id']][$row['seq']] = $row['from']; + } + + return $foreignKeys; + } + + /** + * @param \Phinx\Db\Table\Table $table The Table + * @param string $column Column Name + * @return \Phinx\Db\Util\AlterInstructions + */ + protected function getAddPrimaryKeyInstructions(Table $table, string $column): AlterInstructions + { + $instructions = $this->beginAlterByCopyTable($table->getName()); + + $tableName = $table->getName(); + $instructions->addPostStep(function ($state) use ($column) { + $matchPattern = "/(`$column`)\s+(\w+(\(\d+\))?)\s+((NOT )?NULL)/"; + + $sql = $state['createSQL']; + + if (preg_match($matchPattern, $state['createSQL'], $matches)) { + if (isset($matches[2])) { + if ($matches[2] === 'INTEGER') { + $replace = '$1 INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT'; + } else { + $replace = '$1 $2 NOT NULL PRIMARY KEY'; + } + + $sql = preg_replace($matchPattern, $replace, $state['createSQL'], 1); + } + } + + $this->execute($sql); + + return $state; + }); + + $instructions->addPostStep(function ($state) { + $columns = $this->fetchAll(sprintf('pragma table_info(%s)', $this->quoteTableName($state['tmpTableName']))); + $names = array_map([$this, 'quoteColumnName'], array_column($columns, 'name')); + $selectColumns = $writeColumns = $names; + + return compact('selectColumns', 'writeColumns') + $state; + }); + + return $this->copyAndDropTmpTable($instructions, $tableName); + } + + /** + * @param \Phinx\Db\Table\Table $table Table + * @param string $column Column Name + * @param bool $validateForeignKeys Whether to validate foreign keys after the copy and drop operations. Note that + * enabling this option only has an effect when the `foreign_keys` PRAGMA is set to `ON`! + * @return \Phinx\Db\Util\AlterInstructions + */ + protected function getDropPrimaryKeyInstructions( + Table $table, + string $column, + bool $validateForeignKeys = true + ): AlterInstructions { + $instructions = $this->beginAlterByCopyTable($table->getName()); + + $instructions->addPostStep(function ($state) { + $search = "/(,?\s*PRIMARY KEY\s*\([^\)]*\)|\s+PRIMARY KEY(\s+AUTOINCREMENT)?)/"; + $sql = preg_replace($search, '', $state['createSQL'], 1); + + if ($sql) { + $this->execute($sql); + } + + return $state; + }); + + $instructions->addPostStep(function ($state) use ($column) { + $newState = $this->calculateNewTableColumns($state['tmpTableName'], $column, $column); + + return $newState + $state; + }); + + return $this->copyAndDropTmpTable($instructions, $table->getName(), $validateForeignKeys); + } + + /** + * @inheritDoc + */ + protected function getAddForeignKeyInstructions(Table $table, ForeignKey $foreignKey): AlterInstructions + { + $instructions = $this->beginAlterByCopyTable($table->getName()); + + $tableName = $table->getName(); + $instructions->addPostStep(function ($state) use ($foreignKey, $tableName) { + $this->execute('pragma foreign_keys = ON'); + $sql = substr($state['createSQL'], 0, -1) . ',' . $this->getForeignKeySqlDefinition($foreignKey) . '); '; + + //Delete indexes from original table and recreate them in temporary table + $schema = $this->getSchemaName($tableName, true)['schema']; + $tmpTableName = $state['tmpTableName']; + $indexes = $this->getIndexes($tableName); + foreach (array_keys($indexes) as $indexName) { + if (strpos($indexName, 'sqlite_autoindex_') !== 0) { + $sql .= sprintf( + 'DROP INDEX %s%s; ', + $schema, + $this->quoteColumnName($indexName) + ); + $createIndexSQL = $this->getDeclaringIndexSQL($tableName, $indexName); + $sql .= preg_replace( + "/\b{$tableName}\b/", + $tmpTableName, + $createIndexSQL + ); + } + } + + $this->execute($sql); + + return $state; + }); + + $instructions->addPostStep(function ($state) { + $columns = $this->fetchAll(sprintf('pragma table_info(%s)', $this->quoteTableName($state['tmpTableName']))); + $names = array_map([$this, 'quoteColumnName'], array_column($columns, 'name')); + $selectColumns = $writeColumns = $names; + + return compact('selectColumns', 'writeColumns') + $state; + }); + + return $this->copyAndDropTmpTable($instructions, $tableName); + } + + /** + * {@inheritDoc} + * + * SQLiteAdapter does not implement this functionality, and so will always throw an exception if used. + * + * @throws \BadMethodCallException + */ + protected function getDropForeignKeyInstructions(string $tableName, string $constraint): AlterInstructions + { + throw new BadMethodCallException('SQLite does not have named foreign keys'); + } + + /** + * {@inheritDoc} + * + * @throws \InvalidArgumentException + */ + protected function getDropForeignKeyByColumnsInstructions(string $tableName, array $columns): AlterInstructions + { + $instructions = $this->beginAlterByCopyTable($tableName); + + $instructions->addPostStep(function ($state) use ($columns) { + $sql = ''; + + foreach ($columns as $columnName) { + $search = sprintf( + "/,[^,]*\(%s(?:,`?(.*)`?)?\) REFERENCES[^,]*\([^\)]*\)[^,)]*/", + $this->quoteColumnName($columnName) + ); + $sql = preg_replace($search, '', $state['createSQL'], 1); + } + + if ($sql) { + $this->execute($sql); + } + + return $state; + }); + + $instructions->addPostStep(function ($state) use ($columns) { + $newState = $this->calculateNewTableColumns($state['tmpTableName'], $columns[0], $columns[0]); + + $selectColumns = $newState['selectColumns']; + $columns = array_map([$this, 'quoteColumnName'], $columns); + $diff = array_diff($columns, $selectColumns); + + if (!empty($diff)) { + throw new InvalidArgumentException(sprintf( + 'The specified columns don\'t exist: ' . implode(', ', $diff) + )); + } + + return $newState + $state; + }); + + return $this->copyAndDropTmpTable($instructions, $tableName); + } + + /** + * {@inheritDoc} + * + * @throws \Phinx\Db\Adapter\UnsupportedColumnTypeException + */ + public function getSqlType($type, ?int $limit = null): array + { + $typeLC = strtolower($type); + if ($type instanceof Literal) { + $name = $type; + } elseif (isset(static::$supportedColumnTypes[$typeLC])) { + $name = static::$supportedColumnTypes[$typeLC]; + } elseif (in_array($typeLC, static::$unsupportedColumnTypes, true)) { + throw new UnsupportedColumnTypeException('Column type "' . $type . '" is not supported by SQLite.'); + } else { + throw new UnsupportedColumnTypeException('Column type "' . $type . '" is not known by SQLite.'); + } + + return ['name' => $name, 'limit' => $limit]; + } + + /** + * Returns Phinx type by SQL type + * + * @param string|null $sqlTypeDef SQL Type definition + * @return array + */ + public function getPhinxType(?string $sqlTypeDef): array + { + $limit = null; + $scale = null; + if ($sqlTypeDef === null) { + // in SQLite columns can legitimately have null as a type, which is distinct from the empty string + $name = null; + } elseif (!preg_match('/^([a-z]+)(_(?:integer|float|text|blob))?(?:\((\d+)(?:,(\d+))?\))?$/i', $sqlTypeDef, $match)) { + // doesn't match the pattern of a type we'd know about + $name = Literal::from($sqlTypeDef); + } else { + // possibly a known type + $type = $match[1]; + $typeLC = strtolower($type); + $affinity = $match[2] ?? ''; + $limit = isset($match[3]) && strlen($match[3]) ? (int)$match[3] : null; + $scale = isset($match[4]) && strlen($match[4]) ? (int)$match[4] : null; + if (in_array($typeLC, ['tinyint', 'tinyinteger'], true) && $limit === 1) { + // the type is a MySQL-style boolean + $name = static::PHINX_TYPE_BOOLEAN; + $limit = null; + } elseif (isset(static::$supportedColumnTypes[$typeLC])) { + // the type is an explicitly supported type + $name = $typeLC; + } elseif (isset(static::$supportedColumnTypeAliases[$typeLC])) { + // the type is an alias for a supported type + $name = static::$supportedColumnTypeAliases[$typeLC]; + } elseif (in_array($typeLC, static::$unsupportedColumnTypes, true)) { + // unsupported but known types are passed through lowercased, and without appended affinity + $name = Literal::from($typeLC); + } else { + // unknown types are passed through as-is + $name = Literal::from($type . $affinity); + } + } + + return [ + 'name' => $name, + 'limit' => $limit, + 'scale' => $scale, + ]; + } + + /** + * @inheritDoc + */ + public function createDatabase(string $name, array $options = []): void + { + touch($name . $this->suffix); + } + + /** + * @inheritDoc + */ + public function hasDatabase(string $name): bool + { + return is_file($name . $this->suffix); + } + + /** + * @inheritDoc + */ + public function dropDatabase(string $name): void + { + $this->createdTables = []; + if ($this->getOption('memory')) { + $this->disconnect(); + $this->connect(); + } + if (file_exists($name . $this->suffix)) { + unlink($name . $this->suffix); + } + } + + /** + * Gets the SQLite Column Definition for a Column object. + * + * @param \Phinx\Db\Table\Column $column Column + * @return string + */ + protected function getColumnSqlDefinition(Column $column): string + { + $isLiteralType = $column->getType() instanceof Literal; + if ($isLiteralType) { + $def = (string)$column->getType(); + } else { + $sqlType = $this->getSqlType($column->getType()); + $def = strtoupper($sqlType['name']); + + $limitable = in_array(strtoupper($sqlType['name']), $this->definitionsWithLimits, true); + if (($column->getLimit() || isset($sqlType['limit'])) && $limitable) { + $def .= '(' . ($column->getLimit() ?: $sqlType['limit']) . ')'; + } + } + if ($column->getPrecision() && $column->getScale()) { + $def .= '(' . $column->getPrecision() . ',' . $column->getScale() . ')'; + } + + $default = $column->getDefault(); + + $def .= $column->isNull() ? ' NULL' : ' NOT NULL'; + $def .= $this->getDefaultValueDefinition($default, $column->getType()); + $def .= $column->isIdentity() ? ' PRIMARY KEY AUTOINCREMENT' : ''; + + $def .= $this->getCommentDefinition($column); + + return $def; + } + + /** + * Gets the comment Definition for a Column object. + * + * @param \Phinx\Db\Table\Column $column Column + * @return string + */ + protected function getCommentDefinition(Column $column): string + { + if ($column->getComment()) { + return ' /* ' . $column->getComment() . ' */ '; + } + + return ''; + } + + /** + * Gets the SQLite Index Definition for an Index object. + * + * @param \Phinx\Db\Table\Table $table Table + * @param \Phinx\Db\Table\Index $index Index + * @return string + */ + protected function getIndexSqlDefinition(Table $table, Index $index): string + { + if ($index->getType() === Index::UNIQUE) { + $def = 'UNIQUE INDEX'; + } else { + $def = 'INDEX'; + } + if (is_string($index->getName())) { + $indexName = $index->getName(); + } else { + $indexName = $table->getName() . '_'; + foreach ($index->getColumns() as $column) { + $indexName .= $column . '_'; + } + $indexName .= 'index'; + } + $def .= ' `' . $indexName . '`'; + + return $def; + } + + /** + * @inheritDoc + */ + public function getColumnTypes(): array + { + return array_keys(static::$supportedColumnTypes); + } + + /** + * Gets the SQLite Foreign Key Definition for an ForeignKey object. + * + * @param \Phinx\Db\Table\ForeignKey $foreignKey Foreign key + * @return string + */ + protected function getForeignKeySqlDefinition(ForeignKey $foreignKey): string + { + $def = ''; + if ($foreignKey->getConstraint()) { + $def .= ' CONSTRAINT ' . $this->quoteColumnName($foreignKey->getConstraint()); + } + $columnNames = []; + foreach ($foreignKey->getColumns() as $column) { + $columnNames[] = $this->quoteColumnName($column); + } + $def .= ' FOREIGN KEY (' . implode(',', $columnNames) . ')'; + $refColumnNames = []; + foreach ($foreignKey->getReferencedColumns() as $column) { + $refColumnNames[] = $this->quoteColumnName($column); + } + $def .= ' REFERENCES ' . $this->quoteTableName($foreignKey->getReferencedTable()->getName()) . ' (' . implode(',', $refColumnNames) . ')'; + if ($foreignKey->getOnDelete()) { + $def .= ' ON DELETE ' . $foreignKey->getOnDelete(); + } + if ($foreignKey->getOnUpdate()) { + $def .= ' ON UPDATE ' . $foreignKey->getOnUpdate(); + } + + return $def; + } + + /** + * @inheritDoc + */ + public function getDecoratedConnection(): Connection + { + $options = $this->getOptions(); + $options['quoteIdentifiers'] = true; + + if (!empty($options['name'])) { + $options['database'] = $options['name']; + + if (file_exists($options['name'] . $this->suffix)) { + $options['database'] = $options['name'] . $this->suffix; + } + } + + if ($this->connection === null) { + throw new RuntimeException('You need to connect first.'); + } + + $driver = new SqliteDriver($options); + $driver->setConnection($this->connection); + + return new Connection(['driver' => $driver] + $options); + } +} diff --git a/extend/phinx/Db/Adapter/SqlServerAdapter.php b/extend/phinx/Db/Adapter/SqlServerAdapter.php new file mode 100644 index 0000000..0fe02f0 --- /dev/null +++ b/extend/phinx/Db/Adapter/SqlServerAdapter.php @@ -0,0 +1,1377 @@ + + */ +class SqlServerAdapter extends PdoAdapter +{ + /** + * @var string[] + */ + protected static $specificColumnTypes = [ + self::PHINX_TYPE_FILESTREAM, + self::PHINX_TYPE_BINARYUUID, + ]; + + /** + * @var string + */ + protected $schema = 'dbo'; + + /** + * @var bool[] + */ + protected $signedColumnTypes = [ + self::PHINX_TYPE_INTEGER => true, + self::PHINX_TYPE_BIG_INTEGER => true, + self::PHINX_TYPE_FLOAT => true, + self::PHINX_TYPE_DECIMAL => true, + ]; + + /** + * {@inheritDoc} + * + * @throws \InvalidArgumentException + * @return void + */ + public function connect(): void + { + if ($this->connection === null) { + if (!class_exists('PDO') || !in_array('sqlsrv', PDO::getAvailableDrivers(), true)) { + // try our connection via freetds (Mac/Linux) + $this->connectDblib(); + + return; + } + + $options = $this->getOptions(); + + $dsn = 'sqlsrv:server=' . $options['host']; + // if port is specified use it, otherwise use the SqlServer default + if (!empty($options['port'])) { + $dsn .= ',' . $options['port']; + } + $dsn .= ';database=' . $options['name'] . ';MultipleActiveResultSets=false'; + + // option to add additional connection options + // https://docs.microsoft.com/en-us/sql/connect/php/connection-options?view=sql-server-ver15 + if (isset($options['dsn_options'])) { + foreach ($options['dsn_options'] as $key => $option) { + $dsn .= ';' . $key . '=' . $option; + } + } + + $driverOptions = []; + + // charset support + if (isset($options['charset'])) { + $driverOptions[PDO::SQLSRV_ATTR_ENCODING] = $options['charset']; + } + + // use custom data fetch mode + if (!empty($options['fetch_mode'])) { + $driverOptions[PDO::ATTR_DEFAULT_FETCH_MODE] = constant('\PDO::FETCH_' . strtoupper($options['fetch_mode'])); + } + + // Note, the PDO::ATTR_PERSISTENT attribute is not supported for sqlsrv and will throw an error when used + // See https://github.com/Microsoft/msphpsql/issues/65 + + // support arbitrary \PDO::SQLSRV_ATTR_* driver options and pass them to PDO + // https://php.net/manual/en/ref.pdo-sqlsrv.php#pdo-sqlsrv.constants + foreach ($options as $key => $option) { + if (strpos($key, 'sqlsrv_attr_') === 0) { + $pdoConstant = '\PDO::' . strtoupper($key); + if (!defined($pdoConstant)) { + throw new \UnexpectedValueException('Invalid PDO attribute: ' . $key . ' (' . $pdoConstant . ')'); + } + $driverOptions[constant($pdoConstant)] = $option; + } + } + + $db = $this->createPdoConnection($dsn, $options['user'] ?? null, $options['pass'] ?? null, $driverOptions); + + $this->setConnection($db); + } + } + + /** + * Connect to MSSQL using dblib/freetds. + * + * The "sqlsrv" driver is not available on Unix machines. + * + * @throws \InvalidArgumentException + * @throws \RuntimeException + * @return void + */ + protected function connectDblib(): void + { + if (!class_exists('PDO') || !in_array('dblib', PDO::getAvailableDrivers(), true)) { + // @codeCoverageIgnoreStart + throw new RuntimeException('You need to enable the PDO_Dblib extension for Phinx to run properly.'); + // @codeCoverageIgnoreEnd + } + + $options = $this->getOptions(); + + // if port is specified use it, otherwise use the SqlServer default + if (empty($options['port'])) { + $dsn = 'dblib:host=' . $options['host'] . ';dbname=' . $options['name']; + } else { + $dsn = 'dblib:host=' . $options['host'] . ':' . $options['port'] . ';dbname=' . $options['name']; + } + + $driverOptions = [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]; + + try { + $db = new PDO($dsn, $options['user'], $options['pass'], $driverOptions); + } catch (PDOException $exception) { + throw new InvalidArgumentException(sprintf( + 'There was a problem connecting to the database: %s', + $exception->getMessage() + ), 0, $exception); + } + + $this->setConnection($db); + } + + /** + * @inheritDoc + */ + public function disconnect(): void + { + $this->connection = null; + } + + /** + * @inheritDoc + */ + public function hasTransactions(): bool + { + return true; + } + + /** + * @inheritDoc + */ + public function beginTransaction(): void + { + $this->execute('BEGIN TRANSACTION'); + } + + /** + * @inheritDoc + */ + public function commitTransaction(): void + { + $this->execute('COMMIT TRANSACTION'); + } + + /** + * @inheritDoc + */ + public function rollbackTransaction(): void + { + $this->execute('ROLLBACK TRANSACTION'); + } + + /** + * @inheritDoc + */ + public function quoteTableName(string $tableName): string + { + return str_replace('.', '].[', $this->quoteColumnName($tableName)); + } + + /** + * @inheritDoc + */ + public function quoteColumnName(string $columnName): string + { + return '[' . str_replace(']', '\]', $columnName) . ']'; + } + + /** + * @inheritDoc + */ + public function hasTable(string $tableName): bool + { + if ($this->hasCreatedTable($tableName)) { + return true; + } + + /** @var array $result */ + $result = $this->fetchRow(sprintf("SELECT count(*) as [count] FROM information_schema.tables WHERE table_name = '%s';", $tableName)); + + return $result['count'] > 0; + } + + /** + * @inheritDoc + */ + public function createTable(Table $table, array $columns = [], array $indexes = []): void + { + $options = $table->getOptions(); + + // Add the default primary key + if (!isset($options['id']) || (isset($options['id']) && $options['id'] === true)) { + $options['id'] = 'id'; + } + + if (isset($options['id']) && is_string($options['id'])) { + // Handle id => "field_name" to support AUTO_INCREMENT + $column = new Column(); + $column->setName($options['id']) + ->setType('integer') + ->setOptions(['identity' => true]); + + array_unshift($columns, $column); + if (isset($options['primary_key']) && (array)$options['id'] !== (array)$options['primary_key']) { + throw new InvalidArgumentException('You cannot enable an auto incrementing ID field and a primary key'); + } + $options['primary_key'] = $options['id']; + } + + $sql = 'CREATE TABLE '; + $sql .= $this->quoteTableName($table->getName()) . ' ('; + $sqlBuffer = []; + $columnsWithComments = []; + foreach ($columns as $column) { + $sqlBuffer[] = $this->quoteColumnName($column->getName()) . ' ' . $this->getColumnSqlDefinition($column); + + // set column comments, if needed + if ($column->getComment()) { + $columnsWithComments[] = $column; + } + } + + // set the primary key(s) + if (isset($options['primary_key'])) { + $pkSql = sprintf('CONSTRAINT PK_%s PRIMARY KEY (', $table->getName()); + if (is_string($options['primary_key'])) { // handle primary_key => 'id' + $pkSql .= $this->quoteColumnName($options['primary_key']); + } elseif (is_array($options['primary_key'])) { // handle primary_key => array('tag_id', 'resource_id') + $pkSql .= implode(',', array_map([$this, 'quoteColumnName'], $options['primary_key'])); + } + $pkSql .= ')'; + $sqlBuffer[] = $pkSql; + } + + $sql .= implode(', ', $sqlBuffer); + $sql .= ');'; + + // process column comments + foreach ($columnsWithComments as $column) { + $sql .= $this->getColumnCommentSqlDefinition($column, $table->getName()); + } + + // set the indexes + foreach ($indexes as $index) { + $sql .= $this->getIndexSqlDefinition($index, $table->getName()); + } + + // execute the sql + $this->execute($sql); + + $this->addCreatedTable($table->getName()); + } + + /** + * {@inheritDoc} + * + * @throws \InvalidArgumentException + */ + protected function getChangePrimaryKeyInstructions(Table $table, $newColumns): AlterInstructions + { + $instructions = new AlterInstructions(); + + // Drop the existing primary key + $primaryKey = $this->getPrimaryKey($table->getName()); + if (!empty($primaryKey['constraint'])) { + $sql = sprintf( + 'DROP CONSTRAINT %s', + $this->quoteColumnName($primaryKey['constraint']) + ); + $instructions->addAlter($sql); + } + + // Add the primary key(s) + if (!empty($newColumns)) { + $sql = sprintf( + 'ALTER TABLE %s ADD CONSTRAINT %s PRIMARY KEY (', + $this->quoteTableName($table->getName()), + $this->quoteColumnName('PK_' . $table->getName()) + ); + if (is_string($newColumns)) { // handle primary_key => 'id' + $sql .= $this->quoteColumnName($newColumns); + } elseif (is_array($newColumns)) { // handle primary_key => array('tag_id', 'resource_id') + $sql .= implode(',', array_map([$this, 'quoteColumnName'], $newColumns)); + } else { + throw new InvalidArgumentException(sprintf( + 'Invalid value for primary key: %s', + json_encode($newColumns) + )); + } + $sql .= ')'; + $instructions->addPostStep($sql); + } + + return $instructions; + } + + /** + * @inheritDoc + * + * SqlServer does not implement this functionality, and so will always throw an exception if used. + * @throws \BadMethodCallException + */ + protected function getChangeCommentInstructions(Table $table, ?string $newComment): AlterInstructions + { + throw new BadMethodCallException('SqlServer does not have table comments'); + } + + /** + * Gets the SqlServer Column Comment Defininition for a column object. + * + * @param \Phinx\Db\Table\Column $column Column + * @param string $tableName Table name + * @return string + */ + protected function getColumnCommentSqlDefinition(Column $column, $tableName): string + { + // passing 'null' is to remove column comment + $currentComment = $this->getColumnComment($tableName, $column->getName()); + + $comment = strcasecmp($column->getComment(), 'NULL') !== 0 ? $this->getConnection()->quote($column->getComment()) : '\'\''; + $command = $currentComment === null ? 'sp_addextendedproperty' : 'sp_updateextendedproperty'; + + return sprintf( + "EXECUTE %s N'MS_Description', N%s, N'SCHEMA', N'%s', N'TABLE', N'%s', N'COLUMN', N'%s';", + $command, + $comment, + $this->schema, + $tableName, + $column->getName() + ); + } + + /** + * @inheritDoc + */ + protected function getRenameTableInstructions(string $tableName, string $newTableName): AlterInstructions + { + $this->updateCreatedTableName($tableName, $newTableName); + $sql = sprintf( + "EXEC sp_rename '%s', '%s'", + $tableName, + $newTableName + ); + + return new AlterInstructions([], [$sql]); + } + + /** + * @inheritDoc + */ + protected function getDropTableInstructions(string $tableName): AlterInstructions + { + $this->removeCreatedTable($tableName); + $sql = sprintf('DROP TABLE %s', $this->quoteTableName($tableName)); + + return new AlterInstructions([], [$sql]); + } + + /** + * @inheritDoc + */ + public function truncateTable(string $tableName): void + { + $sql = sprintf( + 'TRUNCATE TABLE %s', + $this->quoteTableName($tableName) + ); + + $this->execute($sql); + } + + /** + * @param string $tableName Table name + * @param string $columnName Column name + * @return string|null + */ + public function getColumnComment(string $tableName, string $columnName): ?string + { + $sql = sprintf("SELECT cast(extended_properties.[value] as nvarchar(4000)) comment + FROM sys.schemas + INNER JOIN sys.tables + ON schemas.schema_id = tables.schema_id + INNER JOIN sys.columns + ON tables.object_id = columns.object_id + INNER JOIN sys.extended_properties + ON tables.object_id = extended_properties.major_id + AND columns.column_id = extended_properties.minor_id + AND extended_properties.name = 'MS_Description' + WHERE schemas.[name] = '%s' AND tables.[name] = '%s' AND columns.[name] = '%s'", $this->schema, $tableName, $columnName); + $row = $this->fetchRow($sql); + + if ($row) { + return trim($row['comment']); + } + + return null; + } + + /** + * @inheritDoc + */ + public function getColumns(string $tableName): array + { + $columns = []; + $sql = sprintf( + "SELECT DISTINCT TABLE_SCHEMA AS [schema], TABLE_NAME as [table_name], COLUMN_NAME AS [name], DATA_TYPE AS [type], + IS_NULLABLE AS [null], COLUMN_DEFAULT AS [default], + CHARACTER_MAXIMUM_LENGTH AS [char_length], + NUMERIC_PRECISION AS [precision], + NUMERIC_SCALE AS [scale], ORDINAL_POSITION AS [ordinal_position], + COLUMNPROPERTY(object_id(TABLE_NAME), COLUMN_NAME, 'IsIdentity') as [identity] + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_NAME = '%s' + ORDER BY ordinal_position", + $tableName + ); + $rows = $this->fetchAll($sql); + foreach ($rows as $columnInfo) { + try { + $type = $this->getPhinxType($columnInfo['type']); + } catch (UnsupportedColumnTypeException $e) { + $type = Literal::from($columnInfo['type']); + } + + $column = new Column(); + $column->setName($columnInfo['name']) + ->setType($type) + ->setNull($columnInfo['null'] !== 'NO') + ->setDefault($this->parseDefault($columnInfo['default'])) + ->setIdentity($columnInfo['identity'] === '1') + ->setComment($this->getColumnComment($columnInfo['table_name'], $columnInfo['name'])); + + if (!empty($columnInfo['char_length'])) { + $column->setLimit($columnInfo['char_length']); + } + + $columns[$columnInfo['name']] = $column; + } + + return $columns; + } + + /** + * @param string|null $default Default + * @return int|string|null + */ + protected function parseDefault(?string $default) + { + // if a column is non-nullable and has no default, the value of column_default is null, + // otherwise it should be a string value that we parse below, including "(NULL)" which + // also stands for a null default + if ($default === null) { + return null; + } + + $result = preg_replace(["/\('(.*)'\)/", "/\(\((.*)\)\)/", "/\((.*)\)/"], '$1', $default); + + if (strtoupper($result) === 'NULL') { + $result = null; + } elseif (is_numeric($result)) { + $result = (int)$result; + } + + return $result; + } + + /** + * @inheritDoc + */ + public function hasColumn(string $tableName, string $columnName): bool + { + $sql = sprintf( + "SELECT count(*) as [count] + FROM information_schema.columns + WHERE table_name = '%s' AND column_name = '%s'", + $tableName, + $columnName + ); + /** @var array $result */ + $result = $this->fetchRow($sql); + + return $result['count'] > 0; + } + + /** + * @inheritDoc + */ + protected function getAddColumnInstructions(Table $table, Column $column): AlterInstructions + { + $alter = sprintf( + 'ALTER TABLE %s ADD %s %s', + $table->getName(), + $this->quoteColumnName($column->getName()), + $this->getColumnSqlDefinition($column) + ); + + return new AlterInstructions([], [$alter]); + } + + /** + * {@inheritDoc} + * + * @throws \InvalidArgumentException + */ + protected function getRenameColumnInstructions(string $tableName, string $columnName, string $newColumnName): AlterInstructions + { + if (!$this->hasColumn($tableName, $columnName)) { + throw new InvalidArgumentException("The specified column does not exist: $columnName"); + } + + $instructions = new AlterInstructions(); + + $oldConstraintName = "DF_{$tableName}_{$columnName}"; + $newConstraintName = "DF_{$tableName}_{$newColumnName}"; + $sql = <<addPostStep(sprintf( + $sql, + $oldConstraintName, + $newConstraintName + )); + + $instructions->addPostStep(sprintf( + "EXECUTE sp_rename N'%s.%s', N'%s', 'COLUMN' ", + $tableName, + $columnName, + $newColumnName + )); + + return $instructions; + } + + /** + * Returns the instructions to change a column default value + * + * @param string $tableName The table where the column is + * @param \Phinx\Db\Table\Column $newColumn The column to alter + * @return \Phinx\Db\Util\AlterInstructions + */ + protected function getChangeDefault(string $tableName, Column $newColumn): AlterInstructions + { + $constraintName = "DF_{$tableName}_{$newColumn->getName()}"; + $default = $newColumn->getDefault(); + $instructions = new AlterInstructions(); + + if ($default === null) { + $default = 'DEFAULT NULL'; + } else { + $default = ltrim($this->getDefaultValueDefinition($default)); + } + + if (empty($default)) { + return $instructions; + } + + $instructions->addPostStep(sprintf( + 'ALTER TABLE %s ADD CONSTRAINT %s %s FOR %s', + $this->quoteTableName($tableName), + $constraintName, + $default, + $this->quoteColumnName($newColumn->getName()) + )); + + return $instructions; + } + + /** + * @inheritDoc + */ + protected function getChangeColumnInstructions(string $tableName, string $columnName, Column $newColumn): AlterInstructions + { + $columns = $this->getColumns($tableName); + $changeDefault = + $newColumn->getDefault() !== $columns[$columnName]->getDefault() || + $newColumn->getType() !== $columns[$columnName]->getType(); + + $instructions = new AlterInstructions(); + + if ($columnName !== $newColumn->getName()) { + $instructions->merge( + $this->getRenameColumnInstructions($tableName, $columnName, $newColumn->getName()) + ); + } + + if ($changeDefault) { + $instructions->merge($this->getDropDefaultConstraint($tableName, $newColumn->getName())); + } + + $instructions->addPostStep(sprintf( + 'ALTER TABLE %s ALTER COLUMN %s %s', + $this->quoteTableName($tableName), + $this->quoteColumnName($newColumn->getName()), + $this->getColumnSqlDefinition($newColumn, false) + )); + // change column comment if needed + if ($newColumn->getComment()) { + $instructions->addPostStep($this->getColumnCommentSqlDefinition($newColumn, $tableName)); + } + + if ($changeDefault) { + $instructions->merge($this->getChangeDefault($tableName, $newColumn)); + } + + return $instructions; + } + + /** + * @inheritDoc + */ + protected function getDropColumnInstructions(string $tableName, string $columnName): AlterInstructions + { + $instructions = $this->getDropDefaultConstraint($tableName, $columnName); + + $instructions->addPostStep(sprintf( + 'ALTER TABLE %s DROP COLUMN %s', + $this->quoteTableName($tableName), + $this->quoteColumnName($columnName) + )); + + return $instructions; + } + + /** + * @param string $tableName Table name + * @param string|null $columnName Column name + * @return \Phinx\Db\Util\AlterInstructions + */ + protected function getDropDefaultConstraint(string $tableName, ?string $columnName): AlterInstructions + { + $defaultConstraint = $this->getDefaultConstraint($tableName, $columnName); + + if (!$defaultConstraint) { + return new AlterInstructions(); + } + + return $this->getDropForeignKeyInstructions($tableName, $defaultConstraint); + } + + /** + * @param string $tableName Table name + * @param string $columnName Column name + * @return string|false + */ + protected function getDefaultConstraint(string $tableName, string $columnName) + { + $sql = "SELECT + default_constraints.name +FROM + sys.all_columns + + INNER JOIN + sys.tables + ON all_columns.object_id = tables.object_id + + INNER JOIN + sys.schemas + ON tables.schema_id = schemas.schema_id + + INNER JOIN + sys.default_constraints + ON all_columns.default_object_id = default_constraints.object_id + +WHERE + schemas.name = 'dbo' + AND tables.name = '{$tableName}' + AND all_columns.name = '{$columnName}'"; + + $rows = $this->fetchAll($sql); + + return empty($rows) ? false : $rows[0]['name']; + } + + /** + * @param int $tableId Table ID + * @param int $indexId Index ID + * @return array + */ + protected function getIndexColums(int $tableId, int $indexId): array + { + $sql = "SELECT AC.[name] AS [column_name] +FROM sys.[index_columns] IC + INNER JOIN sys.[all_columns] AC ON IC.[column_id] = AC.[column_id] +WHERE AC.[object_id] = {$tableId} AND IC.[index_id] = {$indexId} AND IC.[object_id] = {$tableId} +ORDER BY IC.[key_ordinal];"; + + $rows = $this->fetchAll($sql); + $columns = []; + foreach ($rows as $row) { + $columns[] = strtolower($row['column_name']); + } + + return $columns; + } + + /** + * Get an array of indexes from a particular table. + * + * @param string $tableName Table name + * @return array + */ + public function getIndexes(string $tableName): array + { + $indexes = []; + $sql = "SELECT I.[name] AS [index_name], I.[index_id] as [index_id], T.[object_id] as [table_id] +FROM sys.[tables] AS T + INNER JOIN sys.[indexes] I ON T.[object_id] = I.[object_id] +WHERE T.[is_ms_shipped] = 0 AND I.[type_desc] <> 'HEAP' AND T.[name] = '{$tableName}' +ORDER BY T.[name], I.[index_id];"; + + $rows = $this->fetchAll($sql); + foreach ($rows as $row) { + $columns = $this->getIndexColums($row['table_id'], $row['index_id']); + $indexes[$row['index_name']] = ['columns' => $columns]; + } + + return $indexes; + } + + /** + * @inheritDoc + */ + public function hasIndex(string $tableName, $columns): bool + { + if (is_string($columns)) { + $columns = [$columns]; // str to array + } + + $columns = array_map('strtolower', $columns); + $indexes = $this->getIndexes($tableName); + + foreach ($indexes as $index) { + $a = array_diff($columns, $index['columns']); + + if (empty($a)) { + return true; + } + } + + return false; + } + + /** + * @inheritDoc + */ + public function hasIndexByName(string $tableName, string $indexName): bool + { + $indexes = $this->getIndexes($tableName); + + foreach ($indexes as $name => $index) { + if ($name === $indexName) { + return true; + } + } + + return false; + } + + /** + * @inheritDoc + */ + protected function getAddIndexInstructions(Table $table, Index $index): AlterInstructions + { + $sql = $this->getIndexSqlDefinition($index, $table->getName()); + + return new AlterInstructions([], [$sql]); + } + + /** + * {@inheritDoc} + * + * @throws \InvalidArgumentException + */ + protected function getDropIndexByColumnsInstructions(string $tableName, $columns): AlterInstructions + { + if (is_string($columns)) { + $columns = [$columns]; // str to array + } + + $indexes = $this->getIndexes($tableName); + $columns = array_map('strtolower', $columns); + $instructions = new AlterInstructions(); + + foreach ($indexes as $indexName => $index) { + $a = array_diff($columns, $index['columns']); + if (empty($a)) { + $instructions->addPostStep(sprintf( + 'DROP INDEX %s ON %s', + $this->quoteColumnName($indexName), + $this->quoteTableName($tableName) + )); + + return $instructions; + } + } + + throw new InvalidArgumentException(sprintf( + "The specified index on columns '%s' does not exist", + implode(',', $columns) + )); + } + + /** + * {@inheritDoc} + * + * @throws \InvalidArgumentException + */ + protected function getDropIndexByNameInstructions(string $tableName, string $indexName): AlterInstructions + { + $indexes = $this->getIndexes($tableName); + $instructions = new AlterInstructions(); + + foreach ($indexes as $name => $index) { + if ($name === $indexName) { + $instructions->addPostStep(sprintf( + 'DROP INDEX %s ON %s', + $this->quoteColumnName($indexName), + $this->quoteTableName($tableName) + )); + + return $instructions; + } + } + + throw new InvalidArgumentException(sprintf( + "The specified index name '%s' does not exist", + $indexName + )); + } + + /** + * @inheritDoc + */ + public function hasPrimaryKey(string $tableName, $columns, ?string $constraint = null): bool + { + $primaryKey = $this->getPrimaryKey($tableName); + + if (empty($primaryKey)) { + return false; + } + + if ($constraint) { + return $primaryKey['constraint'] === $constraint; + } + + if (is_string($columns)) { + $columns = [$columns]; // str to array + } + $missingColumns = array_diff($columns, $primaryKey['columns']); + + return empty($missingColumns); + } + + /** + * Get the primary key from a particular table. + * + * @param string $tableName Table name + * @return array + */ + public function getPrimaryKey(string $tableName): array + { + $rows = $this->fetchAll(sprintf( + "SELECT + tc.constraint_name, + kcu.column_name + FROM information_schema.table_constraints AS tc + JOIN information_schema.key_column_usage AS kcu + ON tc.constraint_name = kcu.constraint_name + WHERE constraint_type = 'PRIMARY KEY' + AND tc.table_name = '%s' + ORDER BY kcu.ordinal_position", + $tableName + )); + + $primaryKey = [ + 'columns' => [], + ]; + foreach ($rows as $row) { + $primaryKey['constraint'] = $row['constraint_name']; + $primaryKey['columns'][] = $row['column_name']; + } + + return $primaryKey; + } + + /** + * @inheritDoc + */ + public function hasForeignKey(string $tableName, $columns, ?string $constraint = null): bool + { + if (is_string($columns)) { + $columns = [$columns]; // str to array + } + $foreignKeys = $this->getForeignKeys($tableName); + if ($constraint) { + if (isset($foreignKeys[$constraint])) { + return !empty($foreignKeys[$constraint]); + } + + return false; + } + + foreach ($foreignKeys as $key) { + $a = array_diff($columns, $key['columns']); + if (empty($a)) { + return true; + } + } + + return false; + } + + /** + * Get an array of foreign keys from a particular table. + * + * @param string $tableName Table name + * @return array + */ + protected function getForeignKeys(string $tableName): array + { + $foreignKeys = []; + $rows = $this->fetchAll(sprintf( + "SELECT + tc.constraint_name, + tc.table_name, kcu.column_name, + ccu.table_name AS referenced_table_name, + ccu.column_name AS referenced_column_name + FROM + information_schema.table_constraints AS tc + JOIN information_schema.key_column_usage AS kcu ON tc.constraint_name = kcu.constraint_name + JOIN information_schema.constraint_column_usage AS ccu ON ccu.constraint_name = tc.constraint_name + WHERE constraint_type = 'FOREIGN KEY' AND tc.table_name = '%s' + ORDER BY kcu.ordinal_position", + $tableName + )); + foreach ($rows as $row) { + $foreignKeys[$row['constraint_name']]['table'] = $row['table_name']; + $foreignKeys[$row['constraint_name']]['columns'][] = $row['column_name']; + $foreignKeys[$row['constraint_name']]['referenced_table'] = $row['referenced_table_name']; + $foreignKeys[$row['constraint_name']]['referenced_columns'][] = $row['referenced_column_name']; + } + + return $foreignKeys; + } + + /** + * @inheritDoc + */ + protected function getAddForeignKeyInstructions(Table $table, ForeignKey $foreignKey): AlterInstructions + { + $instructions = new AlterInstructions(); + $instructions->addPostStep(sprintf( + 'ALTER TABLE %s ADD %s', + $this->quoteTableName($table->getName()), + $this->getForeignKeySqlDefinition($foreignKey, $table->getName()) + )); + + return $instructions; + } + + /** + * @inheritDoc + */ + protected function getDropForeignKeyInstructions(string $tableName, string $constraint): AlterInstructions + { + $instructions = new AlterInstructions(); + $instructions->addPostStep(sprintf( + 'ALTER TABLE %s DROP CONSTRAINT %s', + $this->quoteTableName($tableName), + $constraint + )); + + return $instructions; + } + + /** + * @inheritDoc + */ + protected function getDropForeignKeyByColumnsInstructions(string $tableName, array $columns): AlterInstructions + { + $instructions = new AlterInstructions(); + + foreach ($columns as $column) { + $rows = $this->fetchAll(sprintf( + "SELECT + tc.constraint_name, + tc.table_name, kcu.column_name, + ccu.table_name AS referenced_table_name, + ccu.column_name AS referenced_column_name + FROM + information_schema.table_constraints AS tc + JOIN information_schema.key_column_usage AS kcu ON tc.constraint_name = kcu.constraint_name + JOIN information_schema.constraint_column_usage AS ccu ON ccu.constraint_name = tc.constraint_name + WHERE constraint_type = 'FOREIGN KEY' AND tc.table_name = '%s' and ccu.column_name='%s' + ORDER BY kcu.ordinal_position", + $tableName, + $column + )); + foreach ($rows as $row) { + $instructions->merge( + $this->getDropForeignKeyInstructions($tableName, $row['constraint_name']) + ); + } + } + + return $instructions; + } + + /** + * {@inheritDoc} + * + * @throws \Phinx\Db\Adapter\UnsupportedColumnTypeException + */ + public function getSqlType($type, ?int $limit = null): array + { + switch ($type) { + case static::PHINX_TYPE_FLOAT: + case static::PHINX_TYPE_DECIMAL: + case static::PHINX_TYPE_DATETIME: + case static::PHINX_TYPE_TIME: + case static::PHINX_TYPE_DATE: + return ['name' => $type]; + case static::PHINX_TYPE_STRING: + return ['name' => 'nvarchar', 'limit' => 255]; + case static::PHINX_TYPE_CHAR: + return ['name' => 'nchar', 'limit' => 255]; + case static::PHINX_TYPE_TEXT: + return ['name' => 'ntext']; + case static::PHINX_TYPE_INTEGER: + return ['name' => 'int']; + case static::PHINX_TYPE_TINY_INTEGER: + return ['name' => 'tinyint']; + case static::PHINX_TYPE_SMALL_INTEGER: + return ['name' => 'smallint']; + case static::PHINX_TYPE_BIG_INTEGER: + return ['name' => 'bigint']; + case static::PHINX_TYPE_TIMESTAMP: + return ['name' => 'datetime']; + case static::PHINX_TYPE_BLOB: + case static::PHINX_TYPE_BINARY: + return ['name' => 'varbinary']; + case static::PHINX_TYPE_BOOLEAN: + return ['name' => 'bit']; + case static::PHINX_TYPE_BINARYUUID: + case static::PHINX_TYPE_UUID: + return ['name' => 'uniqueidentifier']; + case static::PHINX_TYPE_FILESTREAM: + return ['name' => 'varbinary', 'limit' => 'max']; + // Geospatial database types + case static::PHINX_TYPE_GEOGRAPHY: + case static::PHINX_TYPE_POINT: + case static::PHINX_TYPE_LINESTRING: + case static::PHINX_TYPE_POLYGON: + // SQL Server stores all spatial data using a single data type. + // Specific types (point, polygon, etc) are set at insert time. + return ['name' => 'geography']; + // Geometry specific type + case static::PHINX_TYPE_GEOMETRY: + return ['name' => 'geometry']; + default: + throw new UnsupportedColumnTypeException('Column type "' . $type . '" is not supported by SqlServer.'); + } + } + + /** + * Returns Phinx type by SQL type + * + * @internal param string $sqlType SQL type + * @param string $sqlType SQL Type definition + * @throws \Phinx\Db\Adapter\UnsupportedColumnTypeException + * @return string Phinx type + */ + public function getPhinxType(string $sqlType): string + { + switch ($sqlType) { + case 'nvarchar': + case 'varchar': + return static::PHINX_TYPE_STRING; + case 'char': + case 'nchar': + return static::PHINX_TYPE_CHAR; + case 'text': + case 'ntext': + return static::PHINX_TYPE_TEXT; + case 'int': + case 'integer': + return static::PHINX_TYPE_INTEGER; + case 'decimal': + case 'numeric': + case 'money': + return static::PHINX_TYPE_DECIMAL; + case 'tinyint': + return static::PHINX_TYPE_TINY_INTEGER; + case 'smallint': + return static::PHINX_TYPE_SMALL_INTEGER; + case 'bigint': + return static::PHINX_TYPE_BIG_INTEGER; + case 'real': + case 'float': + return static::PHINX_TYPE_FLOAT; + case 'binary': + case 'image': + case 'varbinary': + return static::PHINX_TYPE_BINARY; + case 'time': + return static::PHINX_TYPE_TIME; + case 'date': + return static::PHINX_TYPE_DATE; + case 'datetime': + case 'timestamp': + return static::PHINX_TYPE_DATETIME; + case 'bit': + return static::PHINX_TYPE_BOOLEAN; + case 'uniqueidentifier': + return static::PHINX_TYPE_UUID; + case 'filestream': + return static::PHINX_TYPE_FILESTREAM; + default: + throw new UnsupportedColumnTypeException('Column type "' . $sqlType . '" is not supported by SqlServer.'); + } + } + + /** + * @inheritDoc + */ + public function createDatabase(string $name, array $options = []): void + { + if (isset($options['collation'])) { + $this->execute(sprintf('CREATE DATABASE [%s] COLLATE [%s]', $name, $options['collation'])); + } else { + $this->execute(sprintf('CREATE DATABASE [%s]', $name)); + } + $this->execute(sprintf('USE [%s]', $name)); + } + + /** + * @inheritDoc + */ + public function hasDatabase(string $name): bool + { + /** @var array $result */ + $result = $this->fetchRow( + sprintf( + "SELECT count(*) as [count] FROM master.dbo.sysdatabases WHERE [name] = '%s'", + $name + ) + ); + + return $result['count'] > 0; + } + + /** + * @inheritDoc + */ + public function dropDatabase(string $name): void + { + $sql = <<execute($sql); + $this->createdTables = []; + } + + /** + * Gets the SqlServer Column Definition for a Column object. + * + * @param \Phinx\Db\Table\Column $column Column + * @param bool $create Create column flag + * @return string + */ + protected function getColumnSqlDefinition(Column $column, bool $create = true): string + { + $buffer = []; + if ($column->getType() instanceof Literal) { + $buffer[] = (string)$column->getType(); + } else { + $sqlType = $this->getSqlType($column->getType()); + $buffer[] = strtoupper($sqlType['name']); + // integers cant have limits in SQlServer + $noLimits = [ + 'bigint', + 'int', + 'tinyint', + 'smallint', + ]; + if ($sqlType['name'] === static::PHINX_TYPE_DECIMAL && $column->getPrecision() && $column->getScale()) { + $buffer[] = sprintf( + '(%s, %s)', + $column->getPrecision() ?: $sqlType['precision'], + $column->getScale() ?: $sqlType['scale'] + ); + } elseif (!in_array($sqlType['name'], $noLimits) && ($column->getLimit() || isset($sqlType['limit']))) { + $buffer[] = sprintf('(%s)', $column->getLimit() ?: $sqlType['limit']); + } + } + + $properties = $column->getProperties(); + $buffer[] = $column->getType() === 'filestream' ? 'FILESTREAM' : ''; + $buffer[] = isset($properties['rowguidcol']) ? 'ROWGUIDCOL' : ''; + + $buffer[] = $column->isNull() ? 'NULL' : 'NOT NULL'; + + if ($create === true) { + if ($column->getDefault() === null && $column->isNull()) { + $buffer[] = ' DEFAULT NULL'; + } else { + $buffer[] = $this->getDefaultValueDefinition($column->getDefault()); + } + } + + if ($column->isIdentity()) { + $seed = $column->getSeed() ?: 1; + $increment = $column->getIncrement() ?: 1; + $buffer[] = sprintf('IDENTITY(%d,%d)', $seed, $increment); + } + + return implode(' ', $buffer); + } + + /** + * Gets the SqlServer Index Definition for an Index object. + * + * @param \Phinx\Db\Table\Index $index Index + * @param string $tableName Table name + * @return string + */ + protected function getIndexSqlDefinition(Index $index, string $tableName): string + { + $columnNames = $index->getColumns(); + if (is_string($index->getName())) { + $indexName = $index->getName(); + } else { + $indexName = sprintf('%s_%s', $tableName, implode('_', $columnNames)); + } + $order = $index->getOrder() ?? []; + $columnNames = array_map(function ($columnName) use ($order) { + $ret = '[' . $columnName . ']'; + if (isset($order[$columnName])) { + $ret .= ' ' . $order[$columnName]; + } + + return $ret; + }, $columnNames); + + $includedColumns = $index->getInclude() ? sprintf('INCLUDE ([%s])', implode('],[', $index->getInclude())) : ''; + + return sprintf( + 'CREATE %s INDEX %s ON %s (%s) %s;', + ($index->getType() === Index::UNIQUE ? 'UNIQUE' : ''), + $indexName, + $this->quoteTableName($tableName), + implode(',', $columnNames), + $includedColumns + ); + } + + /** + * Gets the SqlServer Foreign Key Definition for an ForeignKey object. + * + * @param \Phinx\Db\Table\ForeignKey $foreignKey Foreign key + * @param string $tableName Table name + * @return string + */ + protected function getForeignKeySqlDefinition(ForeignKey $foreignKey, string $tableName): string + { + $constraintName = $foreignKey->getConstraint() ?: $tableName . '_' . implode('_', $foreignKey->getColumns()); + $def = ' CONSTRAINT ' . $this->quoteColumnName($constraintName); + $def .= ' FOREIGN KEY ("' . implode('", "', $foreignKey->getColumns()) . '")'; + $def .= " REFERENCES {$this->quoteTableName($foreignKey->getReferencedTable()->getName())} (\"" . implode('", "', $foreignKey->getReferencedColumns()) . '")'; + if ($foreignKey->getOnDelete()) { + $def .= " ON DELETE {$foreignKey->getOnDelete()}"; + } + if ($foreignKey->getOnUpdate()) { + $def .= " ON UPDATE {$foreignKey->getOnUpdate()}"; + } + + return $def; + } + + /** + * @inheritDoc + */ + public function getColumnTypes(): array + { + return array_merge(parent::getColumnTypes(), static::$specificColumnTypes); + } + + /** + * Records a migration being run. + * + * @param \Phinx\Migration\MigrationInterface $migration Migration + * @param string $direction Direction + * @param string $startTime Start Time + * @param string $endTime End Time + * @return \Phinx\Db\Adapter\AdapterInterface + */ + public function migrated(MigrationInterface $migration, string $direction, string $startTime, string $endTime): AdapterInterface + { + $startTime = str_replace(' ', 'T', $startTime); + $endTime = str_replace(' ', 'T', $endTime); + + return parent::migrated($migration, $direction, $startTime, $endTime); + } + + /** + * @inheritDoc + */ + public function getDecoratedConnection(): Connection + { + $options = $this->getOptions(); + $options = [ + 'username' => $options['user'] ?? null, + 'password' => $options['pass'] ?? null, + 'database' => $options['name'], + 'quoteIdentifiers' => true, + ] + $options; + + $driver = new SqlServerDriver($options); + $driver->setConnection($this->connection); + + return new Connection(['driver' => $driver] + $options); + } +} diff --git a/extend/phinx/Db/Adapter/TablePrefixAdapter.php b/extend/phinx/Db/Adapter/TablePrefixAdapter.php new file mode 100644 index 0000000..dafc840 --- /dev/null +++ b/extend/phinx/Db/Adapter/TablePrefixAdapter.php @@ -0,0 +1,494 @@ + + */ +class TablePrefixAdapter extends AdapterWrapper implements DirectActionInterface +{ + /** + * @inheritDoc + */ + public function getAdapterType(): string + { + return 'TablePrefixAdapter'; + } + + /** + * @inheritDoc + */ + public function hasTable(string $tableName): bool + { + $adapterTableName = $this->getAdapterTableName($tableName); + + return parent::hasTable($adapterTableName); + } + + /** + * @inheritDoc + */ + public function createTable(Table $table, array $columns = [], array $indexes = []): void + { + $adapterTable = new Table( + $this->getAdapterTableName($table->getName()), + $table->getOptions() + ); + parent::createTable($adapterTable, $columns, $indexes); + } + + /** + * {@inheritDoc} + * + * @throws \BadMethodCallException + * @return void + */ + public function changePrimaryKey(Table $table, $newColumns): void + { + $adapter = $this->getAdapter(); + if (!$adapter instanceof DirectActionInterface) { + throw new BadMethodCallException('The underlying adapter does not implement DirectActionInterface'); + } + + $adapterTable = new Table( + $this->getAdapterTableName($table->getName()), + $table->getOptions() + ); + $adapter->changePrimaryKey($adapterTable, $newColumns); + } + + /** + * {@inheritDoc} + * + * @throws \BadMethodCallException + * @return void + */ + public function changeComment(Table $table, ?string $newComment): void + { + $adapter = $this->getAdapter(); + if (!$adapter instanceof DirectActionInterface) { + throw new BadMethodCallException('The underlying adapter does not implement DirectActionInterface'); + } + + $adapterTable = new Table( + $this->getAdapterTableName($table->getName()), + $table->getOptions() + ); + $adapter->changeComment($adapterTable, $newComment); + } + + /** + * {@inheritDoc} + * + * @throws \BadMethodCallException + * @return void + */ + public function renameTable(string $tableName, string $newTableName): void + { + $adapter = $this->getAdapter(); + if (!$adapter instanceof DirectActionInterface) { + throw new BadMethodCallException('The underlying adapter does not implement DirectActionInterface'); + } + + $adapterTableName = $this->getAdapterTableName($tableName); + $adapterNewTableName = $this->getAdapterTableName($newTableName); + $adapter->renameTable($adapterTableName, $adapterNewTableName); + } + + /** + * {@inheritDoc} + * + * @throws \BadMethodCallException + * @return void + */ + public function dropTable(string $tableName): void + { + $adapter = $this->getAdapter(); + if (!$adapter instanceof DirectActionInterface) { + throw new BadMethodCallException('The underlying adapter does not implement DirectActionInterface'); + } + $adapterTableName = $this->getAdapterTableName($tableName); + $adapter->dropTable($adapterTableName); + } + + /** + * @inheritDoc + */ + public function truncateTable(string $tableName): void + { + $adapterTableName = $this->getAdapterTableName($tableName); + parent::truncateTable($adapterTableName); + } + + /** + * @inheritDoc + */ + public function getColumns(string $tableName): array + { + $adapterTableName = $this->getAdapterTableName($tableName); + + return parent::getColumns($adapterTableName); + } + + /** + * @inheritDoc + */ + public function hasColumn(string $tableName, string $columnName): bool + { + $adapterTableName = $this->getAdapterTableName($tableName); + + return parent::hasColumn($adapterTableName, $columnName); + } + + /** + * {@inheritDoc} + * + * @throws \BadMethodCallException + * @return void + */ + public function addColumn(Table $table, Column $column): void + { + $adapter = $this->getAdapter(); + if (!$adapter instanceof DirectActionInterface) { + throw new BadMethodCallException('The underlying adapter does not implement DirectActionInterface'); + } + $adapterTableName = $this->getAdapterTableName($table->getName()); + $adapterTable = new Table($adapterTableName, $table->getOptions()); + $adapter->addColumn($adapterTable, $column); + } + + /** + * {@inheritDoc} + * + * @throws \BadMethodCallException + * @return void + */ + public function renameColumn(string $tableName, string $columnName, string $newColumnName): void + { + $adapter = $this->getAdapter(); + if (!$adapter instanceof DirectActionInterface) { + throw new BadMethodCallException('The underlying adapter does not implement DirectActionInterface'); + } + $adapterTableName = $this->getAdapterTableName($tableName); + $adapter->renameColumn($adapterTableName, $columnName, $newColumnName); + } + + /** + * {@inheritDoc} + * + * @throws \BadMethodCallException + * @return void + */ + public function changeColumn(string $tableName, string $columnName, Column $newColumn): void + { + $adapter = $this->getAdapter(); + if (!$adapter instanceof DirectActionInterface) { + throw new BadMethodCallException('The underlying adapter does not implement DirectActionInterface'); + } + $adapterTableName = $this->getAdapterTableName($tableName); + $adapter->changeColumn($adapterTableName, $columnName, $newColumn); + } + + /** + * {@inheritDoc} + * + * @throws \BadMethodCallException + * @return void + */ + public function dropColumn(string $tableName, string $columnName): void + { + $adapter = $this->getAdapter(); + if (!$adapter instanceof DirectActionInterface) { + throw new BadMethodCallException('The underlying adapter does not implement DirectActionInterface'); + } + $adapterTableName = $this->getAdapterTableName($tableName); + $adapter->dropColumn($adapterTableName, $columnName); + } + + /** + * @inheritDoc + */ + public function hasIndex(string $tableName, $columns): bool + { + $adapterTableName = $this->getAdapterTableName($tableName); + + return parent::hasIndex($adapterTableName, $columns); + } + + /** + * @inheritDoc + */ + public function hasIndexByName(string $tableName, string $indexName): bool + { + $adapterTableName = $this->getAdapterTableName($tableName); + + return parent::hasIndexByName($adapterTableName, $indexName); + } + + /** + * {@inheritDoc} + * + * @throws \BadMethodCallException + * @return void + */ + public function addIndex(Table $table, Index $index): void + { + $adapter = $this->getAdapter(); + if (!$adapter instanceof DirectActionInterface) { + throw new BadMethodCallException('The underlying adapter does not implement DirectActionInterface'); + } + $adapterTable = new Table($table->getName(), $table->getOptions()); + $adapter->addIndex($adapterTable, $index); + } + + /** + * {@inheritDoc} + * + * @throws \BadMethodCallException + * @return void + */ + public function dropIndex(string $tableName, $columns): void + { + $adapter = $this->getAdapter(); + if (!$adapter instanceof DirectActionInterface) { + throw new BadMethodCallException('The underlying adapter does not implement DirectActionInterface'); + } + $adapterTableName = $this->getAdapterTableName($tableName); + $adapter->dropIndex($adapterTableName, $columns); + } + + /** + * {@inheritDoc} + * + * @throws \BadMethodCallException + * @return void + */ + public function dropIndexByName(string $tableName, string $indexName): void + { + $adapter = $this->getAdapter(); + if (!$adapter instanceof DirectActionInterface) { + throw new BadMethodCallException('The underlying adapter does not implement DirectActionInterface'); + } + $adapterTableName = $this->getAdapterTableName($tableName); + $adapter->dropIndexByName($adapterTableName, $indexName); + } + + /** + * @inheritDoc + */ + public function hasPrimaryKey(string $tableName, $columns, ?string $constraint = null): bool + { + $adapterTableName = $this->getAdapterTableName($tableName); + + return parent::hasPrimaryKey($adapterTableName, $columns, $constraint); + } + + /** + * @inheritDoc + */ + public function hasForeignKey(string $tableName, $columns, ?string $constraint = null): bool + { + $adapterTableName = $this->getAdapterTableName($tableName); + + return parent::hasForeignKey($adapterTableName, $columns, $constraint); + } + + /** + * {@inheritDoc} + * + * @throws \BadMethodCallException + * @return void + */ + public function addForeignKey(Table $table, ForeignKey $foreignKey): void + { + $adapter = $this->getAdapter(); + if (!$adapter instanceof DirectActionInterface) { + throw new BadMethodCallException('The underlying adapter does not implement DirectActionInterface'); + } + $adapterTableName = $this->getAdapterTableName($table->getName()); + $adapterTable = new Table($adapterTableName, $table->getOptions()); + $adapter->addForeignKey($adapterTable, $foreignKey); + } + + /** + * {@inheritDoc} + * + * @throws \BadMethodCallException + * @return void + */ + public function dropForeignKey(string $tableName, array $columns, ?string $constraint = null): void + { + $adapter = $this->getAdapter(); + if (!$adapter instanceof DirectActionInterface) { + throw new BadMethodCallException('The underlying adapter does not implement DirectActionInterface'); + } + $adapterTableName = $this->getAdapterTableName($tableName); + $adapter->dropForeignKey($adapterTableName, $columns, $constraint); + } + + /** + * @inheritDoc + */ + public function insert(Table $table, array $row): void + { + $adapterTableName = $this->getAdapterTableName($table->getName()); + $adapterTable = new Table($adapterTableName, $table->getOptions()); + parent::insert($adapterTable, $row); + } + + /** + * @inheritDoc + */ + public function bulkinsert(Table $table, array $rows): void + { + $adapterTableName = $this->getAdapterTableName($table->getName()); + $adapterTable = new Table($adapterTableName, $table->getOptions()); + parent::bulkinsert($adapterTable, $rows); + } + + /** + * Gets the table prefix. + * + * @return string + */ + public function getPrefix(): string + { + return (string)$this->getOption('table_prefix'); + } + + /** + * Gets the table suffix. + * + * @return string + */ + public function getSuffix(): string + { + return (string)$this->getOption('table_suffix'); + } + + /** + * Applies the prefix and suffix to the table name. + * + * @param string $tableName Table name + * @return string + */ + public function getAdapterTableName(string $tableName): string + { + return $this->getPrefix() . $tableName . $this->getSuffix(); + } + + /** + * {@inheritDoc} + * + * @throws \InvalidArgumentException + * @return void + */ + public function executeActions(Table $table, array $actions): void + { + $adapterTableName = $this->getAdapterTableName($table->getName()); + $adapterTable = new Table($adapterTableName, $table->getOptions()); + + foreach ($actions as $k => $action) { + switch (true) { + case $action instanceof AddColumn: + /** @var \Phinx\Db\Action\AddColumn $action */ + $actions[$k] = new AddColumn($adapterTable, $action->getColumn()); + break; + + case $action instanceof AddIndex: + /** @var \Phinx\Db\Action\AddIndex $action */ + $actions[$k] = new AddIndex($adapterTable, $action->getIndex()); + break; + + case $action instanceof AddForeignKey: + /** @var \Phinx\Db\Action\AddForeignKey $action */ + $foreignKey = clone $action->getForeignKey(); + $refTable = $foreignKey->getReferencedTable(); + $refTableName = $this->getAdapterTableName($refTable->getName()); + $foreignKey->setReferencedTable(new Table($refTableName, $refTable->getOptions())); + $actions[$k] = new AddForeignKey($adapterTable, $foreignKey); + break; + + case $action instanceof ChangeColumn: + /** @var \Phinx\Db\Action\ChangeColumn $action */ + $actions[$k] = new ChangeColumn($adapterTable, $action->getColumnName(), $action->getColumn()); + break; + + case $action instanceof DropForeignKey: + /** @var \Phinx\Db\Action\DropForeignKey $action */ + $actions[$k] = new DropForeignKey($adapterTable, $action->getForeignKey()); + break; + + case $action instanceof DropIndex: + /** @var \Phinx\Db\Action\DropIndex $action */ + $actions[$k] = new DropIndex($adapterTable, $action->getIndex()); + break; + + case $action instanceof DropTable: + /** @var \Phinx\Db\Action\DropTable $action */ + $actions[$k] = new DropTable($adapterTable); + break; + + case $action instanceof RemoveColumn: + /** @var \Phinx\Db\Action\RemoveColumn $action */ + $actions[$k] = new RemoveColumn($adapterTable, $action->getColumn()); + break; + + case $action instanceof RenameColumn: + /** @var \Phinx\Db\Action\RenameColumn $action */ + $actions[$k] = new RenameColumn($adapterTable, $action->getColumn(), $action->getNewName()); + break; + + case $action instanceof RenameTable: + /** @var \Phinx\Db\Action\RenameTable $action */ + $actions[$k] = new RenameTable($adapterTable, $this->getAdapterTableName($action->getNewName())); + break; + + case $action instanceof ChangePrimaryKey: + /** @var \Phinx\Db\Action\ChangePrimaryKey $action */ + $actions[$k] = new ChangePrimaryKey($adapterTable, $action->getNewColumns()); + break; + + case $action instanceof ChangeComment: + /** @var \Phinx\Db\Action\ChangeComment $action */ + $actions[$k] = new ChangeComment($adapterTable, $action->getNewComment()); + break; + + default: + throw new InvalidArgumentException( + sprintf("Forgot to implement table prefixing for action: '%s'", get_class($action)) + ); + } + } + + parent::executeActions($adapterTable, $actions); + } +} diff --git a/extend/phinx/Db/Adapter/TimedOutputAdapter.php b/extend/phinx/Db/Adapter/TimedOutputAdapter.php new file mode 100644 index 0000000..839a998 --- /dev/null +++ b/extend/phinx/Db/Adapter/TimedOutputAdapter.php @@ -0,0 +1,423 @@ +getAdapter()->getAdapterType(); + } + + /** + * Start timing a command. + * + * @return callable A function that is to be called when the command finishes + */ + public function startCommandTimer(): callable + { + $started = microtime(true); + + return function () use ($started) { + $end = microtime(true); + if (OutputInterface::VERBOSITY_VERBOSE <= $this->getOutput()->getVerbosity()) { + $this->getOutput()->writeln(' -> ' . sprintf('%.4fs', $end - $started)); + } + }; + } + + /** + * Write a Phinx command to the output. + * + * @param string $command Command Name + * @param array $args Command Args + * @return void + */ + public function writeCommand(string $command, array $args = []): void + { + if (OutputInterface::VERBOSITY_VERBOSE > $this->getOutput()->getVerbosity()) { + return; + } + + if (count($args)) { + $outArr = []; + foreach ($args as $arg) { + if (is_array($arg)) { + $arg = array_map( + function ($value) { + return '\'' . $value . '\''; + }, + $arg + ); + $outArr[] = '[' . implode(', ', $arg) . ']'; + continue; + } + + $outArr[] = '\'' . $arg . '\''; + } + $this->getOutput()->writeln(' -- ' . $command . '(' . implode(', ', $outArr) . ')'); + + return; + } + + $this->getOutput()->writeln(' -- ' . $command); + } + + /** + * @inheritDoc + */ + public function insert(Table $table, array $row): void + { + $end = $this->startCommandTimer(); + $this->writeCommand('insert', [$table->getName()]); + parent::insert($table, $row); + $end(); + } + + /** + * @inheritDoc + */ + public function bulkinsert(Table $table, array $rows): void + { + $end = $this->startCommandTimer(); + $this->writeCommand('bulkinsert', [$table->getName()]); + parent::bulkinsert($table, $rows); + $end(); + } + + /** + * @inheritDoc + */ + public function createTable(Table $table, array $columns = [], array $indexes = []): void + { + $end = $this->startCommandTimer(); + $this->writeCommand('createTable', [$table->getName()]); + parent::createTable($table, $columns, $indexes); + $end(); + } + + /** + * {@inheritDoc} + * + * @throws \BadMethodCallException + * @return void + */ + public function changePrimaryKey(Table $table, $newColumns): void + { + $adapter = $this->getAdapter(); + if (!$adapter instanceof DirectActionInterface) { + throw new BadMethodCallException('The adapter needs to implement DirectActionInterface'); + } + $end = $this->startCommandTimer(); + $this->writeCommand('changePrimaryKey', [$table->getName()]); + $adapter->changePrimaryKey($table, $newColumns); + $end(); + } + + /** + * {@inheritDoc} + * + * @throws \BadMethodCallException + * @return void + */ + public function changeComment(Table $table, ?string $newComment): void + { + $adapter = $this->getAdapter(); + if (!$adapter instanceof DirectActionInterface) { + throw new BadMethodCallException('The adapter needs to implement DirectActionInterface'); + } + $end = $this->startCommandTimer(); + $this->writeCommand('changeComment', [$table->getName()]); + $adapter->changeComment($table, $newComment); + $end(); + } + + /** + * {@inheritDoc} + * + * @throws \BadMethodCallException + * @return void + */ + public function renameTable(string $tableName, string $newTableName): void + { + $adapter = $this->getAdapter(); + if (!$adapter instanceof DirectActionInterface) { + throw new BadMethodCallException('The adapter needs to implement DirectActionInterface'); + } + $end = $this->startCommandTimer(); + $this->writeCommand('renameTable', [$tableName, $newTableName]); + $adapter->renameTable($tableName, $newTableName); + $end(); + } + + /** + * {@inheritDoc} + * + * @throws \BadMethodCallException + * @return void + */ + public function dropTable(string $tableName): void + { + $adapter = $this->getAdapter(); + if (!$adapter instanceof DirectActionInterface) { + throw new BadMethodCallException('The adapter needs to implement DirectActionInterface'); + } + $end = $this->startCommandTimer(); + $this->writeCommand('dropTable', [$tableName]); + $adapter->dropTable($tableName); + $end(); + } + + /** + * @inheritDoc + */ + public function truncateTable(string $tableName): void + { + $end = $this->startCommandTimer(); + $this->writeCommand('truncateTable', [$tableName]); + parent::truncateTable($tableName); + $end(); + } + + /** + * {@inheritDoc} + * + * @throws \BadMethodCallException + * @return void + */ + public function addColumn(Table $table, Column $column): void + { + $adapter = $this->getAdapter(); + if (!$adapter instanceof DirectActionInterface) { + throw new BadMethodCallException('The adapter needs to implement DirectActionInterface'); + } + $end = $this->startCommandTimer(); + $this->writeCommand( + 'addColumn', + [ + $table->getName(), + $column->getName(), + $column->getType(), + ] + ); + $adapter->addColumn($table, $column); + $end(); + } + + /** + * {@inheritDoc} + * + * @throws \BadMethodCallException + * @return void + */ + public function renameColumn(string $tableName, string $columnName, string $newColumnName): void + { + $adapter = $this->getAdapter(); + if (!$adapter instanceof DirectActionInterface) { + throw new BadMethodCallException('The adapter needs to implement DirectActionInterface'); + } + $end = $this->startCommandTimer(); + $this->writeCommand('renameColumn', [$tableName, $columnName, $newColumnName]); + $adapter->renameColumn($tableName, $columnName, $newColumnName); + $end(); + } + + /** + * {@inheritDoc} + * + * @throws \BadMethodCallException + * @return void + */ + public function changeColumn(string $tableName, string $columnName, Column $newColumn): void + { + $adapter = $this->getAdapter(); + if (!$adapter instanceof DirectActionInterface) { + throw new BadMethodCallException('The adapter needs to implement DirectActionInterface'); + } + $end = $this->startCommandTimer(); + $this->writeCommand('changeColumn', [$tableName, $columnName, $newColumn->getType()]); + $adapter->changeColumn($tableName, $columnName, $newColumn); + $end(); + } + + /** + * {@inheritDoc} + * + * @throws \BadMethodCallException + * @return void + */ + public function dropColumn(string $tableName, string $columnName): void + { + $adapter = $this->getAdapter(); + if (!$adapter instanceof DirectActionInterface) { + throw new BadMethodCallException('The adapter needs to implement DirectActionInterface'); + } + $end = $this->startCommandTimer(); + $this->writeCommand('dropColumn', [$tableName, $columnName]); + $adapter->dropColumn($tableName, $columnName); + $end(); + } + + /** + * {@inheritDoc} + * + * @throws \BadMethodCallException + * @return void + */ + public function addIndex(Table $table, Index $index): void + { + $adapter = $this->getAdapter(); + if (!$adapter instanceof DirectActionInterface) { + throw new BadMethodCallException('The adapter needs to implement DirectActionInterface'); + } + $end = $this->startCommandTimer(); + $this->writeCommand('addIndex', [$table->getName(), $index->getColumns()]); + $adapter->addIndex($table, $index); + $end(); + } + + /** + * {@inheritDoc} + * + * @throws \BadMethodCallException + * @return void + */ + public function dropIndex(string $tableName, $columns): void + { + $adapter = $this->getAdapter(); + if (!$adapter instanceof DirectActionInterface) { + throw new BadMethodCallException('The adapter needs to implement DirectActionInterface'); + } + $end = $this->startCommandTimer(); + $this->writeCommand('dropIndex', [$tableName, $columns]); + $adapter->dropIndex($tableName, $columns); + $end(); + } + + /** + * {@inheritDoc} + * + * @throws \BadMethodCallException + * @return void + */ + public function dropIndexByName(string $tableName, string $indexName): void + { + $adapter = $this->getAdapter(); + if (!$adapter instanceof DirectActionInterface) { + throw new BadMethodCallException('The adapter needs to implement DirectActionInterface'); + } + $end = $this->startCommandTimer(); + $this->writeCommand('dropIndexByName', [$tableName, $indexName]); + $adapter->dropIndexByName($tableName, $indexName); + $end(); + } + + /** + * {@inheritDoc} + * + * @throws \BadMethodCallException + * @return void + */ + public function addForeignKey(Table $table, ForeignKey $foreignKey): void + { + $adapter = $this->getAdapter(); + if (!$adapter instanceof DirectActionInterface) { + throw new BadMethodCallException('The adapter needs to implement DirectActionInterface'); + } + $end = $this->startCommandTimer(); + $this->writeCommand('addForeignKey', [$table->getName(), $foreignKey->getColumns()]); + $adapter->addForeignKey($table, $foreignKey); + $end(); + } + + /** + * {@inheritDoc} + * + * @throws \BadMethodCallException + * @return void + */ + public function dropForeignKey(string $tableName, array $columns, ?string $constraint = null): void + { + $adapter = $this->getAdapter(); + if (!$adapter instanceof DirectActionInterface) { + throw new BadMethodCallException('The adapter needs to implement DirectActionInterface'); + } + $end = $this->startCommandTimer(); + $this->writeCommand('dropForeignKey', [$tableName, $columns]); + $adapter->dropForeignKey($tableName, $columns, $constraint); + $end(); + } + + /** + * @inheritDoc + */ + public function createDatabase(string $name, array $options = []): void + { + $end = $this->startCommandTimer(); + $this->writeCommand('createDatabase', [$name]); + parent::createDatabase($name, $options); + $end(); + } + + /** + * @inheritDoc + */ + public function dropDatabase(string $name): void + { + $end = $this->startCommandTimer(); + $this->writeCommand('dropDatabase', [$name]); + parent::dropDatabase($name); + $end(); + } + + /** + * @inheritDoc + */ + public function createSchema(string $name = 'public'): void + { + $end = $this->startCommandTimer(); + $this->writeCommand('createSchema', [$name]); + parent::createSchema($name); + $end(); + } + + /** + * @inheritDoc + */ + public function dropSchema(string $name): void + { + $end = $this->startCommandTimer(); + $this->writeCommand('dropSchema', [$name]); + parent::dropSchema($name); + $end(); + } + + /** + * @inheritDoc + */ + public function executeActions(Table $table, array $actions): void + { + $end = $this->startCommandTimer(); + $this->writeCommand(sprintf('Altering table %s', $table->getName())); + parent::executeActions($table, $actions); + $end(); + } +} diff --git a/extend/phinx/Db/Adapter/UnsupportedColumnTypeException.php b/extend/phinx/Db/Adapter/UnsupportedColumnTypeException.php new file mode 100644 index 0000000..5ef75e9 --- /dev/null +++ b/extend/phinx/Db/Adapter/UnsupportedColumnTypeException.php @@ -0,0 +1,19 @@ + + */ +class UnsupportedColumnTypeException extends RuntimeException +{ +} diff --git a/extend/phinx/Db/Adapter/WrapperInterface.php b/extend/phinx/Db/Adapter/WrapperInterface.php new file mode 100644 index 0000000..669d0d2 --- /dev/null +++ b/extend/phinx/Db/Adapter/WrapperInterface.php @@ -0,0 +1,39 @@ + + */ +interface WrapperInterface +{ + /** + * Class constructor, must always wrap another adapter. + * + * @param \Phinx\Db\Adapter\AdapterInterface $adapter Adapter + */ + public function __construct(AdapterInterface $adapter); + + /** + * Sets the database adapter to proxy commands to. + * + * @param \Phinx\Db\Adapter\AdapterInterface $adapter Adapter + * @return \Phinx\Db\Adapter\AdapterInterface + */ + public function setAdapter(AdapterInterface $adapter): AdapterInterface; + + /** + * Gets the database adapter. + * + * @throws \RuntimeException if the adapter has not been set + * @return \Phinx\Db\Adapter\AdapterInterface + */ + public function getAdapter(): AdapterInterface; +} diff --git a/extend/phinx/Db/Plan/AlterTable.php b/extend/phinx/Db/Plan/AlterTable.php new file mode 100644 index 0000000..1746e15 --- /dev/null +++ b/extend/phinx/Db/Plan/AlterTable.php @@ -0,0 +1,72 @@ +table = $table; + } + + /** + * Adds another action to the collection + * + * @param \Phinx\Db\Action\Action $action The action to add + * @return void + */ + public function addAction(Action $action): void + { + $this->actions[] = $action; + } + + /** + * Returns the table associated to this collection + * + * @return \Phinx\Db\Table\Table + */ + public function getTable(): Table + { + return $this->table; + } + + /** + * Returns an array with all collected actions + * + * @return \Phinx\Db\Action\Action[] + */ + public function getActions(): array + { + return $this->actions; + } +} diff --git a/extend/phinx/Db/Plan/Intent.php b/extend/phinx/Db/Plan/Intent.php new file mode 100644 index 0000000..eadd344 --- /dev/null +++ b/extend/phinx/Db/Plan/Intent.php @@ -0,0 +1,55 @@ +actions[] = $action; + } + + /** + * Returns the full list of actions + * + * @return \Phinx\Db\Action\Action[] + */ + public function getActions(): array + { + return $this->actions; + } + + /** + * Merges another Intent object with this one + * + * @param \Phinx\Db\Plan\Intent $another The other intent to merge in + * @return void + */ + public function merge(Intent $another): void + { + $this->actions = array_merge($this->actions, $another->getActions()); + } +} diff --git a/extend/phinx/Db/Plan/NewTable.php b/extend/phinx/Db/Plan/NewTable.php new file mode 100644 index 0000000..0ac62fc --- /dev/null +++ b/extend/phinx/Db/Plan/NewTable.php @@ -0,0 +1,101 @@ +table = $table; + } + + /** + * Adds a column to the collection + * + * @param \Phinx\Db\Table\Column $column The column description + * @return void + */ + public function addColumn(Column $column): void + { + $this->columns[] = $column; + } + + /** + * Adds an index to the collection + * + * @param \Phinx\Db\Table\Index $index The index description + * @return void + */ + public function addIndex(Index $index): void + { + $this->indexes[] = $index; + } + + /** + * Returns the table object associated to this collection + * + * @return \Phinx\Db\Table\Table + */ + public function getTable(): Table + { + return $this->table; + } + + /** + * Returns the columns collection + * + * @return \Phinx\Db\Table\Column[] + */ + public function getColumns(): array + { + return $this->columns; + } + + /** + * Returns the indexes collection + * + * @return \Phinx\Db\Table\Index[] + */ + public function getIndexes(): array + { + return $this->indexes; + } +} diff --git a/extend/phinx/Db/Plan/Plan.php b/extend/phinx/Db/Plan/Plan.php new file mode 100644 index 0000000..4e0b6fd --- /dev/null +++ b/extend/phinx/Db/Plan/Plan.php @@ -0,0 +1,492 @@ +createPlan($intent->getActions()); + } + + /** + * Parses the given Intent and creates the separate steps to execute + * + * @param \Phinx\Db\Action\Action[] $actions The actions to use for the plan + * @return void + */ + protected function createPlan(array $actions): void + { + $this->gatherCreates($actions); + $this->gatherUpdates($actions); + $this->gatherTableMoves($actions); + $this->gatherIndexes($actions); + $this->gatherConstraints($actions); + $this->resolveConflicts(); + } + + /** + * Returns a nested list of all the steps to execute + * + * @return \Phinx\Db\Plan\AlterTable[][] + */ + protected function updatesSequence(): array + { + return [ + $this->tableUpdates, + $this->constraints, + $this->indexes, + $this->columnRemoves, + $this->tableMoves, + ]; + } + + /** + * Returns a nested list of all the steps to execute in inverse order + * + * @return \Phinx\Db\Plan\AlterTable[][] + */ + protected function inverseUpdatesSequence(): array + { + return [ + $this->constraints, + $this->tableMoves, + $this->indexes, + $this->columnRemoves, + $this->tableUpdates, + ]; + } + + /** + * Executes this plan using the given AdapterInterface + * + * @param \Phinx\Db\Adapter\AdapterInterface $executor The executor object for the plan + * @return void + */ + public function execute(AdapterInterface $executor): void + { + foreach ($this->tableCreates as $newTable) { + $executor->createTable($newTable->getTable(), $newTable->getColumns(), $newTable->getIndexes()); + } + + foreach ($this->updatesSequence() as $updates) { + foreach ($updates as $update) { + $executor->executeActions($update->getTable(), $update->getActions()); + } + } + } + + /** + * Executes the inverse plan (rollback the actions) with the given AdapterInterface:w + * + * @param \Phinx\Db\Adapter\AdapterInterface $executor The executor object for the plan + * @return void + */ + public function executeInverse(AdapterInterface $executor): void + { + foreach ($this->inverseUpdatesSequence() as $updates) { + foreach ($updates as $update) { + $executor->executeActions($update->getTable(), $update->getActions()); + } + } + + foreach ($this->tableCreates as $newTable) { + $executor->createTable($newTable->getTable(), $newTable->getColumns(), $newTable->getIndexes()); + } + } + + /** + * Deletes certain actions from the plan if they are found to be conflicting or redundant. + * + * @return void + */ + protected function resolveConflicts(): void + { + foreach ($this->tableMoves as $alterTable) { + foreach ($alterTable->getActions() as $action) { + if ($action instanceof DropTable) { + $this->tableUpdates = $this->forgetTable($action->getTable(), $this->tableUpdates); + $this->constraints = $this->forgetTable($action->getTable(), $this->constraints); + $this->indexes = $this->forgetTable($action->getTable(), $this->indexes); + $this->columnRemoves = $this->forgetTable($action->getTable(), $this->columnRemoves); + } + } + } + + // Renaming a column and then changing the renamed column is something people do, + // but it is a conflicting action. Luckily solving the conflict can be done by moving + // the ChangeColumn action to another AlterTable. + $splitter = new ActionSplitter( + RenameColumn::class, + ChangeColumn::class, + function (RenameColumn $a, ChangeColumn $b) { + return $a->getNewName() === $b->getColumnName(); + } + ); + $tableUpdates = []; + foreach ($this->tableUpdates as $update) { + $tableUpdates = array_merge($tableUpdates, $splitter($update)); + } + $this->tableUpdates = $tableUpdates; + + // Dropping indexes used by foreign keys is a conflict, but one we can resolve + // if the foreign key is also scheduled to be dropped. If we can find such a a case, + // we force the execution of the index drop after the foreign key is dropped. + // Changing constraint properties sometimes require dropping it and then + // creating it again with the new stuff. Unfortunately, we have already bundled + // everything together in as few AlterTable statements as we could, so we need to + // resolve this conflict manually. + $splitter = new ActionSplitter( + DropForeignKey::class, + AddForeignKey::class, + function (DropForeignKey $a, AddForeignKey $b) { + return $a->getForeignKey()->getColumns() === $b->getForeignKey()->getColumns(); + } + ); + $constraints = []; + foreach ($this->constraints as $constraint) { + $constraints = array_merge( + $constraints, + $splitter($this->remapContraintAndIndexConflicts($constraint)) + ); + } + $this->constraints = $constraints; + } + + /** + * Deletes all actions related to the given table and keeps the + * rest + * + * @param \Phinx\Db\Table\Table $table The table to find in the list of actions + * @param \Phinx\Db\Plan\AlterTable[] $actions The actions to transform + * @return \Phinx\Db\Plan\AlterTable[] The list of actions without actions for the given table + */ + protected function forgetTable(Table $table, array $actions): array + { + $result = []; + foreach ($actions as $action) { + if ($action->getTable()->getName() === $table->getName()) { + continue; + } + $result[] = $action; + } + + return $result; + } + + /** + * Finds all DropForeignKey actions in an AlterTable and moves + * all conflicting DropIndex action in `$this->indexes` into the + * given AlterTable. + * + * @param \Phinx\Db\Plan\AlterTable $alter The collection of actions to inspect + * @return \Phinx\Db\Plan\AlterTable The updated AlterTable object. This function + * has the side effect of changing the `$this->indexes` property. + */ + protected function remapContraintAndIndexConflicts(AlterTable $alter): AlterTable + { + $newAlter = new AlterTable($alter->getTable()); + + foreach ($alter->getActions() as $action) { + $newAlter->addAction($action); + if ($action instanceof DropForeignKey) { + [$this->indexes, $dropIndexActions] = $this->forgetDropIndex( + $action->getTable(), + $action->getForeignKey()->getColumns(), + $this->indexes + ); + foreach ($dropIndexActions as $dropIndexAction) { + $newAlter->addAction($dropIndexAction); + } + } + } + + return $newAlter; + } + + /** + * Deletes any DropIndex actions for the given table and exact columns + * + * @param \Phinx\Db\Table\Table $table The table to find in the list of actions + * @param string[] $columns The column names to match + * @param \Phinx\Db\Plan\AlterTable[] $actions The actions to transform + * @return array A tuple containing the list of actions without actions for dropping the index + * and a list of drop index actions that were removed. + */ + protected function forgetDropIndex(Table $table, array $columns, array $actions): array + { + $dropIndexActions = new ArrayObject(); + $indexes = array_map(function ($alter) use ($table, $columns, $dropIndexActions) { + if ($alter->getTable()->getName() !== $table->getName()) { + return $alter; + } + + $newAlter = new AlterTable($table); + foreach ($alter->getActions() as $action) { + if ($action instanceof DropIndex && $action->getIndex()->getColumns() === $columns) { + $dropIndexActions->append($action); + } else { + $newAlter->addAction($action); + } + } + + return $newAlter; + }, $actions); + + return [$indexes, $dropIndexActions->getArrayCopy()]; + } + + /** + * Deletes any RemoveColumn actions for the given table and exact columns + * + * @param \Phinx\Db\Table\Table $table The table to find in the list of actions + * @param string[] $columns The column names to match + * @param \Phinx\Db\Plan\AlterTable[] $actions The actions to transform + * @return array A tuple containing the list of actions without actions for removing the column + * and a list of remove column actions that were removed. + */ + protected function forgetRemoveColumn(Table $table, array $columns, array $actions): array + { + $removeColumnActions = new ArrayObject(); + $indexes = array_map(function ($alter) use ($table, $columns, $removeColumnActions) { + if ($alter->getTable()->getName() !== $table->getName()) { + return $alter; + } + + $newAlter = new AlterTable($table); + foreach ($alter->getActions() as $action) { + if ($action instanceof RemoveColumn && in_array($action->getColumn()->getName(), $columns, true)) { + $removeColumnActions->append($action); + } else { + $newAlter->addAction($action); + } + } + + return $newAlter; + }, $actions); + + return [$indexes, $removeColumnActions->getArrayCopy()]; + } + + /** + * Collects all table creation actions from the given intent + * + * @param \Phinx\Db\Action\Action[] $actions The actions to parse + * @return void + */ + protected function gatherCreates(array $actions): void + { + foreach ($actions as $action) { + if ($action instanceof CreateTable) { + $this->tableCreates[$action->getTable()->getName()] = new NewTable($action->getTable()); + } + } + + foreach ($actions as $action) { + if ( + ($action instanceof AddColumn || $action instanceof AddIndex) + && isset($this->tableCreates[$action->getTable()->getName()]) + ) { + $table = $action->getTable(); + + if ($action instanceof AddColumn) { + $this->tableCreates[$table->getName()]->addColumn($action->getColumn()); + } + + if ($action instanceof AddIndex) { + $this->tableCreates[$table->getName()]->addIndex($action->getIndex()); + } + } + } + } + + /** + * Collects all alter table actions from the given intent + * + * @param \Phinx\Db\Action\Action[] $actions The actions to parse + * @return void + */ + protected function gatherUpdates(array $actions): void + { + foreach ($actions as $action) { + if ( + !($action instanceof AddColumn) + && !($action instanceof ChangeColumn) + && !($action instanceof RemoveColumn) + && !($action instanceof RenameColumn) + ) { + continue; + } elseif (isset($this->tableCreates[$action->getTable()->getName()])) { + continue; + } + $table = $action->getTable(); + $name = $table->getName(); + + if ($action instanceof RemoveColumn) { + if (!isset($this->columnRemoves[$name])) { + $this->columnRemoves[$name] = new AlterTable($table); + } + $this->columnRemoves[$name]->addAction($action); + } else { + if (!isset($this->tableUpdates[$name])) { + $this->tableUpdates[$name] = new AlterTable($table); + } + $this->tableUpdates[$name]->addAction($action); + } + } + } + + /** + * Collects all alter table drop and renames from the given intent + * + * @param \Phinx\Db\Action\Action[] $actions The actions to parse + * @return void + */ + protected function gatherTableMoves(array $actions): void + { + foreach ($actions as $action) { + if ( + !($action instanceof DropTable) + && !($action instanceof RenameTable) + && !($action instanceof ChangePrimaryKey) + && !($action instanceof ChangeComment) + ) { + continue; + } + $table = $action->getTable(); + $name = $table->getName(); + + if (!isset($this->tableMoves[$name])) { + $this->tableMoves[$name] = new AlterTable($table); + } + + $this->tableMoves[$name]->addAction($action); + } + } + + /** + * Collects all index creation and drops from the given intent + * + * @param \Phinx\Db\Action\Action[] $actions The actions to parse + * @return void + */ + protected function gatherIndexes(array $actions): void + { + foreach ($actions as $action) { + if (!($action instanceof AddIndex) && !($action instanceof DropIndex)) { + continue; + } elseif (isset($this->tableCreates[$action->getTable()->getName()])) { + continue; + } + + $table = $action->getTable(); + $name = $table->getName(); + + if (!isset($this->indexes[$name])) { + $this->indexes[$name] = new AlterTable($table); + } + + $this->indexes[$name]->addAction($action); + } + } + + /** + * Collects all foreign key creation and drops from the given intent + * + * @param \Phinx\Db\Action\Action[] $actions The actions to parse + * @return void + */ + protected function gatherConstraints(array $actions): void + { + foreach ($actions as $action) { + if (!($action instanceof AddForeignKey || $action instanceof DropForeignKey)) { + continue; + } + $table = $action->getTable(); + $name = $table->getName(); + + if (!isset($this->constraints[$name])) { + $this->constraints[$name] = new AlterTable($table); + } + + $this->constraints[$name]->addAction($action); + } + } +} diff --git a/extend/phinx/Db/Plan/Solver/ActionSplitter.php b/extend/phinx/Db/Plan/Solver/ActionSplitter.php new file mode 100644 index 0000000..a9a157a --- /dev/null +++ b/extend/phinx/Db/Plan/Solver/ActionSplitter.php @@ -0,0 +1,103 @@ +conflictClass = $conflictClass; + $this->conflictClassDual = $conflictClassDual; + $this->conflictFilter = $conflictFilter; + } + + /** + * Returs a sequence of AlterTable instructions that are non conflicting + * based on the constructor parameters. + * + * @param \Phinx\Db\Plan\AlterTable $alter The collection of actions to inspect + * @return \Phinx\Db\Plan\AlterTable[] A list of AlterTable that can be executed without + * this type of conflict + */ + public function __invoke(AlterTable $alter): array + { + $conflictActions = array_filter($alter->getActions(), function ($action) { + return $action instanceof $this->conflictClass; + }); + + $originalAlter = new AlterTable($alter->getTable()); + $newAlter = new AlterTable($alter->getTable()); + + foreach ($alter->getActions() as $action) { + if (!$action instanceof $this->conflictClassDual) { + $originalAlter->addAction($action); + continue; + } + + $found = false; + $matches = $this->conflictFilter; + foreach ($conflictActions as $ca) { + if ($matches($ca, $action)) { + $found = true; + break; + } + } + + if ($found) { + $newAlter->addAction($action); + } else { + $originalAlter->addAction($action); + } + } + + return [$originalAlter, $newAlter]; + } +} diff --git a/extend/phinx/Db/Table.php b/extend/phinx/Db/Table.php new file mode 100644 index 0000000..e532517 --- /dev/null +++ b/extend/phinx/Db/Table.php @@ -0,0 +1,721 @@ + $options Options + * @param \Phinx\Db\Adapter\AdapterInterface|null $adapter Database Adapter + */ + public function __construct(string $name, array $options = [], ?AdapterInterface $adapter = null) + { + $this->table = new TableValue($name, $options); + $this->actions = new Intent(); + + if ($adapter !== null) { + $this->setAdapter($adapter); + } + } + + /** + * Gets the table name. + * + * @return string + */ + public function getName(): string + { + return $this->table->getName(); + } + + /** + * Gets the table options. + * + * @return array + */ + public function getOptions(): array + { + return $this->table->getOptions(); + } + + /** + * Gets the table name and options as an object + * + * @return \Phinx\Db\Table\Table + */ + public function getTable(): TableValue + { + return $this->table; + } + + /** + * Sets the database adapter. + * + * @param \Phinx\Db\Adapter\AdapterInterface $adapter Database Adapter + * @return $this + */ + public function setAdapter(AdapterInterface $adapter) + { + $this->adapter = $adapter; + + return $this; + } + + /** + * Gets the database adapter. + * + * @throws \RuntimeException + * @return \Phinx\Db\Adapter\AdapterInterface + */ + public function getAdapter(): AdapterInterface + { + if (!$this->adapter) { + throw new RuntimeException('There is no database adapter set yet, cannot proceed'); + } + + return $this->adapter; + } + + /** + * Does the table have pending actions? + * + * @return bool + */ + public function hasPendingActions(): bool + { + return count($this->actions->getActions()) > 0 || count($this->data) > 0; + } + + /** + * Does the table exist? + * + * @return bool + */ + public function exists(): bool + { + return $this->getAdapter()->hasTable($this->getName()); + } + + /** + * Drops the database table. + * + * @return $this + */ + public function drop() + { + $this->actions->addAction(new DropTable($this->table)); + + return $this; + } + + /** + * Renames the database table. + * + * @param string $newTableName New Table Name + * @return $this + */ + public function rename(string $newTableName) + { + $this->actions->addAction(new RenameTable($this->table, $newTableName)); + + return $this; + } + + /** + * Changes the primary key of the database table. + * + * @param string|string[]|null $columns Column name(s) to belong to the primary key, or null to drop the key + * @return $this + */ + public function changePrimaryKey($columns) + { + $this->actions->addAction(new ChangePrimaryKey($this->table, $columns)); + + return $this; + } + + /** + * Checks to see if a primary key exists. + * + * @param string|string[] $columns Column(s) + * @param string|null $constraint Constraint names + * @return bool + */ + public function hasPrimaryKey($columns, ?string $constraint = null): bool + { + return $this->getAdapter()->hasPrimaryKey($this->getName(), $columns, $constraint); + } + + /** + * Changes the comment of the database table. + * + * @param string|null $comment New comment string, or null to drop the comment + * @return $this + */ + public function changeComment(?string $comment) + { + $this->actions->addAction(new ChangeComment($this->table, $comment)); + + return $this; + } + + /** + * Gets an array of the table columns. + * + * @return \Phinx\Db\Table\Column[] + */ + public function getColumns(): array + { + return $this->getAdapter()->getColumns($this->getName()); + } + + /** + * Gets a table column if it exists. + * + * @param string $name Column name + * @return \Phinx\Db\Table\Column|null + */ + public function getColumn(string $name): ?Column + { + $columns = array_filter( + $this->getColumns(), + function ($column) use ($name) { + return $column->getName() === $name; + } + ); + + return array_pop($columns); + } + + /** + * Sets an array of data to be inserted. + * + * @param array $data Data + * @return $this + */ + public function setData(array $data) + { + $this->data = $data; + + return $this; + } + + /** + * Gets the data waiting to be inserted. + * + * @return array + */ + public function getData(): array + { + return $this->data; + } + + /** + * Resets all of the pending data to be inserted + * + * @return void + */ + public function resetData(): void + { + $this->setData([]); + } + + /** + * Resets all of the pending table changes. + * + * @return void + */ + public function reset(): void + { + $this->actions = new Intent(); + $this->resetData(); + } + + /** + * Add a table column. + * + * Type can be: string, text, integer, float, decimal, datetime, timestamp, + * time, date, binary, boolean. + * + * Valid options can be: limit, default, null, precision or scale. + * + * @param string|\Phinx\Db\Table\Column $columnName Column Name + * @param string|\Phinx\Util\Literal|null $type Column Type + * @param array $options Column Options + * @throws \InvalidArgumentException + * @return $this + */ + public function addColumn($columnName, $type = null, array $options = []) + { + if ($columnName instanceof Column) { + $action = new AddColumn($this->table, $columnName); + } elseif ($type instanceof Literal) { + $action = AddColumn::build($this->table, $columnName, $type, $options); + } else { + $action = new AddColumn($this->table, $this->getAdapter()->getColumnForType($columnName, $type, $options)); + } + + // Delegate to Adapters to check column type + if (!$this->getAdapter()->isValidColumnType($action->getColumn())) { + throw new InvalidArgumentException(sprintf( + 'An invalid column type "%s" was specified for column "%s".', + $type, + $action->getColumn()->getName() + )); + } + + $this->actions->addAction($action); + + return $this; + } + + /** + * Remove a table column. + * + * @param string $columnName Column Name + * @return $this + */ + public function removeColumn(string $columnName) + { + $action = RemoveColumn::build($this->table, $columnName); + $this->actions->addAction($action); + + return $this; + } + + /** + * Rename a table column. + * + * @param string $oldName Old Column Name + * @param string $newName New Column Name + * @return $this + */ + public function renameColumn(string $oldName, string $newName) + { + $action = RenameColumn::build($this->table, $oldName, $newName); + $this->actions->addAction($action); + + return $this; + } + + /** + * Change a table column type. + * + * @param string $columnName Column Name + * @param string|\Phinx\Db\Table\Column|\Phinx\Util\Literal $newColumnType New Column Type + * @param array $options Options + * @return $this + */ + public function changeColumn(string $columnName, $newColumnType, array $options = []) + { + if ($newColumnType instanceof Column) { + $action = new ChangeColumn($this->table, $columnName, $newColumnType); + } else { + $action = ChangeColumn::build($this->table, $columnName, $newColumnType, $options); + } + $this->actions->addAction($action); + + return $this; + } + + /** + * Checks to see if a column exists. + * + * @param string $columnName Column Name + * @return bool + */ + public function hasColumn(string $columnName): bool + { + return $this->getAdapter()->hasColumn($this->getName(), $columnName); + } + + /** + * Add an index to a database table. + * + * In $options you can specify unique = true/false, and name (index name). + * + * @param string|array|\Phinx\Db\Table\Index $columns Table Column(s) + * @param array $options Index Options + * @return $this + */ + public function addIndex($columns, array $options = []) + { + $action = AddIndex::build($this->table, $columns, $options); + $this->actions->addAction($action); + + return $this; + } + + /** + * Removes the given index from a table. + * + * @param string|string[] $columns Columns + * @return $this + */ + public function removeIndex($columns) + { + $action = DropIndex::build($this->table, is_string($columns) ? [$columns] : $columns); + $this->actions->addAction($action); + + return $this; + } + + /** + * Removes the given index identified by its name from a table. + * + * @param string $name Index name + * @return $this + */ + public function removeIndexByName(string $name) + { + $action = DropIndex::buildFromName($this->table, $name); + $this->actions->addAction($action); + + return $this; + } + + /** + * Checks to see if an index exists. + * + * @param string|string[] $columns Columns + * @return bool + */ + public function hasIndex($columns): bool + { + return $this->getAdapter()->hasIndex($this->getName(), $columns); + } + + /** + * Checks to see if an index specified by name exists. + * + * @param string $indexName Index name + * @return bool + */ + public function hasIndexByName($indexName): bool + { + return $this->getAdapter()->hasIndexByName($this->getName(), $indexName); + } + + /** + * Add a foreign key to a database table. + * + * In $options you can specify on_delete|on_delete = cascade|no_action .., + * on_update, constraint = constraint name. + * + * @param string|string[] $columns Columns + * @param string|\Phinx\Db\Table\Table $referencedTable Referenced Table + * @param string|string[] $referencedColumns Referenced Columns + * @param array $options Options + * @return $this + */ + public function addForeignKey($columns, $referencedTable, $referencedColumns = ['id'], array $options = []) + { + $action = AddForeignKey::build($this->table, $columns, $referencedTable, $referencedColumns, $options); + $this->actions->addAction($action); + + return $this; + } + + /** + * Add a foreign key to a database table with a given name. + * + * In $options you can specify on_delete|on_delete = cascade|no_action .., + * on_update, constraint = constraint name. + * + * @param string $name The constraint name + * @param string|string[] $columns Columns + * @param string|\Phinx\Db\Table\Table $referencedTable Referenced Table + * @param string|string[] $referencedColumns Referenced Columns + * @param array $options Options + * @return $this + */ + public function addForeignKeyWithName(string $name, $columns, $referencedTable, $referencedColumns = ['id'], array $options = []) + { + $action = AddForeignKey::build( + $this->table, + $columns, + $referencedTable, + $referencedColumns, + $options, + $name + ); + $this->actions->addAction($action); + + return $this; + } + + /** + * Removes the given foreign key from the table. + * + * @param string|string[] $columns Column(s) + * @param string|null $constraint Constraint names + * @return $this + */ + public function dropForeignKey($columns, ?string $constraint = null) + { + $action = DropForeignKey::build($this->table, $columns, $constraint); + $this->actions->addAction($action); + + return $this; + } + + /** + * Checks to see if a foreign key exists. + * + * @param string|string[] $columns Column(s) + * @param string|null $constraint Constraint names + * @return bool + */ + public function hasForeignKey($columns, ?string $constraint = null): bool + { + return $this->getAdapter()->hasForeignKey($this->getName(), $columns, $constraint); + } + + /** + * Add timestamp columns created_at and updated_at to the table. + * + * @param string|false|null $createdAt Alternate name for the created_at column + * @param string|false|null $updatedAt Alternate name for the updated_at column + * @param bool $withTimezone Whether to set the timezone option on the added columns + * @return $this + */ + public function addTimestamps($createdAt = 'created_at', $updatedAt = 'updated_at', bool $withTimezone = false) + { + $createdAt = $createdAt ?? 'created_at'; + $updatedAt = $updatedAt ?? 'updated_at'; + + if (!$createdAt && !$updatedAt) { + throw new \RuntimeException('Cannot set both created_at and updated_at columns to false'); + } + + if ($createdAt) { + $this->addColumn($createdAt, 'timestamp', [ + 'null' => false, + 'default' => 'CURRENT_TIMESTAMP', + 'update' => '', + 'timezone' => $withTimezone, + ]); + } + if ($updatedAt) { + $this->addColumn($updatedAt, 'timestamp', [ + 'null' => true, + 'default' => null, + 'update' => 'CURRENT_TIMESTAMP', + 'timezone' => $withTimezone, + ]); + } + + return $this; + } + + /** + * Alias that always sets $withTimezone to true + * + * @see addTimestamps + * @param string|false|null $createdAt Alternate name for the created_at column + * @param string|false|null $updatedAt Alternate name for the updated_at column + * @return $this + */ + public function addTimestampsWithTimezone($createdAt = null, $updatedAt = null) + { + $this->addTimestamps($createdAt, $updatedAt, true); + + return $this; + } + + /** + * Insert data into the table. + * + * @param array $data array of data in the form: + * array( + * array("col1" => "value1", "col2" => "anotherValue1"), + * array("col2" => "value2", "col2" => "anotherValue2"), + * ) + * or array("col1" => "value1", "col2" => "anotherValue1") + * @return $this + */ + public function insert(array $data) + { + // handle array of array situations + $keys = array_keys($data); + $firstKey = array_shift($keys); + if ($firstKey !== null && is_array($data[$firstKey])) { + foreach ($data as $row) { + $this->data[] = $row; + } + + return $this; + } + + if (count($data) > 0) { + $this->data[] = $data; + } + + return $this; + } + + /** + * Creates a table from the object instance. + * + * @return void + */ + public function create(): void + { + $this->executeActions(false); + $this->saveData(); + $this->reset(); // reset pending changes + } + + /** + * Updates a table from the object instance. + * + * @return void + */ + public function update(): void + { + $this->executeActions(true); + $this->saveData(); + $this->reset(); // reset pending changes + } + + /** + * Commit the pending data waiting for insertion. + * + * @return void + */ + public function saveData(): void + { + $rows = $this->getData(); + if (empty($rows)) { + return; + } + + $bulk = true; + $row = current($rows); + $c = array_keys($row); + foreach ($this->getData() as $row) { + $k = array_keys($row); + if ($k != $c) { + $bulk = false; + break; + } + } + + if ($bulk) { + $this->getAdapter()->bulkinsert($this->table, $this->getData()); + } else { + foreach ($this->getData() as $row) { + $this->getAdapter()->insert($this->table, $row); + } + } + + $this->resetData(); + } + + /** + * Immediately truncates the table. This operation cannot be undone + * + * @return void + */ + public function truncate(): void + { + $this->getAdapter()->truncateTable($this->getName()); + } + + /** + * Commits the table changes. + * + * If the table doesn't exist it is created otherwise it is updated. + * + * @return void + */ + public function save(): void + { + if ($this->exists()) { + $this->update(); // update the table + } else { + $this->create(); // create the table + } + } + + /** + * Executes all the pending actions for this table + * + * @param bool $exists Whether or not the table existed prior to executing this method + * @return void + */ + protected function executeActions(bool $exists): void + { + // Renaming a table is tricky, specially when running a reversible migration + // down. We will just assume the table already exists if the user commands a + // table rename. + if (!$exists) { + foreach ($this->actions->getActions() as $action) { + if ($action instanceof RenameTable) { + $exists = true; + break; + } + } + } + + // If the table does not exist, the last command in the chain needs to be + // a CreateTable action. + if (!$exists) { + $this->actions->addAction(new CreateTable($this->table)); + } + + $plan = new Plan($this->actions); + $plan->execute($this->getAdapter()); + } +} diff --git a/extend/phinx/Db/Table/Column.php b/extend/phinx/Db/Table/Column.php new file mode 100644 index 0000000..f70ffa7 --- /dev/null +++ b/extend/phinx/Db/Table/Column.php @@ -0,0 +1,801 @@ +null = FeatureFlags::$columnNullDefault; + } + + /** + * Sets the column name. + * + * @param string $name Name + * @return $this + */ + public function setName(string $name) + { + $this->name = $name; + + return $this; + } + + /** + * Gets the column name. + * + * @return string|null + */ + public function getName(): ?string + { + return $this->name; + } + + /** + * Sets the column type. + * + * @param string|\Phinx\Util\Literal $type Column type + * @return $this + */ + public function setType($type) + { + $this->type = $type; + + return $this; + } + + /** + * Gets the column type. + * + * @return string|\Phinx\Util\Literal + */ + public function getType() + { + return $this->type; + } + + /** + * Sets the column limit. + * + * @param int|null $limit Limit + * @return $this + */ + public function setLimit(?int $limit) + { + $this->limit = $limit; + + return $this; + } + + /** + * Gets the column limit. + * + * @return int|null + */ + public function getLimit(): ?int + { + return $this->limit; + } + + /** + * Sets whether the column allows nulls. + * + * @param bool $null Null + * @return $this + */ + public function setNull(bool $null) + { + $this->null = (bool)$null; + + return $this; + } + + /** + * Gets whether the column allows nulls. + * + * @return bool + */ + public function getNull(): bool + { + return $this->null; + } + + /** + * Does the column allow nulls? + * + * @return bool + */ + public function isNull(): bool + { + return $this->getNull(); + } + + /** + * Sets the default column value. + * + * @param mixed $default Default + * @return $this + */ + public function setDefault($default) + { + $this->default = $default; + + return $this; + } + + /** + * Gets the default column value. + * + * @return mixed + */ + public function getDefault() + { + return $this->default; + } + + /** + * Sets generated option for identity columns. Ignored otherwise. + * + * @param string|null $generated Generated option + * @return $this + */ + public function setGenerated(?string $generated) + { + $this->generated = $generated; + + return $this; + } + + /** + * Gets generated option for identity columns. Null otherwise + * + * @return string|null + */ + public function getGenerated(): ?string + { + return $this->generated; + } + + /** + * Sets whether or not the column is an identity column. + * + * @param bool $identity Identity + * @return $this + */ + public function setIdentity(bool $identity) + { + $this->identity = $identity; + + return $this; + } + + /** + * Gets whether or not the column is an identity column. + * + * @return bool + */ + public function getIdentity(): bool + { + return $this->identity; + } + + /** + * Is the column an identity column? + * + * @return bool + */ + public function isIdentity(): bool + { + return $this->getIdentity(); + } + + /** + * Sets the name of the column to add this column after. + * + * @param string $after After + * @return $this + */ + public function setAfter(string $after) + { + $this->after = $after; + + return $this; + } + + /** + * Returns the name of the column to add this column after. + * + * @return string|null + */ + public function getAfter(): ?string + { + return $this->after; + } + + /** + * Sets the 'ON UPDATE' mysql column function. + * + * @param string $update On Update function + * @return $this + */ + public function setUpdate(string $update) + { + $this->update = $update; + + return $this; + } + + /** + * Returns the value of the ON UPDATE column function. + * + * @return string|null + */ + public function getUpdate(): ?string + { + return $this->update; + } + + /** + * Sets the number precision for decimal or float column. + * + * For example `DECIMAL(5,2)`, 5 is the precision and 2 is the scale, + * and the column could store value from -999.99 to 999.99. + * + * @param int|null $precision Number precision + * @return $this + */ + public function setPrecision(?int $precision) + { + $this->setLimit($precision); + + return $this; + } + + /** + * Gets the number precision for decimal or float column. + * + * For example `DECIMAL(5,2)`, 5 is the precision and 2 is the scale, + * and the column could store value from -999.99 to 999.99. + * + * @return int|null + */ + public function getPrecision(): ?int + { + return $this->limit; + } + + /** + * Sets the column identity increment. + * + * @param int $increment Number increment + * @return $this + */ + public function setIncrement(int $increment) + { + $this->increment = $increment; + + return $this; + } + + /** + * Gets the column identity increment. + * + * @return int|null + */ + public function getIncrement(): ?int + { + return $this->increment; + } + + /** + * Sets the column identity seed. + * + * @param int $seed Number seed + * @return $this + */ + public function setSeed(int $seed) + { + $this->seed = $seed; + + return $this; + } + + /** + * Gets the column identity seed. + * + * @return int + */ + public function getSeed(): ?int + { + return $this->seed; + } + + /** + * Sets the number scale for decimal or float column. + * + * For example `DECIMAL(5,2)`, 5 is the precision and 2 is the scale, + * and the column could store value from -999.99 to 999.99. + * + * @param int|null $scale Number scale + * @return $this + */ + public function setScale(?int $scale) + { + $this->scale = $scale; + + return $this; + } + + /** + * Gets the number scale for decimal or float column. + * + * For example `DECIMAL(5,2)`, 5 is the precision and 2 is the scale, + * and the column could store value from -999.99 to 999.99. + * + * @return int + */ + public function getScale(): ?int + { + return $this->scale; + } + + /** + * Sets the number precision and scale for decimal or float column. + * + * For example `DECIMAL(5,2)`, 5 is the precision and 2 is the scale, + * and the column could store value from -999.99 to 999.99. + * + * @param int $precision Number precision + * @param int $scale Number scale + * @return $this + */ + public function setPrecisionAndScale(int $precision, int $scale) + { + $this->setLimit($precision); + $this->scale = $scale; + + return $this; + } + + /** + * Sets the column comment. + * + * @param string|null $comment Comment + * @return $this + */ + public function setComment(?string $comment) + { + $this->comment = $comment; + + return $this; + } + + /** + * Gets the column comment. + * + * @return string + */ + public function getComment(): ?string + { + return $this->comment; + } + + /** + * Sets whether field should be signed. + * + * @param bool $signed Signed + * @return $this + */ + public function setSigned(bool $signed) + { + $this->signed = (bool)$signed; + + return $this; + } + + /** + * Gets whether field should be signed. + * + * @return bool + */ + public function getSigned(): bool + { + return $this->signed; + } + + /** + * Should the column be signed? + * + * @return bool + */ + public function isSigned(): bool + { + return $this->getSigned(); + } + + /** + * Sets whether the field should have a timezone identifier. + * Used for date/time columns only! + * + * @param bool $timezone Timezone + * @return $this + */ + public function setTimezone(bool $timezone) + { + $this->timezone = (bool)$timezone; + + return $this; + } + + /** + * Gets whether field has a timezone identifier. + * + * @return bool + */ + public function getTimezone(): bool + { + return $this->timezone; + } + + /** + * Should the column have a timezone? + * + * @return bool + */ + public function isTimezone(): bool + { + return $this->getTimezone(); + } + + /** + * Sets field properties. + * + * @param array $properties Properties + * @return $this + */ + public function setProperties(array $properties) + { + $this->properties = $properties; + + return $this; + } + + /** + * Gets field properties + * + * @return array + */ + public function getProperties(): array + { + return $this->properties; + } + + /** + * Sets field values. + * + * @param string[]|string $values Value(s) + * @return $this + */ + public function setValues($values) + { + if (!is_array($values)) { + $values = preg_split('/,\s*/', $values) ?: []; + } + $this->values = $values; + + return $this; + } + + /** + * Gets field values + * + * @return array|null + */ + public function getValues(): ?array + { + return $this->values; + } + + /** + * Sets the column collation. + * + * @param string $collation Collation + * @return $this + */ + public function setCollation(string $collation) + { + $this->collation = $collation; + + return $this; + } + + /** + * Gets the column collation. + * + * @return string|null + */ + public function getCollation(): ?string + { + return $this->collation; + } + + /** + * Sets the column character set. + * + * @param string $encoding Encoding + * @return $this + */ + public function setEncoding(string $encoding) + { + $this->encoding = $encoding; + + return $this; + } + + /** + * Gets the column character set. + * + * @return string|null + */ + public function getEncoding(): ?string + { + return $this->encoding; + } + + /** + * Sets the column SRID. + * + * @param int $srid SRID + * @return $this + */ + public function setSrid(int $srid) + { + $this->srid = $srid; + + return $this; + } + + /** + * Gets the column SRID. + * + * @return int|null + */ + public function getSrid(): ?int + { + return $this->srid; + } + + /** + * Gets all allowed options. Each option must have a corresponding `setFoo` method. + * + * @return array + */ + protected function getValidOptions(): array + { + return [ + 'limit', + 'default', + 'null', + 'identity', + 'scale', + 'after', + 'update', + 'comment', + 'signed', + 'timezone', + 'properties', + 'values', + 'collation', + 'encoding', + 'srid', + 'seed', + 'increment', + 'generated', + ]; + } + + /** + * Gets all aliased options. Each alias must reference a valid option. + * + * @return array + */ + protected function getAliasedOptions(): array + { + return [ + 'length' => 'limit', + 'precision' => 'limit', + ]; + } + + /** + * Utility method that maps an array of column options to this objects methods. + * + * @param array $options Options + * @throws \RuntimeException + * @return $this + */ + public function setOptions(array $options) + { + $validOptions = $this->getValidOptions(); + $aliasOptions = $this->getAliasedOptions(); + + if (isset($options['identity']) && $options['identity'] && !isset($options['null'])) { + $options['null'] = false; + } + + foreach ($options as $option => $value) { + if (isset($aliasOptions[$option])) { + // proxy alias -> option + $option = $aliasOptions[$option]; + } + + if (!in_array($option, $validOptions, true)) { + throw new RuntimeException(sprintf('"%s" is not a valid column option.', $option)); + } + + $method = 'set' . ucfirst($option); + $this->$method($value); + } + + return $this; + } +} diff --git a/extend/phinx/Db/Table/ForeignKey.php b/extend/phinx/Db/Table/ForeignKey.php new file mode 100644 index 0000000..cd14893 --- /dev/null +++ b/extend/phinx/Db/Table/ForeignKey.php @@ -0,0 +1,237 @@ + + */ + protected static $validOptions = ['delete', 'update', 'constraint']; + + /** + * @var string[] + */ + protected $columns = []; + + /** + * @var \Phinx\Db\Table\Table + */ + protected $referencedTable; + + /** + * @var string[] + */ + protected $referencedColumns = []; + + /** + * @var string|null + */ + protected $onDelete; + + /** + * @var string|null + */ + protected $onUpdate; + + /** + * @var string|null + */ + protected $constraint; + + /** + * Sets the foreign key columns. + * + * @param string[]|string $columns Columns + * @return $this + */ + public function setColumns($columns) + { + $this->columns = is_string($columns) ? [$columns] : $columns; + + return $this; + } + + /** + * Gets the foreign key columns. + * + * @return string[] + */ + public function getColumns(): array + { + return $this->columns; + } + + /** + * Sets the foreign key referenced table. + * + * @param \Phinx\Db\Table\Table $table The table this KEY is pointing to + * @return $this + */ + public function setReferencedTable(Table $table) + { + $this->referencedTable = $table; + + return $this; + } + + /** + * Gets the foreign key referenced table. + * + * @return \Phinx\Db\Table\Table + */ + public function getReferencedTable(): Table + { + return $this->referencedTable; + } + + /** + * Sets the foreign key referenced columns. + * + * @param string[] $referencedColumns Referenced columns + * @return $this + */ + public function setReferencedColumns(array $referencedColumns) + { + $this->referencedColumns = $referencedColumns; + + return $this; + } + + /** + * Gets the foreign key referenced columns. + * + * @return string[] + */ + public function getReferencedColumns(): array + { + return $this->referencedColumns; + } + + /** + * Sets ON DELETE action for the foreign key. + * + * @param string $onDelete On Delete + * @return $this + */ + public function setOnDelete(string $onDelete) + { + $this->onDelete = $this->normalizeAction($onDelete); + + return $this; + } + + /** + * Gets ON DELETE action for the foreign key. + * + * @return string|null + */ + public function getOnDelete(): ?string + { + return $this->onDelete; + } + + /** + * Gets ON UPDATE action for the foreign key. + * + * @return string|null + */ + public function getOnUpdate(): ?string + { + return $this->onUpdate; + } + + /** + * Sets ON UPDATE action for the foreign key. + * + * @param string $onUpdate On Update + * @return $this + */ + public function setOnUpdate(string $onUpdate) + { + $this->onUpdate = $this->normalizeAction($onUpdate); + + return $this; + } + + /** + * Sets constraint for the foreign key. + * + * @param string $constraint Constraint + * @return $this + */ + public function setConstraint(string $constraint) + { + $this->constraint = $constraint; + + return $this; + } + + /** + * Gets constraint name for the foreign key. + * + * @return string|null + */ + public function getConstraint(): ?string + { + return $this->constraint; + } + + /** + * Utility method that maps an array of index options to this objects methods. + * + * @param array $options Options + * @throws \RuntimeException + * @return $this + */ + public function setOptions(array $options) + { + foreach ($options as $option => $value) { + if (!in_array($option, static::$validOptions, true)) { + throw new RuntimeException(sprintf('"%s" is not a valid foreign key option.', $option)); + } + + // handle $options['delete'] as $options['update'] + if ($option === 'delete') { + $this->setOnDelete($value); + } elseif ($option === 'update') { + $this->setOnUpdate($value); + } else { + $method = 'set' . ucfirst($option); + $this->$method($value); + } + } + + return $this; + } + + /** + * From passed value checks if it's correct and fixes if needed + * + * @param string $action Action + * @throws \InvalidArgumentException + * @return string + */ + protected function normalizeAction(string $action): string + { + $constantName = 'static::' . str_replace(' ', '_', strtoupper(trim($action))); + if (!defined($constantName)) { + throw new InvalidArgumentException('Unknown action passed: ' . $action); + } + + return constant($constantName); + } +} diff --git a/extend/phinx/Db/Table/Index.php b/extend/phinx/Db/Table/Index.php new file mode 100644 index 0000000..f405f02 --- /dev/null +++ b/extend/phinx/Db/Table/Index.php @@ -0,0 +1,227 @@ +columns = is_string($columns) ? [$columns] : $columns; + + return $this; + } + + /** + * Gets the index columns. + * + * @return string[]|null + */ + public function getColumns(): ?array + { + return $this->columns; + } + + /** + * Sets the index type. + * + * @param string $type Type + * @return $this + */ + public function setType(string $type) + { + $this->type = $type; + + return $this; + } + + /** + * Gets the index type. + * + * @return string + */ + public function getType(): string + { + return $this->type; + } + + /** + * Sets the index name. + * + * @param string $name Name + * @return $this + */ + public function setName(string $name) + { + $this->name = $name; + + return $this; + } + + /** + * Gets the index name. + * + * @return string|null + */ + public function getName(): ?string + { + return $this->name; + } + + /** + * Sets the index limit. + * + * @param int|array $limit limit value or array of limit value + * @return $this + */ + public function setLimit($limit) + { + $this->limit = $limit; + + return $this; + } + + /** + * Gets the index limit. + * + * @return int|array|null + */ + public function getLimit() + { + return $this->limit; + } + + /** + * Sets the index columns sort order. + * + * @param string[] $order column name sort order key value pair + * @return $this + */ + public function setOrder(array $order) + { + $this->order = $order; + + return $this; + } + + /** + * Gets the index columns sort order. + * + * @return string[]|null + */ + public function getOrder(): ?array + { + return $this->order; + } + + /** + * Sets the index included columns. + * + * @param string[] $includedColumns Columns + * @return $this + */ + public function setInclude(array $includedColumns) + { + $this->includedColumns = $includedColumns; + + return $this; + } + + /** + * Gets the index included columns. + * + * @return string[]|null + */ + public function getInclude(): ?array + { + return $this->includedColumns; + } + + /** + * Utility method that maps an array of index options to this objects methods. + * + * @param array $options Options + * @throws \RuntimeException + * @return $this + */ + public function setOptions(array $options) + { + // Valid Options + $validOptions = ['type', 'unique', 'name', 'limit', 'order', 'include']; + foreach ($options as $option => $value) { + if (!in_array($option, $validOptions, true)) { + throw new RuntimeException(sprintf('"%s" is not a valid index option.', $option)); + } + + // handle $options['unique'] + if (strcasecmp($option, self::UNIQUE) === 0) { + if ((bool)$value) { + $this->setType(self::UNIQUE); + } + continue; + } + + $method = 'set' . ucfirst($option); + $this->$method($value); + } + + return $this; + } +} diff --git a/extend/phinx/Db/Table/Table.php b/extend/phinx/Db/Table/Table.php new file mode 100644 index 0000000..0352157 --- /dev/null +++ b/extend/phinx/Db/Table/Table.php @@ -0,0 +1,84 @@ + + */ + protected $options; + + /** + * @param string $name The table name + * @param array $options The creation options for this table + * @throws \InvalidArgumentException + */ + public function __construct($name, array $options = []) + { + if (empty($name)) { + throw new InvalidArgumentException('Cannot use an empty table name'); + } + + $this->name = $name; + $this->options = $options; + } + + /** + * Sets the table name. + * + * @param string $name The name of the table + * @return $this + */ + public function setName(string $name) + { + $this->name = $name; + + return $this; + } + + /** + * Gets the table name. + * + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * Gets the table options + * + * @return array + */ + public function getOptions(): array + { + return $this->options; + } + + /** + * Sets the table options + * + * @param array $options The options for the table creation + * @return $this + */ + public function setOptions(array $options) + { + $this->options = $options; + + return $this; + } +} diff --git a/extend/phinx/Db/Util/AlterInstructions.php b/extend/phinx/Db/Util/AlterInstructions.php new file mode 100644 index 0000000..ddc49fc --- /dev/null +++ b/extend/phinx/Db/Util/AlterInstructions.php @@ -0,0 +1,122 @@ +alterParts = $alterParts; + $this->postSteps = $postSteps; + } + + /** + * Adds another part to the single ALTER instruction + * + * @param string $part The SQL snipped to add as part of the ALTER instruction + * @return void + */ + public function addAlter(string $part): void + { + $this->alterParts[] = $part; + } + + /** + * Adds a SQL command to be executed after the ALTER instruction. + * This method allows a callable, with will get an empty array as state + * for the first time and will pass the return value of the callable to + * the next callable, if present. + * + * This allows to keep a single state across callbacks. + * + * @param string|callable $sql The SQL to run after, or a callable to execute + * @return void + */ + public function addPostStep($sql): void + { + $this->postSteps[] = $sql; + } + + /** + * Returns the alter SQL snippets + * + * @return string[] + */ + public function getAlterParts(): array + { + return $this->alterParts; + } + + /** + * Returns the SQL commands to run after the ALTER instruction + * + * @return (string|callable)[] + */ + public function getPostSteps(): array + { + return $this->postSteps; + } + + /** + * Merges another AlterInstructions object to this one + * + * @param \Phinx\Db\Util\AlterInstructions $other The other collection of instructions to merge in + * @return void + */ + public function merge(AlterInstructions $other): void + { + $this->alterParts = array_merge($this->alterParts, $other->getAlterParts()); + $this->postSteps = array_merge($this->postSteps, $other->getPostSteps()); + } + + /** + * Executes the ALTER instruction and all of the post steps. + * + * @param string $alterTemplate The template for the alter instruction + * @param callable $executor The function to be used to execute all instructions + * @return void + */ + public function execute(string $alterTemplate, callable $executor): void + { + if ($this->alterParts) { + $alter = sprintf($alterTemplate, implode(', ', $this->alterParts)); + $executor($alter); + } + + $state = []; + + foreach ($this->postSteps as $instruction) { + if (is_callable($instruction)) { + $state = $instruction($state); + continue; + } + + $executor($instruction); + } + } +} diff --git a/extend/phinx/LICENSE b/extend/phinx/LICENSE new file mode 100644 index 0000000..983a906 --- /dev/null +++ b/extend/phinx/LICENSE @@ -0,0 +1,9 @@ +(The MIT license) + +Copyright (c) 2012-present Rob Morgan + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/extend/phinx/Migration/AbstractMigration.php b/extend/phinx/Migration/AbstractMigration.php new file mode 100644 index 0000000..d2b2601 --- /dev/null +++ b/extend/phinx/Migration/AbstractMigration.php @@ -0,0 +1,338 @@ + + */ +abstract class AbstractMigration implements MigrationInterface +{ + /** + * @var string + */ + protected $environment; + + /** + * @var int + */ + protected $version; + + /** + * @var \Phinx\Db\Adapter\AdapterInterface|null + */ + protected $adapter; + + /** + * @var \think\console\Output|null + */ + protected $output; + + /** + * @var \think\console\Input|null + */ + protected $input; + + /** + * Whether this migration is being applied or reverted + * + * @var bool + */ + protected $isMigratingUp = true; + + /** + * List of all the table objects created by this migration + * + * @var array<\Phinx\Db\Table> + */ + protected $tables = []; + + /** + * @param string $environment Environment Detected + * @param int $version Migration Version + * @param \think\console\Input|null $input Input + * @param \think\console\Output|null $output Output + */ + final public function __construct(string $environment, int $version, ?InputInterface $input = null, ?OutputInterface $output = null) + { + $this->environment = $environment; + $this->version = $version; + + if ($input !== null) { + $this->setInput($input); + } + + if ($output !== null) { + $this->setOutput($output); + } + } + + /** + * @inheritDoc + */ + public function setAdapter(AdapterInterface $adapter): MigrationInterface + { + $this->adapter = $adapter; + + return $this; + } + + /** + * @inheritDoc + */ + public function getAdapter(): ?AdapterInterface + { + return $this->adapter; + } + + /** + * @inheritDoc + */ + public function setInput(InputInterface $input): MigrationInterface + { + $this->input = $input; + + return $this; + } + + /** + * @inheritDoc + */ + public function getInput(): ?InputInterface + { + return $this->input; + } + + /** + * @inheritDoc + */ + public function setOutput(OutputInterface $output): MigrationInterface + { + $this->output = $output; + + return $this; + } + + /** + * @inheritDoc + */ + public function getOutput(): ?OutputInterface + { + return $this->output; + } + + /** + * @inheritDoc + */ + public function getName(): string + { + return static::class; + } + + /** + * @inheritDoc + */ + public function getEnvironment(): string + { + return $this->environment; + } + + /** + * @inheritDoc + */ + public function setVersion($version): MigrationInterface + { + $this->version = $version; + + return $this; + } + + /** + * @inheritDoc + */ + public function getVersion(): int + { + return $this->version; + } + + /** + * @inheritDoc + */ + public function setMigratingUp(bool $isMigratingUp): MigrationInterface + { + $this->isMigratingUp = $isMigratingUp; + + return $this; + } + + /** + * @inheritDoc + */ + public function isMigratingUp(): bool + { + return $this->isMigratingUp; + } + + /** + * @inheritDoc + */ + public function execute(string $sql, array $params = []): int + { + return $this->getAdapter()->execute($sql, $params); + } + + /** + * @inheritDoc + */ + public function query(string $sql, array $params = []) + { + return $this->getAdapter()->query($sql, $params); + } + + /** + * @inheritDoc + */ + public function getQueryBuilder(): Query + { + return $this->getAdapter()->getQueryBuilder(); + } + + /** + * @inheritDoc + */ + public function fetchRow(string $sql) + { + return $this->getAdapter()->fetchRow($sql); + } + + /** + * @inheritDoc + */ + public function fetchAll(string $sql): array + { + return $this->getAdapter()->fetchAll($sql); + } + + /** + * @inheritDoc + */ + public function createDatabase(string $name, array $options): void + { + $this->getAdapter()->createDatabase($name, $options); + } + + /** + * @inheritDoc + */ + public function dropDatabase(string $name): void + { + $this->getAdapter()->dropDatabase($name); + } + + /** + * @inheritDoc + */ + public function createSchema(string $name): void + { + $this->getAdapter()->createSchema($name); + } + + /** + * @inheritDoc + */ + public function dropSchema(string $name): void + { + $this->getAdapter()->dropSchema($name); + } + + /** + * @inheritDoc + */ + public function hasTable(string $tableName): bool + { + return $this->getAdapter()->hasTable($tableName); + } + + /** + * @inheritDoc + */ + public function table(string $tableName, array $options = []): Table + { + $table = new Table($tableName, $options, $this->getAdapter()); + $this->tables[] = $table; + + return $table; + } + + /** + * Perform checks on the migration, print a warning + * if there are potential problems. + * + * Right now, the only check is if there is both a `change()` and + * an `up()` or a `down()` method. + * + * @return void + */ + public function preFlightCheck(): void + { + if (method_exists($this, MigrationInterface::CHANGE)) { + if ( + method_exists($this, MigrationInterface::UP) || + method_exists($this, MigrationInterface::DOWN) + ) { + $this->output->writeln(sprintf( + 'warning Migration contains both change() and up()/down() methods. Ignoring up() and down().' + )); + } + } + } + + /** + * Perform checks on the migration after completion + * + * Right now, the only check is whether all changes were committed + * + * @throws \RuntimeException + * @return void + */ + public function postFlightCheck(): void + { + foreach ($this->tables as $table) { + if ($table->hasPendingActions()) { + throw new RuntimeException('Migration has pending actions after execution!'); + } + } + } + + /** + * Checks to see if the migration should be executed. + * + * Returns true by default. + * + * You can use this to prevent a migration from executing. + * + * @return bool + */ + public function shouldExecute(): bool + { + return true; + } +} diff --git a/extend/phinx/Migration/AbstractTemplateCreation.php b/extend/phinx/Migration/AbstractTemplateCreation.php new file mode 100644 index 0000000..95a9a28 --- /dev/null +++ b/extend/phinx/Migration/AbstractTemplateCreation.php @@ -0,0 +1,74 @@ +setInput($input); + } + if ($output !== null) { + $this->setOutput($output); + } + } + + /** + * @inheritDoc + */ + public function getInput(): InputInterface + { + return $this->input; + } + + /** + * @inheritDoc + */ + public function setInput(InputInterface $input): CreationInterface + { + $this->input = $input; + + return $this; + } + + /** + * @inheritDoc + */ + public function getOutput(): OutputInterface + { + return $this->output; + } + + /** + * @inheritDoc + */ + public function setOutput(OutputInterface $output): CreationInterface + { + $this->output = $output; + + return $this; + } +} diff --git a/extend/phinx/Migration/CreationInterface.php b/extend/phinx/Migration/CreationInterface.php new file mode 100644 index 0000000..6a622af --- /dev/null +++ b/extend/phinx/Migration/CreationInterface.php @@ -0,0 +1,69 @@ + + */ +interface CreationInterface +{ + /** + * @param \think\console\Input|null $input Input + * @param \think\console\Output|null $output Output + */ + public function __construct(?InputInterface $input = null, ?OutputInterface $output = null); + + /** + * @param \think\console\Input $input Input + * @return $this + */ + public function setInput(InputInterface $input); + + /** + * @param \think\console\Output $output Output + * @return $this + */ + public function setOutput(OutputInterface $output); + + /** + * @return \think\console\Input + */ + public function getInput(): InputInterface; + + /** + * @return \think\console\Output + */ + public function getOutput(): OutputInterface; + + /** + * Get the migration template. + * + * This will be the content that Phinx will amend to generate the migration file. + * + * @return string The content of the template for Phinx to amend. + */ + public function getMigrationTemplate(): string; + + /** + * Post Migration Creation. + * + * Once the migration file has been created, this method will be called, allowing any additional + * processing, specific to the template to be performed. + * + * @param string $migrationFilename The name of the newly created migration. + * @param string $className The class name. + * @param string $baseClassName The name of the base class. + * @return void + */ + public function postMigrationCreation(string $migrationFilename, string $className, string $baseClassName): void; +} diff --git a/extend/phinx/Migration/IrreversibleMigrationException.php b/extend/phinx/Migration/IrreversibleMigrationException.php new file mode 100644 index 0000000..376bcdc --- /dev/null +++ b/extend/phinx/Migration/IrreversibleMigrationException.php @@ -0,0 +1,20 @@ + + */ +class IrreversibleMigrationException extends Exception +{ +} diff --git a/extend/phinx/Migration/Manager.php b/extend/phinx/Migration/Manager.php new file mode 100644 index 0000000..c6cd9bf --- /dev/null +++ b/extend/phinx/Migration/Manager.php @@ -0,0 +1,1141 @@ +setConfig($config); + $this->setInput($input); + $this->setOutput($output); + } + + /** + * Prints the specified environment's migration status. + * + * @param string $environment environment to print status of + * @param string|null $format format to print status in (either text, json, or null) + * @throws \RuntimeException + * @return array array indicating if there are any missing or down migrations + */ + public function printStatus(string $environment, ?string $format = null): array + { + $output = $this->getOutput(); + $hasDownMigration = false; + $hasMissingMigration = false; + $migrations = $this->getMigrations($environment); + $migrationCount = 0; + $missingCount = 0; + $pendingMigrationCount = 0; + $finalMigrations = []; + $verbosity = $output->getVerbosity(); + if ($format === 'json') { + $output->setVerbosity(OutputInterface::VERBOSITY_QUIET); + } + if (count($migrations)) { + // rewrite using Symfony Table Helper as we already have this library + // included and it will fix formatting issues (e.g drawing the lines) + $output->writeln('', $this->verbosityLevel); + + switch ($this->getConfig()->getVersionOrder()) { + case Config::VERSION_ORDER_CREATION_TIME: + $migrationIdAndStartedHeader = '[Migration ID] Started '; + break; + case Config::VERSION_ORDER_EXECUTION_TIME: + $migrationIdAndStartedHeader = 'Migration ID [Started ]'; + break; + default: + throw new RuntimeException('Invalid version_order configuration option'); + } + + $output->writeln(" Status $migrationIdAndStartedHeader Finished Migration Name ", $this->verbosityLevel); + $output->writeln('----------------------------------------------------------------------------------', $this->verbosityLevel); + + $env = $this->getEnvironment($environment); + $versions = $env->getVersionLog(); + + $maxNameLength = $versions ? max(array_map(function ($version) { + return strlen($version['migration_name']); + }, $versions)) : 0; + + $missingVersions = array_diff_key($versions, $migrations); + $missingCount = count($missingVersions); + + $hasMissingMigration = !empty($missingVersions); + + // get the migrations sorted in the same way as the versions + /** @var \Phinx\Migration\AbstractMigration[] $sortedMigrations */ + $sortedMigrations = []; + + foreach ($versions as $versionCreationTime => $version) { + if (isset($migrations[$versionCreationTime])) { + array_push($sortedMigrations, $migrations[$versionCreationTime]); + unset($migrations[$versionCreationTime]); + } + } + + if (empty($sortedMigrations) && !empty($missingVersions)) { + // this means we have no up migrations, so we write all the missing versions already so they show up + // before any possible down migration + foreach ($missingVersions as $missingVersionCreationTime => $missingVersion) { + $this->printMissingVersion($missingVersion, $maxNameLength); + + unset($missingVersions[$missingVersionCreationTime]); + } + } + + // any migration left in the migrations (ie. not unset when sorting the migrations by the version order) is + // a migration that is down, so we add them to the end of the sorted migrations list + if (!empty($migrations)) { + $sortedMigrations = array_merge($sortedMigrations, $migrations); + } + + $migrationCount = count($sortedMigrations); + foreach ($sortedMigrations as $migration) { + $version = array_key_exists($migration->getVersion(), $versions) ? $versions[$migration->getVersion()] : false; + if ($version) { + // check if there are missing versions before this version + foreach ($missingVersions as $missingVersionCreationTime => $missingVersion) { + if ($this->getConfig()->isVersionOrderCreationTime()) { + if ($missingVersion['version'] > $version['version']) { + break; + } + } else { + if ($missingVersion['start_time'] > $version['start_time']) { + break; + } elseif ( + $missingVersion['start_time'] == $version['start_time'] && + $missingVersion['version'] > $version['version'] + ) { + break; + } + } + + $this->printMissingVersion($missingVersion, $maxNameLength); + + unset($missingVersions[$missingVersionCreationTime]); + } + + $status = ' up '; + } else { + $pendingMigrationCount++; + $hasDownMigration = true; + $status = ' down '; + } + $maxNameLength = max($maxNameLength, strlen($migration->getName())); + + $output->writeln( + sprintf( + '%s %14.0f %19s %19s %s', + $status, + $migration->getVersion(), + ($version ? $version['start_time'] : ''), + ($version ? $version['end_time'] : ''), + $migration->getName() + ), + $this->verbosityLevel + ); + + if ($version && $version['breakpoint']) { + $output->writeln(' BREAKPOINT SET', $this->verbosityLevel); + } + + $finalMigrations[] = ['migration_status' => trim(strip_tags($status)), 'migration_id' => sprintf('%14.0f', $migration->getVersion()), 'migration_name' => $migration->getName()]; + unset($versions[$migration->getVersion()]); + } + + // and finally add any possibly-remaining missing migrations + foreach ($missingVersions as $missingVersionCreationTime => $missingVersion) { + $this->printMissingVersion($missingVersion, $maxNameLength); + + unset($missingVersions[$missingVersionCreationTime]); + } + } else { + // there are no migrations + $output->writeln('', $this->verbosityLevel); + $output->writeln('There are no available migrations. Try creating one using the create command.', $this->verbosityLevel); + } + + // write an empty line + $output->writeln('', $this->verbosityLevel); + + if ($format !== null) { + switch ($format) { + case AbstractCommand::FORMAT_JSON: + $output->setVerbosity($verbosity); + $output->writeln(json_encode( + [ + 'pending_count' => $pendingMigrationCount, + 'missing_count' => $missingCount, + 'total_count' => $migrationCount + $missingCount, + 'migrations' => $finalMigrations, + ] + )); + break; + default: + $output->writeln('Unsupported format: ' . $format . ''); + } + } + + return [ + 'hasMissingMigration' => $hasMissingMigration, + 'hasDownMigration' => $hasDownMigration, + ]; + } + + /** + * Print Missing Version + * + * @param array $version The missing version to print (in the format returned by Environment.getVersionLog). + * @param int $maxNameLength The maximum migration name length. + * @return void + */ + protected function printMissingVersion(array $version, int $maxNameLength): void + { + $this->getOutput()->writeln(sprintf( + ' up %14.0f %19s %19s %s ** MISSING MIGRATION FILE **', + $version['version'], + $version['start_time'], + $version['end_time'], + str_pad($version['migration_name'], $maxNameLength, ' ') + )); + + if ($version && $version['breakpoint']) { + $this->getOutput()->writeln(' BREAKPOINT SET'); + } + } + + /** + * Migrate to the version of the database on a given date. + * + * @param string $environment Environment + * @param \DateTime $dateTime Date to migrate to + * @param bool $fake flag that if true, we just record running the migration, but not actually do the + * migration + * @return void + */ + public function migrateToDateTime(string $environment, DateTime $dateTime, bool $fake = false): void + { + $versions = array_keys($this->getMigrations($environment)); + $dateString = $dateTime->format('YmdHis'); + + $outstandingMigrations = array_filter($versions, function ($version) use ($dateString) { + return $version <= $dateString; + }); + + if (count($outstandingMigrations) > 0) { + $migration = max($outstandingMigrations); + $this->getOutput()->writeln('Migrating to version ' . $migration, $this->verbosityLevel); + $this->migrate($environment, $migration, $fake); + } + } + + /** + * Migrate an environment to the specified version. + * + * @param string $environment Environment + * @param int|null $version version to migrate to + * @param bool $fake flag that if true, we just record running the migration, but not actually do the migration + * @return void + */ + public function migrate(string $environment, ?int $version = null, bool $fake = false): void + { + $migrations = $this->getMigrations($environment); + $env = $this->getEnvironment($environment); + $versions = $env->getVersions(); + $current = $env->getCurrentVersion(); + + if (empty($versions) && empty($migrations)) { + return; + } + + if ($version === null) { + $version = max(array_merge($versions, array_keys($migrations))); + } else { + if ($version != 0 && !isset($migrations[$version])) { + $this->output->writeln(sprintf( + 'warning %s is not a valid version', + $version + )); + + return; + } + } + + // are we migrating up or down? + $direction = $version > $current ? MigrationInterface::UP : MigrationInterface::DOWN; + + if ($direction === MigrationInterface::DOWN) { + // run downs first + krsort($migrations); + foreach ($migrations as $migration) { + if ($migration->getVersion() <= $version) { + break; + } + + if (in_array($migration->getVersion(), $versions)) { + $this->executeMigration($environment, $migration, MigrationInterface::DOWN, $fake); + } + } + } + + ksort($migrations); + foreach ($migrations as $migration) { + if ($migration->getVersion() > $version) { + break; + } + + if (!in_array($migration->getVersion(), $versions)) { + $this->executeMigration($environment, $migration, MigrationInterface::UP, $fake); + } + } + } + + /** + * Execute a migration against the specified environment. + * + * @param string $name Environment Name + * @param \Phinx\Migration\MigrationInterface $migration Migration + * @param string $direction Direction + * @param bool $fake flag that if true, we just record running the migration, but not actually do the migration + * @return void + */ + public function executeMigration(string $name, MigrationInterface $migration, string $direction = MigrationInterface::UP, bool $fake = false): void + { + $this->getOutput()->writeln('', $this->verbosityLevel); + + // Skip the migration if it should not be executed + if (!$migration->shouldExecute()) { + $this->printMigrationStatus($migration, 'skipped'); + + return; + } + + $this->printMigrationStatus($migration, ($direction === MigrationInterface::UP ? 'migrating' : 'reverting')); + + // Execute the migration and log the time elapsed. + $start = microtime(true); + $this->getEnvironment($name)->executeMigration($migration, $direction, $fake); + $end = microtime(true); + + $this->printMigrationStatus( + $migration, + ($direction === MigrationInterface::UP ? 'migrated' : 'reverted'), + sprintf('%.4fs', $end - $start) + ); + } + + /** + * Execute a seeder against the specified environment. + * + * @param string $name Environment Name + * @param \Phinx\Seed\SeedInterface $seed Seed + * @return void + */ + public function executeSeed(string $name, SeedInterface $seed): void + { + $this->getOutput()->writeln('', $this->verbosityLevel); + + // Skip the seed if it should not be executed + if (!$seed->shouldExecute()) { + $this->printSeedStatus($seed, 'skipped'); + + return; + } + + $this->printSeedStatus($seed, 'seeding'); + + // Execute the seeder and log the time elapsed. + $start = microtime(true); + $this->getEnvironment($name)->executeSeed($seed); + $end = microtime(true); + + $this->printSeedStatus( + $seed, + 'seeded', + sprintf('%.4fs', $end - $start) + ); + } + + /** + * Print Migration Status + * + * @param \Phinx\Migration\MigrationInterface $migration Migration + * @param string $status Status of the migration + * @param string|null $duration Duration the migration took the be executed + * @return void + */ + protected function printMigrationStatus(MigrationInterface $migration, string $status, ?string $duration = null): void + { + $this->printStatusOutput( + $migration->getVersion() . ' ' . $migration->getName(), + $status, + $duration + ); + } + + /** + * Print Seed Status + * + * @param \Phinx\Seed\SeedInterface $seed Seed + * @param string $status Status of the seed + * @param string|null $duration Duration the seed took the be executed + * @return void + */ + protected function printSeedStatus(SeedInterface $seed, string $status, ?string $duration = null): void + { + $this->printStatusOutput( + $seed->getName(), + $status, + $duration + ); + } + + /** + * Print Status in Output + * + * @param string $name Name of the migration or seed + * @param string $status Status of the migration or seed + * @param string|null $duration Duration the migration or seed took the be executed + * @return void + */ + protected function printStatusOutput(string $name, string $status, ?string $duration = null): void + { + $this->getOutput()->writeln( + ' ==' . + ' ' . $name . ':' . + ' ' . $status . ' ' . $duration . '', + $this->verbosityLevel + ); + } + + /** + * Rollback an environment to the specified version. + * + * @param string $environment Environment + * @param int|string|null $target Target + * @param bool $force Force + * @param bool $targetMustMatchVersion Target must match version + * @param bool $fake Flag that if true, we just record running the migration, but not actually do the migration + * @return void + */ + public function rollback(string $environment, $target = null, bool $force = false, bool $targetMustMatchVersion = true, bool $fake = false): void + { + // note that the migrations are indexed by name (aka creation time) in ascending order + $migrations = $this->getMigrations($environment); + + // note that the version log are also indexed by name with the proper ascending order according to the version order + $executedVersions = $this->getEnvironment($environment)->getVersionLog(); + + // get a list of migrations sorted in the opposite way of the executed versions + $sortedMigrations = []; + + foreach ($executedVersions as $versionCreationTime => &$executedVersion) { + // if we have a date (ie. the target must not match a version) and we are sorting by execution time, we + // convert the version start time so we can compare directly with the target date + if (!$this->getConfig()->isVersionOrderCreationTime() && !$targetMustMatchVersion) { + /** @var \DateTime $dateTime */ + $dateTime = DateTime::createFromFormat('Y-m-d H:i:s', $executedVersion['start_time']); + $executedVersion['start_time'] = $dateTime->format('YmdHis'); + } + + if (isset($migrations[$versionCreationTime])) { + array_unshift($sortedMigrations, $migrations[$versionCreationTime]); + } else { + // this means the version is missing so we unset it so that we don't consider it when rolling back + // migrations (or choosing the last up version as target) + unset($executedVersions[$versionCreationTime]); + } + } + + if ($target === 'all' || $target === '0') { + $target = 0; + } elseif (!is_numeric($target) && $target !== null) { // try to find a target version based on name + // search through the migrations using the name + $migrationNames = array_map(function ($item) { + return $item['migration_name']; + }, $executedVersions); + $found = array_search($target, $migrationNames, true); + + // check on was found + if ($found !== false) { + $target = (string)$found; + } else { + $this->getOutput()->writeln("No migration found with name ($target)"); + + return; + } + } + + // Check we have at least 1 migration to revert + $executedVersionCreationTimes = array_keys($executedVersions); + if (empty($executedVersionCreationTimes) || $target == end($executedVersionCreationTimes)) { + $this->getOutput()->writeln('No migrations to rollback'); + + return; + } + + // If no target was supplied, revert the last migration + if ($target === null) { + // Get the migration before the last run migration + $prev = count($executedVersionCreationTimes) - 2; + $target = $prev >= 0 ? $executedVersionCreationTimes[$prev] : 0; + } + + // If the target must match a version, check the target version exists + if ($targetMustMatchVersion && $target !== 0 && !isset($migrations[$target])) { + $this->getOutput()->writeln("Target version ($target) not found"); + + return; + } + + // Rollback all versions until we find the wanted rollback target + $rollbacked = false; + + foreach ($sortedMigrations as $migration) { + if ($targetMustMatchVersion && $migration->getVersion() == $target) { + break; + } + + if (in_array($migration->getVersion(), $executedVersionCreationTimes)) { + $executedVersion = $executedVersions[$migration->getVersion()]; + + if (!$targetMustMatchVersion) { + if ( + ($this->getConfig()->isVersionOrderCreationTime() && $executedVersion['version'] <= $target) || + (!$this->getConfig()->isVersionOrderCreationTime() && $executedVersion['start_time'] <= $target) + ) { + break; + } + } + + if ($executedVersion['breakpoint'] != 0 && !$force) { + $this->getOutput()->writeln('Breakpoint reached. Further rollbacks inhibited.'); + break; + } + $this->executeMigration($environment, $migration, MigrationInterface::DOWN, $fake); + $rollbacked = true; + } + } + + if (!$rollbacked) { + $this->getOutput()->writeln('No migrations to rollback'); + } + } + + /** + * Run database seeders against an environment. + * + * @param string $environment Environment + * @param string|null $seed Seeder + * @throws \InvalidArgumentException + * @return void + */ + public function seed(string $environment, ?string $seed = null): void + { + $seeds = $this->getSeeds($environment); + + if ($seed === null) { + // run all seeders + foreach ($seeds as $seeder) { + if (array_key_exists($seeder->getName(), $seeds)) { + $this->executeSeed($environment, $seeder); + } + } + } else { + // run only one seeder + if (array_key_exists($seed, $seeds)) { + $this->executeSeed($environment, $seeds[$seed]); + } else { + throw new InvalidArgumentException(sprintf('The seed class "%s" does not exist', $seed)); + } + } + } + + /** + * Sets the environments. + * + * @param \Phinx\Migration\Manager\Environment[] $environments Environments + * @return $this + */ + public function setEnvironments(array $environments = []) + { + $this->environments = $environments; + + return $this; + } + + /** + * Gets the manager class for the given environment. + * + * @param string $name Environment Name + * @throws \InvalidArgumentException + * @return \Phinx\Migration\Manager\Environment + */ + public function getEnvironment(string $name): Environment + { + if (isset($this->environments[$name])) { + return $this->environments[$name]; + } + + // check the environment exists + if (!$this->getConfig()->hasEnvironment($name)) { + throw new InvalidArgumentException(sprintf( + 'The environment "%s" does not exist', + $name + )); + } + + // create an environment instance and cache it + $envOptions = $this->getConfig()->getEnvironment($name); + $envOptions['version_order'] = $this->getConfig()->getVersionOrder(); + $envOptions['data_domain'] = $this->getConfig()->getDataDomain(); + + $environment = new Environment($name, $envOptions); + $this->environments[$name] = $environment; + $environment->setInput($this->getInput()); + $environment->setOutput($this->getOutput()); + + return $environment; + } + + /** + * Sets the user defined PSR-11 container + * + * @param \Psr\Container\ContainerInterface $container Container + * @return $this + */ + public function setContainer(ContainerInterface $container) + { + $this->container = $container; + + return $this; + } + + /** + * Sets the console input. + * + * @param \think\console\Input $input Input + * @return $this + */ + public function setInput(InputInterface $input) + { + $this->input = $input; + + return $this; + } + + /** + * Gets the console input. + * + * @return \think\console\Input + */ + public function getInput(): InputInterface + { + return $this->input; + } + + /** + * Sets the console output. + * + * @param \think\console\Output $output Output + * @return $this + */ + public function setOutput(OutputInterface $output) + { + $this->output = $output; + + return $this; + } + + /** + * Gets the console output. + * + * @return \think\console\Output + */ + public function getOutput(): OutputInterface + { + return $this->output; + } + + /** + * Sets the database migrations. + * + * @param \Phinx\Migration\AbstractMigration[] $migrations Migrations + * @return $this + */ + public function setMigrations(array $migrations) + { + $this->migrations = $migrations; + + return $this; + } + + /** + * Gets an array of the database migrations, indexed by migration name (aka creation time) and sorted in ascending + * order + * + * @param string $environment Environment + * @throws \InvalidArgumentException + * @return \Phinx\Migration\MigrationInterface[] + */ + public function getMigrations(string $environment): array + { + if ($this->migrations === null) { + $phpFiles = $this->getMigrationFiles(); + + if ($this->getOutput()->getVerbosity() >= OutputInterface::VERBOSITY_DEBUG) { + $this->getOutput()->writeln('Migration file'); + $this->getOutput()->writeln( + array_map( + function ($phpFile) { + return " {$phpFile}"; + }, + $phpFiles + ) + ); + } + + // filter the files to only get the ones that match our naming scheme + $fileNames = []; + /** @var \Phinx\Migration\AbstractMigration[] $versions */ + $versions = []; + + foreach ($phpFiles as $filePath) { + if (Util::isValidMigrationFileName(basename($filePath))) { + if ($this->getOutput()->getVerbosity() >= OutputInterface::VERBOSITY_DEBUG) { + $this->getOutput()->writeln("Valid migration file {$filePath}."); + } + + $version = Util::getVersionFromFileName(basename($filePath)); + + if (isset($versions[$version])) { + throw new InvalidArgumentException(sprintf('Duplicate migration - "%s" has the same version as "%s"', $filePath, $versions[$version]->getVersion())); + } + + $config = $this->getConfig(); + $namespace = $config instanceof NamespaceAwareInterface ? $config->getMigrationNamespaceByPath(dirname($filePath)) : null; + + // convert the filename to a class name + $class = ($namespace === null ? '' : $namespace . '\\') . Util::mapFileNameToClassName(basename($filePath)); + + if (isset($fileNames[$class])) { + throw new InvalidArgumentException(sprintf( + 'Migration "%s" has the same name as "%s"', + basename($filePath), + $fileNames[$class] + )); + } + + $fileNames[$class] = basename($filePath); + + if ($this->getOutput()->getVerbosity() >= OutputInterface::VERBOSITY_DEBUG) { + $this->getOutput()->writeln("Loading class $class from $filePath."); + } + + // load the migration file + $orig_display_errors_setting = ini_get('display_errors'); + ini_set('display_errors', 'On'); + /** @noinspection PhpIncludeInspection */ + require_once $filePath; + ini_set('display_errors', $orig_display_errors_setting); + if (!class_exists($class)) { + throw new InvalidArgumentException(sprintf( + 'Could not find class "%s" in file "%s"', + $class, + $filePath + )); + } + + if ($this->getOutput()->getVerbosity() >= OutputInterface::VERBOSITY_DEBUG) { + $this->getOutput()->writeln("Running $class."); + } + + // instantiate it + $migration = new $class($environment, $version, $this->getInput(), $this->getOutput()); + + if (!($migration instanceof AbstractMigration)) { + throw new InvalidArgumentException(sprintf( + 'The class "%s" in file "%s" must extend \Phinx\Migration\AbstractMigration', + $class, + $filePath + )); + } + + $versions[$version] = $migration; + } else { + if ($this->getOutput()->getVerbosity() >= OutputInterface::VERBOSITY_DEBUG) { + $this->getOutput()->writeln("Invalid migration file {$filePath}."); + } + } + } + + ksort($versions); + $this->setMigrations($versions); + } + + return $this->migrations; + } + + /** + * Returns a list of migration files found in the provided migration paths. + * + * @return string[] + */ + protected function getMigrationFiles(): array + { + return Util::getFiles($this->getConfig()->getMigrationPaths()); + } + + /** + * Sets the database seeders. + * + * @param \Phinx\Seed\SeedInterface[] $seeds Seeders + * @return $this + */ + public function setSeeds(array $seeds) + { + $this->seeds = $seeds; + + return $this; + } + + /** + * Get seed dependencies instances from seed dependency array + * + * @param \Phinx\Seed\SeedInterface $seed Seed + * @return \Phinx\Seed\SeedInterface[] + */ + protected function getSeedDependenciesInstances(SeedInterface $seed): array + { + $dependenciesInstances = []; + $dependencies = $seed->getDependencies(); + if (!empty($dependencies)) { + foreach ($dependencies as $dependency) { + foreach ($this->seeds as $seed) { + if (get_class($seed) === $dependency) { + $dependenciesInstances[get_class($seed)] = $seed; + } + } + } + } + + return $dependenciesInstances; + } + + /** + * Order seeds by dependencies + * + * @param \Phinx\Seed\SeedInterface[] $seeds Seeds + * @return \Phinx\Seed\SeedInterface[] + */ + protected function orderSeedsByDependencies(array $seeds): array + { + $orderedSeeds = []; + foreach ($seeds as $seed) { + $orderedSeeds[get_class($seed)] = $seed; + $dependencies = $this->getSeedDependenciesInstances($seed); + if (!empty($dependencies)) { + $orderedSeeds = array_merge($this->orderSeedsByDependencies($dependencies), $orderedSeeds); + } + } + + return $orderedSeeds; + } + + /** + * Gets an array of database seeders. + * + * @param string $environment Environment + * @throws \InvalidArgumentException + * @return \Phinx\Seed\SeedInterface[] + */ + public function getSeeds(string $environment): array + { + if ($this->seeds === null) { + $phpFiles = $this->getSeedFiles(); + + // filter the files to only get the ones that match our naming scheme + $fileNames = []; + /** @var \Phinx\Seed\SeedInterface[] $seeds */ + $seeds = []; + + foreach ($phpFiles as $filePath) { + if (Util::isValidSeedFileName(basename($filePath))) { + $config = $this->getConfig(); + $namespace = $config instanceof NamespaceAwareInterface ? $config->getSeedNamespaceByPath(dirname($filePath)) : null; + + // convert the filename to a class name + $class = ($namespace === null ? '' : $namespace . '\\') . pathinfo($filePath, PATHINFO_FILENAME); + $fileNames[$class] = basename($filePath); + + // load the seed file + /** @noinspection PhpIncludeInspection */ + require_once $filePath; + if (!class_exists($class)) { + throw new InvalidArgumentException(sprintf( + 'Could not find class "%s" in file "%s"', + $class, + $filePath + )); + } + + // instantiate it + /** @var \Phinx\Seed\AbstractSeed $seed */ + if ($this->container !== null) { + $seed = $this->container->get($class); + } else { + $seed = new $class(); + } + $seed->setEnvironment($environment); + $input = $this->getInput(); + if ($input !== null) { + $seed->setInput($input); + } + $output = $this->getOutput(); + if ($output !== null) { + $seed->setOutput($output); + } + + if (!($seed instanceof AbstractSeed)) { + throw new InvalidArgumentException(sprintf( + 'The class "%s" in file "%s" must extend \Phinx\Seed\AbstractSeed', + $class, + $filePath + )); + } + + $seeds[$class] = $seed; + } + } + + ksort($seeds); + $this->setSeeds($seeds); + } + + $this->seeds = $this->orderSeedsByDependencies($this->seeds); + + return $this->seeds; + } + + /** + * Returns a list of seed files found in the provided seed paths. + * + * @return string[] + */ + protected function getSeedFiles(): array + { + return Util::getFiles($this->getConfig()->getSeedPaths()); + } + + /** + * Sets the config. + * + * @param \Phinx\Config\ConfigInterface $config Configuration Object + * @return $this + */ + public function setConfig(ConfigInterface $config) + { + $this->config = $config; + + return $this; + } + + /** + * Gets the config. + * + * @return \Phinx\Config\ConfigInterface + */ + public function getConfig(): ConfigInterface + { + return $this->config; + } + + /** + * Toggles the breakpoint for a specific version. + * + * @param string $environment Environment name + * @param int|null $version Version + * @return void + */ + public function toggleBreakpoint(string $environment, ?int $version): void + { + $this->markBreakpoint($environment, $version, self::BREAKPOINT_TOGGLE); + } + + /** + * Updates the breakpoint for a specific version. + * + * @param string $environment The required environment + * @param int|null $version The version of the target migration + * @param int $mark The state of the breakpoint as defined by self::BREAKPOINT_xxxx constants. + * @return void + */ + protected function markBreakpoint(string $environment, ?int $version, int $mark): void + { + $migrations = $this->getMigrations($environment); + $env = $this->getEnvironment($environment); + $versions = $env->getVersionLog(); + + if (empty($versions) || empty($migrations)) { + return; + } + + if ($version === null) { + $lastVersion = end($versions); + $version = $lastVersion['version']; + } + + if ($version != 0 && (!isset($versions[$version]) || !isset($migrations[$version]))) { + $this->output->writeln(sprintf( + 'warning %s is not a valid version', + $version + )); + + return; + } + + switch ($mark) { + case self::BREAKPOINT_TOGGLE: + $env->getAdapter()->toggleBreakpoint($migrations[$version]); + break; + case self::BREAKPOINT_SET: + if ($versions[$version]['breakpoint'] == 0) { + $env->getAdapter()->setBreakpoint($migrations[$version]); + } + break; + case self::BREAKPOINT_UNSET: + if ($versions[$version]['breakpoint'] == 1) { + $env->getAdapter()->unsetBreakpoint($migrations[$version]); + } + break; + } + + $versions = $env->getVersionLog(); + + $this->getOutput()->writeln( + ' Breakpoint ' . ($versions[$version]['breakpoint'] ? 'set' : 'cleared') . + ' for ' . $version . '' . + ' ' . $migrations[$version]->getName() . '' + ); + } + + /** + * Remove all breakpoints + * + * @param string $environment The required environment + * @return void + */ + public function removeBreakpoints(string $environment): void + { + $this->getOutput()->writeln(sprintf( + ' %d breakpoints cleared.', + $this->getEnvironment($environment)->getAdapter()->resetAllBreakpoints() + )); + } + + /** + * Set the breakpoint for a specific version. + * + * @param string $environment The required environment + * @param int|null $version The version of the target migration + * @return void + */ + public function setBreakpoint(string $environment, ?int $version): void + { + $this->markBreakpoint($environment, $version, self::BREAKPOINT_SET); + } + + /** + * Unset the breakpoint for a specific version. + * + * @param string $environment The required environment + * @param int|null $version The version of the target migration + * @return void + */ + public function unsetBreakpoint(string $environment, ?int $version): void + { + $this->markBreakpoint($environment, $version, self::BREAKPOINT_UNSET); + } + + /** + * @param int $verbosityLevel Verbosity level for info messages + * @return $this + */ + public function setVerbosityLevel(int $verbosityLevel) + { + $this->verbosityLevel = $verbosityLevel; + + return $this; + } +} diff --git a/extend/phinx/Migration/Manager/Environment.php b/extend/phinx/Migration/Manager/Environment.php new file mode 100644 index 0000000..9993ebc --- /dev/null +++ b/extend/phinx/Migration/Manager/Environment.php @@ -0,0 +1,398 @@ + + */ + protected $options; + + /** + * @var \think\console\Input|null + */ + protected $input; + + /** + * @var \think\console\Output|null + */ + protected $output; + + /** + * @var int + */ + protected $currentVersion; + + /** + * @var string + */ + protected $schemaTableName = 'phinxlog'; + + /** + * @var \Phinx\Db\Adapter\AdapterInterface + */ + protected $adapter; + + /** + * @param string $name Environment Name + * @param array $options Options + */ + public function __construct(string $name, array $options) + { + $this->name = $name; + $this->options = $options; + } + + /** + * Executes the specified migration on this environment. + * + * @param \Phinx\Migration\MigrationInterface $migration Migration + * @param string $direction Direction + * @param bool $fake flag that if true, we just record running the migration, but not actually do the migration + * @return void + */ + public function executeMigration(MigrationInterface $migration, string $direction = MigrationInterface::UP, bool $fake = false): void + { + $direction = $direction === MigrationInterface::UP ? MigrationInterface::UP : MigrationInterface::DOWN; + $migration->setMigratingUp($direction === MigrationInterface::UP); + + $startTime = time(); + $migration->setAdapter($this->getAdapter()); + + $migration->preFlightCheck(); + + if (method_exists($migration, MigrationInterface::INIT)) { + $migration->{MigrationInterface::INIT}(); + } + + if (!$fake) { + // begin the transaction if the adapter supports it + if ($this->getAdapter()->hasTransactions()) { + $this->getAdapter()->beginTransaction(); + } + + // Run the migration + if (method_exists($migration, MigrationInterface::CHANGE)) { + if ($direction === MigrationInterface::DOWN) { + // Create an instance of the ProxyAdapter so we can record all + // of the migration commands for reverse playback + + /** @var \Phinx\Db\Adapter\ProxyAdapter $proxyAdapter */ + $proxyAdapter = AdapterFactory::instance() + ->getWrapper('proxy', $this->getAdapter()); + $migration->setAdapter($proxyAdapter); + $migration->{MigrationInterface::CHANGE}(); + $proxyAdapter->executeInvertedCommands(); + $migration->setAdapter($this->getAdapter()); + } else { + $migration->{MigrationInterface::CHANGE}(); + } + } else { + $migration->{$direction}(); + } + + // commit the transaction if the adapter supports it + if ($this->getAdapter()->hasTransactions()) { + $this->getAdapter()->commitTransaction(); + } + } + + $migration->postFlightCheck(); + + // Record it in the database + $this->getAdapter()->migrated($migration, $direction, date('Y-m-d H:i:s', $startTime), date('Y-m-d H:i:s', time())); + } + + /** + * Executes the specified seeder on this environment. + * + * @param \Phinx\Seed\SeedInterface $seed Seed + * @return void + */ + public function executeSeed(SeedInterface $seed): void + { + $seed->setAdapter($this->getAdapter()); + if (method_exists($seed, SeedInterface::INIT)) { + $seed->{SeedInterface::INIT}(); + } + + // begin the transaction if the adapter supports it + if ($this->getAdapter()->hasTransactions()) { + $this->getAdapter()->beginTransaction(); + } + + // Run the seeder + if (method_exists($seed, SeedInterface::RUN)) { + $seed->{SeedInterface::RUN}(); + } + + // commit the transaction if the adapter supports it + if ($this->getAdapter()->hasTransactions()) { + $this->getAdapter()->commitTransaction(); + } + } + + /** + * Sets the environment's name. + * + * @param string $name Environment Name + * @return $this + */ + public function setName(string $name) + { + $this->name = $name; + + return $this; + } + + /** + * Gets the environment name. + * + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * Sets the environment's options. + * + * @param array $options Environment Options + * @return $this + */ + public function setOptions(array $options) + { + $this->options = $options; + + return $this; + } + + /** + * Gets the environment's options. + * + * @return array + */ + public function getOptions(): array + { + return $this->options; + } + + /** + * Sets the console input. + * + * @param \think\console\Input $input Input + * @return $this + */ + public function setInput(InputInterface $input) + { + $this->input = $input; + + return $this; + } + + /** + * Gets the console input. + * + * @return \think\console\Input|null + */ + public function getInput(): ?InputInterface + { + return $this->input; + } + + /** + * Sets the console output. + * + * @param \think\console\Output $output Output + * @return $this + */ + public function setOutput(OutputInterface $output) + { + $this->output = $output; + + return $this; + } + + /** + * Gets the console output. + * + * @return \think\console\Output|null + */ + public function getOutput(): ?OutputInterface + { + return $this->output; + } + + /** + * Gets all migrated version numbers. + * + * @return array + */ + public function getVersions(): array + { + return $this->getAdapter()->getVersions(); + } + + /** + * Get all migration log entries, indexed by version creation time and sorted in ascending order by the configuration's + * version_order option + * + * @return array + */ + public function getVersionLog(): array + { + return $this->getAdapter()->getVersionLog(); + } + + /** + * Sets the current version of the environment. + * + * @param int $version Environment Version + * @return $this + */ + public function setCurrentVersion(int $version) + { + $this->currentVersion = $version; + + return $this; + } + + /** + * Gets the current version of the environment. + * + * @return int + */ + public function getCurrentVersion(): int + { + // We don't cache this code as the current version is pretty volatile. + // that means they're no point in a setter then? + // maybe we should cache and call a reset() method every time a migration is run + $versions = $this->getVersions(); + $version = 0; + + if (!empty($versions)) { + $version = end($versions); + } + + $this->setCurrentVersion($version); + + return $this->currentVersion; + } + + /** + * Sets the database adapter. + * + * @param \Phinx\Db\Adapter\AdapterInterface $adapter Database Adapter + * @return $this + */ + public function setAdapter(AdapterInterface $adapter) + { + $this->adapter = $adapter; + + return $this; + } + + /** + * Gets the database adapter. + * + * @throws \RuntimeException + * @return \Phinx\Db\Adapter\AdapterInterface + */ + public function getAdapter(): AdapterInterface + { + if (isset($this->adapter)) { + return $this->adapter; + } + + $options = $this->getOptions(); + if (isset($options['connection'])) { + if (!($options['connection'] instanceof PDO)) { + throw new RuntimeException('The specified connection is not a PDO instance'); + } + + $options['connection']->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $options['adapter'] = $options['connection']->getAttribute(PDO::ATTR_DRIVER_NAME); + } + if (!isset($options['adapter'])) { + throw new RuntimeException('No adapter was specified for environment: ' . $this->getName()); + } + + $factory = AdapterFactory::instance(); + $adapter = $factory + ->getAdapter($options['adapter'], $options); + + // Automatically time the executed commands + $adapter = $factory->getWrapper('timed', $adapter); + + if (isset($options['wrapper'])) { + $adapter = $factory + ->getWrapper($options['wrapper'], $adapter); + } + + /** @var \think\console\Input|null $input */ + $input = $this->getInput(); + if ($input) { + $adapter->setInput($this->getInput()); + } + + /** @var \think\console\Output|null $output */ + $output = $this->getOutput(); + if ($output) { + $adapter->setOutput($this->getOutput()); + } + + // Use the TablePrefixAdapter if table prefix/suffixes are in use + if ($adapter->hasOption('table_prefix') || $adapter->hasOption('table_suffix')) { + $adapter = AdapterFactory::instance() + ->getWrapper('prefix', $adapter); + } + + $this->setAdapter($adapter); + + return $adapter; + } + + /** + * Sets the schema table name. + * + * @param string $schemaTableName Schema Table Name + * @return $this + */ + public function setSchemaTableName($schemaTableName) + { + $this->schemaTableName = $schemaTableName; + + return $this; + } + + /** + * Gets the schema table name. + * + * @return string + */ + public function getSchemaTableName(): string + { + return $this->schemaTableName; + } +} diff --git a/extend/phinx/Migration/Migration.change.template.php.dist b/extend/phinx/Migration/Migration.change.template.php.dist new file mode 100644 index 0000000..12eb6fb --- /dev/null +++ b/extend/phinx/Migration/Migration.change.template.php.dist @@ -0,0 +1,23 @@ + + */ +interface MigrationInterface +{ + /** + * @var string + */ + public const CHANGE = 'change'; + + /** + * @var string + */ + public const UP = 'up'; + + /** + * @var string + */ + public const DOWN = 'down'; + + /** + * @var string + */ + public const INIT = 'init'; + + /** + * Sets the database adapter. + * + * @param \Phinx\Db\Adapter\AdapterInterface $adapter Database Adapter + * @return $this + */ + public function setAdapter(AdapterInterface $adapter); + + /** + * Gets the database adapter. + * + * @return \Phinx\Db\Adapter\AdapterInterface|null + */ + public function getAdapter(): ?AdapterInterface; + + /** + * Sets the input object to be used in migration object + * + * @param \think\console\Input $input Input + * @return $this + */ + public function setInput(InputInterface $input); + + /** + * Gets the input object to be used in migration object + * + * @return \think\console\Input|null + */ + public function getInput(): ?InputInterface; + + /** + * Sets the output object to be used in migration object + * + * @param \think\console\Output $output Output + * @return $this + */ + public function setOutput(OutputInterface $output); + + /** + * Gets the output object to be used in migration object + * + * @return \think\console\Output|null + */ + public function getOutput(): ?OutputInterface; + + /** + * Gets the name. + * + * @return string + */ + public function getName(): string; + + /** + * Gets the detected environment + * + * @return string + */ + public function getEnvironment(): string; + + /** + * Sets the migration version number. + * + * @param int $version Version + * @return $this + */ + public function setVersion(int $version); + + /** + * Gets the migration version number. + * + * @return int + */ + public function getVersion(): int; + + /** + * Sets whether this migration is being applied or reverted + * + * @param bool $isMigratingUp True if the migration is being applied + * @return $this + */ + public function setMigratingUp(bool $isMigratingUp); + + /** + * Gets whether this migration is being applied or reverted. + * True means that the migration is being applied. + * + * @return bool + */ + public function isMigratingUp(): bool; + + /** + * Executes a SQL statement and returns the number of affected rows. + * + * @param string $sql SQL + * @param array $params parameters to use for prepared query + * @return int + */ + public function execute(string $sql, array $params = []): int; + + /** + * Executes a SQL statement. + * + * The return type depends on the underlying adapter being used. To improve + * IDE auto-completion possibility, you can overwrite the query method + * phpDoc in your (typically custom abstract parent) migration class, where + * you can set the return type by the adapter in your current use. + * + * @param string $sql SQL + * @param array $params parameters to use for prepared query + * @return mixed + */ + public function query(string $sql, array $params = []); + + /** + * Returns a new Query object that can be used to build complex SELECT, UPDATE, INSERT or DELETE + * queries and execute them against the current database. + * + * Queries executed through the query builder are always sent to the database, regardless of the + * the dry-run settings. + * + * @see https://api.cakephp.org/3.6/class-Cake.Database.Query.html + * @return \Cake\Database\Query + */ + public function getQueryBuilder(): Query; + + /** + * Executes a query and returns only one row as an array. + * + * @param string $sql SQL + * @return array|false + */ + public function fetchRow(string $sql); + + /** + * Executes a query and returns an array of rows. + * + * @param string $sql SQL + * @return array + */ + public function fetchAll(string $sql): array; + + /** + * Create a new database. + * + * @param string $name Database Name + * @param array $options Options + * @return void + */ + public function createDatabase(string $name, array $options): void; + + /** + * Drop a database. + * + * @param string $name Database Name + * @return void + */ + public function dropDatabase(string $name): void; + + /** + * Creates schema. + * + * This will thrown an error for adapters that do not support schemas. + * + * @param string $name Schema name + * @return void + * @throws \BadMethodCallException + */ + public function createSchema(string $name): void; + + /** + * Drops schema. + * + * This will thrown an error for adapters that do not support schemas. + * + * @param string $name Schema name + * @return void + * @throws \BadMethodCallException + */ + public function dropSchema(string $name): void; + + /** + * Checks to see if a table exists. + * + * @param string $tableName Table name + * @return bool + */ + public function hasTable(string $tableName): bool; + + /** + * Returns an instance of the \Table class. + * + * You can use this class to create and manipulate tables. + * + * @param string $tableName Table name + * @param array $options Options + * @return \Phinx\Db\Table + */ + public function table(string $tableName, array $options): Table; + + /** + * Perform checks on the migration, printing a warning + * if there are potential problems. + * + * @return void + */ + public function preFlightCheck(): void; + + /** + * Perform checks on the migration after completion + * + * Right now, the only check is whether all changes were committed + * + * @return void + */ + public function postFlightCheck(): void; + + /** + * Checks to see if the migration should be executed. + * + * Returns true by default. + * + * You can use this to prevent a migration from executing. + * + * @return bool + */ + public function shouldExecute(): bool; +} diff --git a/extend/phinx/README.md b/extend/phinx/README.md new file mode 100644 index 0000000..410ef99 --- /dev/null +++ b/extend/phinx/README.md @@ -0,0 +1,143 @@ +# [Phinx](https://phinx.org): Simple PHP Database Migrations + +[![Build Status](https://github.com/cakephp/phinx/workflows/CI/badge.svg?branch=master&event=push)](https://github.com/cakephp/phinx/actions?query=workflow%3A%22CI%22+branch%3Amaster+event%3Apush) +[![Code Coverage](https://codecov.io/gh/cakephp/phinx/branch/master/graph/badge.svg)](https://codecov.io/gh/cakephp/phinx) +[![Latest Stable Version](https://poser.pugx.org/robmorgan/phinx/version.png)](https://packagist.org/packages/robmorgan/phinx) +[![Minimum PHP Version](https://img.shields.io/badge/php-%3E%3D%207.2-8892BF.svg)](https://php.net/) +[![Total Downloads](https://poser.pugx.org/robmorgan/phinx/d/total.png)](https://packagist.org/packages/robmorgan/phinx) + +## Intro + +Phinx makes it ridiculously easy to manage the database migrations for your PHP app. In less than 5 minutes, you can install Phinx and create your first database migration. Phinx is just about migrations without all the bloat of a database ORM system or framework. + +**Check out [book.cakephp.org/phinx](https://book.cakephp.org/phinx) ([EN](https://book.cakephp.org/phinx), [ZH](https://tsy12321.gitbooks.io/phinx-doc/)) for the comprehensive documentation.** + +![phinxterm](https://cloud.githubusercontent.com/assets/178939/3887559/e6b5e524-21f2-11e4-8256-0ba6040725fc.gif) + +### Features + +* Write database migrations using database agnostic PHP code. +* Migrate up and down. +* Migrate on deployment. +* Seed data after database creation. +* Get going in less than 5 minutes. +* Stop worrying about the state of your database. +* Take advantage of SCM features such as branching. +* Integrate with any app. + +### Supported Adapters + +Phinx natively supports the following database adapters: + +* MySQL +* PostgreSQL +* SQLite +* Microsoft SQL Server + +## Install & Run + +See [version and branch overview](https://github.com/cakephp/phinx/wiki#version-and-branch-overview) for branch and PHP compatibility. + +### Composer + +The fastest way to install Phinx is to add it to your project using Composer (https://getcomposer.org/). + +1. Install Composer: + + ``` + curl -sS https://getcomposer.org/installer | php + ``` + +1. Require Phinx as a dependency using Composer: + + ``` + php composer.phar require robmorgan/phinx + ``` + +1. Install Phinx: + + ``` + php composer.phar install + ``` + +1. Execute Phinx: + + ``` + php vendor/bin/phinx + ``` + +### As a Phar + +You can also use the Box application to build Phinx as a Phar archive (https://box-project.github.io/box2/). + +1. Clone Phinx from GitHub + + ``` + git clone https://github.com/cakephp/phinx.git + cd phinx + ``` + +1. Install Composer + + ``` + curl -s https://getcomposer.org/installer | php + ``` + +1. Install the Phinx dependencies + + ``` + php composer.phar install + ``` + +1. Install Box: + + ``` + curl -LSs https://box-project.github.io/box2/installer.php | php + ``` + +1. Create a Phar archive + + ``` + php box.phar build + ``` + +## Documentation + +Check out https://book.cakephp.org/phinx for the comprehensive documentation. + +Other translations include: + + * [Chinese](https://tsy12321.gitbooks.io/phinx-doc/) (Maintained by [@tsy12321](https://github.com/tsy12321/phinx-doc)) + +## Contributing + +Please read the [CONTRIBUTING](CONTRIBUTING.md) document. + +## News & Updates + +Follow [@CakePHP](https://twitter.com/cakephp) on Twitter to stay up to date. + +## Limitations + +### PostgreSQL + +- Not able to set a unique constraint on a table (). + + +## Misc + +### Version History + +Please read the [release notes](https://github.com/cakephp/phinx/releases). + +### License + +(The MIT license) + +Copyright (c) 2017 Rob Morgan + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/extend/phinx/Seed/AbstractSeed.php b/extend/phinx/Seed/AbstractSeed.php new file mode 100644 index 0000000..f4e13e4 --- /dev/null +++ b/extend/phinx/Seed/AbstractSeed.php @@ -0,0 +1,222 @@ + + */ +abstract class AbstractSeed implements SeedInterface +{ + /** + * @var string + */ + protected $environment; + + /** + * @var \Phinx\Db\Adapter\AdapterInterface + */ + protected $adapter; + + /** + * @var \think\console\Input + */ + protected $input; + + /** + * @var \think\console\Output + */ + protected $output; + + /** + * Override to specify dependencies for dependency injection from the configured PSR-11 container + */ + public function __construct() + { + } + + /** + * @inheritDoc + */ + public function run(): void + { + } + + /** + * @inheritDoc + */ + public function getDependencies(): array + { + return []; + } + + /** + * @inheritDoc + */ + public function setEnvironment(string $environment) + { + $this->environment = $environment; + + return $this; + } + + /** + * @inheritDoc + */ + public function getEnvironment(): string + { + return $this->environment; + } + + /** + * @inheritDoc + */ + public function setAdapter(AdapterInterface $adapter): SeedInterface + { + $this->adapter = $adapter; + + return $this; + } + + /** + * @inheritDoc + */ + public function getAdapter(): AdapterInterface + { + return $this->adapter; + } + + /** + * @inheritDoc + */ + public function setInput(InputInterface $input) + { + $this->input = $input; + + return $this; + } + + /** + * @inheritDoc + */ + public function getInput(): InputInterface + { + return $this->input; + } + + /** + * @inheritDoc + */ + public function setOutput(OutputInterface $output): SeedInterface + { + $this->output = $output; + + return $this; + } + + /** + * @inheritDoc + */ + public function getOutput(): OutputInterface + { + return $this->output; + } + + /** + * @inheritDoc + */ + public function getName(): string + { + return static::class; + } + + /** + * @inheritDoc + */ + public function execute(string $sql, array $params = []) + { + return $this->getAdapter()->execute($sql, $params); + } + + /** + * @inheritDoc + */ + public function query(string $sql, array $params = []) + { + return $this->getAdapter()->query($sql, $params); + } + + /** + * @inheritDoc + */ + public function fetchRow(string $sql) + { + return $this->getAdapter()->fetchRow($sql); + } + + /** + * @inheritDoc + */ + public function fetchAll(string $sql): array + { + return $this->getAdapter()->fetchAll($sql); + } + + /** + * @inheritDoc + */ + public function insert(string $table, array $data): void + { + // convert to table object + if (is_string($table)) { + $table = new Table($table, [], $this->getAdapter()); + } + $table->insert($data)->save(); + } + + /** + * @inheritDoc + */ + public function hasTable(string $tableName): bool + { + return $this->getAdapter()->hasTable($tableName); + } + + /** + * @inheritDoc + */ + public function table(string $tableName, array $options = []): Table + { + return new Table($tableName, $options, $this->getAdapter()); + } + + /** + * Checks to see if the seed should be executed. + * + * Returns true by default. + * + * You can use this to prevent a seed from executing. + * + * @return bool + */ + public function shouldExecute(): bool + { + return true; + } +} diff --git a/extend/phinx/Seed/Seed.template.php.dist b/extend/phinx/Seed/Seed.template.php.dist new file mode 100644 index 0000000..74ba622 --- /dev/null +++ b/extend/phinx/Seed/Seed.template.php.dist @@ -0,0 +1,20 @@ + + */ +interface SeedInterface +{ + /** + * @var string + */ + public const RUN = 'run'; + + /** + * @var string + */ + public const INIT = 'init'; + + /** + * Run the seeder. + * + * @return void + */ + public function run(): void; + + /** + * Return seeds dependencies. + * + * @return array + */ + public function getDependencies(): array; + + /** + * Sets the environment. + * + * @return $this + */ + public function setEnvironment(string $environment); + + /** + * Gets the environment. + * + * @return string + */ + public function getEnvironment(): string; + + /** + * Sets the database adapter. + * + * @param \Phinx\Db\Adapter\AdapterInterface $adapter Database Adapter + * @return $this + */ + public function setAdapter(AdapterInterface $adapter); + + /** + * Gets the database adapter. + * + * @return \Phinx\Db\Adapter\AdapterInterface + */ + public function getAdapter(): AdapterInterface; + + /** + * Sets the input object to be used in migration object + * + * @param \think\console\Input $input Input + * @return $this + */ + public function setInput(InputInterface $input); + + /** + * Gets the input object to be used in migration object + * + * @return \think\console\Input + */ + public function getInput(): InputInterface; + + /** + * Sets the output object to be used in migration object + * + * @param \think\console\Output $output Output + * @return $this + */ + public function setOutput(OutputInterface $output); + + /** + * Gets the output object to be used in migration object + * + * @return \think\console\Output + */ + public function getOutput(): OutputInterface; + + /** + * Gets the name. + * + * @return string + */ + public function getName(): string; + + /** + * Executes a SQL statement and returns the number of affected rows. + * + * @param string $sql SQL + * @param array $params parameters to use for prepared query + * @return int + */ + public function execute(string $sql, array $params = []); + + /** + * Executes a SQL statement. + * + * The return type depends on the underlying adapter being used. To improve + * IDE auto-completion possibility, you can overwrite the query method + * phpDoc in your (typically custom abstract parent) seed class, where + * you can set the return type by the adapter in your current use. + * + * @param string $sql SQL + * @param array $params parameters to use for prepared query + * @return mixed + */ + public function query(string $sql, array $params = []); + + /** + * Executes a query and returns only one row as an array. + * + * @param string $sql SQL + * @return array|false + */ + public function fetchRow(string $sql); + + /** + * Executes a query and returns an array of rows. + * + * @param string $sql SQL + * @return array + */ + public function fetchAll(string $sql): array; + + /** + * Insert data into a table. + * + * @param string $tableName Table name + * @param array $data Data + * @return void + */ + public function insert(string $tableName, array $data): void; + + /** + * Checks to see if a table exists. + * + * @param string $tableName Table name + * @return bool + */ + public function hasTable(string $tableName): bool; + + /** + * Returns an instance of the \Table class. + * + * You can use this class to create and manipulate tables. + * + * @param string $tableName Table name + * @param array $options Options + * @return \Phinx\Db\Table + */ + public function table(string $tableName, array $options): \Phinx\Db\Table; + + /** + * Checks to see if the seed should be executed. + * + * Returns true by default. + * + * You can use this to prevent a seed from executing. + * + * @return bool + */ + public function shouldExecute(): bool; +} diff --git a/extend/phinx/Util/Expression.php b/extend/phinx/Util/Expression.php new file mode 100644 index 0000000..23a6b9e --- /dev/null +++ b/extend/phinx/Util/Expression.php @@ -0,0 +1,41 @@ +value = $value; + } + + /** + * @return string Returns the expression + */ + public function __toString(): string + { + return $this->value; + } + + /** + * @param string $value The expression + * @return self + */ + public static function from(string $value): Expression + { + return new self($value); + } +} diff --git a/extend/phinx/Util/Literal.php b/extend/phinx/Util/Literal.php new file mode 100644 index 0000000..5889832 --- /dev/null +++ b/extend/phinx/Util/Literal.php @@ -0,0 +1,41 @@ +value = $value; + } + + /** + * @return string Returns the literal's value + */ + public function __toString(): string + { + return $this->value; + } + + /** + * @param string $value The literal's value + * @return self + */ + public static function from(string $value): Literal + { + return new self($value); + } +} diff --git a/extend/phinx/Util/Util.php b/extend/phinx/Util/Util.php new file mode 100644 index 0000000..442f6d5 --- /dev/null +++ b/extend/phinx/Util/Util.php @@ -0,0 +1,361 @@ +format(static::DATE_FORMAT); + } + + /** + * Gets an array of all the existing migration class names. + * + * @param string $path Path + * @return string[] + */ + public static function getExistingMigrationClassNames(string $path): array + { + $classNames = []; + + if (!is_dir($path)) { + return $classNames; + } + + // filter the files to only get the ones that match our naming scheme + $phpFiles = static::getFiles($path); + + foreach ($phpFiles as $filePath) { + $fileName = basename($filePath); + if (preg_match(static::MIGRATION_FILE_NAME_PATTERN, $fileName)) { + $classNames[] = static::mapFileNameToClassName($fileName); + } + } + + return $classNames; + } + + /** + * Get the version from the beginning of a file name. + * + * @param string $fileName File Name + * @return int + */ + public static function getVersionFromFileName(string $fileName): int + { + $matches = []; + preg_match('/^[0-9]+/', basename($fileName), $matches); + $value = (int)($matches[0] ?? null); + if (!$value) { + throw new RuntimeException(sprintf('Cannot get a valid version from filename `%s`', $fileName)); + } + + return $value; + } + + /** + * Turn migration names like 'CreateUserTable' into file names like + * '12345678901234_create_user_table.php' or 'LimitResourceNamesTo30Chars' into + * '12345678901234_limit_resource_names_to_30_chars.php'. + * + * @param string $className Class Name + * @return string + */ + public static function mapClassNameToFileName(string $className): string + { + $snake = function ($matches) { + return '_' . strtolower($matches[0]); + }; + $fileName = preg_replace_callback('/\d+|[A-Z]/', $snake, $className); + $fileName = static::getCurrentTimestamp() . "$fileName.php"; + + return $fileName; + } + + /** + * Turn file names like '12345678901234_create_user_table.php' into class + * names like 'CreateUserTable'. + * + * @param string $fileName File Name + * @return string + */ + public static function mapFileNameToClassName(string $fileName): string + { + $matches = []; + if (preg_match(static::MIGRATION_FILE_NAME_PATTERN, $fileName, $matches)) { + $fileName = $matches[1]; + } elseif (preg_match(static::MIGRATION_FILE_NAME_NO_NAME_PATTERN, $fileName)) { + return 'V' . substr($fileName, 0, strlen($fileName) - 4); + } + + $className = str_replace('_', '', ucwords($fileName, '_')); + + return $className; + } + + /** + * Check if a migration class name is unique regardless of the + * timestamp. + * + * This method takes a class name and a path to a migrations directory. + * + * Migration class names must be in PascalCase format but consecutive + * capitals are allowed. + * e.g: AddIndexToPostsTable or CustomHTMLTitle. + * + * @param string $className Class Name + * @param string $path Path + * @return bool + */ + public static function isUniqueMigrationClassName(string $className, string $path): bool + { + $existingClassNames = static::getExistingMigrationClassNames($path); + + return !in_array($className, $existingClassNames, true); + } + + /** + * Check if a migration/seed class name is valid. + * + * Migration & Seed class names must be in CamelCase format. + * e.g: CreateUserTable, AddIndexToPostsTable or UserSeeder. + * + * Single words are not allowed on their own. + * + * @param string $className Class Name + * @return bool + */ + public static function isValidPhinxClassName(string $className): bool + { + return (bool)preg_match(static::CLASS_NAME_PATTERN, $className); + } + + /** + * Check if a migration file name is valid. + * + * @param string $fileName File Name + * @return bool + */ + public static function isValidMigrationFileName(string $fileName): bool + { + return (bool)preg_match(static::MIGRATION_FILE_NAME_PATTERN, $fileName) + || (bool)preg_match(static::MIGRATION_FILE_NAME_NO_NAME_PATTERN, $fileName); + } + + /** + * Check if a seed file name is valid. + * + * @param string $fileName File Name + * @return bool + */ + public static function isValidSeedFileName(string $fileName): bool + { + return (bool)preg_match(static::SEED_FILE_NAME_PATTERN, $fileName); + } + + /** + * Expands a set of paths with curly braces (if supported by the OS). + * + * @param string[] $paths Paths + * @return string[] + */ + public static function globAll(array $paths): array + { + $result = []; + + foreach ($paths as $path) { + $result = array_merge($result, static::glob($path)); + } + + return $result; + } + + /** + * Expands a path with curly braces (if supported by the OS). + * + * @param string $path Path + * @return string[] + */ + public static function glob(string $path): array + { + return glob($path, defined('GLOB_BRACE') ? GLOB_BRACE : 0); + } + + /** + * Takes the path to a php file and attempts to include it if readable + * + * @param string $filename Filename + * @param \think\console\Input|null $input Input + * @param \think\console\Output|null $output Output + * @param \Phinx\Console\Command\AbstractCommand|mixed|null $context Context + * @throws \Exception + * @return string + */ + public static function loadPhpFile(string $filename, ?InputInterface $input = null, ?OutputInterface $output = null, $context = null): string + { + $filePath = realpath($filename); + if (!file_exists($filePath)) { + throw new Exception(sprintf("File does not exist: %s \n", $filename)); + } + + /** + * I lifed this from phpunits FileLoader class + * + * @see https://github.com/sebastianbergmann/phpunit/pull/2751 + */ + $isReadable = @fopen($filePath, 'r') !== false; + + if (!$isReadable) { + throw new Exception(sprintf("Cannot open file %s \n", $filename)); + } + + // prevent this to be propagated to the included file + unset($isReadable); + + include_once $filePath; + + return $filePath; + } + + /** + * Given an array of paths, return all unique PHP files that are in them + * + * @param string|string[] $paths Path or array of paths to get .php files. + * @return string[] + */ + public static function getFiles($paths): array + { + $files = static::globAll(array_map(function ($path) { + return $path . DIRECTORY_SEPARATOR . '*.php'; + }, (array)$paths)); + // glob() can return the same file multiple times + // This will cause the migration to fail with a + // false assumption of duplicate migrations + // https://php.net/manual/en/function.glob.php#110340 + $files = array_unique($files); + + return $files; + } + + /** + * Attempt to remove the current working directory from a path for output. + * + * @param string $path Path to remove cwd prefix from + * @return string + */ + public static function relativePath(string $path): string + { + $realpath = realpath($path); + if ($realpath !== false) { + $path = $realpath; + } + + $cwd = getcwd(); + if ($cwd !== false) { + $cwd .= DIRECTORY_SEPARATOR; + $cwdLen = strlen($cwd); + + if (substr($path, 0, $cwdLen) === $cwd) { + $path = substr($path, $cwdLen); + } + } + + return $path; + } + + /** + * Parses DSN string into db config array. + * + * @param string $dsn DSN string + * @return array + */ + public static function parseDsn(string $dsn): array + { + $pattern = <<<'REGEXP' +{ + ^ + (?: + (?P[\w\\\\]+):// + ) + (?: + (?P.*?) + (?: + :(?P.*?) + )? + @ + )? + (?: + (?P[^?#/:@]+) + (?: + :(?P\d+) + )? + )? + (?: + /(?P[^?#]*) + )? + (?: + \?(?P[^#]*) + )? + $ +}x +REGEXP; + + if (!preg_match($pattern, $dsn, $parsed)) { + return []; + } + + // filter out everything except the matched groups + $config = array_intersect_key($parsed, array_flip(['adapter', 'user', 'pass', 'host', 'port', 'name'])); + $config = array_filter($config); + + parse_str($parsed['query'] ?? '', $query); + $config = array_merge($query, $config); + + return $config; + } +} diff --git a/extend/think/migration/Command.php b/extend/think/migration/Command.php new file mode 100644 index 0000000..339796f --- /dev/null +++ b/extend/think/migration/Command.php @@ -0,0 +1,97 @@ + +// +---------------------------------------------------------------------- +namespace think\migration; + +use InvalidArgumentException; +use Phinx\Config\Config; +use Phinx\Db\Adapter\AdapterFactory; + +abstract class Command extends \think\console\Command +{ + protected $adapter; + + public function getAdapter() + { + if (isset($this->adapter)) { + return $this->adapter; + } + + $options = $this->getDbConfig(); + + $adapter = AdapterFactory::instance()->getAdapter($options['adapter'], $options); + + if ($adapter->hasOption('table_prefix') || $adapter->hasOption('table_suffix')) { + $adapter = AdapterFactory::instance()->getWrapper('prefix', $adapter); + } + + $adapter->setInput($this->input); + $adapter->setOutput($this->output); + + $this->adapter = $adapter; + + return $adapter; + } + + /** + * 获取数据库配置 + * @return array + */ + protected function getDbConfig(): array + { + $default = $this->app->config->get('database.default'); + + $config = $this->app->config->get("database.connections.{$default}"); + + if (0 == $config['deploy']) { + $dbConfig = [ + 'adapter' => $config['type'], + 'host' => $config['hostname'], + 'name' => $config['database'], + 'user' => $config['username'], + 'pass' => $config['password'], + 'port' => $config['hostport'], + 'charset' => $config['charset'], + 'suffix' => $config['suffix'] ?? '', + 'table_prefix' => $config['prefix'], + ]; + } else { + $dbConfig = [ + 'adapter' => explode(',', $config['type'])[0], + 'host' => explode(',', $config['hostname'])[0], + 'name' => explode(',', $config['database'])[0], + 'user' => explode(',', $config['username'])[0], + 'pass' => explode(',', $config['password'])[0], + 'port' => explode(',', $config['hostport'])[0], + 'charset' => explode(',', $config['charset'])[0], + 'suffix' => explode(',', $config['suffix'] ?? '')[0], + 'table_prefix' => explode(',', $config['prefix'])[0], + ]; + } + + $table = $this->app->config->get('database.migration_table', 'migrations'); + + $dbConfig['migration_table'] = $dbConfig['table_prefix'] . $table; + $dbConfig['version_order'] = Config::VERSION_ORDER_CREATION_TIME; + + return $dbConfig; + } + + protected function verifyMigrationDirectory(string $path) + { + if (!is_dir($path)) { + throw new InvalidArgumentException(sprintf('Migration directory "%s" does not exist', $path)); + } + + if (!is_writable($path)) { + throw new InvalidArgumentException(sprintf('Migration directory "%s" is not writable', $path)); + } + } +} diff --git a/extend/think/migration/Creator.php b/extend/think/migration/Creator.php new file mode 100644 index 0000000..f4bc804 --- /dev/null +++ b/extend/think/migration/Creator.php @@ -0,0 +1,77 @@ +app = $app; + } + + public function create(string $className) + { + $path = $this->ensureDirectory(); + + if (!Util::isValidPhinxClassName($className)) { + throw new InvalidArgumentException(sprintf('The migration class name "%s" is invalid. Please use CamelCase format.', $className)); + } + + if (!Util::isUniqueMigrationClassName($className, $path)) { + throw new InvalidArgumentException(sprintf('The migration class name "%s" already exists', $className)); + } + + // Compute the file path + $fileName = Util::mapClassNameToFileName($className); + $filePath = $path . DIRECTORY_SEPARATOR . $fileName; + + if (is_file($filePath)) { + throw new InvalidArgumentException(sprintf('The file "%s" already exists', $filePath)); + } + + // Verify that the template creation class (or the aliased class) exists and that it implements the required interface. + $aliasedClassName = null; + + // Load the alternative template if it is defined. + $contents = file_get_contents($this->getTemplate()); + + // inject the class names appropriate to this migration + $contents = strtr($contents, [ + 'MigratorClass' => $className, + ]); + + if (false === file_put_contents($filePath, $contents)) { + throw new RuntimeException(sprintf('The file "%s" could not be written to', $path)); + } + + return $filePath; + } + + protected function ensureDirectory() + { + $path = $this->app->getRootPath() . 'database' . DIRECTORY_SEPARATOR . 'migrations'; + + if (!is_dir($path) && !mkdir($path, 0755, true)) { + throw new InvalidArgumentException(sprintf('directory "%s" does not exist', $path)); + } + + if (!is_writable($path)) { + throw new InvalidArgumentException(sprintf('directory "%s" is not writable', $path)); + } + + return $path; + } + + protected function getTemplate() + { + return __DIR__ . '/command/stubs/migrate.stub'; + } +} diff --git a/extend/think/migration/Factory.php b/extend/think/migration/Factory.php new file mode 100644 index 0000000..976a999 --- /dev/null +++ b/extend/think/migration/Factory.php @@ -0,0 +1,313 @@ +faker = $faker; + } + + /** + * Define a class with a given short-name. + * + * @param string $class + * @param string $name + * @param callable $attributes + * @return $this + */ + public function defineAs(string $class, string $name, callable $attributes) + { + return $this->define($class, $attributes, $name); + } + + /** + * Define a class with a given set of attributes. + * + * @param string $class + * @param callable $attributes + * @param string $name + * @return $this + */ + public function define(string $class, callable $attributes, string $name = 'default') + { + $this->definitions[$class][$name] = $attributes; + + return $this; + } + + /** + * Define a state with a given set of attributes. + * + * @param string $class + * @param string $state + * @param callable|array $attributes + * @return $this + */ + public function state(string $class, string $state, $attributes) + { + $this->states[$class][$state] = $attributes; + + return $this; + } + + /** + * Define a callback to run after making a model. + * + * @param string $class + * @param callable $callback + * @param string $name + * @return $this + */ + public function afterMaking(string $class, callable $callback, string $name = 'default') + { + $this->afterMaking[$class][$name][] = $callback; + + return $this; + } + + /** + * Define a callback to run after making a model with given state. + * + * @param string $class + * @param string $state + * @param callable $callback + * @return $this + */ + public function afterMakingState(string $class, string $state, callable $callback) + { + return $this->afterMaking($class, $callback, $state); + } + + /** + * Define a callback to run after creating a model. + * + * @param string $class + * @param callable $callback + * @param string $name + * @return $this + */ + public function afterCreating(string $class, callable $callback, string $name = 'default') + { + $this->afterCreating[$class][$name][] = $callback; + + return $this; + } + + /** + * Define a callback to run after creating a model with given state. + * + * @param string $class + * @param string $state + * @param callable $callback + * @return $this + */ + public function afterCreatingState(string $class, string $state, callable $callback) + { + return $this->afterCreating($class, $callback, $state); + } + + /** + * Create an instance of the given model and persist it to the database. + * + * @param string $class + * @param array $attributes + * @return mixed + */ + public function create(string $class, array $attributes = []) + { + return $this->of($class)->create($attributes); + } + + /** + * Create an instance of the given model and type and persist it to the database. + * + * @param string $class + * @param string $name + * @param array $attributes + * @return mixed + */ + public function createAs(string $class, string $name, array $attributes = []) + { + return $this->of($class, $name)->create($attributes); + } + + /** + * Create an instance of the given model. + * + * @param string $class + * @param array $attributes + * @return mixed + */ + public function make(string $class, array $attributes = []) + { + return $this->of($class)->make($attributes); + } + + /** + * Create an instance of the given model and type. + * + * @param string $class + * @param string $name + * @param array $attributes + * @return mixed + */ + public function makeAs(string $class, string $name, array $attributes = []) + { + return $this->of($class, $name)->make($attributes); + } + + /** + * Get the raw attribute array for a given named model. + * + * @param string $class + * @param string $name + * @param array $attributes + * @return array + */ + public function rawOf(string $class, string $name, array $attributes = []) + { + return $this->raw($class, $attributes, $name); + } + + /** + * Get the raw attribute array for a given model. + * + * @param string $class + * @param array $attributes + * @param string $name + * @return array + */ + public function raw(string $class, array $attributes = [], string $name = 'default') + { + return array_merge( + call_user_func($this->definitions[$class][$name], $this->faker), $attributes + ); + } + + /** + * Create a builder for the given model. + * + * @param string $class + * @param string $name + * @return FactoryBuilder + */ + public function of(string $class, string $name = 'default') + { + return new FactoryBuilder( + $class, $name, $this->definitions, $this->states, + $this->afterMaking, $this->afterCreating, $this->faker + ); + } + + /** + * Load factories from path. + * + * @param string $path + * @return $this + */ + public function load(string $path) + { + $factory = $this; + + if (is_dir($path)) { + foreach (glob($path . '*.php') as $file) { + require $file; + } + } + + return $factory; + } + + /** + * Determine if the given offset exists. + * + * @param string $offset + * @return bool + */ + public function offsetExists($offset) + { + return isset($this->definitions[$offset]); + } + + /** + * Get the value of the given offset. + * + * @param string $offset + * @return mixed + */ + public function offsetGet($offset) + { + return $this->make($offset); + } + + /** + * Set the given offset to the given value. + * + * @param string $offset + * @param callable $value + * @return void + */ + public function offsetSet($offset, $value) + { + $this->define($offset, $value); + } + + /** + * Unset the value at the given offset. + * + * @param string $offset + * @return void + */ + public function offsetUnset($offset) + { + unset($this->definitions[$offset]); + } + +} diff --git a/extend/think/migration/FactoryBuilder.php b/extend/think/migration/FactoryBuilder.php new file mode 100644 index 0000000..a72b07e --- /dev/null +++ b/extend/think/migration/FactoryBuilder.php @@ -0,0 +1,437 @@ +name = $name; + $this->class = $class; + $this->faker = $faker; + $this->states = $states; + $this->definitions = $definitions; + $this->afterMaking = $afterMaking; + $this->afterCreating = $afterCreating; + } + + /** + * Set the amount of models you wish to create / make. + * + * @param int $amount + * @return $this + */ + public function times($amount) + { + $this->amount = $amount; + + return $this; + } + + /** + * Set the state to be applied to the model. + * + * @param string $state + * @return $this + */ + public function state($state) + { + return $this->states([$state]); + } + + /** + * Set the states to be applied to the model. + * + * @param array|mixed $states + * @return $this + */ + public function states($states) + { + $this->activeStates = is_array($states) ? $states : func_get_args(); + + return $this; + } + + /** + * Set the database connection on which the model instance should be persisted. + * + * @param string $name + * @return $this + */ + public function connection($name) + { + $this->connection = $name; + + return $this; + } + + /** + * Create a model and persist it in the database if requested. + * + * @param array $attributes + * @return \Closure + */ + public function lazy(array $attributes = []) + { + return function () use ($attributes) { + return $this->create($attributes); + }; + } + + /** + * Create a collection of models and persist them to the database. + * + * @param array $attributes + * @return mixed + */ + public function create(array $attributes = []) + { + $results = $this->make($attributes); + + if ($results instanceof Model) { + $this->store(new Collection([$results])); + + $this->callAfterCreating(new Collection([$results])); + } else { + $this->store($results); + + $this->callAfterCreating($results); + } + + return $results; + } + + /** + * Set the connection name on the results and store them. + * + * @param Collection $results + * @return void + */ + protected function store($results) + { + $results->each(function (Model $model) { + $model->save(); + }); + } + + /** + * Create a collection of models. + * + * @param array $attributes + * @return mixed + */ + public function make(array $attributes = []) + { + if ($this->amount === null) { + return tap($this->makeInstance($attributes), function ($instance) { + $this->callAfterMaking(new Collection([$instance])); + }); + } + + if ($this->amount < 1) { + return (new $this->class)->toCollection(); + } + + $instances = (new $this->class)->toCollection(array_map(function () use ($attributes) { + return $this->makeInstance($attributes); + }, range(1, $this->amount))); + + $this->callAfterMaking($instances); + + return $instances; + } + + /** + * Create an array of raw attribute arrays. + * + * @param array $attributes + * @return mixed + */ + public function raw(array $attributes = []) + { + if ($this->amount === null) { + return $this->getRawAttributes($attributes); + } + + if ($this->amount < 1) { + return []; + } + + return array_map(function () use ($attributes) { + return $this->getRawAttributes($attributes); + }, range(1, $this->amount)); + } + + /** + * Get a raw attributes array for the model. + * + * @param array $attributes + * @return mixed + * + * @throws \InvalidArgumentException + */ + protected function getRawAttributes(array $attributes = []) + { + if (!isset($this->definitions[$this->class][$this->name])) { + throw new InvalidArgumentException("Unable to locate factory with name [{$this->name}] [{$this->class}]."); + } + + $definition = call_user_func( + $this->definitions[$this->class][$this->name], + $this->faker, $attributes + ); + + return $this->expandAttributes( + array_merge($this->applyStates($definition, $attributes), $attributes) + ); + } + + /** + * Make an instance of the model with the given attributes. + * + * @param array $attributes + * @return Model + */ + protected function makeInstance(array $attributes = []) + { + /** @var Model $model */ + $model = new $this->class; + + $model->setAttrs($this->getRawAttributes($attributes)); + + return $model; + } + + /** + * Apply the active states to the model definition array. + * + * @param array $definition + * @param array $attributes + * @return array + */ + protected function applyStates(array $definition, array $attributes = []) + { + foreach ($this->activeStates as $state) { + if (!isset($this->states[$this->class][$state])) { + if ($this->stateHasAfterCallback($state)) { + continue; + } + + throw new InvalidArgumentException("Unable to locate [{$state}] state for [{$this->class}]."); + } + + $definition = array_merge( + $definition, + $this->stateAttributes($state, $attributes) + ); + } + + return $definition; + } + + /** + * Get the state attributes. + * + * @param string $state + * @param array $attributes + * @return array + */ + protected function stateAttributes($state, array $attributes) + { + $stateAttributes = $this->states[$this->class][$state]; + + if (!is_callable($stateAttributes)) { + return $stateAttributes; + } + + return call_user_func( + $stateAttributes, + $this->faker, $attributes + ); + } + + /** + * Expand all attributes to their underlying values. + * + * @param array $attributes + * @return array + */ + protected function expandAttributes(array $attributes) + { + foreach ($attributes as &$attribute) { + if (is_callable($attribute) && !is_string($attribute) && !is_array($attribute)) { + $attribute = $attribute($attributes); + } + + if ($attribute instanceof static) { + $attribute = $attribute->create()->getKey(); + } + + if ($attribute instanceof Model) { + $attribute = $attribute->getKey(); + } + } + + return $attributes; + } + + /** + * Run after making callbacks on a collection of models. + * + * @param Collection $models + * @return void + */ + public function callAfterMaking($models) + { + $this->callAfter($this->afterMaking, $models); + } + + /** + * Run after creating callbacks on a collection of models. + * + * @param Collection $models + * @return void + */ + public function callAfterCreating($models) + { + $this->callAfter($this->afterCreating, $models); + } + + /** + * Call after callbacks for each model and state. + * + * @param array $afterCallbacks + * @param Collection $models + * @return void + */ + protected function callAfter(array $afterCallbacks, $models) + { + $states = array_merge([$this->name], $this->activeStates); + + $models->each(function ($model) use ($states, $afterCallbacks) { + foreach ($states as $state) { + $this->callAfterCallbacks($afterCallbacks, $model, $state); + } + }); + } + + /** + * Call after callbacks for each model and state. + * + * @param array $afterCallbacks + * @param Model $model + * @param string $state + * @return void + */ + protected function callAfterCallbacks(array $afterCallbacks, $model, $state) + { + if (!isset($afterCallbacks[$this->class][$state])) { + return; + } + + foreach ($afterCallbacks[$this->class][$state] as $callback) { + $callback($model, $this->faker); + } + } + + /** + * Determine if the given state has an "after" callback. + * + * @param string $state + * @return bool + */ + protected function stateHasAfterCallback($state) + { + return isset($this->afterMaking[$this->class][$state]) || + isset($this->afterCreating[$this->class][$state]); + } +} diff --git a/extend/think/migration/Migrator.php b/extend/think/migration/Migrator.php new file mode 100644 index 0000000..a7707c0 --- /dev/null +++ b/extend/think/migration/Migrator.php @@ -0,0 +1,27 @@ + +// +---------------------------------------------------------------------- +namespace think\migration; + +use Phinx\Migration\AbstractMigration; +use think\migration\db\Table; + +class Migrator extends AbstractMigration +{ + /** + * @param string $tableName + * @param array $options + * @return Table + */ + public function table($tableName, $options = []): \Phinx\Db\Table + { + return new Table($tableName, $options, $this->getAdapter()); + } +} diff --git a/extend/think/migration/NullOutput.php b/extend/think/migration/NullOutput.php new file mode 100644 index 0000000..d750595 --- /dev/null +++ b/extend/think/migration/NullOutput.php @@ -0,0 +1,13 @@ + +// +---------------------------------------------------------------------- +namespace think\migration; + +use Phinx\Seed\AbstractSeed; + +class Seeder extends AbstractSeed +{ + /** + * @return Factory + */ + public function factory() + { + return app(Factory::class); + } +} diff --git a/extend/think/migration/Service.php b/extend/think/migration/Service.php new file mode 100644 index 0000000..614e9e0 --- /dev/null +++ b/extend/think/migration/Service.php @@ -0,0 +1,51 @@ + +// +---------------------------------------------------------------------- + +namespace think\migration; + +use Faker\Factory as FakerFactory; +use Faker\Generator as FakerGenerator; +use think\migration\command\factory\Create as FactoryCreate; +use think\migration\command\migrate\Breakpoint as MigrateBreakpoint; +use think\migration\command\migrate\Create as MigrateCreate; +use think\migration\command\migrate\Rollback as MigrateRollback; +use think\migration\command\migrate\Run as MigrateRun; +use think\migration\command\migrate\Status as MigrateStatus; +use think\migration\command\seed\Create as SeedCreate; +use think\migration\command\seed\Run as SeedRun; + +class Service extends \think\Service +{ + + public function boot() + { + $this->app->bind(FakerGenerator::class, function () { + return FakerFactory::create($this->app->config->get('app.faker_locale', 'zh_CN')); + }); + + $this->app->bind(Factory::class, function () { + return (new Factory($this->app->make(FakerGenerator::class)))->load($this->app->getRootPath() . 'database/factories/'); + }); + + $this->app->bind('migration.creator', Creator::class); + + $this->commands([ + MigrateCreate::class, + MigrateRun::class, + MigrateRollback::class, + MigrateBreakpoint::class, + MigrateStatus::class, + SeedCreate::class, + SeedRun::class, + FactoryCreate::class, + ]); + } +} diff --git a/extend/think/migration/UsePhinx.php b/extend/think/migration/UsePhinx.php new file mode 100644 index 0000000..144c5b6 --- /dev/null +++ b/extend/think/migration/UsePhinx.php @@ -0,0 +1,116 @@ +isDevMode()) { + return; + } + + $files = [ + 'vendor/robmorgan/phinx/LICENSE' => 'phinx/LICENSE', + 'vendor/robmorgan/phinx/README.md' => 'phinx/README.md', + 'vendor/robmorgan/phinx/src/Phinx/Config/' => 'phinx/Config/', + 'vendor/robmorgan/phinx/src/Phinx/Db/' => 'phinx/Db/', + 'vendor/robmorgan/phinx/src/Phinx/Migration/' => 'phinx/Migration/', + 'vendor/robmorgan/phinx/src/Phinx/Seed/' => 'phinx/Seed/', + 'vendor/robmorgan/phinx/src/Phinx/Util/' => 'phinx/Util/', + ]; + + $io = $event->getIO(); + + $fs = new Filesystem; + + //clear + $fs->remove('phinx'); + + foreach ($files as $from => $to) { + // check pattern + $pattern = null; + if (strpos($from, '#') > 0) { + [$from, $pattern] = explode('#', $from, 2); + } + + // check the overwrite newer files disable flag (? in end of path) + $overwriteNewerFiles = substr($to, -1) != '?'; + if (!$overwriteNewerFiles) { + $to = substr($to, 0, -1); + } + + // Check the renaming of file for direct moving (file-to-file) + $isRenameFile = substr($to, -1) != '/' && !is_dir($from); + + if (file_exists($to) && !is_dir($to) && !$isRenameFile) { + throw new \InvalidArgumentException('Destination directory is not a directory.'); + } + + try { + if ($isRenameFile) { + $fs->mkdir(dirname($to)); + } else { + $fs->mkdir($to); + } + } catch (IOException $e) { + throw new \InvalidArgumentException(sprintf('Could not create directory %s.', $to), $e->getCode(), $e); + } + + if (false === file_exists($from)) { + throw new \InvalidArgumentException(sprintf('Source directory or file "%s" does not exist.', $from)); + } + + if (is_dir($from)) { + $finder = new Finder; + $finder->files()->ignoreDotFiles(false)->in($from); + + if ($pattern) { + $finder->path("#{$pattern}#"); + } + + foreach ($finder as $file) { + $dest = sprintf('%s/%s', $to, $file->getRelativePathname()); + + try { + $fs->copy($file, $dest, $overwriteNewerFiles); + + // replace namespace + $content = file_get_contents($dest); + $replaces = [ + 'use Symfony\Component\Console\Input\InputInterface;' => 'use think\console\Input as InputInterface;', + 'use Symfony\Component\Console\Output\OutputInterface;' => 'use think\console\Output as OutputInterface;', + '\Symfony\Component\Console\Output\OutputInterface' => '\think\console\Output', + '\Symfony\Component\Console\Input\InputInterface' => '\think\console\Input', + 'use Symfony\Component\Console\Output\NullOutput;' => 'use think\migration\NullOutput;', + ]; + $content = str_replace(array_keys($replaces), array_values($replaces), $content); + file_put_contents($dest, $content); + } catch (IOException $e) { + throw new \InvalidArgumentException(sprintf('Could not copy %s', $file->getBaseName()), $e->getCode(), $e); + } + } + } else { + try { + if ($isRenameFile) { + $fs->copy($from, $to, $overwriteNewerFiles); + } else { + $fs->copy($from, $to . '/' . basename($from), $overwriteNewerFiles); + } + } catch (IOException $e) { + throw new \InvalidArgumentException(sprintf('Could not copy %s', $from), $e->getCode(), $e); + } + } + + $io->write(sprintf('Copied file(s) from %s to %s.', $from, $to)); + } + + //clear + $fs->remove('vendor/robmorgan/phinx'); + } +} diff --git a/extend/think/migration/command/Migrate.php b/extend/think/migration/command/Migrate.php new file mode 100644 index 0000000..b16619a --- /dev/null +++ b/extend/think/migration/command/Migrate.php @@ -0,0 +1,153 @@ + +// +---------------------------------------------------------------------- + +namespace think\migration\command; + +use Phinx\Db\Adapter\AdapterFactory; +use Phinx\Migration\AbstractMigration; +use Phinx\Migration\MigrationInterface; +use Phinx\Util\Util; +use think\migration\Command; +use think\migration\Migrator; + +abstract class Migrate extends Command +{ + /** + * @var array + */ + protected $migrations; + + protected function getPath() + { + return $this->app->getRootPath() . 'database' . DIRECTORY_SEPARATOR . 'migrations'; + } + + protected function executeMigration(MigrationInterface $migration, $direction = MigrationInterface::UP) + { + $this->output->writeln(''); + $this->output->writeln(' ==' . ' ' . $migration->getVersion() . ' ' . $migration->getName() . ':' . ' ' . (MigrationInterface::UP === $direction ? 'migrating' : 'reverting') . ''); + + // Execute the migration and log the time elapsed. + $start = microtime(true); + + $startTime = time(); + $direction = (MigrationInterface::UP === $direction) ? MigrationInterface::UP : MigrationInterface::DOWN; + $migration->setMigratingUp($direction === MigrationInterface::UP); + $migration->setAdapter($this->getAdapter()); + + $migration->preFlightCheck(); + + if (method_exists($migration, MigrationInterface::INIT)) { + $migration->{MigrationInterface::INIT}(); + } + + // begin the transaction if the adapter supports it + if ($this->getAdapter()->hasTransactions()) { + $this->getAdapter()->beginTransaction(); + } + + // Run the migration + if (method_exists($migration, MigrationInterface::CHANGE)) { + if (MigrationInterface::DOWN === $direction) { + // Create an instance of the ProxyAdapter so we can record all + // of the migration commands for reverse playback + /** @var \Phinx\Db\Adapter\ProxyAdapter $proxyAdapter */ + $proxyAdapter = AdapterFactory::instance()->getWrapper('proxy', $this->getAdapter()); + $migration->setAdapter($proxyAdapter); + $migration->{MigrationInterface::CHANGE}(); + $proxyAdapter->executeInvertedCommands(); + $migration->setAdapter($this->getAdapter()); + } else { + /** @noinspection PhpUndefinedMethodInspection */ + $migration->change(); + } + } else { + $migration->{$direction}(); + } + + // commit the transaction if the adapter supports it + if ($this->getAdapter()->hasTransactions()) { + $this->getAdapter()->commitTransaction(); + } + + $migration->postFlightCheck(); + + // Record it in the database + $this->getAdapter() + ->migrated($migration, $direction, date('Y-m-d H:i:s', $startTime), date('Y-m-d H:i:s', time())); + + $end = microtime(true); + + $this->output->writeln(' ==' . ' ' . $migration->getVersion() . ' ' . $migration->getName() . ':' . ' ' . (MigrationInterface::UP === $direction ? 'migrated' : 'reverted') . ' ' . sprintf('%.4fs', $end - $start) . ''); + } + + protected function getVersionLog() + { + return $this->getAdapter()->getVersionLog(); + } + + protected function getVersions() + { + return $this->getAdapter()->getVersions(); + } + + protected function getMigrations() + { + if (null === $this->migrations) { + $phpFiles = glob($this->getPath() . DIRECTORY_SEPARATOR . '*.php', defined('GLOB_BRACE') ? GLOB_BRACE : 0); + + // filter the files to only get the ones that match our naming scheme + $fileNames = []; + /** @var Migrator[] $versions */ + $versions = []; + + foreach ($phpFiles as $filePath) { + if (Util::isValidMigrationFileName(basename($filePath))) { + $version = Util::getVersionFromFileName(basename($filePath)); + + if (isset($versions[$version])) { + throw new \InvalidArgumentException(sprintf('Duplicate migration - "%s" has the same version as "%s"', $filePath, $versions[$version]->getVersion())); + } + + // convert the filename to a class name + $class = Util::mapFileNameToClassName(basename($filePath)); + + if (isset($fileNames[$class])) { + throw new \InvalidArgumentException(sprintf('Migration "%s" has the same name as "%s"', basename($filePath), $fileNames[$class])); + } + + $fileNames[$class] = basename($filePath); + + // load the migration file + /** @noinspection PhpIncludeInspection */ + require_once $filePath; + if (!class_exists($class)) { + throw new \InvalidArgumentException(sprintf('Could not find class "%s" in file "%s"', $class, $filePath)); + } + + // instantiate it + $migration = new $class('default', $version, $this->input, $this->output); + + if (!($migration instanceof AbstractMigration)) { + throw new \InvalidArgumentException(sprintf('The class "%s" in file "%s" must extend \Phinx\Migration\AbstractMigration', $class, $filePath)); + } + + $versions[$version] = $migration; + } + } + + ksort($versions); + $this->migrations = $versions; + } + + return $this->migrations; + } +} diff --git a/extend/think/migration/command/Seed.php b/extend/think/migration/command/Seed.php new file mode 100644 index 0000000..00d7b3c --- /dev/null +++ b/extend/think/migration/command/Seed.php @@ -0,0 +1,78 @@ + +// +---------------------------------------------------------------------- + +namespace think\migration\command; + +use InvalidArgumentException; +use Phinx\Seed\AbstractSeed; +use Phinx\Util\Util; +use think\migration\Command; +use think\migration\Seeder; + +abstract class Seed extends Command +{ + + /** + * @var array + */ + protected $seeds; + + protected function getPath() + { + return $this->app->getRootPath() . 'database' . DIRECTORY_SEPARATOR . 'seeds'; + } + + public function getSeeds() + { + if (null === $this->seeds) { + $phpFiles = glob($this->getPath() . DIRECTORY_SEPARATOR . '*.php', defined('GLOB_BRACE') ? GLOB_BRACE : 0); + + // filter the files to only get the ones that match our naming scheme + $fileNames = []; + /** @var Seeder[] $seeds */ + $seeds = []; + + foreach ($phpFiles as $filePath) { + if (Util::isValidSeedFileName(basename($filePath))) { + // convert the filename to a class name + $class = pathinfo($filePath, PATHINFO_FILENAME); + $fileNames[$class] = basename($filePath); + + // load the seed file + /** @noinspection PhpIncludeInspection */ + require_once $filePath; + if (!class_exists($class)) { + throw new InvalidArgumentException(sprintf('Could not find class "%s" in file "%s"', $class, $filePath)); + } + + // instantiate it + $seed = new $class($this->input, $this->output); + + if (!($seed instanceof AbstractSeed)) { + throw new InvalidArgumentException(sprintf('The class "%s" in file "%s" must extend \Phinx\Seed\AbstractSeed', $class, $filePath)); + } + + if($seed instanceof Seeder){ + $seed->setInput($this->input); + $seed->setOutput($this->output); + } + + $seeds[$class] = $seed; + } + } + + ksort($seeds); + $this->seeds = $seeds; + } + + return $this->seeds; + } +} diff --git a/extend/think/migration/command/factory/Create.php b/extend/think/migration/command/factory/Create.php new file mode 100644 index 0000000..a7b4684 --- /dev/null +++ b/extend/think/migration/command/factory/Create.php @@ -0,0 +1,82 @@ + +// +---------------------------------------------------------------------- + +namespace think\migration\command\factory; + +use InvalidArgumentException; +use Phinx\Util\Util; +use RuntimeException; +use think\console\Command; +use think\console\input\Argument as InputArgument; + +class Create extends Command +{ + protected function configure() + { + $this->setName('factory:create') + ->setDescription('Create a new model factory') + ->addArgument('name', InputArgument::REQUIRED, 'What is the name of the model?'); + } + + public function handle() + { + $path = $this->getPath(); + + if (!file_exists($path)) { + mkdir($path, 0755, true); + } + + if (!is_dir($path)) { + throw new InvalidArgumentException(sprintf('Factory directory "%s" does not exist', $path)); + } + + if (!is_writable($path)) { + throw new InvalidArgumentException(sprintf('Factory directory "%s" is not writable', $path)); + } + + $path = realpath($path); + $className = $this->input->getArgument('name'); + + if (!Util::isValidPhinxClassName($className)) { + throw new InvalidArgumentException(sprintf('The migration class name "%s" is invalid. Please use CamelCase format.', $className)); + } + + $filePath = $path . DIRECTORY_SEPARATOR . $className . '.php'; + + if (is_file($filePath)) { + throw new InvalidArgumentException(sprintf('The file "%s" already exists', $filePath)); + } + + // Load the alternative template if it is defined. + $contents = file_get_contents($this->getTemplate()); + + // inject the class names appropriate to this migration + $contents = strtr($contents, [ + '"ModelClass"' => "\\app\\model\\" . $className . '::class', + ]); + + if (false === file_put_contents($filePath, $contents)) { + throw new RuntimeException(sprintf('The file "%s" could not be written to', $path)); + } + + $this->output->writeln('created .' . str_replace(getcwd(), '', $filePath)); + } + + protected function getTemplate() + { + return __DIR__ . '/../stubs/factory.stub'; + } + + protected function getPath() + { + return $this->app->getRootPath() . 'database' . DIRECTORY_SEPARATOR . 'factories'; + } +} diff --git a/extend/think/migration/command/migrate/Breakpoint.php b/extend/think/migration/command/migrate/Breakpoint.php new file mode 100644 index 0000000..cfb2d32 --- /dev/null +++ b/extend/think/migration/command/migrate/Breakpoint.php @@ -0,0 +1,92 @@ + +// +---------------------------------------------------------------------- + +namespace think\migration\command\migrate; + +use think\console\Input; +use think\console\input\Option as InputOption; +use think\console\Output; +use think\migration\command\Migrate; + +class Breakpoint extends Migrate +{ + protected function configure() + { + $this->setName('migrate:breakpoint') + ->setDescription('Manage breakpoints') + ->addOption('--target', '-t', InputOption::VALUE_REQUIRED, 'The version number to set or clear a breakpoint against') + ->addOption('--remove-all', '-r', InputOption::VALUE_NONE, 'Remove all breakpoints') + ->setHelp(<<breakpoint command allows you to set or clear a breakpoint against a specific target to inhibit rollbacks beyond a certain target. +If no target is supplied then the most recent migration will be used. +You cannot specify un-migrated targets + +php think migrate:breakpoint +php think migrate:breakpoint -t 20110103081132 +php think migrate:breakpoint -r +EOT + ); + } + + protected function execute(Input $input, Output $output) + { + $version = $input->getOption('target'); + $removeAll = $input->getOption('remove-all'); + + if ($version && $removeAll) { + throw new \InvalidArgumentException('Cannot toggle a breakpoint and remove all breakpoints at the same time.'); + } + + // Remove all breakpoints + if ($removeAll) { + $this->removeBreakpoints(); + } else { + // Toggle the breakpoint. + $this->toggleBreakpoint($version); + } + } + + protected function toggleBreakpoint($version) + { + $migrations = $this->getMigrations(); + $versions = $this->getVersionLog(); + + if (empty($versions) || empty($migrations)) { + return; + } + + if (null === $version) { + $lastVersion = end($versions); + $version = $lastVersion['version']; + } + + if (0 != $version && !isset($migrations[$version])) { + $this->output->writeln(sprintf('warning %s is not a valid version', $version)); + return; + } + + $this->getAdapter()->toggleBreakpoint($migrations[$version]); + + $versions = $this->getVersionLog(); + + $this->output->writeln(' Breakpoint ' . ($versions[$version]['breakpoint'] ? 'set' : 'cleared') . ' for ' . $version . '' . ' ' . $migrations[$version]->getName() . ''); + } + + /** + * Remove all breakpoints + * + * @return void + */ + protected function removeBreakpoints() + { + $this->output->writeln(sprintf(' %d breakpoints cleared.', $this->getAdapter()->resetAllBreakpoints())); + } +} diff --git a/extend/think/migration/command/migrate/Create.php b/extend/think/migration/command/migrate/Create.php new file mode 100644 index 0000000..8f96de7 --- /dev/null +++ b/extend/think/migration/command/migrate/Create.php @@ -0,0 +1,55 @@ + +// +---------------------------------------------------------------------- + +namespace think\migration\command\migrate; + +use InvalidArgumentException; +use RuntimeException; +use think\console\Command; +use think\console\Input; +use think\console\input\Argument as InputArgument; +use think\console\Output; +use think\migration\Creator; + +class Create extends Command +{ + + /** + * {@inheritdoc} + */ + protected function configure() + { + $this->setName('migrate:create') + ->setDescription('Create a new migration') + ->addArgument('name', InputArgument::REQUIRED, 'What is the name of the migration?') + ->setHelp(sprintf('%sCreates a new database migration%s', PHP_EOL, PHP_EOL)); + } + + /** + * Create the new migration. + * + * @param Input $input + * @param Output $output + * @return void + * @throws InvalidArgumentException + * @throws RuntimeException + */ + protected function execute(Input $input, Output $output) + { + /** @var Creator $creator */ + $creator = $this->app->get('migration.creator'); + + $className = $input->getArgument('name'); + + $path = $creator->create($className); + + $output->writeln('created .' . str_replace(getcwd(), '', realpath($path))); + } + +} diff --git a/extend/think/migration/command/migrate/Rollback.php b/extend/think/migration/command/migrate/Rollback.php new file mode 100644 index 0000000..e668ac7 --- /dev/null +++ b/extend/think/migration/command/migrate/Rollback.php @@ -0,0 +1,146 @@ + +// +---------------------------------------------------------------------- + +namespace think\migration\command\migrate; + +use Phinx\Migration\MigrationInterface; +use think\console\input\Option as InputOption; +use think\console\Input; +use think\console\Output; +use think\migration\command\Migrate; + +class Rollback extends Migrate +{ + /** + * {@inheritdoc} + */ + protected function configure() + { + $this->setName('migrate:rollback') + ->setDescription('Rollback the last or to a specific migration') + ->addOption('--target', '-t', InputOption::VALUE_REQUIRED, 'The version number to rollback to') + ->addOption('--date', '-d', InputOption::VALUE_REQUIRED, 'The date to rollback to') + ->addOption('--force', '-f', InputOption::VALUE_NONE, 'Force rollback to ignore breakpoints') + ->setHelp(<<migrate:rollback command reverts the last migration, or optionally up to a specific version + +php think migrate:rollback +php think migrate:rollback -t 20111018185412 +php think migrate:rollback -d 20111018 +php think migrate:rollback -v + +EOT + ); + } + + /** + * Rollback the migration. + * + * @param Input $input + * @param Output $output + * @return void + */ + protected function execute(Input $input, Output $output) + { + $version = $input->getOption('target'); + $date = $input->getOption('date'); + $force = !!$input->getOption('force'); + + // rollback the specified environment + $start = microtime(true); + if (null !== $date) { + $this->rollbackToDateTime(new \DateTime($date), $force); + } else { + $this->rollback($version, $force); + } + $end = microtime(true); + + $output->writeln(''); + $output->writeln('All Done. Took ' . sprintf('%.4fs', $end - $start) . ''); + } + + protected function rollback($version = null, $force = false) + { + $migrations = $this->getMigrations(); + $versionLog = $this->getVersionLog(); + $versions = array_keys($versionLog); + + ksort($migrations); + sort($versions); + + // Check we have at least 1 migration to revert + if (empty($versions) || $version == end($versions)) { + $this->output->writeln('No migrations to rollback'); + return; + } + + // If no target version was supplied, revert the last migration + if (null === $version) { + // Get the migration before the last run migration + $prev = count($versions) - 2; + $version = $prev < 0 ? 0 : $versions[$prev]; + } else { + // Get the first migration number + $first = $versions[0]; + + // If the target version is before the first migration, revert all migrations + if ($version < $first) { + $version = 0; + } + } + + // Check the target version exists + if (0 !== $version && !isset($migrations[$version])) { + $this->output->writeln("Target version ($version) not found"); + return; + } + + // Revert the migration(s) + krsort($migrations); + foreach ($migrations as $migration) { + if ($migration->getVersion() <= $version) { + break; + } + + if (in_array($migration->getVersion(), $versions)) { + if (isset($versionLog[$migration->getVersion()]) && 0 != $versionLog[$migration->getVersion()]['breakpoint'] && !$force) { + $this->output->writeln('Breakpoint reached. Further rollbacks inhibited.'); + break; + } + $this->executeMigration($migration, MigrationInterface::DOWN); + } + } + } + + protected function rollbackToDateTime(\DateTime $dateTime, $force = false) + { + $versions = $this->getVersions(); + $dateString = $dateTime->format('YmdHis'); + sort($versions); + + $earlierVersion = null; + $availableMigrations = array_filter($versions, function ($version) use ($dateString, &$earlierVersion) { + if ($version <= $dateString) { + $earlierVersion = $version; + } + return $version >= $dateString; + }); + + if (count($availableMigrations) > 0) { + if (is_null($earlierVersion)) { + $this->output->writeln('Rolling back all migrations'); + $migration = 0; + } else { + $this->output->writeln('Rolling back to version ' . $earlierVersion); + $migration = $earlierVersion; + } + $this->rollback($migration, $force); + } + } +} diff --git a/extend/think/migration/command/migrate/Run.php b/extend/think/migration/command/migrate/Run.php new file mode 100644 index 0000000..4714f02 --- /dev/null +++ b/extend/think/migration/command/migrate/Run.php @@ -0,0 +1,140 @@ + +// +---------------------------------------------------------------------- + +namespace think\migration\command\migrate; + +use Phinx\Migration\MigrationInterface; +use think\console\Input; +use think\console\input\Option as InputOption; +use think\console\Output; +use think\migration\command\Migrate; + +class Run extends Migrate +{ + /** + * {@inheritdoc} + */ + protected function configure() + { + $this->setName('migrate:run') + ->setDescription('Migrate the database') + ->addOption('--target', '-t', InputOption::VALUE_REQUIRED, 'The version number to migrate to') + ->addOption('--date', '-d', InputOption::VALUE_REQUIRED, 'The date to migrate to') + ->setHelp(<<migrate:run command runs all available migrations, optionally up to a specific version + +php think migrate:run +php think migrate:run -t 20110103081132 +php think migrate:run -d 20110103 +php think migrate:run -v + +EOT + ); + } + + /** + * Migrate the database. + * + * @param Input $input + * @param Output $output + */ + protected function execute(Input $input, Output $output) + { + $version = $input->getOption('target'); + $date = $input->getOption('date'); + + // run the migrations + $start = microtime(true); + if (null !== $date) { + $this->migrateToDateTime(new \DateTime($date)); + } else { + $this->migrate($version); + } + $end = microtime(true); + + $output->writeln(''); + $output->writeln('All Done. Took ' . sprintf('%.4fs', $end - $start) . ''); + } + + public function migrateToDateTime(\DateTime $dateTime) + { + $versions = array_keys($this->getMigrations()); + $dateString = $dateTime->format('YmdHis'); + + $outstandingMigrations = array_filter($versions, function ($version) use ($dateString) { + return $version <= $dateString; + }); + + if (count($outstandingMigrations) > 0) { + $migration = max($outstandingMigrations); + $this->output->writeln('Migrating to version ' . $migration); + $this->migrate($migration); + } + } + + protected function migrate($version = null) + { + $migrations = $this->getMigrations(); + $versions = $this->getVersions(); + $current = $this->getCurrentVersion(); + + if (empty($versions) && empty($migrations)) { + return; + } + + if (null === $version) { + $version = max(array_merge($versions, array_keys($migrations))); + } else { + if (0 != $version && !isset($migrations[$version])) { + $this->output->writeln(sprintf('warning %s is not a valid version', $version)); + return; + } + } + + // are we migrating up or down? + $direction = $version > $current ? MigrationInterface::UP : MigrationInterface::DOWN; + + if ($direction === MigrationInterface::DOWN) { + // run downs first + krsort($migrations); + foreach ($migrations as $migration) { + if ($migration->getVersion() <= $version) { + break; + } + + if (in_array($migration->getVersion(), $versions)) { + $this->executeMigration($migration, MigrationInterface::DOWN); + } + } + } + + ksort($migrations); + foreach ($migrations as $migration) { + if ($migration->getVersion() > $version) { + break; + } + + if (!in_array($migration->getVersion(), $versions)) { + $this->executeMigration($migration, MigrationInterface::UP); + } + } + } + + protected function getCurrentVersion() + { + $versions = $this->getVersions(); + $version = 0; + + if (!empty($versions)) { + $version = end($versions); + } + + return $version; + } +} diff --git a/extend/think/migration/command/migrate/Status.php b/extend/think/migration/command/migrate/Status.php new file mode 100644 index 0000000..ccf424a --- /dev/null +++ b/extend/think/migration/command/migrate/Status.php @@ -0,0 +1,126 @@ + +// +---------------------------------------------------------------------- + +namespace think\migration\command\migrate; + +use think\console\input\Option as InputOption; +use think\console\Input; +use think\console\Output; +use think\migration\command\Migrate; + +class Status extends Migrate +{ + /** + * {@inheritdoc} + */ + protected function configure() + { + $this->setName('migrate:status') + ->setDescription('Show migration status') + ->addOption('--format', '-f', InputOption::VALUE_REQUIRED, 'The output format: text or json. Defaults to text.') + ->setHelp(<<migrate:status command prints a list of all migrations, along with their current status + +php think migrate:status +php think migrate:status -f json +EOT + ); + } + + /** + * Show the migration status. + * + * @param Input $input + * @param Output $output + * @return integer 0 if all migrations are up, or an error code + */ + protected function execute(Input $input, Output $output) + { + $format = $input->getOption('format'); + + if (null !== $format) { + $output->writeln('using format ' . $format); + } + + // print the status + return $this->printStatus($format); + } + + protected function printStatus($format = null) + { + $output = $this->output; + $migrations = []; + if (count($this->getMigrations())) { + // TODO - rewrite using Symfony Table Helper as we already have this library + // included and it will fix formatting issues (e.g drawing the lines) + $output->writeln(''); + $output->writeln(' Status Migration ID Started Finished Migration Name '); + $output->writeln('----------------------------------------------------------------------------------'); + + $versions = $this->getVersionLog(); + $maxNameLength = $versions ? max(array_map(function ($version) { + return strlen($version['migration_name']); + }, $versions)) : 0; + + foreach ($this->getMigrations() as $migration) { + $version = array_key_exists($migration->getVersion(), $versions) ? $versions[$migration->getVersion()] : false; + if ($version) { + $status = ' up '; + } else { + $status = ' down '; + $version = []; + $version['start_time'] = $version['end_time'] = $version['breakpoint'] = ''; + } + $maxNameLength = max($maxNameLength, strlen($migration->getName())); + + $output->writeln(sprintf('%s %14.0f %19s %19s %s', $status, $migration->getVersion(), $version['start_time'], $version['end_time'], $migration->getName())); + + if ($version && $version['breakpoint']) { + $output->writeln(' BREAKPOINT SET'); + } + + $migrations[] = [ + 'migration_status' => trim(strip_tags($status)), + 'migration_id' => sprintf('%14.0f', $migration->getVersion()), + 'migration_name' => $migration->getName() + ]; + unset($versions[$migration->getVersion()]); + } + + if (count($versions)) { + foreach ($versions as $missing => $version) { + $output->writeln(sprintf(' up %14.0f %19s %19s %s ** MISSING **', $missing, $version['start_time'], $version['end_time'], str_pad($version['migration_name'], $maxNameLength, ' '))); + + if ($version && $version['breakpoint']) { + $output->writeln(' BREAKPOINT SET'); + } + } + } + } else { + // there are no migrations + $output->writeln(''); + $output->writeln('There are no available migrations. Try creating one using the create command.'); + } + + // write an empty line + $output->writeln(''); + if ($format !== null) { + switch ($format) { + case 'json': + $output->writeln(json_encode([ + 'pending_count' => count($this->getMigrations()), + 'migrations' => $migrations + ])); + break; + default: + $output->writeln('Unsupported format: ' . $format . ''); + } + } + } +} diff --git a/extend/think/migration/command/seed/Create.php b/extend/think/migration/command/seed/Create.php new file mode 100644 index 0000000..6873980 --- /dev/null +++ b/extend/think/migration/command/seed/Create.php @@ -0,0 +1,83 @@ + +// +---------------------------------------------------------------------- + +namespace think\migration\command\seed; + +use Phinx\Util\Util; +use think\console\Input; +use think\console\input\Argument as InputArgument; +use think\console\Output; +use think\migration\command\Seed; + +class Create extends Seed +{ + /** + * {@inheritdoc} + */ + protected function configure() + { + $this->setName('seed:create') + ->setDescription('Create a new database seeder') + ->addArgument('name', InputArgument::REQUIRED, 'What is the name of the seeder?') + ->setHelp(sprintf('%sCreates a new database seeder%s', PHP_EOL, PHP_EOL)); + } + + /** + * Create the new seeder. + * + * @param Input $input + * @param Output $output + * @return void + * @throws \InvalidArgumentException + * @throws \RuntimeException + */ + protected function execute(Input $input, Output $output) + { + $path = $this->getPath(); + + if (!file_exists($path)) { + mkdir($path, 0755, true); + } + + $this->verifyMigrationDirectory($path); + + $path = realpath($path); + + $className = $input->getArgument('name'); + + if (!Util::isValidPhinxClassName($className)) { + throw new \InvalidArgumentException(sprintf('The seed class name "%s" is invalid. Please use CamelCase format', $className)); + } + + // Compute the file path + $filePath = $path . DIRECTORY_SEPARATOR . $className . '.php'; + + if (is_file($filePath)) { + throw new \InvalidArgumentException(sprintf('The file "%s" already exists', basename($filePath))); + } + + // inject the class names appropriate to this seeder + $contents = file_get_contents($this->getTemplate()); + $classes = [ + 'SeederClass' => $className, + ]; + $contents = strtr($contents, $classes); + + if (false === file_put_contents($filePath, $contents)) { + throw new \RuntimeException(sprintf('The file "%s" could not be written to', $path)); + } + + $output->writeln('created .' . str_replace(getcwd(), '', $filePath)); + } + + protected function getTemplate() + { + return __DIR__ . '/../stubs/seed.stub'; + } +} diff --git a/extend/think/migration/command/seed/Run.php b/extend/think/migration/command/seed/Run.php new file mode 100644 index 0000000..a0ac8f9 --- /dev/null +++ b/extend/think/migration/command/seed/Run.php @@ -0,0 +1,107 @@ + +// +---------------------------------------------------------------------- + +namespace think\migration\command\seed; + +use Phinx\Seed\SeedInterface; +use think\console\Input; +use think\console\input\Option as InputOption; +use think\console\Output; +use think\migration\command\Seed; + +class Run extends Seed +{ + /** + * {@inheritdoc} + */ + protected function configure() + { + $this->setName('seed:run') + ->setDescription('Run database seeders') + ->addOption('--seed', '-s', InputOption::VALUE_REQUIRED, 'What is the name of the seeder?') + ->setHelp(<<seed:run command runs all available or individual seeders + +php think seed:run +php think seed:run -s UserSeeder +php think seed:run -v + +EOT + ); + } + + /** + * Run database seeders. + * + * @param Input $input + * @param Output $output + * @return void + */ + protected function execute(Input $input, Output $output) + { + $seed = $input->getOption('seed'); + + // run the seed(ers) + $start = microtime(true); + $this->seed($seed); + $end = microtime(true); + + $output->writeln(''); + $output->writeln('All Done. Took ' . sprintf('%.4fs', $end - $start) . ''); + } + + public function seed($seed = null) + { + $seeds = $this->getSeeds(); + + if (null === $seed) { + // run all seeders + foreach ($seeds as $seeder) { + if (array_key_exists($seeder->getName(), $seeds)) { + $this->executeSeed($seeder); + } + } + } else { + // run only one seeder + if (array_key_exists($seed, $seeds)) { + $this->executeSeed($seeds[$seed]); + } else { + throw new \InvalidArgumentException(sprintf('The seed class "%s" does not exist', $seed)); + } + } + } + + protected function executeSeed(SeedInterface $seed) + { + $this->output->writeln(''); + $this->output->writeln(' ==' . ' ' . $seed->getName() . ':' . ' seeding'); + + // Execute the seeder and log the time elapsed. + $start = microtime(true); + $seed->setAdapter($this->getAdapter()); + + // begin the transaction if the adapter supports it + if ($this->getAdapter()->hasTransactions()) { + $this->getAdapter()->beginTransaction(); + } + + // Run the seeder + if (method_exists($seed, SeedInterface::RUN)) { + $seed->run(); + } + + // commit the transaction if the adapter supports it + if ($this->getAdapter()->hasTransactions()) { + $this->getAdapter()->commitTransaction(); + } + $end = microtime(true); + + $this->output->writeln(' ==' . ' ' . $seed->getName() . ':' . ' seeded' . ' ' . sprintf('%.4fs', $end - $start) . ''); + } +} diff --git a/extend/think/migration/command/stubs/factory.stub b/extend/think/migration/command/stubs/factory.stub new file mode 100644 index 0000000..eef91dc --- /dev/null +++ b/extend/think/migration/command/stubs/factory.stub @@ -0,0 +1,11 @@ +define("ModelClass", function (Faker $faker) { + return [ + // + ]; +}); diff --git a/extend/think/migration/command/stubs/migrate.stub b/extend/think/migration/command/stubs/migrate.stub new file mode 100644 index 0000000..f11d29a --- /dev/null +++ b/extend/think/migration/command/stubs/migrate.stub @@ -0,0 +1,33 @@ + +// +---------------------------------------------------------------------- + +namespace think\migration\db; + +use Phinx\Db\Adapter\AdapterInterface; +use Phinx\Db\Adapter\MysqlAdapter; + +class Column extends \Phinx\Db\Table\Column +{ + protected $unique = false; + + public function setNullable() + { + return $this->setNull(true); + } + + public function setUnsigned() + { + return $this->setSigned(false); + } + + public function setUnique() + { + $this->unique = true; + return $this; + } + + public function getUnique() + { + return $this->unique; + } + + public function isUnique() + { + return $this->getUnique(); + } + + public static function make($name, $type, $options = []) + { + $column = new self(); + $column->setName($name); + $column->setType($type); + $column->setOptions($options); + return $column; + } + + public static function bigInteger($name) + { + return self::make($name, AdapterInterface::PHINX_TYPE_BIG_INTEGER); + } + + public static function binary($name) + { + return self::make($name, AdapterInterface::PHINX_TYPE_BLOB); + } + + public static function boolean($name) + { + return self::make($name, AdapterInterface::PHINX_TYPE_BOOLEAN); + } + + public static function char($name, $length = 255) + { + return self::make($name, AdapterInterface::PHINX_TYPE_CHAR, compact('length')); + } + + public static function date($name) + { + return self::make($name, AdapterInterface::PHINX_TYPE_DATE); + } + + public static function dateTime($name) + { + return self::make($name, AdapterInterface::PHINX_TYPE_DATETIME); + } + + public static function decimal($name, $precision = 8, $scale = 2) + { + return self::make($name, AdapterInterface::PHINX_TYPE_DECIMAL, compact('precision', 'scale')); + } + + public static function enum($name, array $values) + { + return self::make($name, AdapterInterface::PHINX_TYPE_ENUM, compact('values')); + } + + public static function float($name) + { + return self::make($name, AdapterInterface::PHINX_TYPE_FLOAT); + } + + public static function integer($name) + { + return self::make($name, AdapterInterface::PHINX_TYPE_INTEGER); + } + + public static function json($name) + { + return self::make($name, AdapterInterface::PHINX_TYPE_JSON); + } + + public static function jsonb($name) + { + return self::make($name, AdapterInterface::PHINX_TYPE_JSONB); + } + + public static function longText($name) + { + return self::make($name, AdapterInterface::PHINX_TYPE_TEXT, ['length' => MysqlAdapter::TEXT_LONG]); + } + + public static function mediumInteger($name) + { + return self::make($name, AdapterInterface::PHINX_TYPE_INTEGER, ['length' => MysqlAdapter::INT_MEDIUM]); + } + + public static function mediumText($name) + { + return self::make($name, AdapterInterface::PHINX_TYPE_TEXT, ['length' => MysqlAdapter::TEXT_MEDIUM]); + } + + public static function smallInteger($name) + { + return self::make($name, AdapterInterface::PHINX_TYPE_INTEGER, ['length' => MysqlAdapter::INT_SMALL]); + } + + public static function string($name, $length = 255) + { + return self::make($name, AdapterInterface::PHINX_TYPE_STRING, compact('length')); + } + + public static function text($name) + { + return self::make($name, AdapterInterface::PHINX_TYPE_TEXT); + } + + public static function time($name) + { + return self::make($name, AdapterInterface::PHINX_TYPE_TIME); + } + + public static function tinyInteger($name) + { + return self::make($name, AdapterInterface::PHINX_TYPE_INTEGER, ['length' => MysqlAdapter::INT_TINY]); + } + + public static function unsignedInteger($name) + { + return self::integer($name)->setUnSigned(); + } + + public static function timestamp($name) + { + return self::make($name, AdapterInterface::PHINX_TYPE_TIMESTAMP, ['null' => true, 'default' => null]); + } + + public static function uuid($name) + { + return self::make($name, AdapterInterface::PHINX_TYPE_UUID); + } + +} diff --git a/extend/think/migration/db/Table.php b/extend/think/migration/db/Table.php new file mode 100644 index 0000000..be66cd9 --- /dev/null +++ b/extend/think/migration/db/Table.php @@ -0,0 +1,159 @@ + +// +---------------------------------------------------------------------- + +namespace think\migration\db; + +use Phinx\Db\Table\Index; + +class Table extends \Phinx\Db\Table +{ + + protected function setOption($name, $value) + { + $options = $this->getOptions(); + + $options[$name] = $value; + + $this->table->setOptions($options); + + return $this; + } + + /** + * 设置id + * @param $id + * @return $this + */ + public function setId($id) + { + return $this->setOption('id', $id); + } + + /** + * 设置主键 + * @param $key + * @return $this + */ + public function setPrimaryKey($key) + { + return $this->setOption('primary_key', $key); + } + + /** + * 设置引擎 + * @param $engine + * @return $this + */ + public function setEngine($engine) + { + return $this->setOption('engine', $engine); + } + + /** + * 设置表注释 + * @param $comment + * @return $this + */ + public function setComment($comment) + { + return $this->setOption('comment', $comment); + } + + /** + * 设置排序比对方法 + * @param $collation + * @return $this + */ + public function setCollation($collation) + { + return $this->setOption('collation', $collation); + } + + public function addSoftDelete() + { + $this->addColumn(Column::timestamp('delete_time')->setNullable()); + return $this; + } + + public function addMorphs($name, $indexName = null) + { + $this->addColumn(Column::unsignedInteger("{$name}_id")); + $this->addColumn(Column::string("{$name}_type")); + $this->addIndex(["{$name}_id", "{$name}_type"], ['name' => $indexName]); + return $this; + } + + public function addNullableMorphs($name, $indexName = null) + { + $this->addColumn(Column::unsignedInteger("{$name}_id")->setNullable()); + $this->addColumn(Column::string("{$name}_type")->setNullable()); + $this->addIndex(["{$name}_id", "{$name}_type"], ['name' => $indexName]); + return $this; + } + + /** + * @param string $createdAt + * @param string $updatedAt + * @return $this + */ + public function addTimestamps($createdAt = 'create_time', $updatedAt = 'update_time', bool $withTimezone = false) + { + if ($createdAt) { + $this->addColumn($createdAt, 'timestamp', [ + 'null' => false, + 'default' => 'CURRENT_TIMESTAMP', + 'update' => '', + 'timezone' => $withTimezone, + ]); + } + if ($updatedAt) { + $this->addColumn($updatedAt, 'timestamp', [ + 'null' => true, + 'default' => null, + 'update' => '', + 'timezone' => $withTimezone, + ]); + } + + return $this; + } + + /** + * @param \Phinx\Db\Table\Column|string $columnName + * @param null $type + * @param array $options + * @return $this + */ + public function addColumn($columnName, $type = null, $options = []) + { + if ($columnName instanceof Column && $columnName->getUnique()) { + $index = new Index(); + $index->setColumns([$columnName->getName()]); + $index->setType(Index::UNIQUE); + $this->addIndex($index); + } + return parent::addColumn($columnName, $type, $options); + } + + /** + * @param string $columnName + * @param null $newColumnType + * @param array $options + * @return $this + */ + public function changeColumn($columnName, $newColumnType = null, $options = []) + { + if ($columnName instanceof \Phinx\Db\Table\Column) { + return parent::changeColumn($columnName->getName(), $columnName, $options); + } + return parent::changeColumn($columnName, $newColumnType, $options); + } +} diff --git a/extend/think/migration/helper.php b/extend/think/migration/helper.php new file mode 100644 index 0000000..6dc47ab --- /dev/null +++ b/extend/think/migration/helper.php @@ -0,0 +1,40 @@ +of($arguments[0], $arguments[1])->times($arguments[2] ?? null); + } elseif (isset($arguments[1])) { + return $factory->of($arguments[0])->times($arguments[1]); + } + + return $factory->of($arguments[0]); + } +} + +if (!function_exists('database_path')) { + /** + * 获取数据迁移脚本地址 + * @param string $path + * @return string + */ + function database_path($path = '') + { + return app()->getRootPath() . 'database' . DIRECTORY_SEPARATOR . $path; + } +}