Skip to content

Commit 5f7b33f

Browse files
authored
Merge pull request #46194 from nextcloud/schema-export-cmnd
feat: add commands for exporting current and expected database schema
2 parents 8469904 + 4f01486 commit 5f7b33f

8 files changed

Lines changed: 242 additions & 3 deletions

File tree

core/Command/Db/ExpectedSchema.php

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
/**
5+
* SPDX-FileCopyrightText: 2024 Robin Appelman <robin@icewind.nl>
6+
* SPDX-License-Identifier: AGPL-3.0-or-later
7+
*/
8+
9+
namespace OC\Core\Command\Db;
10+
11+
use Doctrine\DBAL\Schema\Schema;
12+
use OC\Core\Command\Base;
13+
use OC\DB\Connection;
14+
use OC\DB\MigrationService;
15+
use OC\DB\SchemaWrapper;
16+
use OC\Migration\NullOutput;
17+
use Symfony\Component\Console\Input\InputInterface;
18+
use Symfony\Component\Console\Input\InputOption;
19+
use Symfony\Component\Console\Output\OutputInterface;
20+
21+
class ExpectedSchema extends Base {
22+
public function __construct(
23+
protected Connection $connection,
24+
) {
25+
parent::__construct();
26+
}
27+
28+
protected function configure(): void {
29+
$this
30+
->setName('db:schema:expected')
31+
->setDescription('Export the expected database schema for a fresh installation')
32+
->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.")
33+
->addOption('sql', null, InputOption::VALUE_NONE, 'Dump the SQL statements for creating the expected schema');
34+
parent::configure();
35+
}
36+
37+
protected function execute(InputInterface $input, OutputInterface $output): int {
38+
$schema = new Schema();
39+
40+
$this->applyMigrations('core', $schema);
41+
42+
$apps = \OC_App::getEnabledApps();
43+
foreach ($apps as $app) {
44+
$this->applyMigrations($app, $schema);
45+
}
46+
47+
$sql = $input->getOption('sql');
48+
if ($sql) {
49+
$output->writeln($schema->toSql($this->connection->getDatabasePlatform()));
50+
} else {
51+
$encoder = new SchemaEncoder();
52+
$this->writeArrayInOutputFormat($input, $output, $encoder->encodeSchema($schema, $this->connection->getDatabasePlatform()));
53+
}
54+
55+
return 0;
56+
}
57+
58+
private function applyMigrations(string $app, Schema $schema): void {
59+
$output = new NullOutput();
60+
$ms = new MigrationService($app, $this->connection, $output);
61+
foreach ($ms->getAvailableVersions() as $version) {
62+
$migration = $ms->createInstance($version);
63+
$migration->changeSchema($output, function () use (&$schema) {
64+
return new SchemaWrapper($this->connection, $schema);
65+
}, []);
66+
}
67+
}
68+
}

core/Command/Db/ExportSchema.php

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
/**
5+
* SPDX-FileCopyrightText: 2024 Robin Appelman <robin@icewind.nl>
6+
* SPDX-License-Identifier: AGPL-3.0-or-later
7+
*/
8+
9+
namespace OC\Core\Command\Db;
10+
11+
use OC\Core\Command\Base;
12+
use OCP\IDBConnection;
13+
use Symfony\Component\Console\Input\InputInterface;
14+
use Symfony\Component\Console\Input\InputOption;
15+
use Symfony\Component\Console\Output\OutputInterface;
16+
17+
class ExportSchema extends Base {
18+
public function __construct(
19+
protected IDBConnection $connection,
20+
) {
21+
parent::__construct();
22+
}
23+
24+
protected function configure(): void {
25+
$this
26+
->setName('db:schema:export')
27+
->setDescription('Export the current database schema')
28+
->addOption('sql', null, InputOption::VALUE_NONE, 'Dump the SQL statements for creating a copy of the schema');
29+
parent::configure();
30+
}
31+
32+
protected function execute(InputInterface $input, OutputInterface $output): int {
33+
$schema = $this->connection->createSchema();
34+
$sql = $input->getOption('sql');
35+
if ($sql) {
36+
$output->writeln($schema->toSql($this->connection->getDatabasePlatform()));
37+
} else {
38+
$encoder = new SchemaEncoder();
39+
$this->writeArrayInOutputFormat($input, $output, $encoder->encodeSchema($schema, $this->connection->getDatabasePlatform()));
40+
}
41+
42+
return 0;
43+
}
44+
}

