Skip to content

Commit 2c0ddb1

Browse files
committed
fix(DB): support up to 63 character long table and index names
We do not support Oracle 11 anymore but at least Oracle 12c (12.2). So the limitation is gone (Oracle now supports up to 128 character long names). Instead we are now limited by MySQL (64 characters) and PostgreSQL (63 characters). Signed-off-by: Ferdinand Thiessen <[email protected]>
1 parent 1c7ed89 commit 2c0ddb1

File tree

3 files changed

+708
-465
lines changed

3 files changed

+708
-465
lines changed

lib/private/DB/MigrationService.php

Lines changed: 140 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88
namespace OC\DB;
99

10+
use Doctrine\DBAL\Platforms\OraclePlatform;
1011
use Doctrine\DBAL\Schema\Index;
1112
use Doctrine\DBAL\Schema\Schema;
1213
use Doctrine\DBAL\Schema\SchemaException;
@@ -17,13 +18,14 @@
1718
use OC\Migration\SimpleOutput;
1819
use OCP\App\IAppManager;
1920
use OCP\AppFramework\App;
20-
use OCP\AppFramework\QueryException;
2121
use OCP\DB\ISchemaWrapper;
2222
use OCP\DB\Types;
23+
use OCP\IConfig;
2324
use OCP\IDBConnection;
2425
use OCP\Migration\IMigrationStep;
2526
use OCP\Migration\IOutput;
2627
use OCP\Server;
28+
use Psr\Container\NotFoundExceptionInterface;
2729
use Psr\Log\LoggerInterface;
2830

