diff options
Diffstat (limited to 'core/Command/Db')
-rw-r--r-- | core/Command/Db/AddMissingColumns.php | 78 | ||||
-rw-r--r-- | core/Command/Db/AddMissingIndices.php | 129 | ||||
-rw-r--r-- | core/Command/Db/AddMissingPrimaryKeys.php | 84 | ||||
-rw-r--r-- | core/Command/Db/ConvertFilecacheBigInt.php | 107 | ||||
-rw-r--r-- | core/Command/Db/ConvertMysqlToMB4.php | 59 | ||||
-rw-r--r-- | core/Command/Db/ConvertType.php | 465 | ||||
-rw-r--r-- | core/Command/Db/ExpectedSchema.php | 68 | ||||
-rw-r--r-- | core/Command/Db/ExportSchema.php | 44 | ||||
-rw-r--r-- | core/Command/Db/Migrations/ExecuteCommand.php | 96 | ||||
-rw-r--r-- | core/Command/Db/Migrations/GenerateCommand.php | 228 | ||||
-rw-r--r-- | core/Command/Db/Migrations/GenerateMetadataCommand.php | 79 | ||||
-rw-r--r-- | core/Command/Db/Migrations/MigrateCommand.php | 78 | ||||
-rw-r--r-- | core/Command/Db/Migrations/PreviewCommand.php | 111 | ||||
-rw-r--r-- | core/Command/Db/Migrations/StatusCommand.php | 126 | ||||
-rw-r--r-- | core/Command/Db/SchemaEncoder.php | 115 |
15 files changed, 1867 insertions, 0 deletions
diff --git a/core/Command/Db/AddMissingColumns.php b/core/Command/Db/AddMissingColumns.php new file mode 100644 index 00000000000..33b4b24a6cb --- /dev/null +++ b/core/Command/Db/AddMissingColumns.php @@ -0,0 +1,78 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Core\Command\Db; + +use OC\DB\Connection; +use OC\DB\SchemaWrapper; +use OCP\DB\Events\AddMissingColumnsEvent; +use OCP\EventDispatcher\IEventDispatcher; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * Class AddMissingColumns + * + * if you added a new lazy column to the database, this is the right place to add + * your update routine for existing instances + * + * @package OC\Core\Command\Db + */ +class AddMissingColumns extends Command { + public function __construct( + private Connection $connection, + private IEventDispatcher $dispatcher, + ) { + parent::__construct(); + } + + protected function configure() { + $this + ->setName('db:add-missing-columns') + ->setDescription('Add missing optional columns to the database tables') + ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Output the SQL queries instead of running them.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $dryRun = $input->getOption('dry-run'); + + // Dispatch event so apps can also update columns if needed + $event = new AddMissingColumnsEvent(); + $this->dispatcher->dispatchTyped($event); + $missingColumns = $event->getMissingColumns(); + $updated = false; + + if (!empty($missingColumns)) { + $schema = new SchemaWrapper($this->connection); + + foreach ($missingColumns as $missingColumn) { + if ($schema->hasTable($missingColumn['tableName'])) { + $table = $schema->getTable($missingColumn['tableName']); + if (!$table->hasColumn($missingColumn['columnName'])) { + $output->writeln('<info>Adding additional ' . $missingColumn['columnName'] . ' column to the ' . $missingColumn['tableName'] . ' table, this can take some time...</info>'); + $table->addColumn($missingColumn['columnName'], $missingColumn['typeName'], $missingColumn['options']); + $sqlQueries = $this->connection->migrateToSchema($schema->getWrappedSchema(), $dryRun); + if ($dryRun && $sqlQueries !== null) { + $output->writeln($sqlQueries); + } + $updated = true; + $output->writeln('<info>' . $missingColumn['tableName'] . ' table updated successfully.</info>'); + } + } + } + } + + if (!$updated) { + $output->writeln('<info>Done.</info>'); + } + + return 0; + } +} diff --git a/core/Command/Db/AddMissingIndices.php b/core/Command/Db/AddMissingIndices.php new file mode 100644 index 00000000000..eec0aedce11 --- /dev/null +++ b/core/Command/Db/AddMissingIndices.php @@ -0,0 +1,129 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Core\Command\Db; + +use OC\DB\Connection; +use OC\DB\SchemaWrapper; +use OCP\DB\Events\AddMissingIndicesEvent; +use OCP\EventDispatcher\IEventDispatcher; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * Class AddMissingIndices + * + * if you added any new indices to the database, this is the right place to add + * your update routine for existing instances + * + * @package OC\Core\Command\Db + */ +class AddMissingIndices extends Command { + public function __construct( + private Connection $connection, + private IEventDispatcher $dispatcher, + ) { + parent::__construct(); + } + + protected function configure() { + $this + ->setName('db:add-missing-indices') + ->setDescription('Add missing indices to the database tables') + ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Output the SQL queries instead of running them.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $dryRun = $input->getOption('dry-run'); + + // Dispatch event so apps can also update indexes if needed + $event = new AddMissingIndicesEvent(); + $this->dispatcher->dispatchTyped($event); + + $missingIndices = $event->getMissingIndices(); + $toReplaceIndices = $event->getIndicesToReplace(); + + if ($missingIndices !== [] || $toReplaceIndices !== []) { + $schema = new SchemaWrapper($this->connection); + + foreach ($missingIndices as $missingIndex) { + if ($schema->hasTable($missingIndex['tableName'])) { + $table = $schema->getTable($missingIndex['tableName']); + if (!$table->hasIndex($missingIndex['indexName'])) { + $output->writeln('<info>Adding additional ' . $missingIndex['indexName'] . ' index to the ' . $table->getName() . ' table, this can take some time...</info>'); + + if ($missingIndex['dropUnnamedIndex']) { + foreach ($table->getIndexes() as $index) { + $columns = $index->getColumns(); + if ($columns === $missingIndex['columns']) { + $table->dropIndex($index->getName()); + } + } + } + + if ($missingIndex['uniqueIndex']) { + $table->addUniqueIndex($missingIndex['columns'], $missingIndex['indexName'], $missingIndex['options']); + } else { + $table->addIndex($missingIndex['columns'], $missingIndex['indexName'], [], $missingIndex['options']); + } + + if (!$dryRun) { + $this->connection->migrateToSchema($schema->getWrappedSchema()); + } + $output->writeln('<info>' . $table->getName() . ' table updated successfully.</info>'); + } + } + } + + foreach ($toReplaceIndices as $toReplaceIndex) { + if ($schema->hasTable($toReplaceIndex['tableName'])) { + $table = $schema->getTable($toReplaceIndex['tableName']); + + if ($table->hasIndex($toReplaceIndex['newIndexName'])) { + continue; + } + + $output->writeln('<info>Adding additional ' . $toReplaceIndex['newIndexName'] . ' index to the ' . $table->getName() . ' table, this can take some time...</info>'); + + if ($toReplaceIndex['uniqueIndex']) { + $table->addUniqueIndex($toReplaceIndex['columns'], $toReplaceIndex['newIndexName'], $toReplaceIndex['options']); + } else { + $table->addIndex($toReplaceIndex['columns'], $toReplaceIndex['newIndexName'], [], $toReplaceIndex['options']); + } + + if (!$dryRun) { + $this->connection->migrateToSchema($schema->getWrappedSchema()); + } + + foreach ($toReplaceIndex['oldIndexNames'] as $oldIndexName) { + if ($table->hasIndex($oldIndexName)) { + $output->writeln('<info>Removing ' . $oldIndexName . ' index from the ' . $table->getName() . ' table</info>'); + $table->dropIndex($oldIndexName); + } + } + + if (!$dryRun) { + $this->connection->migrateToSchema($schema->getWrappedSchema()); + } + $output->writeln('<info>' . $table->getName() . ' table updated successfully.</info>'); + } + } + + if ($dryRun) { + $sqlQueries = $this->connection->migrateToSchema($schema->getWrappedSchema(), $dryRun); + if ($sqlQueries !== null) { + $output->writeln($sqlQueries); + } + } + } + + return 0; + } +} diff --git a/core/Command/Db/AddMissingPrimaryKeys.php b/core/Command/Db/AddMissingPrimaryKeys.php new file mode 100644 index 00000000000..1eb11c894fa --- /dev/null +++ b/core/Command/Db/AddMissingPrimaryKeys.php @@ -0,0 +1,84 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Core\Command\Db; + +use OC\DB\Connection; +use OC\DB\SchemaWrapper; +use OCP\DB\Events\AddMissingPrimaryKeyEvent; +use OCP\EventDispatcher\IEventDispatcher; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * Class AddMissingPrimaryKeys + * + * if you added primary keys to the database, this is the right place to add + * your update routine for existing instances + * + * @package OC\Core\Command\Db + */ +class AddMissingPrimaryKeys extends Command { + public function __construct( + private Connection $connection, + private IEventDispatcher $dispatcher, + ) { + parent::__construct(); + } + + protected function configure() { + $this + ->setName('db:add-missing-primary-keys') + ->setDescription('Add missing primary keys to the database tables') + ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Output the SQL queries instead of running them.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $dryRun = $input->getOption('dry-run'); + + // Dispatch event so apps can also update indexes if needed + $event = new AddMissingPrimaryKeyEvent(); + $this->dispatcher->dispatchTyped($event); + $missingKeys = $event->getMissingPrimaryKeys(); + $updated = false; + + if (!empty($missingKeys)) { + $schema = new SchemaWrapper($this->connection); + + foreach ($missingKeys as $missingKey) { + if ($schema->hasTable($missingKey['tableName'])) { + $table = $schema->getTable($missingKey['tableName']); + if (!$table->hasPrimaryKey()) { + $output->writeln('<info>Adding primary key to the ' . $missingKey['tableName'] . ' table, this can take some time...</info>'); + $table->setPrimaryKey($missingKey['columns'], $missingKey['primaryKeyName']); + + if ($missingKey['formerIndex'] && $table->hasIndex($missingKey['formerIndex'])) { + $table->dropIndex($missingKey['formerIndex']); + } + + $sqlQueries = $this->connection->migrateToSchema($schema->getWrappedSchema(), $dryRun); + if ($dryRun && $sqlQueries !== null) { + $output->writeln($sqlQueries); + } + + $updated = true; + $output->writeln('<info>' . $missingKey['tableName'] . ' table updated successfully.</info>'); + } + } + } + } + + if (!$updated) { + $output->writeln('<info>Done.</info>'); + } + + return 0; + } +} diff --git a/core/Command/Db/ConvertFilecacheBigInt.php b/core/Command/Db/ConvertFilecacheBigInt.php new file mode 100644 index 00000000000..0d96d139701 --- /dev/null +++ b/core/Command/Db/ConvertFilecacheBigInt.php @@ -0,0 +1,107 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Core\Command\Db; + +use Doctrine\DBAL\Types\Type; +use OC\DB\Connection; +use OC\DB\SchemaWrapper; +use OCP\DB\Types; +use OCP\IDBConnection; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\QuestionHelper; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Question\ConfirmationQuestion; + +class ConvertFilecacheBigInt extends Command { + public function __construct( + private Connection $connection, + ) { + parent::__construct(); + } + + protected function configure() { + $this + ->setName('db:convert-filecache-bigint') + ->setDescription('Convert the ID columns of the filecache to BigInt'); + } + + /** + * @return array<string,string[]> + */ + public static function getColumnsByTable(): array { + return [ + 'activity' => ['activity_id', 'object_id'], + 'activity_mq' => ['mail_id'], + 'authtoken' => ['id'], + 'bruteforce_attempts' => ['id'], + 'federated_reshares' => ['share_id'], + 'filecache' => ['fileid', 'storage', 'parent', 'mimetype', 'mimepart', 'mtime', 'storage_mtime'], + 'filecache_extended' => ['fileid'], + 'files_trash' => ['auto_id'], + 'file_locks' => ['id'], + 'file_metadata' => ['id'], + 'jobs' => ['id'], + 'mimetypes' => ['id'], + 'mounts' => ['id', 'storage_id', 'root_id', 'mount_id'], + 'share_external' => ['id', 'parent'], + 'storages' => ['numeric_id'], + ]; + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $schema = new SchemaWrapper($this->connection); + $isSqlite = $this->connection->getDatabaseProvider() === IDBConnection::PLATFORM_SQLITE; + $updates = []; + + $tables = static::getColumnsByTable(); + foreach ($tables as $tableName => $columns) { + if (!$schema->hasTable($tableName)) { + continue; + } + + $table = $schema->getTable($tableName); + + foreach ($columns as $columnName) { + $column = $table->getColumn($columnName); + $isAutoIncrement = $column->getAutoincrement(); + $isAutoIncrementOnSqlite = $isSqlite && $isAutoIncrement; + if ($column->getType()->getName() !== Types::BIGINT && !$isAutoIncrementOnSqlite) { + $column->setType(Type::getType(Types::BIGINT)); + $column->setOptions(['length' => 20]); + + $updates[] = '* ' . $tableName . '.' . $columnName; + } + } + } + + if (empty($updates)) { + $output->writeln('<info>All tables already up to date!</info>'); + return 0; + } + + $output->writeln('<comment>Following columns will be updated:</comment>'); + $output->writeln(''); + $output->writeln($updates); + $output->writeln(''); + $output->writeln('<comment>This can take up to hours, depending on the number of files in your instance!</comment>'); + + if ($input->isInteractive()) { + /** @var QuestionHelper $helper */ + $helper = $this->getHelper('question'); + $question = new ConfirmationQuestion('Continue with the conversion (y/n)? [n] ', false); + + if (!$helper->ask($input, $output, $question)) { + return 1; + } + } + + $this->connection->migrateToSchema($schema->getWrappedSchema()); + + return 0; + } +} diff --git a/core/Command/Db/ConvertMysqlToMB4.php b/core/Command/Db/ConvertMysqlToMB4.php new file mode 100644 index 00000000000..926e56c4300 --- /dev/null +++ b/core/Command/Db/ConvertMysqlToMB4.php @@ -0,0 +1,59 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017 ownCloud GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Core\Command\Db; + +use OC\DB\MySqlTools; +use OC\Migration\ConsoleOutput; +use OC\Repair\Collation; +use OCP\IConfig; +use OCP\IDBConnection; +use OCP\IURLGenerator; +use Psr\Log\LoggerInterface; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +class ConvertMysqlToMB4 extends Command { + public function __construct( + private IConfig $config, + private IDBConnection $connection, + private IURLGenerator $urlGenerator, + private LoggerInterface $logger, + ) { + parent::__construct(); + } + + protected function configure() { + $this + ->setName('db:convert-mysql-charset') + ->setDescription('Convert charset of MySQL/MariaDB to use utf8mb4'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + if ($this->connection->getDatabaseProvider() !== IDBConnection::PLATFORM_MYSQL) { + $output->writeln('This command is only valid for MySQL/MariaDB databases.'); + return 1; + } + + $tools = new MySqlTools(); + if (!$tools->supports4ByteCharset($this->connection)) { + $url = $this->urlGenerator->linkToDocs('admin-mysql-utf8mb4'); + $output->writeln('The database is not properly setup to use the charset utf8mb4.'); + $output->writeln("For more information please read the documentation at $url"); + return 1; + } + + // enable charset + $this->config->setSystemValue('mysql.utf8mb4', true); + + // run conversion + $coll = new Collation($this->config, $this->logger, $this->connection, false); + $coll->run(new ConsoleOutput($output)); + + return 0; + } +} diff --git a/core/Command/Db/ConvertType.php b/core/Command/Db/ConvertType.php new file mode 100644 index 00000000000..0067bec4d9e --- /dev/null +++ b/core/Command/Db/ConvertType.php @@ -0,0 +1,465 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Core\Command\Db; + +use Doctrine\DBAL\Exception; +use Doctrine\DBAL\Schema\AbstractAsset; +use Doctrine\DBAL\Schema\Table; +use OC\DB\Connection; +use OC\DB\ConnectionFactory; +use OC\DB\MigrationService; +use OC\DB\PgSqlTools; +use OCP\App\IAppManager; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\DB\Types; +use OCP\IConfig; +use OCP\Server; +use Stecman\Component\Symfony\Console\BashCompletion\Completion\CompletionAwareInterface; +use Stecman\Component\Symfony\Console\BashCompletion\CompletionContext; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\ProgressBar; +use Symfony\Component\Console\Helper\QuestionHelper; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Question\ConfirmationQuestion; +use Symfony\Component\Console\Question\Question; +use function preg_match; +use function preg_quote; + +class ConvertType extends Command implements CompletionAwareInterface { + protected array $columnTypes = []; + + public function __construct( + protected IConfig $config, + protected ConnectionFactory $connectionFactory, + protected IAppManager $appManager, + ) { + parent::__construct(); + } + + protected function configure() { + $this + ->setName('db:convert-type') + ->setDescription('Convert the Nextcloud database to the newly configured one') + ->addArgument( + 'type', + InputArgument::REQUIRED, + 'the type of the database to convert to' + ) + ->addArgument( + 'username', + InputArgument::REQUIRED, + 'the username of the database to convert to' + ) + ->addArgument( + 'hostname', + InputArgument::REQUIRED, + 'the hostname of the database to convert to' + ) + ->addArgument( + 'database', + InputArgument::REQUIRED, + 'the name of the database to convert to' + ) + ->addOption( + 'port', + null, + InputOption::VALUE_REQUIRED, + 'the port of the database to convert to' + ) + ->addOption( + 'password', + null, + InputOption::VALUE_REQUIRED, + 'the password of the database to convert to. Will be asked when not specified. Can also be passed via stdin.' + ) + ->addOption( + 'clear-schema', + null, + InputOption::VALUE_NONE, + 'remove all tables from the destination database' + ) + ->addOption( + 'all-apps', + null, + InputOption::VALUE_NONE, + 'whether to create schema for all apps instead of only installed apps' + ) + ->addOption( + 'chunk-size', + null, + InputOption::VALUE_REQUIRED, + 'the maximum number of database rows to handle in a single query, bigger tables will be handled in chunks of this size. Lower this if the process runs out of memory during conversion.', + '1000' + ) + ; + } + + protected function validateInput(InputInterface $input, OutputInterface $output) { + $type = $this->connectionFactory->normalizeType($input->getArgument('type')); + if ($type === 'sqlite3') { + throw new \InvalidArgumentException( + 'Converting to SQLite (sqlite3) is currently not supported.' + ); + } + if ($type === $this->config->getSystemValue('dbtype', '')) { + throw new \InvalidArgumentException(sprintf( + 'Can not convert from %1$s to %1$s.', + $type + )); + } + if ($type === 'oci' && $input->getOption('clear-schema')) { + // Doctrine unconditionally tries (at least in version 2.3) + // to drop sequence triggers when dropping a table, even though + // such triggers may not exist. This results in errors like + // "ORA-04080: trigger 'OC_STORAGES_AI_PK' does not exist". + throw new \InvalidArgumentException( + 'The --clear-schema option is not supported when converting to Oracle (oci).' + ); + } + } + + protected function readPassword(InputInterface $input, OutputInterface $output) { + // Explicitly specified password + if ($input->getOption('password')) { + return; + } + + // Read from stdin. stream_set_blocking is used to prevent blocking + // when nothing is passed via stdin. + stream_set_blocking(STDIN, 0); + $password = file_get_contents('php://stdin'); + stream_set_blocking(STDIN, 1); + if (trim($password) !== '') { + $input->setOption('password', $password); + return; + } + + // Read password by interacting + if ($input->isInteractive()) { + /** @var QuestionHelper $helper */ + $helper = $this->getHelper('question'); + $question = new Question('What is the database password (press <enter> for none)? '); + $question->setHidden(true); + $question->setHiddenFallback(false); + $password = $helper->ask($input, $output, $question); + if ($password === null) { + $password = ''; // possibly unnecessary + } + $input->setOption('password', $password); + return; + } + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $this->validateInput($input, $output); + $this->readPassword($input, $output); + + /** @var Connection $fromDB */ + $fromDB = Server::get(Connection::class); + $toDB = $this->getToDBConnection($input, $output); + + if ($input->getOption('clear-schema')) { + $this->clearSchema($toDB, $input, $output); + } + + $this->createSchema($fromDB, $toDB, $input, $output); + + $toTables = $this->getTables($toDB); + $fromTables = $this->getTables($fromDB); + + // warn/fail if there are more tables in 'from' database + $extraFromTables = array_diff($fromTables, $toTables); + if (!empty($extraFromTables)) { + $output->writeln('<comment>The following tables will not be converted:</comment>'); + $output->writeln($extraFromTables); + if (!$input->getOption('all-apps')) { + $output->writeln('<comment>Please note that tables belonging to disabled (but not removed) apps</comment>'); + $output->writeln('<comment>can be included by specifying the --all-apps option.</comment>'); + } + + $continueConversion = !$input->isInteractive(); // assume yes for --no-interaction and no otherwise. + $question = new ConfirmationQuestion('Continue with the conversion (y/n)? [n] ', $continueConversion); + + /** @var QuestionHelper $helper */ + $helper = $this->getHelper('question'); + + if (!$helper->ask($input, $output, $question)) { + return 1; + } + } + $intersectingTables = array_intersect($toTables, $fromTables); + $this->convertDB($fromDB, $toDB, $intersectingTables, $input, $output); + return 0; + } + + protected function createSchema(Connection $fromDB, Connection $toDB, InputInterface $input, OutputInterface $output) { + $output->writeln('<info>Creating schema in new database</info>'); + + $fromMS = new MigrationService('core', $fromDB); + $currentMigration = $fromMS->getMigration('current'); + if ($currentMigration !== '0') { + $toMS = new MigrationService('core', $toDB); + $toMS->migrate($currentMigration); + } + + $apps = $input->getOption('all-apps') + ? $this->appManager->getAllAppsInAppsFolders() + : $this->appManager->getEnabledApps(); + foreach ($apps as $app) { + $output->writeln('<info> - ' . $app . '</info>'); + // Make sure autoloading works... + $this->appManager->loadApp($app); + $fromMS = new MigrationService($app, $fromDB); + $currentMigration = $fromMS->getMigration('current'); + if ($currentMigration !== '0') { + $toMS = new MigrationService($app, $toDB); + $toMS->migrate($currentMigration, true); + } + } + } + + protected function getToDBConnection(InputInterface $input, OutputInterface $output) { + $type = $input->getArgument('type'); + $connectionParams = $this->connectionFactory->createConnectionParams(type: $type); + $connectionParams = array_merge($connectionParams, [ + 'host' => $input->getArgument('hostname'), + 'user' => $input->getArgument('username'), + 'password' => $input->getOption('password'), + 'dbname' => $input->getArgument('database'), + ]); + + // parse port + if ($input->getOption('port')) { + $connectionParams['port'] = $input->getOption('port'); + } + + // parse hostname for unix socket + if (preg_match('/^(.+)(:(\d+|[^:]+))?$/', $input->getArgument('hostname'), $matches)) { + $connectionParams['host'] = $matches[1]; + if (isset($matches[3])) { + if (is_numeric($matches[3])) { + $connectionParams['port'] = $matches[3]; + } else { + $connectionParams['unix_socket'] = $matches[3]; + } + } + } + + return $this->connectionFactory->getConnection($type, $connectionParams); + } + + protected function clearSchema(Connection $db, InputInterface $input, OutputInterface $output) { + $toTables = $this->getTables($db); + if (!empty($toTables)) { + $output->writeln('<info>Clearing schema in new database</info>'); + } + foreach ($toTables as $table) { + $db->createSchemaManager()->dropTable($table); + } + } + + protected function getTables(Connection $db) { + $db->getConfiguration()->setSchemaAssetsFilter(function ($asset) { + /** @var string|AbstractAsset $asset */ + $filterExpression = '/^' . preg_quote($this->config->getSystemValue('dbtableprefix', 'oc_')) . '/'; + if ($asset instanceof AbstractAsset) { + return preg_match($filterExpression, $asset->getName()) !== false; + } + return preg_match($filterExpression, $asset) !== false; + }); + return $db->createSchemaManager()->listTableNames(); + } + + /** + * @param Connection $fromDB + * @param Connection $toDB + * @param Table $table + * @param InputInterface $input + * @param OutputInterface $output + */ + protected function copyTable(Connection $fromDB, Connection $toDB, Table $table, InputInterface $input, OutputInterface $output) { + if ($table->getName() === $toDB->getPrefix() . 'migrations') { + $output->writeln('<comment>Skipping migrations table because it was already filled by running the migrations</comment>'); + return; + } + + $chunkSize = (int)$input->getOption('chunk-size'); + + $query = $fromDB->getQueryBuilder(); + $query->automaticTablePrefix(false); + $query->select($query->func()->count('*', 'num_entries')) + ->from($table->getName()); + $result = $query->executeQuery(); + $count = $result->fetchOne(); + $result->closeCursor(); + + $numChunks = ceil($count / $chunkSize); + if ($numChunks > 1) { + $output->writeln('chunked query, ' . $numChunks . ' chunks'); + } + + $progress = new ProgressBar($output, $count); + $progress->setFormat('very_verbose'); + $progress->start(); + $redraw = $count > $chunkSize ? 100 : ($count > 100 ? 5 : 1); + $progress->setRedrawFrequency($redraw); + + $query = $fromDB->getQueryBuilder(); + $query->automaticTablePrefix(false); + $query->select('*') + ->from($table->getName()) + ->setMaxResults($chunkSize); + + try { + $orderColumns = $table->getPrimaryKeyColumns(); + } catch (Exception $e) { + $orderColumns = $table->getColumns(); + } + + foreach ($orderColumns as $column) { + $query->addOrderBy($column->getName()); + } + + $insertQuery = $toDB->getQueryBuilder(); + $insertQuery->automaticTablePrefix(false); + $insertQuery->insert($table->getName()); + $parametersCreated = false; + + for ($chunk = 0; $chunk < $numChunks; $chunk++) { + $query->setFirstResult($chunk * $chunkSize); + + $result = $query->executeQuery(); + + try { + $toDB->beginTransaction(); + + while ($row = $result->fetch()) { + $progress->advance(); + if (!$parametersCreated) { + foreach ($row as $key => $value) { + $insertQuery->setValue($key, $insertQuery->createParameter($key)); + } + $parametersCreated = true; + } + + foreach ($row as $key => $value) { + $type = $this->getColumnType($table, $key); + if ($type !== false) { + $insertQuery->setParameter($key, $value, $type); + } else { + $insertQuery->setParameter($key, $value); + } + } + $insertQuery->execute(); + } + $result->closeCursor(); + + $toDB->commit(); + } catch (\Throwable $e) { + $toDB->rollBack(); + throw $e; + } + } + + $progress->finish(); + $output->writeln(''); + } + + protected function getColumnType(Table $table, $columnName) { + $tableName = $table->getName(); + if (isset($this->columnTypes[$tableName][$columnName])) { + return $this->columnTypes[$tableName][$columnName]; + } + + $type = $table->getColumn($columnName)->getType()->getName(); + + switch ($type) { + case Types::BLOB: + case Types::TEXT: + $this->columnTypes[$tableName][$columnName] = IQueryBuilder::PARAM_LOB; + break; + case Types::BOOLEAN: + $this->columnTypes[$tableName][$columnName] = IQueryBuilder::PARAM_BOOL; + break; + default: + $this->columnTypes[$tableName][$columnName] = false; + } + + return $this->columnTypes[$tableName][$columnName]; + } + + protected function convertDB(Connection $fromDB, Connection $toDB, array $tables, InputInterface $input, OutputInterface $output) { + $this->config->setSystemValue('maintenance', true); + $schema = $fromDB->createSchema(); + + try { + // copy table rows + foreach ($tables as $table) { + $output->writeln('<info> - ' . $table . '</info>'); + $this->copyTable($fromDB, $toDB, $schema->getTable($table), $input, $output); + } + if ($input->getArgument('type') === 'pgsql') { + $tools = new PgSqlTools($this->config); + $tools->resynchronizeDatabaseSequences($toDB); + } + // save new database config + $this->saveDBInfo($input); + } catch (\Exception $e) { + $this->config->setSystemValue('maintenance', false); + throw $e; + } + $this->config->setSystemValue('maintenance', false); + } + + protected function saveDBInfo(InputInterface $input) { + $type = $input->getArgument('type'); + $username = $input->getArgument('username'); + $dbHost = $input->getArgument('hostname'); + $dbName = $input->getArgument('database'); + $password = $input->getOption('password'); + if ($input->getOption('port')) { + $dbHost .= ':' . $input->getOption('port'); + } + + $this->config->setSystemValues([ + 'dbtype' => $type, + 'dbname' => $dbName, + 'dbhost' => $dbHost, + 'dbuser' => $username, + 'dbpassword' => $password, + ]); + } + + /** + * Return possible values for the named option + * + * @param string $optionName + * @param CompletionContext $context + * @return string[] + */ + public function completeOptionValues($optionName, CompletionContext $context) { + return []; + } + + /** + * Return possible values for the named argument + * + * @param string $argumentName + * @param CompletionContext $context + * @return string[] + */ + public function completeArgumentValues($argumentName, CompletionContext $context) { + if ($argumentName === 'type') { + return ['mysql', 'oci', 'pgsql']; + } + return []; + } +} diff --git a/core/Command/Db/ExpectedSchema.php b/core/Command/Db/ExpectedSchema.php new file mode 100644 index 00000000000..1f35daba089 --- /dev/null +++ b/core/Command/Db/ExpectedSchema.php @@ -0,0 +1,68 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2024 Robin Appelman <robin@icewind.nl> + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OC\Core\Command\Db; + +use Doctrine\DBAL\Schema\Schema; +use OC\Core\Command\Base; +use OC\DB\Connection; +use OC\DB\MigrationService; +use OC\DB\SchemaWrapper; +use OC\Migration\NullOutput; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +class ExpectedSchema extends Base { + public function __construct( + protected Connection $connection, + ) { + parent::__construct(); + } + + protected function configure(): void { + $this + ->setName('db:schema:expected') + ->setDescription('Export the expected database schema for a fresh installation') + ->setHelp("Note that the expected schema might not exactly match the exported live schema as the expected schema doesn't take into account any database wide settings or defaults.") + ->addOption('sql', null, InputOption::VALUE_NONE, 'Dump the SQL statements for creating the expected schema'); + parent::configure(); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $schema = new Schema(); + + $this->applyMigrations('core', $schema); + + $apps = \OC_App::getEnabledApps(); + foreach ($apps as $app) { + $this->applyMigrations($app, $schema); + } + + $sql = $input->getOption('sql'); + if ($sql) { + $output->writeln($schema->toSql($this->connection->getDatabasePlatform())); + } else { + $encoder = new SchemaEncoder(); + $this->writeArrayInOutputFormat($input, $output, $encoder->encodeSchema($schema, $this->connection->getDatabasePlatform())); + } + + return 0; + } + + private function applyMigrations(string $app, Schema $schema): void { + $output = new NullOutput(); + $ms = new MigrationService($app, $this->connection, $output); + foreach ($ms->getAvailableVersions() as $version) { + $migration = $ms->createInstance($version); + $migration->changeSchema($output, function () use (&$schema) { + return new SchemaWrapper($this->connection, $schema); + }, []); + } + } +} diff --git a/core/Command/Db/ExportSchema.php b/core/Command/Db/ExportSchema.php new file mode 100644 index 00000000000..581824eea5f --- /dev/null +++ b/core/Command/Db/ExportSchema.php @@ -0,0 +1,44 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2024 Robin Appelman <robin@icewind.nl> + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OC\Core\Command\Db; + +use OC\Core\Command\Base; +use OCP\IDBConnection; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +class ExportSchema extends Base { + public function __construct( + protected IDBConnection $connection, + ) { + parent::__construct(); + } + + protected function configure(): void { + $this + ->setName('db:schema:export') + ->setDescription('Export the current database schema') + ->addOption('sql', null, InputOption::VALUE_NONE, 'Dump the SQL statements for creating a copy of the schema'); + parent::configure(); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $schema = $this->connection->createSchema(); + $sql = $input->getOption('sql'); + if ($sql) { + $output->writeln($schema->toSql($this->connection->getDatabasePlatform())); + } else { + $encoder = new SchemaEncoder(); + $this->writeArrayInOutputFormat($input, $output, $encoder->encodeSchema($schema, $this->connection->getDatabasePlatform())); + } + + return 0; + } +} diff --git a/core/Command/Db/Migrations/ExecuteCommand.php b/core/Command/Db/Migrations/ExecuteCommand.php new file mode 100644 index 00000000000..a89072c1ad1 --- /dev/null +++ b/core/Command/Db/Migrations/ExecuteCommand.php @@ -0,0 +1,96 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2017 ownCloud GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Core\Command\Db\Migrations; + +use OC\DB\Connection; +use OC\DB\MigrationService; +use OC\Migration\ConsoleOutput; +use OCP\IConfig; +use Stecman\Component\Symfony\Console\BashCompletion\Completion\CompletionAwareInterface; +use Stecman\Component\Symfony\Console\BashCompletion\CompletionContext; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +class ExecuteCommand extends Command implements CompletionAwareInterface { + public function __construct( + private Connection $connection, + private IConfig $config, + ) { + parent::__construct(); + } + + protected function configure() { + $this + ->setName('migrations:execute') + ->setDescription('Execute a single migration version manually.') + ->addArgument('app', InputArgument::REQUIRED, 'Name of the app this migration command shall work on') + ->addArgument('version', InputArgument::REQUIRED, 'The version to execute.', null); + + parent::configure(); + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * @return int + */ + public function execute(InputInterface $input, OutputInterface $output): int { + $appName = $input->getArgument('app'); + $ms = new MigrationService($appName, $this->connection, new ConsoleOutput($output)); + $version = $input->getArgument('version'); + + if ($this->config->getSystemValue('debug', false) === false) { + $olderVersions = $ms->getMigratedVersions(); + $olderVersions[] = '0'; + $olderVersions[] = 'prev'; + if (in_array($version, $olderVersions, true)) { + $output->writeln('<error>Can not go back to previous migration without debug enabled</error>'); + return 1; + } + } + + + $ms->executeStep($version); + return 0; + } + + /** + * @param string $optionName + * @param CompletionContext $context + * @return string[] + */ + public function completeOptionValues($optionName, CompletionContext $context) { + return []; + } + + /** + * @param string $argumentName + * @param CompletionContext $context + * @return string[] + */ + public function completeArgumentValues($argumentName, CompletionContext $context) { + if ($argumentName === 'app') { + $allApps = \OC_App::getAllApps(); + return array_diff($allApps, \OC_App::getEnabledApps(true, true)); + } + + if ($argumentName === 'version') { + $appName = $context->getWordAtIndex($context->getWordIndex() - 1); + + $ms = new MigrationService($appName, $this->connection); + $migrations = $ms->getAvailableVersions(); + + array_unshift($migrations, 'next', 'latest'); + return $migrations; + } + + return []; + } +} diff --git a/core/Command/Db/Migrations/GenerateCommand.php b/core/Command/Db/Migrations/GenerateCommand.php new file mode 100644 index 00000000000..a75280fa8b1 --- /dev/null +++ b/core/Command/Db/Migrations/GenerateCommand.php @@ -0,0 +1,228 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2017 ownCloud GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Core\Command\Db\Migrations; + +use OC\DB\Connection; +use OC\DB\MigrationService; +use OC\Migration\ConsoleOutput; +use OCP\App\IAppManager; +use OCP\Util; +use Stecman\Component\Symfony\Console\BashCompletion\Completion\CompletionAwareInterface; +use Stecman\Component\Symfony\Console\BashCompletion\CompletionContext; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Exception\RuntimeException; +use Symfony\Component\Console\Helper\QuestionHelper; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Question\ConfirmationQuestion; + +class GenerateCommand extends Command implements CompletionAwareInterface { + protected static $_templateSimple + = '<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: {{year}} Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace {{namespace}}; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; +use Override; + +/** + * FIXME Auto-generated migration step: Please modify to your needs! + */ +class {{classname}} extends SimpleMigrationStep { + + /** + * @param IOutput $output + * @param Closure(): ISchemaWrapper $schemaClosure + * @param array $options + */ + #[Override] + public function preSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { + } + + /** + * @param IOutput $output + * @param Closure(): ISchemaWrapper $schemaClosure + * @param array $options + * @return null|ISchemaWrapper + */ + #[Override] + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { +{{schemabody}} + } + + /** + * @param IOutput $output + * @param Closure(): ISchemaWrapper $schemaClosure + * @param array $options + */ + #[Override] + public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { + } +} +'; + + public function __construct( + protected Connection $connection, + protected IAppManager $appManager, + ) { + parent::__construct(); + } + + protected function configure() { + $this + ->setName('migrations:generate') + ->addArgument('app', InputArgument::REQUIRED, 'Name of the app this migration command shall work on') + ->addArgument('version', InputArgument::REQUIRED, 'Major version of this app, to allow versions on parallel development branches') + ; + + parent::configure(); + } + + public function execute(InputInterface $input, OutputInterface $output): int { + $appName = $input->getArgument('app'); + $version = $input->getArgument('version'); + + if (!preg_match('/^\d{1,16}$/', $version)) { + $output->writeln('<error>The given version is invalid. Only 0-9 are allowed (max. 16 digits)</error>'); + return 1; + } + + if ($appName === 'core') { + $fullVersion = implode('.', Util::getVersion()); + } else { + try { + $fullVersion = $this->appManager->getAppVersion($appName, false); + } catch (\Throwable $e) { + $fullVersion = ''; + } + } + + if ($fullVersion) { + [$major, $minor] = explode('.', $fullVersion); + $shouldVersion = (string)((int)$major * 1000 + (int)$minor); + if ($version !== $shouldVersion) { + $output->writeln('<comment>Unexpected migration version for current version: ' . $fullVersion . '</comment>'); + $output->writeln('<comment> - Pattern: XYYY </comment>'); + $output->writeln('<comment> - Expected: ' . $shouldVersion . '</comment>'); + $output->writeln('<comment> - Actual: ' . $version . '</comment>'); + + if ($input->isInteractive()) { + /** @var QuestionHelper $helper */ + $helper = $this->getHelper('question'); + $question = new ConfirmationQuestion('Continue with your given version? (y/n) [n] ', false); + + if (!$helper->ask($input, $output, $question)) { + return 1; + } + } + } + } + + $ms = new MigrationService($appName, $this->connection, new ConsoleOutput($output)); + + $date = date('YmdHis'); + $path = $this->generateMigration($ms, 'Version' . $version . 'Date' . $date); + + $output->writeln("New migration class has been generated to <info>$path</info>"); + return 0; + } + + /** + * @param string $optionName + * @param CompletionContext $context + * @return string[] + */ + public function completeOptionValues($optionName, CompletionContext $context) { + return []; + } + + /** + * @param string $argumentName + * @param CompletionContext $context + * @return string[] + */ + public function completeArgumentValues($argumentName, CompletionContext $context) { + if ($argumentName === 'app') { + $allApps = $this->appManager->getAllAppsInAppsFolders(); + return array_diff($allApps, \OC_App::getEnabledApps(true, true)); + } + + if ($argumentName === 'version') { + $appName = $context->getWordAtIndex($context->getWordIndex() - 1); + + $version = explode('.', $this->appManager->getAppVersion($appName)); + return [$version[0] . sprintf('%1$03d', $version[1])]; + } + + return []; + } + + /** + * @param MigrationService $ms + * @param string $className + * @param string $schemaBody + * @return string + */ + protected function generateMigration(MigrationService $ms, $className, $schemaBody = '') { + if ($schemaBody === '') { + $schemaBody = "\t\t" . 'return null;'; + } + + + $placeHolders = [ + '{{namespace}}', + '{{classname}}', + '{{schemabody}}', + '{{year}}', + ]; + $replacements = [ + $ms->getMigrationsNamespace(), + $className, + $schemaBody, + date('Y') + ]; + $code = str_replace($placeHolders, $replacements, self::$_templateSimple); + $dir = $ms->getMigrationsDirectory(); + + $this->ensureMigrationDirExists($dir); + $path = $dir . '/' . $className . '.php'; + + if (file_put_contents($path, $code) === false) { + throw new RuntimeException('Failed to generate new migration step. Could not write to ' . $path); + } + + return $path; + } + + protected function ensureMigrationDirExists($directory) { + if (file_exists($directory) && is_dir($directory)) { + return; + } + + if (file_exists($directory)) { + throw new \RuntimeException("Could not create folder \"$directory\""); + } + + $this->ensureMigrationDirExists(dirname($directory)); + + if (!@mkdir($directory) && !is_dir($directory)) { + throw new \RuntimeException("Could not create folder \"$directory\""); + } + } +} diff --git a/core/Command/Db/Migrations/GenerateMetadataCommand.php b/core/Command/Db/Migrations/GenerateMetadataCommand.php new file mode 100644 index 00000000000..581259c99df --- /dev/null +++ b/core/Command/Db/Migrations/GenerateMetadataCommand.php @@ -0,0 +1,79 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Core\Command\Db\Migrations; + +use OC\Migration\MetadataManager; +use OCP\App\IAppManager; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * @since 30.0.0 + */ +class GenerateMetadataCommand extends Command { + public function __construct( + private readonly MetadataManager $metadataManager, + private readonly IAppManager $appManager, + ) { + parent::__construct(); + } + + protected function configure(): void { + $this->setName('migrations:generate-metadata') + ->setHidden(true) + ->setDescription('Generate metadata from DB migrations - internal and should not be used'); + + parent::configure(); + } + + public function execute(InputInterface $input, OutputInterface $output): int { + $output->writeln( + json_encode( + [ + 'migrations' => $this->extractMigrationMetadata() + ], + JSON_PRETTY_PRINT + ) + ); + + return 0; + } + + private function extractMigrationMetadata(): array { + return [ + 'core' => $this->extractMigrationMetadataFromCore(), + 'apps' => $this->extractMigrationMetadataFromApps() + ]; + } + + private function extractMigrationMetadataFromCore(): array { + return $this->metadataManager->extractMigrationAttributes('core'); + } + + /** + * get all apps and extract attributes + * + * @return array + * @throws \Exception + */ + private function extractMigrationMetadataFromApps(): array { + $allApps = $this->appManager->getAllAppsInAppsFolders(); + $metadata = []; + foreach ($allApps as $appId) { + // We need to load app before being able to extract Migrations + $alreadyLoaded = $this->appManager->isAppLoaded($appId); + if (!$alreadyLoaded) { + $this->appManager->loadApp($appId); + } + $metadata[$appId] = $this->metadataManager->extractMigrationAttributes($appId); + } + return $metadata; + } +} diff --git a/core/Command/Db/Migrations/MigrateCommand.php b/core/Command/Db/Migrations/MigrateCommand.php new file mode 100644 index 00000000000..2e02f031479 --- /dev/null +++ b/core/Command/Db/Migrations/MigrateCommand.php @@ -0,0 +1,78 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2017 ownCloud GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Core\Command\Db\Migrations; + +use OC\DB\Connection; +use OC\DB\MigrationService; +use OC\Migration\ConsoleOutput; +use Stecman\Component\Symfony\Console\BashCompletion\Completion\CompletionAwareInterface; +use Stecman\Component\Symfony\Console\BashCompletion\CompletionContext; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +class MigrateCommand extends Command implements CompletionAwareInterface { + public function __construct( + private Connection $connection, + ) { + parent::__construct(); + } + + protected function configure() { + $this + ->setName('migrations:migrate') + ->setDescription('Execute a migration to a specified version or the latest available version.') + ->addArgument('app', InputArgument::REQUIRED, 'Name of the app this migration command shall work on') + ->addArgument('version', InputArgument::OPTIONAL, 'The version number (YYYYMMDDHHMMSS) or alias (first, prev, next, latest) to migrate to.', 'latest'); + + parent::configure(); + } + + public function execute(InputInterface $input, OutputInterface $output): int { + $appName = $input->getArgument('app'); + $ms = new MigrationService($appName, $this->connection, new ConsoleOutput($output)); + $version = $input->getArgument('version'); + + $ms->migrate($version); + return 0; + } + + /** + * @param string $optionName + * @param CompletionContext $context + * @return string[] + */ + public function completeOptionValues($optionName, CompletionContext $context) { + return []; + } + + /** + * @param string $argumentName + * @param CompletionContext $context + * @return string[] + */ + public function completeArgumentValues($argumentName, CompletionContext $context) { + if ($argumentName === 'app') { + $allApps = \OC_App::getAllApps(); + return array_diff($allApps, \OC_App::getEnabledApps(true, true)); + } + + if ($argumentName === 'version') { + $appName = $context->getWordAtIndex($context->getWordIndex() - 1); + + $ms = new MigrationService($appName, $this->connection); + $migrations = $ms->getAvailableVersions(); + + array_unshift($migrations, 'next', 'latest'); + return $migrations; + } + + return []; + } +} diff --git a/core/Command/Db/Migrations/PreviewCommand.php b/core/Command/Db/Migrations/PreviewCommand.php new file mode 100644 index 00000000000..f5b850fff76 --- /dev/null +++ b/core/Command/Db/Migrations/PreviewCommand.php @@ -0,0 +1,111 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Core\Command\Db\Migrations; + +use OC\Migration\MetadataManager; +use OC\Updater\ReleaseMetadata; +use OCP\Migration\Attributes\MigrationAttribute; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\Table; +use Symfony\Component\Console\Helper\TableCell; +use Symfony\Component\Console\Helper\TableCellStyle; +use Symfony\Component\Console\Helper\TableSeparator; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * @since 30.0.0 + */ +class PreviewCommand extends Command { + private bool $initiated = false; + public function __construct( + private readonly MetadataManager $metadataManager, + private readonly ReleaseMetadata $releaseMetadata, + ) { + parent::__construct(); + } + + protected function configure(): void { + $this + ->setName('migrations:preview') + ->setDescription('Get preview of available DB migrations in case of initiating an upgrade') + ->addArgument('version', InputArgument::REQUIRED, 'The destination version number'); + + parent::configure(); + } + + public function execute(InputInterface $input, OutputInterface $output): int { + $version = $input->getArgument('version'); + if (filter_var($version, FILTER_VALIDATE_URL)) { + $metadata = $this->releaseMetadata->downloadMetadata($version); + } elseif (str_starts_with($version, '/')) { + $metadata = json_decode(file_get_contents($version), true, flags: JSON_THROW_ON_ERROR); + } else { + $metadata = $this->releaseMetadata->getMetadata($version); + } + + $parsed = $this->metadataManager->getMigrationsAttributesFromReleaseMetadata($metadata['migrations'] ?? [], true); + + $table = new Table($output); + $this->displayMigrations($table, 'core', $parsed['core'] ?? []); + foreach ($parsed['apps'] as $appId => $migrations) { + if (!empty($migrations)) { + $this->displayMigrations($table, $appId, $migrations); + } + } + $table->render(); + + $unsupportedApps = $this->metadataManager->getUnsupportedApps($metadata['migrations']); + if (!empty($unsupportedApps)) { + $output->writeln(''); + $output->writeln('Those apps are not supporting metadata yet and might initiate migrations on upgrade: <info>' . implode(', ', $unsupportedApps) . '</info>'); + } + + return 0; + } + + private function displayMigrations(Table $table, string $appId, array $data): void { + if (empty($data)) { + return; + } + + if ($this->initiated) { + $table->addRow(new TableSeparator()); + } + $this->initiated = true; + + $table->addRow( + [ + new TableCell( + $appId, + [ + 'colspan' => 2, + 'style' => new TableCellStyle(['cellFormat' => '<info>%s</info>']) + ] + ) + ] + )->addRow(new TableSeparator()); + + /** @var MigrationAttribute[] $attributes */ + foreach ($data as $migration => $attributes) { + $attributesStr = []; + if (empty($attributes)) { + $attributesStr[] = '<comment>(metadata not set)</comment>'; + } + foreach ($attributes as $attribute) { + $definition = '<info>' . $attribute->definition() . '</info>'; + $definition .= empty($attribute->getDescription()) ? '' : "\n " . $attribute->getDescription(); + $definition .= empty($attribute->getNotes()) ? '' : "\n <comment>" . implode("</comment>\n <comment>", $attribute->getNotes()) . '</comment>'; + $attributesStr[] = $definition; + } + $table->addRow([$migration, implode("\n", $attributesStr)]); + } + } +} diff --git a/core/Command/Db/Migrations/StatusCommand.php b/core/Command/Db/Migrations/StatusCommand.php new file mode 100644 index 00000000000..97ecc76a924 --- /dev/null +++ b/core/Command/Db/Migrations/StatusCommand.php @@ -0,0 +1,126 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2017 ownCloud GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Core\Command\Db\Migrations; + +use OC\DB\Connection; +use OC\DB\MigrationService; +use OC\Migration\ConsoleOutput; +use Stecman\Component\Symfony\Console\BashCompletion\Completion\CompletionAwareInterface; +use Stecman\Component\Symfony\Console\BashCompletion\CompletionContext; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +class StatusCommand extends Command implements CompletionAwareInterface { + public function __construct( + private Connection $connection, + ) { + parent::__construct(); + } + + protected function configure() { + $this + ->setName('migrations:status') + ->setDescription('View the status of a set of migrations.') + ->addArgument('app', InputArgument::REQUIRED, 'Name of the app this migration command shall work on'); + } + + public function execute(InputInterface $input, OutputInterface $output): int { + $appName = $input->getArgument('app'); + $ms = new MigrationService($appName, $this->connection, new ConsoleOutput($output)); + + $infos = $this->getMigrationsInfos($ms); + foreach ($infos as $key => $value) { + if (is_array($value)) { + $output->writeln(" <comment>>></comment> $key:"); + foreach ($value as $subKey => $subValue) { + $output->writeln(" <comment>>></comment> $subKey: " . str_repeat(' ', 46 - strlen($subKey)) . $subValue); + } + } else { + $output->writeln(" <comment>>></comment> $key: " . str_repeat(' ', 50 - strlen($key)) . $value); + } + } + return 0; + } + + /** + * @param string $optionName + * @param CompletionContext $context + * @return string[] + */ + public function completeOptionValues($optionName, CompletionContext $context) { + return []; + } + + /** + * @param string $argumentName + * @param CompletionContext $context + * @return string[] + */ + public function completeArgumentValues($argumentName, CompletionContext $context) { + if ($argumentName === 'app') { + $allApps = \OC_App::getAllApps(); + return array_diff($allApps, \OC_App::getEnabledApps(true, true)); + } + return []; + } + + /** + * @param MigrationService $ms + * @return array associative array of human readable info name as key and the actual information as value + */ + public function getMigrationsInfos(MigrationService $ms) { + $executedMigrations = $ms->getMigratedVersions(); + $availableMigrations = $ms->getAvailableVersions(); + $executedUnavailableMigrations = array_diff($executedMigrations, array_keys($availableMigrations)); + + $numExecutedUnavailableMigrations = count($executedUnavailableMigrations); + $numNewMigrations = count(array_diff(array_keys($availableMigrations), $executedMigrations)); + $pending = $ms->describeMigrationStep('lastest'); + + $infos = [ + 'App' => $ms->getApp(), + 'Version Table Name' => $ms->getMigrationsTableName(), + 'Migrations Namespace' => $ms->getMigrationsNamespace(), + 'Migrations Directory' => $ms->getMigrationsDirectory(), + 'Previous Version' => $this->getFormattedVersionAlias($ms, 'prev'), + 'Current Version' => $this->getFormattedVersionAlias($ms, 'current'), + 'Next Version' => $this->getFormattedVersionAlias($ms, 'next'), + 'Latest Version' => $this->getFormattedVersionAlias($ms, 'latest'), + 'Executed Migrations' => count($executedMigrations), + 'Executed Unavailable Migrations' => $numExecutedUnavailableMigrations, + 'Available Migrations' => count($availableMigrations), + 'New Migrations' => $numNewMigrations, + 'Pending Migrations' => count($pending) ? $pending : 'None' + ]; + + return $infos; + } + + /** + * @param MigrationService $migrationService + * @param string $alias + * @return mixed|null|string + */ + private function getFormattedVersionAlias(MigrationService $migrationService, $alias) { + $migration = $migrationService->getMigration($alias); + //No version found + if ($migration === null) { + if ($alias === 'next') { + return 'Already at latest migration step'; + } + + if ($alias === 'prev') { + return 'Already at first migration step'; + } + } + + return $migration; + } +} diff --git a/core/Command/Db/SchemaEncoder.php b/core/Command/Db/SchemaEncoder.php new file mode 100644 index 00000000000..beae3a81264 --- /dev/null +++ b/core/Command/Db/SchemaEncoder.php @@ -0,0 +1,115 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2024 Robin Appelman <robin@icewind.nl> + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OC\Core\Command\Db; + +use Doctrine\DBAL\Platforms\AbstractMySQLPlatform; +use Doctrine\DBAL\Platforms\AbstractPlatform; +use Doctrine\DBAL\Platforms\PostgreSQLPlatform; +use Doctrine\DBAL\Schema\Schema; +use Doctrine\DBAL\Schema\Table; +use Doctrine\DBAL\Types\PhpIntegerMappingType; +use Doctrine\DBAL\Types\Type; + +class SchemaEncoder { + /** + * Encode a DBAL schema to json, performing some normalization based on the database platform + * + * @param Schema $schema + * @param AbstractPlatform $platform + * @return array + */ + public function encodeSchema(Schema $schema, AbstractPlatform $platform): array { + $encoded = ['tables' => [], 'sequences' => []]; + foreach ($schema->getTables() as $table) { + $encoded[$table->getName()] = $this->encodeTable($table, $platform); + } + ksort($encoded); + return $encoded; + } + + /** + * @psalm-type ColumnArrayType = + */ + private function encodeTable(Table $table, AbstractPlatform $platform): array { + $encoded = ['columns' => [], 'indexes' => []]; + foreach ($table->getColumns() as $column) { + /** + * @var array{ + * name: string, + * default: mixed, + * notnull: bool, + * length: ?int, + * precision: int, + * scale: int, + * unsigned: bool, + * fixed: bool, + * autoincrement: bool, + * comment: string, + * columnDefinition: ?string, + * collation?: string, + * charset?: string, + * jsonb?: bool, + * } $data + **/ + $data = $column->toArray(); + $data['type'] = Type::getTypeRegistry()->lookupName($column->getType()); + $data['default'] = $column->getType()->convertToPHPValue($column->getDefault(), $platform); + if ($platform instanceof PostgreSQLPlatform) { + $data['unsigned'] = false; + if ($column->getType() instanceof PhpIntegerMappingType) { + $data['length'] = null; + } + unset($data['jsonb']); + } elseif ($platform instanceof AbstractMySqlPlatform) { + if ($column->getType() instanceof PhpIntegerMappingType) { + $data['length'] = null; + } elseif (in_array($data['type'], ['text', 'blob', 'datetime', 'float', 'json'])) { + $data['length'] = 0; + } + unset($data['collation']); + unset($data['charset']); + } + if ($data['type'] === 'string' && $data['length'] === null) { + $data['length'] = 255; + } + $encoded['columns'][$column->getName()] = $data; + } + ksort($encoded['columns']); + foreach ($table->getIndexes() as $index) { + $options = $index->getOptions(); + if (isset($options['lengths']) && count(array_filter($options['lengths'])) === 0) { + unset($options['lengths']); + } + if ($index->isPrimary()) { + if ($platform instanceof PostgreSqlPlatform) { + $name = $table->getName() . '_pkey'; + } elseif ($platform instanceof AbstractMySQLPlatform) { + $name = 'PRIMARY'; + } else { + $name = $index->getName(); + } + } else { + $name = $index->getName(); + } + if ($platform instanceof PostgreSqlPlatform) { + $name = strtolower($name); + } + $encoded['indexes'][$name] = [ + 'name' => $name, + 'columns' => $index->getColumns(), + 'unique' => $index->isUnique(), + 'primary' => $index->isPrimary(), + 'flags' => $index->getFlags(), + 'options' => $options, + ]; + } + ksort($encoded['indexes']); + return $encoded; + } +} |