core/Command/Db/SchemaEncoder.php

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
/**
5+
* SPDX-FileCopyrightText: 2024 Robin Appelman <robin@icewind.nl>
6+
* SPDX-License-Identifier: AGPL-3.0-or-later
7+
*/
8+
9+
namespace OC\Core\Command\Db;
10+
11+
use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
12+
use Doctrine\DBAL\Platforms\AbstractPlatform;
13+
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
14+
use Doctrine\DBAL\Schema\Schema;
15+
use Doctrine\DBAL\Schema\Table;
16+
use Doctrine\DBAL\Types\PhpIntegerMappingType;
17+
use Doctrine\DBAL\Types\Type;
18+
19+
class SchemaEncoder {
20+
/**
21+
* Encode a DBAL schema to json, performing some normalization based on the database platform
22+
*
23+
* @param Schema $schema
24+
* @param AbstractPlatform $platform
25+
* @return array
26+
*/
27+
public function encodeSchema(Schema $schema, AbstractPlatform $platform): array {
28+
$encoded = ['tables' => [], 'sequences' => []];
29+
foreach ($schema->getTables() as $table) {
30+
$encoded[$table->getName()] = $this->encodeTable($table, $platform);
31+
}
32+
ksort($encoded);
33+
return $encoded;
34+
}
35+
36+
/**
37+
* @psalm-type ColumnArrayType =
38+
*/
39+
private function encodeTable(Table $table, AbstractPlatform $platform): array {
40+
$encoded = ['columns' => [], 'indexes' => []];
41+
foreach ($table->getColumns() as $column) {
42+
/**
43+
* @var array{
44+
* name: string,
45+
* default: mixed,
46+
* notnull: bool,
47+
* length: ?int,
48+
* precision: int,
49+
* scale: int,
50+
* unsigned: bool,
51+
* fixed: bool,
52+
* autoincrement: bool,
53+
* comment: string,
54+
* columnDefinition: ?string,
55+
* collation?: string,
56+
* charset?: string,
57+
* jsonb?: bool,
58+
* } $data
59+
**/
60+
$data = $column->toArray();
61+
$data['type'] = Type::getTypeRegistry()->lookupName($column->getType());
62+
$data['default'] = $column->getType()->convertToPHPValue($column->getDefault(), $platform);
63+
if ($platform instanceof PostgreSQLPlatform) {
64+
$data['unsigned'] = false;
65+
if ($column->getType() instanceof PhpIntegerMappingType) {
66+
$data['length'] = null;
67+
}
68+
unset($data['jsonb']);
69+
} elseif ($platform instanceof AbstractMySqlPlatform) {
70+
if ($column->getType() instanceof PhpIntegerMappingType) {
71+
$data['length'] = null;
72+
} elseif (in_array($data['type'], ['text', 'blob', 'datetime', 'float', 'json'])) {
73+
$data['length'] = 0;
74+
}
75+
unset($data['collation']);
76+
unset($data['charset']);
77+
}
78+
if ($data['type'] === 'string' && $data['length'] === null) {
79+
$data['length'] = 255;
80+
}
81+
$encoded['columns'][$column->getName()] = $data;
82+
}
83+
ksort($encoded['columns']);
84+
foreach ($table->getIndexes() as $index) {
85+
$options = $index->getOptions();
86+
if (isset($options['lengths']) && count(array_filter($options['lengths'])) === 0) {
87+
unset($options['lengths']);
88+
}
89+
if ($index->isPrimary()) {
90+
if ($platform instanceof PostgreSqlPlatform) {
91+
$name = $table->getName() . '_pkey';
92+
} elseif ($platform instanceof AbstractMySQLPlatform) {
93+
$name = "PRIMARY";
94+
} else {
95+
$name = $index->getName();
96+
}
97+
} else {
98+
$name = $index->getName();
99+
}
100+
if ($platform instanceof PostgreSqlPlatform) {
101+
$name = strtolower($name);
102+
}
103+
$encoded['indexes'][$name] = [
104+
'name' => $name,
105+
'columns' => $index->getColumns(),
106+
'unique' => $index->isUnique(),
107+
'primary' => $index->isPrimary(),
108+
'flags' => $index->getFlags(),
109+
'options' => $options,
110+
];
111+
}
112+
ksort($encoded['indexes']);
113+
return $encoded;
114+
}
115+
}

core/register_command.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@
6464
$application->add(Server::get(Command\Db\AddMissingColumns::class));
6565
$application->add(Server::get(Command\Db\AddMissingIndices::class));
6666
$application->add(Server::get(Command\Db\AddMissingPrimaryKeys::class));
67+
$application->add(Server::get(Command\Db\ExpectedSchema::class));
68+
$application->add(Server::get(Command\Db\ExportSchema::class));
6769