2931
class MigrationService {
@@ -47,6 +49,7 @@ public function __construct(
4749
?LoggerInterface $logger = null,
4850
) {
4951
$this->appName = $appName;
52+
$this->checkOracle = false;
5053
$this->connection = $connection;
5154
if ($logger === null) {
5255
$this->logger = Server::get(LoggerInterface::class);
@@ -103,7 +106,7 @@ private function createMigrationTable(): bool {
103106
return false;
104107
}
105108

106-
if ($this->connection->tableExists('migrations') && \OC::$server->getConfig()->getAppValue('core', 'vendor', '') !== 'owncloud') {
109+
if ($this->connection->tableExists('migrations') && \OCP\Server::get(IConfig::class)->getAppValue('core', 'vendor', '') !== 'owncloud') {
107110
$this->migrationTableCreated = true;
108111
return false;
109112
}
@@ -282,7 +285,7 @@ private function shallBeExecuted($m, $knownMigrations) {
282285
/**
283286
* @param string $version
284287
*/
285-
private function markAsExecuted($version) {
288+
private function markAsExecuted($version): void {
286289
$this->connection->insertIfNotExist('*PREFIX*migrations', [
287290
'app' => $this->appName,
288291
'version' => $version
@@ -343,7 +346,7 @@ private function getRelativeVersion(string $version, int $delta): ?string {
343346

344347
$versions = $this->getAvailableVersions();
345348
array_unshift($versions, '0');
346-
/** @var int $offset */
349+
/** @var int|false $offset */
347350
$offset = array_search($version, $versions, true);
348351
if ($offset === false || !isset($versions[$offset + $delta])) {
349352
// Unknown version or delta out of bounds.
@@ -358,8 +361,7 @@ private function getCurrentVersion(): string {
358361
if (count($m) === 0) {
359362
return '0';
360363
}
361-
$migrations = array_values($m);
362-
return @end($migrations);
364+
return @end($m);
363365
}
364366

365367
/**
@@ -431,10 +433,11 @@ public function migrateSchemaOnly(string $to = 'latest'): void {
431433
if ($toSchema instanceof SchemaWrapper) {
432434
$this->output->debug('- Checking target database schema');
433435
$targetSchema = $toSchema->getWrappedSchema();
436+
$beforeSchema = $this->connection->createSchema();
434437
$this->ensureUniqueNamesConstraints($targetSchema, true);
438+
$this->ensureNamingConstraints($beforeSchema, $targetSchema, \strlen($this->connection->getPrefix()));
435439
if ($this->checkOracle) {
436-
$beforeSchema = $this->connection->createSchema();
437-
$this->ensureOracleConstraints($beforeSchema, $targetSchema, strlen($this->connection->getPrefix()));
440+
$this->ensureOracleConstraints($beforeSchema, $targetSchema);
438441
}
439442

440443
$this->output->debug('- Migrate database schema');
@@ -472,21 +475,21 @@ public function describeMigrationStep($to = 'latest') {
472475
* @throws \InvalidArgumentException
473476
*/
474477
public function createInstance($version) {
478+
/** @psalm-var class-string<IMigrationStep> $class */
475479
$class = $this->getClass($version);
476480
try {
477481
$s = \OCP\Server::get($class);
478-
479-
if (!$s instanceof IMigrationStep) {
480-
throw new \InvalidArgumentException('Not a valid migration');
481-
}
482-
} catch (QueryException $e) {
482+
} catch (NotFoundExceptionInterface) {
483483
if (class_exists($class)) {
484484
$s = new $class();
485485
} else {
486486
throw new \InvalidArgumentException("Migration step '$class' is unknown");
487487
}
488488
}
489489

490+
if (!$s instanceof IMigrationStep) {
491+
throw new \InvalidArgumentException('Not a valid migration');
492+
}
490493
return $s;
491494
}
492495

@@ -497,7 +500,7 @@ public function createInstance($version) {
497500
* @param bool $schemaOnly
498501
* @throws \InvalidArgumentException
499502
*/
500-
public function executeStep($version, $schemaOnly = false) {
503+
public function executeStep($version, $schemaOnly = false): void {
501504
$instance = $this->createInstance($version);
502505

503506
if (!$schemaOnly) {
@@ -512,10 +515,11 @@ public function executeStep($version, $schemaOnly = false) {
512515

513516
if ($toSchema instanceof SchemaWrapper) {
514517
$targetSchema = $toSchema->getWrappedSchema();
518+
$sourceSchema = $this->connection->createSchema();
515519
$this->ensureUniqueNamesConstraints($targetSchema, $schemaOnly);
520+
$this->ensureNamingConstraints($sourceSchema, $targetSchema, \strlen($this->connection->getPrefix()));
516521
if ($this->checkOracle) {
517-
$sourceSchema = $this->connection->createSchema();
518-
$this->ensureOracleConstraints($sourceSchema, $targetSchema, strlen($this->connection->getPrefix()));
522+
$this->ensureOracleConstraints($sourceSchema, $targetSchema);
519523
}
520524
$this->connection->migrateToSchema($targetSchema);
521525
$toSchema->performDropTableCalls();
@@ -531,12 +535,108 @@ public function executeStep($version, $schemaOnly = false) {
531535
}
532536

533537
/**
538+
* Enforces some naming conventions to make sure tables can be used on all supported database engines.
539+
*
534540
* Naming constraints:
535-
* - Tables names must be 30 chars or shorter (27 + oc_ prefix)
536-
* - Column names must be 30 chars or shorter
537-
* - Index names must be 30 chars or shorter
538-
* - Sequence names must be 30 chars or shorter
539-
* - Primary key names must be set or the table name 23 chars or shorter
541+
* - Tables names must be 63 chars or shorter (including its prefix (default 'oc_'))
542+
* - Column names must be 63 chars or shorter
543+
* - Index names must be 63 chars or shorter
544+
* - Sequence names must be 63 chars or shorter
545+
* - Primary key names must be set to 63 chars or shorts - or the table name must be <= 56 characters (63 - 5 for '_pKey' suffix) including the tablename prefix
546+
*
547+
* This is based on the identifier limits set by our supported database engines:
548+
* - MySQL and MariaDB support 64 characters
549+
* - Oracle supports 128 characters (since 12.2 (12c) before it was 30)
550+
* - PostgreSQL support 63
551+
* - SQLite does not have any limits
552+
*
553+
* @see https://github.com/nextcloud/documentation/blob/master/developer_manual/basics/storage/database.rst
554+
*
555+
* @throws \Doctrine\DBAL\Exception
556+
*/
557+
public function ensureNamingConstraints(Schema $sourceSchema, Schema $targetSchema, int $prefixLength): void {
558+
$MAX_NAME_LENGTH = 63;
559+
$sequences = $targetSchema->getSequences();
560+
561+
foreach ($targetSchema->getTables() as $table) {
562+
try {
563+
$sourceTable = $sourceSchema->getTable($table->getName());
564+
} catch (SchemaException $e) {
565+
// we only validate new tables
566+
if (\strlen($table->getName()) + $prefixLength > $MAX_NAME_LENGTH) {
567+
throw new \InvalidArgumentException('Table name "' . $table->getName() . '" exceeds the maximum length of ' . $MAX_NAME_LENGTH);
568+
}
569+
$sourceTable = null;
570+
}
571+
572+
foreach ($table->getColumns() as $thing) {
573+
// If the table doesn't exist OR if the column doesn't exist in the table
574+
if ((!$sourceTable instanceof Table || !$sourceTable->hasColumn($thing->getName()))
575+
&& \strlen($thing->getName()) > $MAX_NAME_LENGTH
576+
) {
577+
throw new \InvalidArgumentException('Column name "' . $table->getName() . '"."' . $thing->getName() . '" exceeds the maximum length of ' . $MAX_NAME_LENGTH);
578+
}
579+
}
580+
581+
foreach ($table->getIndexes() as $thing) {
582+
if ((!$sourceTable instanceof Table || !$sourceTable->hasIndex($thing->getName()))
583+
&& \strlen($thing->getName()) > $MAX_NAME_LENGTH
584+
) {
585+
throw new \InvalidArgumentException('Index name "' . $table->getName() . '"."' . $thing->getName() . '" exceeds the maximum length of ' . $MAX_NAME_LENGTH);
586+
}
587+
}
588+
589+
foreach ($table->getForeignKeys() as $thing) {
590+
if ((!$sourceTable instanceof Table || !$sourceTable->hasForeignKey($thing->getName()))
591+
&& \strlen($thing->getName()) > $MAX_NAME_LENGTH
592+
) {
593+
throw new \InvalidArgumentException('Foreign key name "' . $table->getName() . '"."' . $thing->getName() . '" exceeds the maximum length of ' . $MAX_NAME_LENGTH);
594+
}
595+
}
596+
597+
$primaryKey = $table->getPrimaryKey();
598+
// only check if there is a primary key
599+
// and there was non in the old table or there was no old table
600+
if ($primaryKey !== null && ($sourceTable === null || $sourceTable->getPrimaryKey() === null)) {
601+
$indexName = strtolower($primaryKey->getName());
602+
$isUsingDefaultName = $indexName === 'primary';
603+
// This is the default name when using postgres - we use this for length comparison
604+
// as this is the longest default names for the DB engines provided by doctrine
605+
$defaultName = strtolower($table->getName() . '_pkey');
606+
607+
if ($this->connection->getDatabaseProvider() === IDBConnection::PLATFORM_POSTGRES) {
608+
$isUsingDefaultName = $defaultName === $indexName;
609+
610+
if ($isUsingDefaultName) {
611+
$sequenceName = $table->getName() . '_' . implode('_', $primaryKey->getColumns()) . '_seq';
612+
$sequences = array_filter($sequences, function (Sequence $sequence) use ($sequenceName) {
613+
return $sequence->getName() !== $sequenceName;
614+
});
615+
}
616+
} elseif ($this->connection->getDatabaseProvider() === IDBConnection::PLATFORM_ORACLE) {
617+
$isUsingDefaultName = strtolower($table->getName() . '_seq') === $indexName;
618+
}
619+
620+
if (!$isUsingDefaultName && \strlen($indexName) > $MAX_NAME_LENGTH) {
621+
throw new \InvalidArgumentException('Primary index name on "' . $table->getName() . '" exceeds the maximum length of ' . $MAX_NAME_LENGTH);
622+
}
623+
if ($isUsingDefaultName && \strlen($defaultName) + $prefixLength > $MAX_NAME_LENGTH) {
624+
throw new \InvalidArgumentException('Primary index name on "' . $table->getName() . '" exceeds the maximum length of ' . $MAX_NAME_LENGTH);
625+
}
626+
}
627+
}
628+
629+
foreach ($sequences as $sequence) {
630+
if (!$sourceSchema->hasSequence($sequence->getName())
631+
&& \strlen($sequence->getName()) > $MAX_NAME_LENGTH
632+
) {
633+
throw new \InvalidArgumentException('Sequence name "' . $sequence->getName() . '" exceeds the maximum length of ' . $MAX_NAME_LENGTH);
634+
}
635+
}
636+
}
637+
638+
/**
639+
* Enforces some data conventions to make sure tables can be used on Oracle SQL.
540640
*
541641
* Data constraints:
542642
* - Tables need a primary key (Not specific to Oracle, but required for performant clustering support)
@@ -546,66 +646,47 @@ public function executeStep($version, $schemaOnly = false) {
546646
* - Columns with type "string" can not be longer than 4.000 characters, use "text" instead
547647
*
548648
* @see https://github.com/nextcloud/documentation/blob/master/developer_manual/basics/storage/database.rst
549-
*
550-
* @param Schema $sourceSchema
551-
* @param Schema $targetSchema
552-
* @param int $prefixLength
553649
* @throws \Doctrine\DBAL\Exception
554650
*/
555-
public function ensureOracleConstraints(Schema $sourceSchema, Schema $targetSchema, int $prefixLength) {
651+
public function ensureOracleConstraints(Schema $sourceSchema, Schema $targetSchema): void {
556652
$sequences = $targetSchema->getSequences();
557653

558654
foreach ($targetSchema->getTables() as $table) {
559655
try {
560656
$sourceTable = $sourceSchema->getTable($table->getName());
561657
} catch (SchemaException $e) {
562-
if (\strlen($table->getName()) - $prefixLength > 27) {
563-
throw new \InvalidArgumentException('Table name "' . $table->getName() . '" is too long.');
564-
}
565658
$sourceTable = null;
566659
}
567660

568-
foreach ($table->getColumns() as $thing) {
661+
foreach ($table->getColumns() as $column) {
569662
// If the table doesn't exist OR if the column doesn't exist in the table
570-
if (!$sourceTable instanceof Table || !$sourceTable->hasColumn($thing->getName())) {
571-
if (\strlen($thing->getName()) > 30) {
572-
throw new \InvalidArgumentException('Column name "' . $table->getName() . '"."' . $thing->getName() . '" is too long.');
573-
}
574-
575-
if ($thing->getNotnull() && $thing->getDefault() === ''
576-
&& $sourceTable instanceof Table && !$sourceTable->hasColumn($thing->getName())) {
577-
throw new \InvalidArgumentException('Column "' . $table->getName() . '"."' . $thing->getName() . '" is NotNull, but has empty string or null as default.');
663+
if (!$sourceTable instanceof Table || !$sourceTable->hasColumn($column->getName())) {
664+
if ($column->getNotnull() && $column->getDefault() === ''
665+
&& $sourceTable instanceof Table && !$sourceTable->hasColumn($column->getName())) {
666+
// null and empty string are the same on Oracle SQL
667+
throw new \InvalidArgumentException('Column "' . $table->getName() . '"."' . $column->getName() . '" is NotNull, but has empty string or null as default.');
578668
}
579669

580-
if ($this->connection->getDatabaseProvider() === IDBConnection::PLATFORM_ORACLE) {
670+
if ($this->connection->getDatabaseProvider() === IDBConnection::PLATFORM_ORACLE
671+
&& $column->getNotnull()
672+
&& Type::lookupName($column->getType()) === Types::BOOLEAN
673+
) {
581674
// Oracle doesn't support boolean column with non-null value
582-
if ($thing->getNotnull() && Type::lookupName($thing->getType()) === Types::BOOLEAN) {
583-
$thing->setNotnull(false);
584-
}
675+
// to still allow lighter DB schemas on other providers we force it to not null
676+
// see https://github.com/nextcloud/server/pull/55156
677+
$column->setNotnull(false);
585678
}
586679

587680
$sourceColumn = null;
588681
} else {
589-
$sourceColumn = $sourceTable->getColumn($thing->getName());
682+
$sourceColumn = $sourceTable->getColumn($column->getName());
590683
}
591684

592685
// If the column was just created OR the length changed OR the type changed
593686
// we will NOT detect invalid length if the column is not modified
594-
if (($sourceColumn === null || $sourceColumn->getLength() !== $thing->getLength() || Type::lookupName($sourceColumn->getType()) !== Types::STRING)
595-
&& $thing->getLength() > 4000 && Type::lookupName($thing->getType()) === Types::STRING) {
596-
throw new \InvalidArgumentException('Column "' . $table->getName() . '"."' . $thing->getName() . '" is type String, but exceeding the 4.000 length limit.');
597-
}
598-
}
599-
600-
foreach ($table->getIndexes() as $thing) {
601-
if ((!$sourceTable instanceof Table || !$sourceTable->hasIndex($thing->getName())) && \strlen($thing->getName()) > 30) {
602-
throw new \InvalidArgumentException('Index name "' . $table->getName() . '"."' . $thing->getName() . '" is too long.');
603-
}
604-
}
605-
606-
foreach ($table->getForeignKeys() as $thing) {
607-
if ((!$sourceTable instanceof Table || !$sourceTable->hasForeignKey($thing->getName())) && \strlen($thing->getName()) > 30) {
608-
throw new \InvalidArgumentException('Foreign key name "' . $table->getName() . '"."' . $thing->getName() . '" is too long.');
687+
if (($sourceColumn === null || $sourceColumn->getLength() !== $column->getLength() || Type::lookupName($sourceColumn->getType()) !== Types::STRING)
688+
&& $column->getLength() > 4000 && Type::lookupName($column->getType()) === Types::STRING) {
689+
throw new \InvalidArgumentException('Column "' . $table->getName() . '"."' . $column->getName() . '" is type String, but exceeding the 4.000 length limit.');
609690
}
610691
}
611692

@@ -628,26 +709,13 @@ public function ensureOracleConstraints(Schema $sourceSchema, Schema $targetSche
628709
$defaultName = $table->getName() . '_seq';
629710
$isUsingDefaultName = strtolower($defaultName) === $indexName;
630711
}
631-
632-
if (!$isUsingDefaultName && \strlen($indexName) > 30) {
633-
throw new \InvalidArgumentException('Primary index name on "' . $table->getName() . '" is too long.');
634-
}
635-
if ($isUsingDefaultName && \strlen($table->getName()) - $prefixLength >= 23) {
636-
throw new \InvalidArgumentException('Primary index name on "' . $table->getName() . '" is too long.');
637-
}
638712
} elseif (!$primaryKey instanceof Index && !$sourceTable instanceof Table) {
639713
/** @var LoggerInterface $logger */
640-
$logger = \OC::$server->get(LoggerInterface::class);
714+
$logger = \OCP\Server::get(LoggerInterface::class);
641715
$logger->error('Table "' . $table->getName() . '" has no primary key and therefor will not behave sane in clustered setups. This will throw an exception and not be installable in a future version of Nextcloud.');
642716
// throw new \InvalidArgumentException('Table "' . $table->getName() . '" has no primary key and therefor will not behave sane in clustered setups.');
643717
}
644718
}
645-
646-
foreach ($sequences as $sequence) {
647-
if (!$sourceSchema->hasSequence($sequence->getName()) && \strlen($sequence->getName()) > 30) {
648-
throw new \InvalidArgumentException('Sequence name "' . $sequence->getName() . '" is too long.');
649-
}
650-
}
651719
}
652720

653721
/**

0 commit comments

Comments
 (0)