aboutsummaryrefslogtreecommitdiffstats
path: root/core/Command/Db
diff options
context:
space:
mode:
Diffstat (limited to 'core/Command/Db')
-rw-r--r--core/Command/Db/AddMissingColumns.php78
-rw-r--r--core/Command/Db/AddMissingIndices.php129
-rw-r--r--core/Command/Db/AddMissingPrimaryKeys.php84
-rw-r--r--core/Command/Db/ConvertFilecacheBigInt.php107
-rw-r--r--core/Command/Db/ConvertMysqlToMB4.php59
-rw-r--r--core/Command/Db/ConvertType.php465
-rw-r--r--core/Command/Db/ExpectedSchema.php68
-rw-r--r--core/Command/Db/ExportSchema.php44
-rw-r--r--core/Command/Db/Migrations/ExecuteCommand.php96
-rw-r--r--core/Command/Db/Migrations/GenerateCommand.php228
-rw-r--r--core/Command/Db/Migrations/GenerateMetadataCommand.php79
-rw-r--r--core/Command/Db/Migrations/MigrateCommand.php78
-rw-r--r--core/Command/Db/Migrations/PreviewCommand.php111
-rw-r--r--core/Command/Db/Migrations/StatusCommand.php126
-rw-r--r--core/Command/Db/SchemaEncoder.php115
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;
+ }
+}