You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448
  1. <?php
  2. /**
  3. * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
  4. * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
  5. * SPDX-License-Identifier: AGPL-3.0-only
  6. */
  7. namespace OC\Core\Command\Db;
  8. use Doctrine\DBAL\Exception;
  9. use Doctrine\DBAL\Schema\AbstractAsset;
  10. use Doctrine\DBAL\Schema\Table;
  11. use OC\DB\Connection;
  12. use OC\DB\ConnectionFactory;
  13. use OC\DB\MigrationService;
  14. use OCP\DB\QueryBuilder\IQueryBuilder;
  15. use OCP\DB\Types;
  16. use OCP\IConfig;
  17. use Stecman\Component\Symfony\Console\BashCompletion\Completion\CompletionAwareInterface;
  18. use Stecman\Component\Symfony\Console\BashCompletion\CompletionContext;
  19. use Symfony\Component\Console\Command\Command;
  20. use Symfony\Component\Console\Helper\ProgressBar;
  21. use Symfony\Component\Console\Helper\QuestionHelper;
  22. use Symfony\Component\Console\Input\InputArgument;
  23. use Symfony\Component\Console\Input\InputInterface;
  24. use Symfony\Component\Console\Input\InputOption;
  25. use Symfony\Component\Console\Output\OutputInterface;
  26. use Symfony\Component\Console\Question\ConfirmationQuestion;
  27. use Symfony\Component\Console\Question\Question;
  28. use function preg_match;
  29. use function preg_quote;
  30. class ConvertType extends Command implements CompletionAwareInterface {
  31. protected array $columnTypes = [];
  32. public function __construct(
  33. protected IConfig $config,
  34. protected ConnectionFactory $connectionFactory,
  35. ) {
  36. parent::__construct();
  37. }
  38. protected function configure() {
  39. $this
  40. ->setName('db:convert-type')
  41. ->setDescription('Convert the Nextcloud database to the newly configured one')
  42. ->addArgument(
  43. 'type',
  44. InputArgument::REQUIRED,
  45. 'the type of the database to convert to'
  46. )
  47. ->addArgument(
  48. 'username',
  49. InputArgument::REQUIRED,
  50. 'the username of the database to convert to'
  51. )
  52. ->addArgument(
  53. 'hostname',
  54. InputArgument::REQUIRED,
  55. 'the hostname of the database to convert to'
  56. )
  57. ->addArgument(
  58. 'database',
  59. InputArgument::REQUIRED,
  60. 'the name of the database to convert to'
  61. )
  62. ->addOption(
  63. 'port',
  64. null,
  65. InputOption::VALUE_REQUIRED,
  66. 'the port of the database to convert to'
  67. )
  68. ->addOption(
  69. 'password',
  70. null,
  71. InputOption::VALUE_REQUIRED,
  72. 'the password of the database to convert to. Will be asked when not specified. Can also be passed via stdin.'
  73. )
  74. ->addOption(
  75. 'clear-schema',
  76. null,
  77. InputOption::VALUE_NONE,
  78. 'remove all tables from the destination database'
  79. )
  80. ->addOption(
  81. 'all-apps',
  82. null,
  83. InputOption::VALUE_NONE,
  84. 'whether to create schema for all apps instead of only installed apps'
  85. )
  86. ->addOption(
  87. 'chunk-size',
  88. null,
  89. InputOption::VALUE_REQUIRED,
  90. '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.',
  91. '1000'
  92. )
  93. ;
  94. }
  95. protected function validateInput(InputInterface $input, OutputInterface $output) {
  96. $type = $this->connectionFactory->normalizeType($input->getArgument('type'));
  97. if ($type === 'sqlite3') {
  98. throw new \InvalidArgumentException(
  99. 'Converting to SQLite (sqlite3) is currently not supported.'
  100. );
  101. }
  102. if ($type === $this->config->getSystemValue('dbtype', '')) {
  103. throw new \InvalidArgumentException(sprintf(
  104. 'Can not convert from %1$s to %1$s.',
  105. $type
  106. ));
  107. }
  108. if ($type === 'oci' && $input->getOption('clear-schema')) {
  109. // Doctrine unconditionally tries (at least in version 2.3)
  110. // to drop sequence triggers when dropping a table, even though
  111. // such triggers may not exist. This results in errors like
  112. // "ORA-04080: trigger 'OC_STORAGES_AI_PK' does not exist".
  113. throw new \InvalidArgumentException(
  114. 'The --clear-schema option is not supported when converting to Oracle (oci).'
  115. );
  116. }
  117. }
  118. protected function readPassword(InputInterface $input, OutputInterface $output) {
  119. // Explicitly specified password
  120. if ($input->getOption('password')) {
  121. return;
  122. }
  123. // Read from stdin. stream_set_blocking is used to prevent blocking
  124. // when nothing is passed via stdin.
  125. stream_set_blocking(STDIN, 0);
  126. $password = file_get_contents('php://stdin');
  127. stream_set_blocking(STDIN, 1);
  128. if (trim($password) !== '') {
  129. $input->setOption('password', $password);
  130. return;
  131. }
  132. // Read password by interacting
  133. if ($input->isInteractive()) {
  134. /** @var QuestionHelper $helper */
  135. $helper = $this->getHelper('question');
  136. $question = new Question('What is the database password?');
  137. $question->setHidden(true);
  138. $question->setHiddenFallback(false);
  139. $password = $helper->ask($input, $output, $question);
  140. $input->setOption('password', $password);
  141. return;
  142. }
  143. }
  144. protected function execute(InputInterface $input, OutputInterface $output): int {
  145. // WARNING:
  146. // Leave in place until #45257 is addressed to prevent data loss (hopefully in time for the next maintenance release)
  147. //
  148. throw new \InvalidArgumentException(
  149. 'This command is temporarily disabled (until the next maintenance release).'
  150. );
  151. $this->validateInput($input, $output);
  152. $this->readPassword($input, $output);
  153. /** @var Connection $fromDB */
  154. $fromDB = \OC::$server->get(Connection::class);
  155. $toDB = $this->getToDBConnection($input, $output);
  156. if ($input->getOption('clear-schema')) {
  157. $this->clearSchema($toDB, $input, $output);
  158. }
  159. $this->createSchema($fromDB, $toDB, $input, $output);
  160. $toTables = $this->getTables($toDB);
  161. $fromTables = $this->getTables($fromDB);
  162. // warn/fail if there are more tables in 'from' database
  163. $extraFromTables = array_diff($fromTables, $toTables);
  164. if (!empty($extraFromTables)) {
  165. $output->writeln('<comment>The following tables will not be converted:</comment>');
  166. $output->writeln($extraFromTables);
  167. if (!$input->getOption('all-apps')) {
  168. $output->writeln('<comment>Please note that tables belonging to disabled (but not removed) apps</comment>');
  169. $output->writeln('<comment>can be included by specifying the --all-apps option.</comment>');
  170. }
  171. $continueConversion = !$input->isInteractive(); // assume yes for --no-interaction and no otherwise.
  172. $question = new ConfirmationQuestion('Continue with the conversion (y/n)? [n] ', $continueConversion);
  173. /** @var QuestionHelper $helper */
  174. $helper = $this->getHelper('question');
  175. if (!$helper->ask($input, $output, $question)) {
  176. return 1;
  177. }
  178. }
  179. $intersectingTables = array_intersect($toTables, $fromTables);
  180. $this->convertDB($fromDB, $toDB, $intersectingTables, $input, $output);
  181. return 0;
  182. }
  183. protected function createSchema(Connection $fromDB, Connection $toDB, InputInterface $input, OutputInterface $output) {
  184. $output->writeln('<info>Creating schema in new database</info>');
  185. $fromMS = new MigrationService('core', $fromDB);
  186. $currentMigration = $fromMS->getMigration('current');
  187. if ($currentMigration !== '0') {
  188. $toMS = new MigrationService('core', $toDB);
  189. $toMS->migrate($currentMigration);
  190. }
  191. $apps = $input->getOption('all-apps') ? \OC_App::getAllApps() : \OC_App::getEnabledApps();
  192. foreach ($apps as $app) {
  193. $output->writeln('<info> - '.$app.'</info>');
  194. // Make sure autoloading works...
  195. \OC_App::loadApp($app);
  196. $fromMS = new MigrationService($app, $fromDB);
  197. $currentMigration = $fromMS->getMigration('current');
  198. if ($currentMigration !== '0') {
  199. $toMS = new MigrationService($app, $toDB);
  200. $toMS->migrate($currentMigration, true);
  201. }
  202. }
  203. }
  204. protected function getToDBConnection(InputInterface $input, OutputInterface $output) {
  205. $type = $input->getArgument('type');
  206. $connectionParams = $this->connectionFactory->createConnectionParams();
  207. $connectionParams = array_merge($connectionParams, [
  208. 'host' => $input->getArgument('hostname'),
  209. 'user' => $input->getArgument('username'),
  210. 'password' => $input->getOption('password'),
  211. 'dbname' => $input->getArgument('database'),
  212. ]);
  213. if ($input->getOption('port')) {
  214. $connectionParams['port'] = $input->getOption('port');
  215. }
  216. return $this->connectionFactory->getConnection($type, $connectionParams);
  217. }
  218. protected function clearSchema(Connection $db, InputInterface $input, OutputInterface $output) {
  219. $toTables = $this->getTables($db);
  220. if (!empty($toTables)) {
  221. $output->writeln('<info>Clearing schema in new database</info>');
  222. }
  223. foreach ($toTables as $table) {
  224. $db->getSchemaManager()->dropTable($table);
  225. }
  226. }
  227. protected function getTables(Connection $db) {
  228. $db->getConfiguration()->setSchemaAssetsFilter(function ($asset) {
  229. /** @var string|AbstractAsset $asset */
  230. $filterExpression = '/^' . preg_quote($this->config->getSystemValue('dbtableprefix', 'oc_')) . '/';
  231. if ($asset instanceof AbstractAsset) {
  232. return preg_match($filterExpression, $asset->getName()) !== false;
  233. }
  234. return preg_match($filterExpression, $asset) !== false;
  235. });
  236. return $db->getSchemaManager()->listTableNames();
  237. }
  238. /**
  239. * @param Connection $fromDB
  240. * @param Connection $toDB
  241. * @param Table $table
  242. * @param InputInterface $input
  243. * @param OutputInterface $output
  244. */
  245. protected function copyTable(Connection $fromDB, Connection $toDB, Table $table, InputInterface $input, OutputInterface $output) {
  246. if ($table->getName() === $toDB->getPrefix() . 'migrations') {
  247. $output->writeln('<comment>Skipping migrations table because it was already filled by running the migrations</comment>');
  248. return;
  249. }
  250. $chunkSize = (int)$input->getOption('chunk-size');
  251. $query = $fromDB->getQueryBuilder();
  252. $query->automaticTablePrefix(false);
  253. $query->select($query->func()->count('*', 'num_entries'))
  254. ->from($table->getName());
  255. $result = $query->execute();
  256. $count = $result->fetchOne();
  257. $result->closeCursor();
  258. $numChunks = ceil($count / $chunkSize);
  259. if ($numChunks > 1) {
  260. $output->writeln('chunked query, ' . $numChunks . ' chunks');
  261. }
  262. $progress = new ProgressBar($output, $count);
  263. $progress->setFormat('very_verbose');
  264. $progress->start();
  265. $redraw = $count > $chunkSize ? 100 : ($count > 100 ? 5 : 1);
  266. $progress->setRedrawFrequency($redraw);
  267. $query = $fromDB->getQueryBuilder();
  268. $query->automaticTablePrefix(false);
  269. $query->select('*')
  270. ->from($table->getName())
  271. ->setMaxResults($chunkSize);
  272. try {
  273. $orderColumns = $table->getPrimaryKeyColumns();
  274. } catch (Exception $e) {
  275. $orderColumns = $table->getColumns();
  276. }
  277. foreach ($orderColumns as $column) {
  278. $query->addOrderBy($column->getName());
  279. }
  280. $insertQuery = $toDB->getQueryBuilder();
  281. $insertQuery->automaticTablePrefix(false);
  282. $insertQuery->insert($table->getName());
  283. $parametersCreated = false;
  284. for ($chunk = 0; $chunk < $numChunks; $chunk++) {
  285. $query->setFirstResult($chunk * $chunkSize);
  286. $result = $query->execute();
  287. try {
  288. $toDB->beginTransaction();
  289. while ($row = $result->fetch()) {
  290. $progress->advance();
  291. if (!$parametersCreated) {
  292. foreach ($row as $key => $value) {
  293. $insertQuery->setValue($key, $insertQuery->createParameter($key));
  294. }
  295. $parametersCreated = true;
  296. }
  297. foreach ($row as $key => $value) {
  298. $type = $this->getColumnType($table, $key);
  299. if ($type !== false) {
  300. $insertQuery->setParameter($key, $value, $type);
  301. } else {
  302. $insertQuery->setParameter($key, $value);
  303. }
  304. }
  305. $insertQuery->execute();
  306. }
  307. $result->closeCursor();
  308. $toDB->commit();
  309. } catch (\Throwable $e) {
  310. $toDB->rollBack();
  311. throw $e;
  312. }
  313. }
  314. $progress->finish();
  315. $output->writeln('');
  316. }
  317. protected function getColumnType(Table $table, $columnName) {
  318. $tableName = $table->getName();
  319. if (isset($this->columnTypes[$tableName][$columnName])) {
  320. return $this->columnTypes[$tableName][$columnName];
  321. }
  322. $type = $table->getColumn($columnName)->getType()->getName();
  323. switch ($type) {
  324. case Types::BLOB:
  325. case Types::TEXT:
  326. $this->columnTypes[$tableName][$columnName] = IQueryBuilder::PARAM_LOB;
  327. break;
  328. case Types::BOOLEAN:
  329. $this->columnTypes[$tableName][$columnName] = IQueryBuilder::PARAM_BOOL;
  330. break;
  331. default:
  332. $this->columnTypes[$tableName][$columnName] = false;
  333. }
  334. return $this->columnTypes[$tableName][$columnName];
  335. }
  336. protected function convertDB(Connection $fromDB, Connection $toDB, array $tables, InputInterface $input, OutputInterface $output) {
  337. $this->config->setSystemValue('maintenance', true);
  338. $schema = $fromDB->createSchema();
  339. try {
  340. // copy table rows
  341. foreach ($tables as $table) {
  342. $output->writeln('<info> - '.$table.'</info>');
  343. $this->copyTable($fromDB, $toDB, $schema->getTable($table), $input, $output);
  344. }
  345. if ($input->getArgument('type') === 'pgsql') {
  346. $tools = new \OC\DB\PgSqlTools($this->config);
  347. $tools->resynchronizeDatabaseSequences($toDB);
  348. }
  349. // save new database config
  350. $this->saveDBInfo($input);
  351. } catch (\Exception $e) {
  352. $this->config->setSystemValue('maintenance', false);
  353. throw $e;
  354. }
  355. $this->config->setSystemValue('maintenance', false);
  356. }
  357. protected function saveDBInfo(InputInterface $input) {
  358. $type = $input->getArgument('type');
  359. $username = $input->getArgument('username');
  360. $dbHost = $input->getArgument('hostname');
  361. $dbName = $input->getArgument('database');
  362. $password = $input->getOption('password');
  363. if ($input->getOption('port')) {
  364. $dbHost .= ':'.$input->getOption('port');
  365. }
  366. $this->config->setSystemValues([
  367. 'dbtype' => $type,
  368. 'dbname' => $dbName,
  369. 'dbhost' => $dbHost,
  370. 'dbuser' => $username,
  371. 'dbpassword' => $password,
  372. ]);
  373. }
  374. /**
  375. * Return possible values for the named option
  376. *
  377. * @param string $optionName
  378. * @param CompletionContext $context
  379. * @return string[]
  380. */
  381. public function completeOptionValues($optionName, CompletionContext $context) {
  382. return [];
  383. }
  384. /**
  385. * Return possible values for the named argument
  386. *
  387. * @param string $argumentName
  388. * @param CompletionContext $context
  389. * @return string[]
  390. */
  391. public function completeArgumentValues($argumentName, CompletionContext $context) {
  392. if ($argumentName === 'type') {
  393. return ['mysql', 'oci', 'pgsql'];
  394. }
  395. return [];
  396. }
  397. }