6870
if ($config->getSystemValueBool('debug', false)) {
6971
$application->add(Server::get(Command\Db\Migrations\StatusCommand::class));

lib/composer/composer/autoload_classmap.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1106,10 +1106,13 @@
11061106
'OC\\Core\\Command\\Db\\ConvertFilecacheBigInt' => $baseDir . '/core/Command/Db/ConvertFilecacheBigInt.php',
11071107
'OC\\Core\\Command\\Db\\ConvertMysqlToMB4' => $baseDir . '/core/Command/Db/ConvertMysqlToMB4.php',
11081108
'OC\\Core\\Command\\Db\\ConvertType' => $baseDir . '/core/Command/Db/ConvertType.php',
1109+
'OC\\Core\\Command\\Db\\ExpectedSchema' => $baseDir . '/core/Command/Db/ExpectedSchema.php',
1110+
'OC\\Core\\Command\\Db\\ExportSchema' => $baseDir . '/core/Command/Db/ExportSchema.php',
11091111
'OC\\Core\\Command\\Db\\Migrations\\ExecuteCommand' => $baseDir . '/core/Command/Db/Migrations/ExecuteCommand.php',
11101112
'OC\\Core\\Command\\Db\\Migrations\\GenerateCommand' => $baseDir . '/core/Command/Db/Migrations/GenerateCommand.php',
11111113
'OC\\Core\\Command\\Db\\Migrations\\MigrateCommand' => $baseDir . '/core/Command/Db/Migrations/MigrateCommand.php',
11121114
'OC\\Core\\Command\\Db\\Migrations\\StatusCommand' => $baseDir . '/core/Command/Db/Migrations/StatusCommand.php',
1115+
'OC\\Core\\Command\\Db\\SchemaEncoder' => $baseDir . '/core/Command/Db/SchemaEncoder.php',
11131116
'OC\\Core\\Command\\Encryption\\ChangeKeyStorageRoot' => $baseDir . '/core/Command/Encryption/ChangeKeyStorageRoot.php',
11141117
'OC\\Core\\Command\\Encryption\\DecryptAll' => $baseDir . '/core/Command/Encryption/DecryptAll.php',
11151118
'OC\\Core\\Command\\Encryption\\Disable' => $baseDir . '/core/Command/Encryption/Disable.php',

lib/composer/composer/autoload_static.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1139,10 +1139,13 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
11391139
'OC\\Core\\Command\\Db\\ConvertFilecacheBigInt' => __DIR__ . '/../../..' . '/core/Command/Db/ConvertFilecacheBigInt.php',
11401140
'OC\\Core\\Command\\Db\\ConvertMysqlToMB4' => __DIR__ . '/../../..' . '/core/Command/Db/ConvertMysqlToMB4.php',
11411141
'OC\\Core\\Command\\Db\\ConvertType' => __DIR__ . '/../../..' . '/core/Command/Db/ConvertType.php',
1142+
'OC\\Core\\Command\\Db\\ExpectedSchema' => __DIR__ . '/../../..' . '/core/Command/Db/ExpectedSchema.php',
1143+
'OC\\Core\\Command\\Db\\ExportSchema' => __DIR__ . '/../../..' . '/core/Command/Db/ExportSchema.php',
11421144
'OC\\Core\\Command\\Db\\Migrations\\ExecuteCommand' => __DIR__ . '/../../..' . '/core/Command/Db/Migrations/ExecuteCommand.php',
11431145
'OC\\Core\\Command\\Db\\Migrations\\GenerateCommand' => __DIR__ . '/../../..' . '/core/Command/Db/Migrations/GenerateCommand.php',
11441146
'OC\\Core\\Command\\Db\\Migrations\\MigrateCommand' => __DIR__ . '/../../..' . '/core/Command/Db/Migrations/MigrateCommand.php',
11451147
'OC\\Core\\Command\\Db\\Migrations\\StatusCommand' => __DIR__ . '/../../..' . '/core/Command/Db/Migrations/StatusCommand.php',
1148+
'OC\\Core\\Command\\Db\\SchemaEncoder' => __DIR__ . '/../../..' . '/core/Command/Db/SchemaEncoder.php',
11461149
'OC\\Core\\Command\\Encryption\\ChangeKeyStorageRoot' => __DIR__ . '/../../..' . '/core/Command/Encryption/ChangeKeyStorageRoot.php',
11471150
'OC\\Core\\Command\\Encryption\\DecryptAll' => __DIR__ . '/../../..' . '/core/Command/Encryption/DecryptAll.php',
11481151
'OC\\Core\\Command\\Encryption\\Disable' => __DIR__ . '/../../..' . '/core/Command/Encryption/Disable.php',

lib/private/DB/MigrationService.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -459,7 +459,7 @@ public function describeMigrationStep($to = 'latest') {
459459
* @return IMigrationStep
460460
* @throws \InvalidArgumentException
461461
*/
462-
protected function createInstance($version) {
462+
public function createInstance($version) {
463463
$class = $this->getClass($version);
464464
try {
465465
$s = \OCP\Server::get($class);

lib/private/DB/SchemaWrapper.php

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,13 @@ class SchemaWrapper implements ISchemaWrapper {
2020
/** @var array */
2121
protected $tablesToDelete = [];
2222

23-
public function __construct(Connection $connection) {
23+
public function __construct(Connection $connection, ?Schema $schema = null) {
2424
$this->connection = $connection;
25-
$this->schema = $this->connection->createSchema();
25+
if ($schema) {
26+
$this->schema = $schema;
27+
} else {
28+
$this->schema = $this->connection->createSchema();
29+
}
2630
}
2731

2832
public function getWrappedSchema() {

0 commit comments

Comments
 (0)