diff --git a/.github/workflows/appstore-build-publish.yml b/.github/workflows/appstore-build-publish.yml index 903205380d..d30033652f 100644 --- a/.github/workflows/appstore-build-publish.yml +++ b/.github/workflows/appstore-build-publish.yml @@ -87,7 +87,7 @@ jobs: filename: ${{ env.APP_NAME }}/appinfo/info.xml - name: Set up php ${{ steps.php-versions.outputs.php-min }} - uses: shivammathur/setup-php@ccf2c627fe61b1b4d924adfcbd19d661a18133a0 # v2.35.2 + uses: shivammathur/setup-php@20529878ed81ef8e78ddf08b480401e6101a850f # v2.35.3 with: php-version: ${{ steps.php-versions.outputs.php-min }} coverage: none diff --git a/.github/workflows/lint-php-cs.yml b/.github/workflows/lint-php-cs.yml index 058c742290..16e72e4dd0 100644 --- a/.github/workflows/lint-php-cs.yml +++ b/.github/workflows/lint-php-cs.yml @@ -34,7 +34,7 @@ jobs: uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1 - name: Set up php${{ steps.versions.outputs.php-min }} - uses: shivammathur/setup-php@ccf2c627fe61b1b4d924adfcbd19d661a18133a0 # v2.35.2 + uses: shivammathur/setup-php@20529878ed81ef8e78ddf08b480401e6101a850f # v2.35.3 with: php-version: ${{ steps.versions.outputs.php-min }} extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, sqlite, pdo_sqlite diff --git a/.github/workflows/lint-php.yml b/.github/workflows/lint-php.yml index 6ddc133c55..268740e2c4 100644 --- a/.github/workflows/lint-php.yml +++ b/.github/workflows/lint-php.yml @@ -48,7 +48,7 @@ jobs: persist-credentials: false - name: Set up php ${{ matrix.php-versions }} - uses: shivammathur/setup-php@ccf2c627fe61b1b4d924adfcbd19d661a18133a0 # v2.35.2 + uses: shivammathur/setup-php@20529878ed81ef8e78ddf08b480401e6101a850f # v2.35.3 with: php-version: ${{ matrix.php-versions }} extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, sqlite, pdo_sqlite diff --git a/.github/workflows/phpunit-mariadb.yml b/.github/workflows/phpunit-mariadb.yml index e04de74e88..bae575c1f0 100644 --- a/.github/workflows/phpunit-mariadb.yml +++ b/.github/workflows/phpunit-mariadb.yml @@ -105,7 +105,7 @@ jobs: path: apps/${{ env.APP_NAME }} - name: Set up php ${{ matrix.php-versions }} - uses: shivammathur/setup-php@ccf2c627fe61b1b4d924adfcbd19d661a18133a0 # v2.35.2 + uses: shivammathur/setup-php@20529878ed81ef8e78ddf08b480401e6101a850f # v2.35.3 with: php-version: ${{ matrix.php-versions }} # https://docs.nextcloud.com/server/stable/admin_manual/installation/source_installation.html#prerequisites-for-manual-installation diff --git a/.github/workflows/phpunit-mysql.yml b/.github/workflows/phpunit-mysql.yml index 2c8a6538c7..a06f1b2a30 100644 --- a/.github/workflows/phpunit-mysql.yml +++ b/.github/workflows/phpunit-mysql.yml @@ -103,7 +103,7 @@ jobs: path: apps/${{ env.APP_NAME }} - name: Set up php ${{ matrix.php-versions }} - uses: shivammathur/setup-php@ccf2c627fe61b1b4d924adfcbd19d661a18133a0 # v2.35.2 + uses: shivammathur/setup-php@20529878ed81ef8e78ddf08b480401e6101a850f # v2.35.3 with: php-version: ${{ matrix.php-versions }} # https://docs.nextcloud.com/server/stable/admin_manual/installation/source_installation.html#prerequisites-for-manual-installation diff --git a/.github/workflows/phpunit-pgsql.yml b/.github/workflows/phpunit-pgsql.yml index d5f491f0b9..2947edf46c 100644 --- a/.github/workflows/phpunit-pgsql.yml +++ b/.github/workflows/phpunit-pgsql.yml @@ -106,7 +106,7 @@ jobs: path: apps/${{ env.APP_NAME }} - name: Set up php ${{ matrix.php-versions }} - uses: shivammathur/setup-php@ccf2c627fe61b1b4d924adfcbd19d661a18133a0 # v2.35.2 + uses: shivammathur/setup-php@20529878ed81ef8e78ddf08b480401e6101a850f # v2.35.3 with: php-version: ${{ matrix.php-versions }} # https://docs.nextcloud.com/server/stable/admin_manual/installation/source_installation.html#prerequisites-for-manual-installation diff --git a/.github/workflows/phpunit-sqlite.yml b/.github/workflows/phpunit-sqlite.yml index 2889e9ae35..6998d43ee6 100644 --- a/.github/workflows/phpunit-sqlite.yml +++ b/.github/workflows/phpunit-sqlite.yml @@ -95,7 +95,7 @@ jobs: path: apps/${{ env.APP_NAME }} - name: Set up php ${{ matrix.php-versions }} - uses: shivammathur/setup-php@ccf2c627fe61b1b4d924adfcbd19d661a18133a0 # v2.35.2 + uses: shivammathur/setup-php@20529878ed81ef8e78ddf08b480401e6101a850f # v2.35.3 with: php-version: ${{ matrix.php-versions }} # https://docs.nextcloud.com/server/stable/admin_manual/installation/source_installation.html#prerequisites-for-manual-installation diff --git a/.github/workflows/psalm-matrix.yml b/.github/workflows/psalm-matrix.yml index 0bcc6ae2dc..1451544319 100644 --- a/.github/workflows/psalm-matrix.yml +++ b/.github/workflows/psalm-matrix.yml @@ -48,7 +48,7 @@ jobs: persist-credentials: false - name: Set up php${{ matrix.php-min }} - uses: shivammathur/setup-php@ccf2c627fe61b1b4d924adfcbd19d661a18133a0 # v2.35.2 + uses: shivammathur/setup-php@20529878ed81ef8e78ddf08b480401e6101a850f # v2.35.3 with: php-version: ${{ matrix.php-min }} extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, sqlite, pdo_sqlite diff --git a/appinfo/info.xml b/appinfo/info.xml index a245fa1305..60cec9b4b1 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -4,7 +4,7 @@ Polls A polls app, similar to Doodle/DuD-Poll with the possibility to restrict access. A polls app, similar to Doodle/DuD-Poll with the possibility to restrict access (members, certain groups/users, hidden and public). - 8.3.0-beta.1 + 8.3.0-beta.3 agpl Vinzenz Rosenkranz René Gieling @@ -31,25 +31,11 @@ OCA\Polls\Cron\JanitorCron OCA\Polls\Cron\AutoReminderCron - - - OCA\Polls\Migration\RepairSteps\RemoveObsoleteMigrations - - - OCA\Polls\Migration\RepairSteps\DropOrphanedTables - OCA\Polls\Migration\RepairSteps\DropOrphanedColumns - OCA\Polls\Migration\RepairSteps\DeleteInvalidRecords - OCA\Polls\Migration\RepairSteps\UpdateHashes - OCA\Polls\Migration\RepairSteps\CreateIndices - - - OCA\Polls\Migration\RepairSteps\Install - - OCA\Polls\Command\Db\CleanMigrations OCA\Polls\Command\Db\CreateIndices OCA\Polls\Command\Db\Purge + OCA\Polls\Command\Db\FixDB OCA\Polls\Command\Db\Rebuild OCA\Polls\Command\Db\RemoveFKConstraints OCA\Polls\Command\Db\RemoveOptionalIndices diff --git a/l10n/es.js b/l10n/es.js index c49695955c..1febb29d2e 100644 --- a/l10n/es.js +++ b/l10n/es.js @@ -263,6 +263,7 @@ OC.L10N.register( "minus" : "menos", "plus" : "mas", "Please wait…" : "Por favor, espere…", + "Possibly affected calendar events" : "Eventos de calendario posiblemente afectados", "Add" : "Añadir", "You are asked to propose more options." : "Se le pide que proponga más opciones.", "The proposal period ends {timeRelative}." : "El período para las propuestas termina en {timeRelative}.", diff --git a/l10n/es.json b/l10n/es.json index ce6cf12c31..39fc1b7307 100644 --- a/l10n/es.json +++ b/l10n/es.json @@ -261,6 +261,7 @@ "minus" : "menos", "plus" : "mas", "Please wait…" : "Por favor, espere…", + "Possibly affected calendar events" : "Eventos de calendario posiblemente afectados", "Add" : "Añadir", "You are asked to propose more options." : "Se le pide que proponga más opciones.", "The proposal period ends {timeRelative}." : "El período para las propuestas termina en {timeRelative}.", diff --git a/lib/Command/Command.php b/lib/Command/Command.php index 06ca6cd277..b5d8074dd2 100644 --- a/lib/Command/Command.php +++ b/lib/Command/Command.php @@ -24,6 +24,7 @@ class Command extends \Symfony\Component\Console\Command\Command { protected string $description = ''; protected array $operationHints = []; protected bool $defaultContinueAnswer = false; + protected bool $skipQuestion = false; protected mixed $helper; protected InputInterface $input; protected OutputInterface $output; @@ -56,6 +57,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int } protected function requestConfirmation(InputInterface $input, OutputInterface $output): int { + if ($this->skipQuestion) { + return 0; + } + if ($input->isInteractive()) { /** @var QuestionHelper */ $this->helper = $this->getHelper('question'); diff --git a/lib/Command/Db/CleanMigrations.php b/lib/Command/Db/CleanMigrations.php index 7d7b66643e..ddbc9a7dd4 100644 --- a/lib/Command/Db/CleanMigrations.php +++ b/lib/Command/Db/CleanMigrations.php @@ -10,7 +10,7 @@ use Doctrine\DBAL\Schema\Schema; use OCA\Polls\Command\Command; -use OCA\Polls\Db\TableManager; +use OCA\Polls\Db\V2\TableManager; use OCP\IDBConnection; /** diff --git a/lib/Command/Db/CreateIndices.php b/lib/Command/Db/CreateIndices.php index cff915db88..602665ea69 100644 --- a/lib/Command/Db/CreateIndices.php +++ b/lib/Command/Db/CreateIndices.php @@ -10,7 +10,7 @@ use Doctrine\DBAL\Schema\Schema; use OCA\Polls\Command\Command; -use OCA\Polls\Db\IndexManager; +use OCA\Polls\Db\V2\IndexManager; use OCP\IDBConnection; /** diff --git a/lib/Command/Db/FixDB.php b/lib/Command/Db/FixDB.php new file mode 100644 index 0000000000..1ef12f9add --- /dev/null +++ b/lib/Command/Db/FixDB.php @@ -0,0 +1,57 @@ +schema = $this->connection->createSchema(); + $this->tableManager->setSchema($this->schema); + + $this->createOrUpdateSchema(); + + $this->connection->migrateToSchema($this->schema); + + return 0; + } + + /** + * Iterate over tables and make sure, the are created or updated + * according to the schema + */ + private function createOrUpdateSchema(): void { + $this->printComment(' - Set db structure'); + $messages = $this->tableManager->createTables(); + $this->printInfo($messages, ' '); + } +} diff --git a/lib/Command/Db/Purge.php b/lib/Command/Db/Purge.php index 26bf499bae..f43d1e0041 100644 --- a/lib/Command/Db/Purge.php +++ b/lib/Command/Db/Purge.php @@ -9,7 +9,7 @@ namespace OCA\Polls\Command\Db; use OCA\Polls\Command\Command; -use OCA\Polls\Db\TableManager; +use OCA\Polls\Db\V2\TableManager; use OCP\IDBConnection; /** diff --git a/lib/Command/Db/Rebuild.php b/lib/Command/Db/Rebuild.php index 623815c405..9d7f83aa3d 100644 --- a/lib/Command/Db/Rebuild.php +++ b/lib/Command/Db/Rebuild.php @@ -9,8 +9,8 @@ namespace OCA\Polls\Command\Db; use Doctrine\DBAL\Schema\Schema; -use OCA\Polls\Db\TableManager; -use OCA\Polls\Db\IndexManager; +use OCA\Polls\Db\V2\TableManager; +use OCA\Polls\Db\V2\IndexManager; use OCA\Polls\Command\Command; use OCP\IDBConnection; @@ -52,32 +52,33 @@ protected function runCommands(): int { $this->deleteGenericIndices(); $this->deleteUniqueIndices(); $this->deleteNamedIndices(); - - $this->printComment('Step 2. Remove all orphaned tables and columns'); - $this->removeObsoleteTables(); - $this->removeObsoleteColumns(); - $this->connection->migrateToSchema($this->schema); + $this->printComment('Step 2. Tidy records before rebuilding the schema'); + $this->fixNullish(); + $this->cleanTables(); + $this->printComment('Step 3. Create or update tables to current shema'); $this->createOrUpdateSchema(); + $this->connection->migrateToSchema($this->schema); + $this->printComment('Step 4. Remove orphaned tables and columns'); + $this->dropObsoleteTables(); + $this->dropObsoleteColumns(); $this->connection->migrateToSchema($this->schema); - $this->printComment('Step 4. set hashes for votes and options'); + $this->printComment('Step 5. Validate and fix records'); $this->migrateOptionsToHash(); - - $this->printComment('Step 5. Remove invalid records (orphaned and duplicates)'); - $this->cleanTables(); + $this->setLastInteraction(); $this->printComment('Step 6. Recreate unique indices and foreign key constraints'); $this->addForeignKeyConstraints(); $this->addUniqueIndices(); + $this->connection->migrateToSchema($this->schema); + $this->printComment('Rebuild finished. The database structure is now up to date.'); $this->printComment('Execute \'occ db:add-missing-indices\' to add missing optional indices'); - $this->connection->migrateToSchema($this->schema); - return 0; } @@ -90,6 +91,14 @@ private function addForeignKeyConstraints(): void { $this->printInfo($messages, ' '); } + private function fixNullish(): void { + $this->printComment(' - Fix nullish values'); + $messages = $this->tableManager->fixNullishShares(); + $this->printInfo($messages, ' '); + + $messages = $this->tableManager->fixNullishPollGroupRelations(); + $this->printInfo($messages, ' '); + } /** * Create index for $table */ @@ -118,7 +127,7 @@ private function migrateOptionsToHash(): void { $this->printInfo($messages, ' '); } - private function removeObsoleteColumns(): void { + private function dropObsoleteColumns(): void { $this->printComment(' - Drop orphaned columns'); $messages = $this->tableManager->removeObsoleteColumns(); $this->printInfo($messages, ' '); @@ -127,7 +136,7 @@ private function removeObsoleteColumns(): void { /** * Remove obsolete tables if they still exist */ - private function removeObsoleteTables(): void { + private function dropObsoleteTables(): void { $this->printComment(' - Drop orphaned tables'); $messages = $this->tableManager->removeObsoleteTables(); $this->printInfo($messages, ' '); @@ -136,8 +145,8 @@ private function removeObsoleteTables(): void { /** * Initialize last poll interactions timestamps */ - public function resetLastInteraction(): void { - $messages = $this->tableManager->resetLastInteraction(); + public function setLastInteraction(): void { + $messages = $this->tableManager->setLastInteraction(); $this->printInfo($messages, ' '); } @@ -146,9 +155,9 @@ public function resetLastInteraction(): void { */ private function cleanTables(): void { $this->printComment(' - Remove orphaned records'); - $orphaned = $this->tableManager->removeOrphaned(); - foreach ($orphaned as $table => $count) { - $this->printInfo(" Removed $count orphaned records from $table"); + $messages = $this->tableManager->removeOrphaned(); + foreach ($messages as $message) { + $this->printInfo(" $message"); } $this->printComment(' - Remove duplicates'); @@ -187,7 +196,7 @@ private function deleteUniqueIndices(): void { * remove all named indices */ private function deleteNamedIndices(): void { - $this->printComment(' - Remove common indices'); + $this->printComment(' - Remove optional indices'); $messages = $this->indexManager->removeNamedIndices(); $this->printInfo($messages, ' - '); } diff --git a/lib/Command/Db/RemoveFKConstraints.php b/lib/Command/Db/RemoveFKConstraints.php index ed486c5d11..915a93eccd 100644 --- a/lib/Command/Db/RemoveFKConstraints.php +++ b/lib/Command/Db/RemoveFKConstraints.php @@ -10,7 +10,7 @@ use Doctrine\DBAL\Schema\Schema; use OCA\Polls\Command\Command; -use OCA\Polls\Db\IndexManager; +use OCA\Polls\Db\V2\IndexManager; use OCP\IDBConnection; /** diff --git a/lib/Command/Db/RemoveOptionalIndices.php b/lib/Command/Db/RemoveOptionalIndices.php index 23fcf37a01..a90b864f45 100644 --- a/lib/Command/Db/RemoveOptionalIndices.php +++ b/lib/Command/Db/RemoveOptionalIndices.php @@ -10,7 +10,7 @@ use Doctrine\DBAL\Schema\Schema; use OCA\Polls\Command\Command; -use OCA\Polls\Db\IndexManager; +use OCA\Polls\Db\V2\IndexManager; use OCP\IDBConnection; /** diff --git a/lib/Command/Db/RemoveUniqueIndices.php b/lib/Command/Db/RemoveUniqueIndices.php index d9ee937d6a..5d0b077489 100644 --- a/lib/Command/Db/RemoveUniqueIndices.php +++ b/lib/Command/Db/RemoveUniqueIndices.php @@ -10,7 +10,7 @@ use Doctrine\DBAL\Schema\Schema; use OCA\Polls\Command\Command; -use OCA\Polls\Db\IndexManager; +use OCA\Polls\Db\V2\IndexManager; use OCP\IDBConnection; /** diff --git a/lib/Command/Db/ResetWatch.php b/lib/Command/Db/ResetWatch.php index 7380b36c75..aa639b2a2e 100644 --- a/lib/Command/Db/ResetWatch.php +++ b/lib/Command/Db/ResetWatch.php @@ -10,10 +10,10 @@ use Doctrine\DBAL\Schema\Schema; use OCA\Polls\Command\Command; -use OCA\Polls\Db\IndexManager; -use OCA\Polls\Db\TableManager; +use OCA\Polls\Db\V2\IndexManager; +use OCA\Polls\Db\V2\TableManager; use OCA\Polls\Db\Watch; -use OCA\Polls\Migration\TableSchema; +use OCA\Polls\Migration\V2\TableSchema; use OCP\IDBConnection; /** diff --git a/lib/Cron/JanitorCron.php b/lib/Cron/JanitorCron.php index 471ed45a82..b32b4e8e22 100644 --- a/lib/Cron/JanitorCron.php +++ b/lib/Cron/JanitorCron.php @@ -15,9 +15,8 @@ use OCA\Polls\Db\OptionMapper; use OCA\Polls\Db\PollMapper; use OCA\Polls\Db\ShareMapper; -use OCA\Polls\Db\TableManager; +use OCA\Polls\Db\V2\TableManager; use OCA\Polls\Db\VoteMapper; -use OCA\Polls\Db\WatchMapper; use OCA\Polls\Helper\Container; use OCA\Polls\Model\Settings\AppSettings; use OCP\AppFramework\Utility\ITimeFactory; @@ -41,7 +40,6 @@ public function __construct( private PollMapper $pollMapper, private ShareMapper $shareMapper, private VoteMapper $voteMapper, - private WatchMapper $watchMapper, private TableManager $tableManager, ) { parent::__construct($time); @@ -65,7 +63,7 @@ protected function run($argument) { $this->logMapper->deleteOldEntries(time() - (86400 * 7)); // delete entries older than 1 day - $this->watchMapper->deleteOldEntries(time() - 86400); + $this->tableManager->tidyWatchTable(time() - 86400); // purge entries virtually deleted more than 12 hours ago $deleted['comments'] = $this->commentMapper->purgeDeletedComments(time() - 4320); @@ -93,14 +91,9 @@ protected function run($argument) { } // delete orphaned entries (poll_id = null) - $orphaned = $this->tableManager->removeOrphaned(); - foreach ($orphaned as $type => $count) { - if ($count > 0) { - $this->logger->info( - 'JanitorCron: Purged {count} orphaned record(s) from {type}.', - ['count' => $count, 'type' => $type] - ); - } + $messages = $this->tableManager->removeOrphaned(); + foreach ($messages as $message) { + $this->logger->info('JanitorCron: ' . $message); } diff --git a/lib/Db/PollGroupMapper.php b/lib/Db/PollGroupMapper.php index 2de4f2d44d..b086fa7fd4 100644 --- a/lib/Db/PollGroupMapper.php +++ b/lib/Db/PollGroupMapper.php @@ -9,6 +9,7 @@ namespace OCA\Polls\Db; use Exception; +use OCA\Polls\Helper\SqlHelper; use OCA\Polls\UserSession; use OCP\AppFramework\Db\QBMapper; use OCP\DB\QueryBuilder\IQueryBuilder; @@ -144,7 +145,7 @@ protected function joinPollIds( IQueryBuilder $qb, string $joinAlias = 'polls', ): void { - TableManager::getConcatenatedArray( + SqlHelper::getConcatenatedArray( qb: $qb, concatColumn: $joinAlias . '.poll_id', asColumn: 'poll_ids', diff --git a/lib/Db/PollMapper.php b/lib/Db/PollMapper.php index 78b74943da..c19c95ae3b 100644 --- a/lib/Db/PollMapper.php +++ b/lib/Db/PollMapper.php @@ -8,6 +8,7 @@ namespace OCA\Polls\Db; +use OCA\Polls\Helper\SqlHelper; use OCA\Polls\UserSession; use OCP\AppFramework\Db\QBMapper; use OCP\DB\QueryBuilder\IParameter; @@ -253,7 +254,7 @@ protected function joinGroupShares( string $joinAlias = 'group_shares', ): void { - TableManager::getConcatenatedArray( + SqlHelper::getConcatenatedArray( qb: $qb, concatColumn: $joinAlias . '.user_id', asColumn: 'group_shares', @@ -285,7 +286,7 @@ protected function joinPollGroups( string $joinAlias = 'poll_groups', ): void { - TableManager::getConcatenatedArray( + SqlHelper::getConcatenatedArray( qb: $qb, concatColumn: $joinAlias . '.group_id', asColumn: 'poll_groups', @@ -325,7 +326,7 @@ protected function joinPollGroupShares( string $joinAlias = 'poll_group_shares', ): void { - TableManager::getConcatenatedArray( + SqlHelper::getConcatenatedArray( qb: $qb, concatColumn: $joinAlias . '.type', asColumn: 'poll_group_user_shares', diff --git a/lib/Db/V2/DbManager.php b/lib/Db/V2/DbManager.php new file mode 100644 index 0000000000..938128330a --- /dev/null +++ b/lib/Db/V2/DbManager.php @@ -0,0 +1,143 @@ +dbPrefix = $this->config->getSystemValue('dbtableprefix', 'oc_'); + } + + /** + * Set the schema. + * This method is used to set the schema for the database manager. + * It can be used to overwrite the current schema. + * It must be called before any other methods that require a schema. + * @param Schema|ISchemaWrapper $schema + * @return void + */ + public function setSchema(Schema|ISchemaWrapper &$schema): void { + $this->schema = $schema; + } + + /** + * Create a new schema. + * This method is used to create a new schema instance. + * It must be called before any other methods that require a schema. + * + * @throws Exception if the schema cannot be created + */ + public function createSchema(): void { + $this->schema = $this->connection->createSchema(); + } + + /** + * Migrate the database to the current schema. + * This method is used to apply the schema changes to the database. + * It must be called after the schema is set. + * + * @throws InvalidClassException if the schema is not an instance of Schema class + */ + public function migrateToSchema() : void { + // Schema must be of class Schema + $this->needsSchema(allowISchemWrapperClass: false); + $this->connection->migrateToSchema($this->schema); + } + + /** + * Set the database connection. + * Use it to overwrite the managers own connection. + * + * @param IDBConnection $connection + */ + public function setConnection(IDBConnection &$connection): void { + $this->connection = $connection; + } + + /** + * Get the table name with the prefix. + * If the schema is an instance of Schema, we need to prefix the table name. + * ISchemaWrapper already uses the prefixed table name, but Schema does not. + * + * @param string $tableName without prefix + * @return string|null + */ + protected function getTableName(string $tableName): ?string { + if ($this->schema instanceof Schema) { + // If the schema is an instance of Schema, we need to prefix the table name + return $this->dbPrefix . $tableName; + } + return $tableName; + } + + /** + * Use this as a predetermined breaking point to ensure if a method needs a schema to be set. + * + * @param bool $allowSchemaClass allow schema to be an instance of Schema (default is true) + * @param bool $allowISchemWrapperClass allow schema to be an instance of ISchemaWrapper (default is true) + * @throws InvalidClassException if the schema is not set or not of a required class + */ + protected function needsSchema(bool $allowSchemaClass = true, bool $allowISchemWrapperClass = true): void { + if (($this->schema instanceof Schema) && $allowSchemaClass) { + return; + } + + if (($this->schema instanceof ISchemaWrapper) && $allowISchemWrapperClass) { + return; + } + + if ($allowSchemaClass && $allowISchemWrapperClass) { + // If the schema is not set or not an instance of Schema or ISchemaWrapper, throw an exception + throw new InvalidClassException('Schema is not set or not an instance of Schema or ISchemaWrapper (caller: ' . self::formatCaller() . ')'); + } + if ($allowSchemaClass) { + // If the schema is not set or not an instance of Schema, throw an exception + throw new InvalidClassException('Schema is not set or not an instance of Schema (caller: ' . self::formatCaller() . ')'); + } + if ($allowISchemWrapperClass) { + // If the schema is not set or not an instance of ISchemaWrapper, throw an exception + throw new InvalidClassException('Schema is not set or not an instance of ISchemaWrapper(caller: ' . self::formatCaller() . ')'); + } + throw new InvalidClassException('Unexpected. Schema is an instance of ' . get_class($this->schema) . '(caller: ' . self::formatCaller() . ')'); + } + + private static function formatCaller(int $skip = 1): string { + $bt = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, $skip + 2); + $f = $bt[$skip + 0] ?? null; // Frame of this method (0) + $c = $bt[$skip + 1] ?? null; // Frame of the caller (1) + + $cls = $c['class'] ?? ''; + $typ = $c['type'] ?? ''; + $fn = $c['function'] ?? '??'; + $fil = $c['file'] ?? ($f['file'] ?? '??'); + $ln = $c['line'] ?? ($f['line'] ?? 0); + + return sprintf('%s%s%s@%s:%d', $cls, $typ, $fn, self::short($fil), $ln); + } + + private static function short(string $path): string { + $norm = str_replace('\\', '/', $path); + $pos = strpos($norm, '/lib/'); + return $pos === false ? basename($norm) : substr($norm, $pos + 1); // "lib/…" + } +} diff --git a/lib/Db/IndexManager.php b/lib/Db/V2/IndexManager.php similarity index 84% rename from lib/Db/IndexManager.php rename to lib/Db/V2/IndexManager.php index ebd34a5d10..4f03a26ae2 100644 --- a/lib/Db/IndexManager.php +++ b/lib/Db/V2/IndexManager.php @@ -2,36 +2,31 @@ declare(strict_types=1); /** - * SPDX-FileCopyrightText: 2021 Nextcloud contributors + * SPDX-FileCopyrightText: 2025 Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ - -namespace OCA\Polls\Db; +namespace OCA\Polls\Db\V2; use Doctrine\DBAL\Schema\Exception\IndexDoesNotExist; use Doctrine\DBAL\Schema\Schema; -use OCA\Polls\Migration\TableSchema; +use Exception; +use OCA\Polls\Migration\V2\TableSchema; +use OCP\DB\ISchemaWrapper; use OCP\IConfig; +use OCP\IDBConnection; -class IndexManager { +/** @psalm-suppress UnusedClass */ +class IndexManager extends DbManager { - private string $dbPrefix; + // private Schema|ISchemaWrapper $schema; /** @psalm-suppress PossiblyUnusedMethod */ public function __construct( - private IConfig $config, - private Schema $schema, + protected IConfig $config, + protected IDBConnection $connection, ) { - $this->setUp(); - } - - private function setUp(): void { - $this->dbPrefix = $this->config->getSystemValue('dbtableprefix', 'oc_'); - } - - public function setSchema(Schema &$schema): void { - $this->schema = $schema; + parent::__construct($config, $connection); } /** @@ -98,8 +93,10 @@ public function createForeignKeyConstraints(): array { * @return string log message */ public function createForeignKeyConstraint(string $parentTableName, string $childTableName, string $constraintColumn): string { - $parentTableName = $this->dbPrefix . $parentTableName; - $childTableName = $this->dbPrefix . $childTableName; + $this->needsSchema(); + $parentTableName = $this->getTableName($parentTableName); + $childTableName = $this->getTableName($childTableName); + $parentTable = $this->schema->getTable($parentTableName); $childTable = $this->schema->getTable($childTableName); @@ -117,7 +114,8 @@ public function createForeignKeyConstraint(string $parentTableName, string $chil * @return string log message */ public function createIndex(string $tableName, string $indexName, array $columns, bool $unique = false): string { - $tableName = $this->dbPrefix . $tableName; + $this->needsSchema(); + $tableName = $this->getTableName($tableName); if ($this->schema->hasTable($tableName)) { @@ -210,8 +208,9 @@ public function removeAllUniqueIndices(): array { * @return string[] logged messages */ public function removeForeignKeysFromTable(string $tableName): array { + $this->needsSchema(); + $tableName = $this->getTableName($tableName); $messages = []; - $tableName = $this->dbPrefix . $tableName; if ($this->schema->hasTable($tableName)) { @@ -233,8 +232,9 @@ public function removeForeignKeysFromTable(string $tableName): array { * @return string[] logged messages */ public function removeUniqueIndicesFromTable(string $tableName): array { + $this->needsSchema(); + $tableName = $this->getTableName($tableName); $messages = []; - $tableName = $this->dbPrefix . $tableName; if ($this->schema->hasTable($tableName)) { @@ -257,8 +257,9 @@ public function removeUniqueIndicesFromTable(string $tableName): array { * @return string[] logged messages */ public function removeGenericIndicesFromTable(string $tableName): array { + $this->needsSchema(); + $tableName = $this->getTableName($tableName); $messages = []; - $tableName = $this->dbPrefix . $tableName; if ($this->schema->hasTable($tableName)) { @@ -266,8 +267,19 @@ public function removeGenericIndicesFromTable(string $tableName): array { foreach ($table->getIndexes() as $index) { if (strpos($index->getName(), 'IDX_') === 0) { - $table->dropIndex($index->getName()); - $messages[] = 'Removes ' . $index->getName() . ' from ' . $tableName; + try { + $messages[] = 'Removes ' . $index->getName() . ' from ' . $tableName; + $table->dropIndex($index->getName()); + } catch (Exception $e) { + /** + * If this fails, it is not a generic index, skip it + * + * This can happen if the index is already removed + * For some strange reason, an index name is + * reported, although it does not exist anymore + */ + continue; + } } } } @@ -282,8 +294,10 @@ public function removeGenericIndicesFromTable(string $tableName): array { * @return null|string */ public function removeNamedIndexFromTable(string $tableName, string $indexName): ?string { - $tableName = $this->dbPrefix . $tableName; + $this->needsSchema(); + $tableName = $this->getTableName($tableName); $message = null; + try { if ($this->schema->hasTable($tableName)) { $table = $this->schema->getTable($tableName); diff --git a/lib/Db/TableManager.php b/lib/Db/V2/TableManager.php similarity index 71% rename from lib/Db/TableManager.php rename to lib/Db/V2/TableManager.php index 297ea8f461..f5ce629ec2 100644 --- a/lib/Db/TableManager.php +++ b/lib/Db/V2/TableManager.php @@ -2,19 +2,24 @@ declare(strict_types=1); /** - * SPDX-FileCopyrightText: 2021 Nextcloud contributors + * SPDX-FileCopyrightText: 2025 Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ +namespace OCA\Polls\Db\V2; -namespace OCA\Polls\Db; - -use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Types\Type; use Exception; use OCA\Polls\AppConstants; +use OCA\Polls\Db\OptionMapper; +use OCA\Polls\Db\Poll; +use OCA\Polls\Db\PollGroup; +use OCA\Polls\Db\PollMapper; +use OCA\Polls\Db\Share; +use OCA\Polls\Db\VoteMapper; +use OCA\Polls\Db\Watch; use OCA\Polls\Helper\Hash; -use OCA\Polls\Migration\TableSchema; +use OCA\Polls\Migration\V2\TableSchema; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IConfig; use OCP\IDBConnection; @@ -22,36 +27,40 @@ use PDO; use Psr\Log\LoggerInterface; -class TableManager { +class TableManager extends DbManager { - private string $dbPrefix; + // private string $dbPrefix; + // private Schema|ISchemaWrapper $schema; /** @psalm-suppress PossiblyUnusedMethod */ public function __construct( - private IConfig $config, - private IDBConnection $connection, + protected IConfig $config, + protected IDBConnection $connection, private LoggerInterface $logger, private OptionMapper $optionMapper, private VoteMapper $voteMapper, - private Schema $schema, ) { - $this->setUp(); + parent::__construct($config, $connection); + // $this->dbPrefix = $this->config->getSystemValue('dbtableprefix', 'oc_'); } - /** - * setUp - */ - private function setUp(): void { - $this->dbPrefix = $this->config->getSystemValue('dbtableprefix', 'oc_'); - } + // public function setSchema(Schema|ISchemaWrapper &$schema): void { + // $this->schema = $schema; + // } - public function setSchema(Schema &$schema): void { - $this->schema = $schema; - } + // public function createSchema(): Schema { + // $this->schema = $this->connection->createSchema(); + // return $this->schema; + // } + // public function migrateToSchema() : void { + // // Schema must be of class Schema + // $this->needsSchema(iSchemWrapperClass: false); + // $this->connection->migrateToSchema($this->schema); + // } - public function setConnection(IDBConnection &$connection): void { - $this->connection = $connection; - } + // public function setConnection(IDBConnection &$connection): void { + // $this->connection = $connection; + // } /** * @return string[] @@ -142,16 +151,20 @@ public function removeWatch(): array { * @psalm-return non-empty-list */ public function createTable(string $tableName): array { + $this->needsSchema(); + $messages = []; $columns = TableSchema::TABLES[$tableName]; - $ocTable = $this->dbPrefix . $tableName; - if ($this->schema->hasTable($ocTable)) { - $table = $this->schema->getTable($ocTable); + // Ensure the table name is prefixed correctly + $tableName = $this->getTableName($tableName); + + if ($this->schema->hasTable($tableName)) { + $table = $this->schema->getTable($tableName); $messages[] = 'Validating table ' . $table->getName(); $tableCreated = false; } else { - $table = $this->schema->createTable($ocTable); + $table = $this->schema->createTable($tableName); $tableCreated = true; $messages[] = 'Creating table ' . $table->getName(); } @@ -185,6 +198,7 @@ public function createTable(string $tableName): array { * @psalm-return non-empty-list */ public function createTables(): array { + $this->needsSchema(); $messages = []; foreach (array_keys(TableSchema::TABLES) as $tableName) { @@ -319,8 +333,17 @@ public function removeOrphaned(): array { $query->delete(Poll::TABLE) ->where($query->expr()->isNull('id')); $orphaned[Poll::TABLE] = $query->executeStatement(); + $messages = []; + foreach ($orphaned as $type => $count) { + if ($count > 0) { + $this->logger->info( + 'Purged ' . $count . ' orphaned record(s) from ' . $type, + ['count' => $count, 'type' => $type] + ); + } + } - return $orphaned; + return $messages; } /** @@ -346,10 +369,30 @@ public function deleteAllDuplicates(?IOutput $output = null): array { } } return $messages; + } + /** + * Delete entries per timestamp + */ + public function tidyWatchTable(int $offset): string { + $query = $this->connection->getQueryBuilder(); + $query->delete(Watch::TABLE) + ->where( + $query->expr()->lt('updated', $query->createNamedParameter($offset)) + ); + $count = $query->executeStatement(); + + if ($count > 0) { + $this->logger->info('Removed {number} old watch records', ['number' => $count, 'db' => $this->dbPrefix . Watch::TABLE]); + return 'Removed ' . $count . ' old watch records'; + } + + $this->logger->info('Watch table is clean'); + return 'Watch table is clean'; } private function deleteDuplicates(string $table, array $columns):int { + $this->needsSchema(); $qb = $this->connection->getQueryBuilder(); if ($this->schema->hasTable($this->dbPrefix . $table)) { @@ -416,8 +459,75 @@ public function fixVotes(): void { } } - public function resetLastInteraction(?int $timestamp = null): array { + public function fixNullishShares(): array { $messages = []; + $query = $this->connection->getQueryBuilder(); + + // replace all nullish group_ids with 0 in share table + $query->update(Share::TABLE) + ->set('group_id', $query->createNamedParameter(0, IQueryBuilder::PARAM_INT)) + ->where($query->expr()->isNull('group_id')); + + $count = $query->executeStatement(); + + if ($count > 0) { + $messages[] = 'Updated ' . $count . ' shares and set group_id to 0 for nullish values'; + } + + // replace all nullish poll_id with 0 in share table + $query = $this->connection->getQueryBuilder(); + $query->update(Share::TABLE) + ->set('poll_id', $query->createNamedParameter(0, IQueryBuilder::PARAM_INT)) + ->where($query->expr()->isNull('poll_id')); + + $count = $query->executeStatement(); + + if ($count > 0) { + $messages[] = 'Updated ' . $count . ' shares and set poll_id to 0 for nullish values'; + } + + if (empty($messages)) { + return ['All shares are valid']; + } + + return $messages; + } + + public function fixNullishPollGroupRelations(): array { + $messages = []; + $query = $this->connection->getQueryBuilder(); + + // replace all nullish group_ids with 0 in share table + $query->update(PollGroup::RELATION_TABLE) + ->set('group_id', $query->createNamedParameter(0, IQueryBuilder::PARAM_INT)) + ->where($query->expr()->isNull('group_id')); + + $count = $query->executeStatement(); + + if ($count > 0) { + $messages[] = 'Updated ' . $count . ' pollgroup relations and set group_id to 0 for nullish values'; + } + + // replace all nullish poll_id with 0 in share table + $query = $this->connection->getQueryBuilder(); + $query->update(PollGroup::RELATION_TABLE) + ->set('poll_id', $query->createNamedParameter(0, IQueryBuilder::PARAM_INT)) + ->where($query->expr()->isNull('poll_id')); + + $count = $query->executeStatement(); + + if ($count > 0) { + $messages[] = 'Updated ' . $count . ' poll group relations and set poll_id to 0 for nullish values'; + } + + if (empty($messages)) { + return ['All poll group relations are valid']; + } + + return $messages; + } + + public function setLastInteraction(?int $timestamp = null): string { $timestamp = $timestamp ?? time(); $query = $this->connection->getQueryBuilder(); @@ -428,16 +538,16 @@ public function resetLastInteraction(?int $timestamp = null): array { if ($count > 0) { $this->logger->info('Updated {number} polls in {db} and set last_interaction to current timestamp {timestamp}', ['number' => $count, 'db' => $this->dbPrefix . PollMapper::TABLE, 'timestamp' => $timestamp]); - $messages[] = 'Updated ' . $count . ' polls'; - } else { - $this->logger->info('No polls needed to get updated with last interaction info'); - $messages[] = 'No polls needed to get updated with last interaction info'; + return 'Updated last interaction in ' . $count . ' polls'; } - return $messages; + $this->logger->info('No polls needed to get updated with last interaction info'); + return 'Last interaction all set'; + } public function migrateOptionsToHash(): array { + $this->needsSchema(); $messages = []; if ($this->schema->hasTable($this->dbPrefix . OptionMapper::TABLE)) { @@ -492,31 +602,36 @@ public function migrateOptionsToHash(): array { return $messages; } - /** - * Get a concatenated array of values from a column in the query builder. - * - * @param IQueryBuilder $qb The query builder instance per reference - * @param string $concatColumn The column to concatenate - * @param string $asColumn The alias for the concatenated column - * @param string $dbProvider The database provider (default: IDBConnection::PLATFORM_MYSQL) - * @param string $separator The separator for concatenation (default: ',') - * - * @psalm-param IDBConnection::PLATFORM_* $dbProvider - * - */ - public static function getConcatenatedArray( - IQueryBuilder &$qb, - string $concatColumn, - string $asColumn, - string $dbProvider, - string $separator = ',', - ): void { - $qb->addSelect(match ($dbProvider) { - IDBConnection::PLATFORM_POSTGRES => $qb->createFunction('string_agg(distinct ' . $concatColumn . '::varchar, \'' . $separator . '\') AS ' . $asColumn), - IDBConnection::PLATFORM_ORACLE => $qb->createFunction('listagg(distinct ' . $concatColumn . ', \'' . $separator . '\') WITHIN GROUP (ORDER BY ' . $concatColumn . ') AS ' . $asColumn), - IDBConnection::PLATFORM_SQLITE => $qb->createFunction('group_concat(replace(distinct ' . $concatColumn . ' ,\'\',\'\'), \'' . $separator . '\') AS ' . $asColumn), - default => $qb->createFunction('group_concat(distinct ' . $concatColumn . ' SEPARATOR "' . $separator . '") AS ' . $asColumn), - }); - } + // protected function getTableName(string $tableName): ?string { + // if ($this->schema instanceof Schema) { + // // If the schema is an instance of Schema, we need to prefix the table name + // return $this->config->getSystemValue('dbtableprefix', 'oc_') . $tableName; + // } + // return $tableName; + // } + + // protected function needsSchema(bool $schemaClass = true, bool $iSchemWrapperClass = true): void { + // if (($this->schema instanceof Schema) && $schemaClass) { + // return; + // } + + // if (($this->schema instanceof ISchemaWrapper) && $iSchemWrapperClass) { + // return; + // } + + // if ($schemaClass && $iSchemWrapperClass) { + // // If the schema is not set or not an instance of Schema or ISchemaWrapper, throw an exception + // throw new Exception('Schema is not set or not an instance of Schema or ISchemaWrapper'); + // } + // if ($schemaClass) { + // // If the schema is not set or not an instance of Schema, throw an exception + // throw new Exception('Schema is not set or not an instance of Schema'); + // } + // if ($iSchemWrapperClass) { + // // If the schema is not set or not an instance of ISchemaWrapper, throw an exception + // throw new Exception('Schema is not set or not an instance of ISchemaWrapper'); + // } + // throw new Exception('Unexpected. Schema is an instance of ' . get_class($this->schema)); + // } } diff --git a/lib/Db/WatchMapper.php b/lib/Db/WatchMapper.php index 48ea68b037..85ec5df2a8 100644 --- a/lib/Db/WatchMapper.php +++ b/lib/Db/WatchMapper.php @@ -69,17 +69,4 @@ public function findForPollIdAndTable(int $pollId, string $table): Watch { return $this->findEntity($qb); } - - /** - * Delete entries per timestamp - * @return void - */ - public function deleteOldEntries(int $offset): void { - $query = $this->db->getQueryBuilder(); - $query->delete($this->getTableName()) - ->where( - $query->expr()->lt('updated', $query->createNamedParameter($offset)) - ); - $query->executeStatement(); - } } diff --git a/lib/Helper/SqlHelper.php b/lib/Helper/SqlHelper.php new file mode 100644 index 0000000000..c40c898de0 --- /dev/null +++ b/lib/Helper/SqlHelper.php @@ -0,0 +1,41 @@ +addSelect(match ($dbProvider) { + IDBConnection::PLATFORM_POSTGRES => $qb->createFunction('string_agg(distinct ' . $concatColumn . '::varchar, \'' . $separator . '\') AS ' . $asColumn), + IDBConnection::PLATFORM_ORACLE => $qb->createFunction('listagg(distinct ' . $concatColumn . ', \'' . $separator . '\') WITHIN GROUP (ORDER BY ' . $concatColumn . ') AS ' . $asColumn), + IDBConnection::PLATFORM_SQLITE => $qb->createFunction('group_concat(replace(distinct ' . $concatColumn . ' ,\'\',\'\'), \'' . $separator . '\') AS ' . $asColumn), + default => $qb->createFunction('group_concat(distinct ' . $concatColumn . ' SEPARATOR "' . $separator . '") AS ' . $asColumn), + }); + } +} diff --git a/lib/Listener/AddMissingIndicesListener.php b/lib/Listener/AddMissingIndicesListener.php index ffc937fb12..fb2d455b0e 100644 --- a/lib/Listener/AddMissingIndicesListener.php +++ b/lib/Listener/AddMissingIndicesListener.php @@ -8,7 +8,7 @@ namespace OCA\Polls\Listener; -use OCA\Polls\Migration\TableSchema; +use OCA\Polls\Migration\V2\TableSchema; use OCP\DB\Events\AddMissingIndicesEvent; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; diff --git a/lib/Migration/FixVotes.php b/lib/Migration/FixVotes.php index 90602fe1b0..6cb527f01a 100644 --- a/lib/Migration/FixVotes.php +++ b/lib/Migration/FixVotes.php @@ -10,7 +10,7 @@ namespace OCA\Polls\Migration; use Doctrine\DBAL\Schema\Schema; -use OCA\Polls\Db\TableManager; +use OCA\Polls\Db\V2\TableManager; use OCP\IDBConnection; use OCP\Migration\IOutput; use OCP\Migration\IRepairStep; diff --git a/lib/Migration/RepairSteps/DeleteInvalidRecords.php b/lib/Migration/RepairSteps/CleanTables.php similarity index 71% rename from lib/Migration/RepairSteps/DeleteInvalidRecords.php rename to lib/Migration/RepairSteps/CleanTables.php index e4ecd6c2a6..1c04a4a091 100644 --- a/lib/Migration/RepairSteps/DeleteInvalidRecords.php +++ b/lib/Migration/RepairSteps/CleanTables.php @@ -12,8 +12,7 @@ use Doctrine\DBAL\Schema\Schema; use Exception; use OCA\Polls\Db\Poll; -use OCA\Polls\Db\TableManager; -use OCA\Polls\Db\WatchMapper; +use OCA\Polls\Db\V2\TableManager; use OCP\IDBConnection; use OCP\Migration\IOutput; use OCP\Migration\IRepairStep; @@ -21,13 +20,10 @@ /** * Preparation before migration * Remove all invalid records to avoid erros while adding indices ans constraints - * - * @psalm-suppress UnusedClass */ -class DeleteInvalidRecords implements IRepairStep { +class CleanTables implements IRepairStep { public function __construct( private IDBConnection $connection, - private WatchMapper $watchMapper, private TableManager $tableManager, private Schema $schema, ) { @@ -40,17 +36,12 @@ public function getName():string { public function run(IOutput $output):void { if ($this->connection->tableExists(Poll::TABLE)) { try { - $this->schema = $this->connection->createSchema(); - $this->tableManager->setSchema($this->schema); - $this->tableManager->removeOrphaned(); $this->tableManager->deleteAllDuplicates(); - - $this->watchMapper->deleteOldEntries(time()); + $this->tableManager->tidyWatchTable(time()); } catch (Exception $e) { // Simply skip repair, if it breaks and rely on the next run } - $this->connection->migrateToSchema($this->schema); } } } diff --git a/lib/Migration/RepairSteps/CreateIndices.php b/lib/Migration/RepairSteps/CreateIndices.php index aa6b825995..92961b4274 100644 --- a/lib/Migration/RepairSteps/CreateIndices.php +++ b/lib/Migration/RepairSteps/CreateIndices.php @@ -10,15 +10,12 @@ namespace OCA\Polls\Migration\RepairSteps; use Doctrine\DBAL\Schema\Schema; -use OCA\Polls\Db\IndexManager; use OCA\Polls\Db\Share; +use OCA\Polls\Db\V2\IndexManager; use OCP\IDBConnection; use OCP\Migration\IOutput; use OCP\Migration\IRepairStep; -/** - * @psalm-suppress UnusedClass - */ class CreateIndices implements IRepairStep { public function __construct( private IndexManager $indexManager, diff --git a/lib/Migration/RepairSteps/CreateTables.php b/lib/Migration/RepairSteps/CreateTables.php index 2b5a1f2bf9..8f37c73fea 100644 --- a/lib/Migration/RepairSteps/CreateTables.php +++ b/lib/Migration/RepairSteps/CreateTables.php @@ -10,14 +10,11 @@ namespace OCA\Polls\Migration\RepairSteps; use Doctrine\DBAL\Schema\Schema; -use OCA\Polls\Db\TableManager; +use OCA\Polls\Db\V2\TableManager; use OCP\IDBConnection; use OCP\Migration\IOutput; use OCP\Migration\IRepairStep; -/** - * @psalm-suppress UnusedClass - */ class CreateTables implements IRepairStep { public function __construct( private TableManager $tableManager, diff --git a/lib/Migration/RepairSteps/DropOrphanedColumns.php b/lib/Migration/RepairSteps/DropOrphanedColumns.php index f647aca75f..6f3aa3738b 100644 --- a/lib/Migration/RepairSteps/DropOrphanedColumns.php +++ b/lib/Migration/RepairSteps/DropOrphanedColumns.php @@ -10,14 +10,11 @@ namespace OCA\Polls\Migration\RepairSteps; use Doctrine\DBAL\Schema\Schema; -use OCA\Polls\Db\TableManager; +use OCA\Polls\Db\V2\TableManager; use OCP\IDBConnection; use OCP\Migration\IOutput; use OCP\Migration\IRepairStep; -/** - * @psalm-suppress UnusedClass - */ class DropOrphanedColumns implements IRepairStep { public function __construct( private TableManager $tableManager, diff --git a/lib/Migration/RepairSteps/DropOrphanedTables.php b/lib/Migration/RepairSteps/DropOrphanedTables.php index 12aefdbf6e..d551549716 100644 --- a/lib/Migration/RepairSteps/DropOrphanedTables.php +++ b/lib/Migration/RepairSteps/DropOrphanedTables.php @@ -10,14 +10,11 @@ namespace OCA\Polls\Migration\RepairSteps; use Doctrine\DBAL\Schema\Schema; -use OCA\Polls\Db\TableManager; +use OCA\Polls\Db\V2\TableManager; use OCP\IDBConnection; use OCP\Migration\IOutput; use OCP\Migration\IRepairStep; -/** - * @psalm-suppress UnusedClass - */ class DropOrphanedTables implements IRepairStep { public function __construct( private TableManager $tableManager, diff --git a/lib/Migration/RepairSteps/FixNullish.php b/lib/Migration/RepairSteps/FixNullish.php new file mode 100644 index 0000000000..a3ae393606 --- /dev/null +++ b/lib/Migration/RepairSteps/FixNullish.php @@ -0,0 +1,42 @@ +tableManager->setConnection($this->connection); + + $messages = $this->tableManager->fixNullishShares(); + foreach ($messages as $message) { + $output->info('Polls - ' . $message); + } + + $messages = $this->tableManager->fixNullishPollGroupRelations(); + foreach ($messages as $message) { + $output->info('Polls - ' . $message); + } + + } +} diff --git a/lib/Migration/RepairSteps/Install.php b/lib/Migration/RepairSteps/Install.php index ae3d9fc1e1..4ed42615ee 100644 --- a/lib/Migration/RepairSteps/Install.php +++ b/lib/Migration/RepairSteps/Install.php @@ -10,14 +10,11 @@ namespace OCA\Polls\Migration\RepairSteps; use Doctrine\DBAL\Schema\Schema; -use OCA\Polls\Db\IndexManager; +use OCA\Polls\Db\V2\IndexManager; use OCP\IDBConnection; use OCP\Migration\IOutput; use OCP\Migration\IRepairStep; -/** - * @psalm-suppress UnusedClass - */ class Install implements IRepairStep { public function __construct( private IndexManager $indexManager, diff --git a/lib/Migration/RepairSteps/RemoveIndices.php b/lib/Migration/RepairSteps/RemoveIndices.php index 2569e9def3..5e4d494d85 100644 --- a/lib/Migration/RepairSteps/RemoveIndices.php +++ b/lib/Migration/RepairSteps/RemoveIndices.php @@ -9,7 +9,7 @@ namespace OCA\Polls\Migration\RepairSteps; use Doctrine\DBAL\Schema\Schema; -use OCA\Polls\Db\IndexManager; +use OCA\Polls\Db\V2\IndexManager; use OCP\IDBConnection; use OCP\Migration\IOutput; use OCP\Migration\IRepairStep; @@ -19,7 +19,6 @@ * Remove all indices and foreign key constraints to avoid errors * while changing the schema * - * @psalm-suppress UnusedClass */ class RemoveIndices implements IRepairStep { public function __construct( diff --git a/lib/Migration/RepairSteps/RemoveObsoleteMigrations.php b/lib/Migration/RepairSteps/RemoveObsoleteMigrations.php index 01d9972bdb..bfacfcdf51 100644 --- a/lib/Migration/RepairSteps/RemoveObsoleteMigrations.php +++ b/lib/Migration/RepairSteps/RemoveObsoleteMigrations.php @@ -9,7 +9,7 @@ namespace OCA\Polls\Migration\RepairSteps; use Doctrine\DBAL\Schema\Schema; -use OCA\Polls\Db\TableManager; +use OCA\Polls\Db\V2\TableManager; use OCP\IDBConnection; use OCP\Migration\IOutput; use OCP\Migration\IRepairStep; @@ -19,7 +19,6 @@ * including migration versions from test releases * theoretically, only this migration should be existent. If not, no matter * - * @psalm-suppress UnusedClass */ class RemoveObsoleteMigrations implements IRepairStep { public function __construct( diff --git a/lib/Migration/RepairSteps/SetLastInteraction.php b/lib/Migration/RepairSteps/SetLastInteraction.php new file mode 100644 index 0000000000..092755ac3d --- /dev/null +++ b/lib/Migration/RepairSteps/SetLastInteraction.php @@ -0,0 +1,34 @@ +tableManager->setLastInteraction(); + $output->info($message); + } +} diff --git a/lib/Migration/RepairSteps/UpdateHashes.php b/lib/Migration/RepairSteps/UpdateHashes.php index 7c573c5fa7..c8f3042687 100644 --- a/lib/Migration/RepairSteps/UpdateHashes.php +++ b/lib/Migration/RepairSteps/UpdateHashes.php @@ -9,14 +9,11 @@ namespace OCA\Polls\Migration\RepairSteps; -use OCA\Polls\Db\TableManager; +use OCA\Polls\Db\V2\TableManager; use OCP\IDBConnection; use OCP\Migration\IOutput; use OCP\Migration\IRepairStep; -/** - * @psalm-suppress UnusedClass - */ class UpdateHashes implements IRepairStep { public function __construct( private TableManager $tableManager, diff --git a/lib/Migration/RepairSteps/UpdateInteraction.php b/lib/Migration/RepairSteps/UpdateInteraction.php index 312015e793..10ce2eb653 100644 --- a/lib/Migration/RepairSteps/UpdateInteraction.php +++ b/lib/Migration/RepairSteps/UpdateInteraction.php @@ -9,14 +9,11 @@ namespace OCA\Polls\Migration\RepairSteps; -use OCA\Polls\Db\TableManager; +use OCA\Polls\Db\V2\TableManager; use OCP\IDBConnection; use OCP\Migration\IOutput; use OCP\Migration\IRepairStep; -/** - * @psalm-suppress UnusedClass - */ class UpdateInteraction implements IRepairStep { public function __construct( private TableManager $tableManager, @@ -31,9 +28,8 @@ public function getName() { public function run(IOutput $output): void { $this->tableManager->setConnection($this->connection); - $messages = $this->tableManager->resetLastInteraction(); - foreach ($messages as $message) { - $output->info($message); - } + $message = $this->tableManager->setLastInteraction(); + + $output->info($message); } } diff --git a/lib/Migration/TableSchema.php b/lib/Migration/V2/TableSchema.php similarity index 97% rename from lib/Migration/TableSchema.php rename to lib/Migration/V2/TableSchema.php index 21ead0dff9..a3ee77877a 100644 --- a/lib/Migration/TableSchema.php +++ b/lib/Migration/V2/TableSchema.php @@ -2,11 +2,11 @@ declare(strict_types=1); /** - * SPDX-FileCopyrightText: 2017 Nextcloud contributors + * SPDX-FileCopyrightText: 2025 Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -namespace OCA\Polls\Migration; +namespace OCA\Polls\Migration\V2; use OCA\Polls\Db\Comment; use OCA\Polls\Db\Log; @@ -208,10 +208,10 @@ abstract class TableSchema { // 'processed', // dropped in 8.1, orphaned ], Option::TABLE => [ - 'poll_option_hash_bin', + 'poll_option_hash_bin', // used and dropped in dev branch (8.3.x), leave here for security ], Vote::TABLE => [ - 'vote_option_hash_bin', + 'vote_option_hash_bin', // used and dropped in dev branch (8.3.x), leave here for security ], ]; @@ -232,8 +232,8 @@ abstract class TableSchema { ], PollGroup::RELATION_TABLE => [ 'id' => ['type' => Types::BIGINT, 'options' => ['autoincrement' => true, 'notnull' => true, 'length' => 20]], - 'poll_id' => ['type' => Types::BIGINT, 'options' => ['notnull' => true, 'default' => null, 'length' => 20]], - 'group_id' => ['type' => Types::BIGINT, 'options' => ['notnull' => true, 'default' => null, 'length' => 20]], + 'poll_id' => ['type' => Types::BIGINT, 'options' => ['notnull' => true, 'default' => 0, 'length' => 20]], + 'group_id' => ['type' => Types::BIGINT, 'options' => ['notnull' => true, 'default' => 0, 'length' => 20]], ], Poll::TABLE => [ 'id' => ['type' => Types::BIGINT, 'options' => ['autoincrement' => true, 'notnull' => true, 'length' => 20]], @@ -295,8 +295,8 @@ abstract class TableSchema { ], Share::TABLE => [ 'id' => ['type' => Types::BIGINT, 'options' => ['autoincrement' => true, 'notnull' => true, 'length' => 20]], - 'poll_id' => ['type' => Types::BIGINT, 'options' => ['notnull' => true, 'default' => null, 'length' => 20]], - 'group_id' => ['type' => Types::BIGINT, 'options' => ['notnull' => true, 'default' => null, 'length' => 20]], + 'poll_id' => ['type' => Types::BIGINT, 'options' => ['notnull' => true, 'default' => 0, 'length' => 20]], + 'group_id' => ['type' => Types::BIGINT, 'options' => ['notnull' => true, 'default' => 0, 'length' => 20]], 'token' => ['type' => Types::STRING, 'options' => ['notnull' => true, 'default' => '', 'length' => 64]], 'type' => ['type' => Types::STRING, 'options' => ['notnull' => true, 'default' => '', 'length' => 64]], 'label' => ['type' => Types::STRING, 'options' => ['notnull' => false, 'default' => '', 'length' => 256]], diff --git a/lib/Migration/Version060100Date20240209073304.php b/lib/Migration/Version060100Date20240209073304.php deleted file mode 100644 index 17e7cde380..0000000000 --- a/lib/Migration/Version060100Date20240209073304.php +++ /dev/null @@ -1,117 +0,0 @@ -schema = $schemaClosure(); - $messages = $this->createTables(); - - foreach ($messages as $message) { - $output->info('Polls - ' . $message); - }; - - return $this->schema; - } - - /** - * @return void - */ - public function postSchemaChange(IOutput $output, \Closure $schemaClosure, array $options) { - $now = time(); - $query = $this->connection->getQueryBuilder(); - $query->update(Poll::TABLE) - ->set('last_interaction', $query->createNamedParameter($now)) - ->where($query->expr()->eq('last_interaction', $query->createNamedParameter(0))); - $query->executeStatement(); - } - - - /** - * @return string[] - * - * @psalm-return non-empty-list - */ - public function createTable(string $tableName, array $columns): array { - $messages = []; - - if ($this->schema->hasTable($tableName)) { - $table = $this->schema->getTable($tableName); - $messages[] = 'Validating table ' . $table->getName(); - $tableCreated = false; - } else { - $table = $this->schema->createTable($tableName); - $tableCreated = true; - $messages[] = 'Creating table ' . $table->getName(); - } - - foreach ($columns as $columnName => $columnDefinition) { - if ($table->hasColumn($columnName)) { - $column = $table->getColumn($columnName); - if (Type::lookupName($column->getType()) !== $columnDefinition['type']) { - $messages[] = 'Migrated type of ' . $table->getName() . '[\'' . $columnName . '\'] from ' . Type::lookupName($column->getType()) . ' to ' . $columnDefinition['type']; - $column->setType(Type::getType($columnDefinition['type'])); - } - $column->setOptions($columnDefinition['options']); - - // force change to current options definition - $table->modifyColumn($columnName, $columnDefinition['options']); - } else { - $table->addColumn($columnName, $columnDefinition['type'], $columnDefinition['options']); - $messages[] = 'Added ' . $table->getName() . ', ' . $columnName . ' (' . $columnDefinition['type'] . ')'; - } - } - - if ($tableCreated) { - $table->setPrimaryKey(['id']); - } - return $messages; - } - - /** - * @return string[] - * - * @psalm-return non-empty-list - */ - public function createTables(): array { - $messages = []; - - foreach (TableSchema::TABLES as $tableName => $columns) { - $messages = array_merge($messages, $this->createTable($tableName, $columns)); - } - return $messages; - } -} diff --git a/lib/Migration/Version080000Date20250622002601.php b/lib/Migration/Version080000Date20250622002601.php deleted file mode 100644 index 44e887f083..0000000000 --- a/lib/Migration/Version080000Date20250622002601.php +++ /dev/null @@ -1,103 +0,0 @@ -schema = $schemaClosure(); - $messages = $this->createTables(); - - foreach ($messages as $message) { - $output->info('Polls - ' . $message); - }; - - return $this->schema; - } - - /** - * @return string[] - * - * @psalm-return non-empty-list - */ - public function createTable(string $tableName, array $columns): array { - $messages = []; - - if ($this->schema->hasTable($tableName)) { - $table = $this->schema->getTable($tableName); - $messages[] = 'Validating table ' . $table->getName(); - $tableCreated = false; - } else { - $table = $this->schema->createTable($tableName); - $tableCreated = true; - $messages[] = 'Creating table ' . $table->getName(); - } - - foreach ($columns as $columnName => $columnDefinition) { - if ($table->hasColumn($columnName)) { - $column = $table->getColumn($columnName); - if (Type::lookupName($column->getType()) !== $columnDefinition['type']) { - $messages[] = 'Migrated type of ' . $table->getName() . '[\'' . $columnName . '\'] from ' . Type::lookupName($column->getType()) . ' to ' . $columnDefinition['type']; - $column->setType(Type::getType($columnDefinition['type'])); - } - $column->setOptions($columnDefinition['options']); - - // force change to current options definition - $table->modifyColumn($columnName, $columnDefinition['options']); - } else { - $table->addColumn($columnName, $columnDefinition['type'], $columnDefinition['options']); - $messages[] = 'Added ' . $table->getName() . ', ' . $columnName . ' (' . $columnDefinition['type'] . ')'; - } - } - - if ($tableCreated) { - $table->setPrimaryKey(['id']); - } - return $messages; - } - - /** - * @return string[] - * - * @psalm-return non-empty-list - */ - public function createTables(): array { - $messages = []; - - foreach (TableSchema::TABLES as $tableName => $columns) { - $messages = array_merge($messages, $this->createTable($tableName, $columns)); - } - return $messages; - } -} diff --git a/lib/Migration/Version080100Date20250623202002.php b/lib/Migration/Version080100Date20250623202002.php deleted file mode 100644 index fc49e31c09..0000000000 --- a/lib/Migration/Version080100Date20250623202002.php +++ /dev/null @@ -1,103 +0,0 @@ -schema = $schemaClosure(); - $messages = $this->createTables(); - - foreach ($messages as $message) { - $output->info('Polls - ' . $message); - }; - - return $this->schema; - } - - /** - * @return string[] - * - * @psalm-return non-empty-list - */ - public function createTable(string $tableName, array $columns): array { - $messages = []; - - if ($this->schema->hasTable($tableName)) { - $table = $this->schema->getTable($tableName); - $messages[] = 'Validating table ' . $table->getName(); - $tableCreated = false; - } else { - $table = $this->schema->createTable($tableName); - $tableCreated = true; - $messages[] = 'Creating table ' . $table->getName(); - } - - foreach ($columns as $columnName => $columnDefinition) { - if ($table->hasColumn($columnName)) { - $column = $table->getColumn($columnName); - if (Type::lookupName($column->getType()) !== $columnDefinition['type']) { - $messages[] = 'Migrated type of ' . $table->getName() . '[\'' . $columnName . '\'] from ' . Type::lookupName($column->getType()) . ' to ' . $columnDefinition['type']; - $column->setType(Type::getType($columnDefinition['type'])); - } - $column->setOptions($columnDefinition['options']); - - // force change to current options definition - $table->modifyColumn($columnName, $columnDefinition['options']); - } else { - $table->addColumn($columnName, $columnDefinition['type'], $columnDefinition['options']); - $messages[] = 'Added ' . $table->getName() . ', ' . $columnName . ' (' . $columnDefinition['type'] . ')'; - } - } - - if ($tableCreated) { - $table->setPrimaryKey(['id']); - } - return $messages; - } - - /** - * @return string[] - * - * @psalm-return non-empty-list - */ - public function createTables(): array { - $messages = []; - - foreach (TableSchema::TABLES as $tableName => $columns) { - $messages = array_merge($messages, $this->createTable($tableName, $columns)); - } - return $messages; - } -} diff --git a/lib/Migration/Version080300Date20250812231603.php b/lib/Migration/Version080300Date20250812231603.php deleted file mode 100644 index 561adb9ee7..0000000000 --- a/lib/Migration/Version080300Date20250812231603.php +++ /dev/null @@ -1,103 +0,0 @@ -schema = $schemaClosure(); - $messages = $this->createTables(); - - foreach ($messages as $message) { - $output->info('Polls - ' . $message); - }; - - return $this->schema; - } - - /** - * @return string[] - * - * @psalm-return non-empty-list - */ - public function createTable(string $tableName, array $columns): array { - $messages = []; - - if ($this->schema->hasTable($tableName)) { - $table = $this->schema->getTable($tableName); - $messages[] = 'Validating table ' . $table->getName(); - $tableCreated = false; - } else { - $table = $this->schema->createTable($tableName); - $tableCreated = true; - $messages[] = 'Creating table ' . $table->getName(); - } - - foreach ($columns as $columnName => $columnDefinition) { - if ($table->hasColumn($columnName)) { - $column = $table->getColumn($columnName); - if (Type::lookupName($column->getType()) !== $columnDefinition['type']) { - $messages[] = 'Migrated type of ' . $table->getName() . '[\'' . $columnName . '\'] from ' . Type::lookupName($column->getType()) . ' to ' . $columnDefinition['type']; - $column->setType(Type::getType($columnDefinition['type'])); - } - $column->setOptions($columnDefinition['options']); - - // force change to current options definition - $table->modifyColumn($columnName, $columnDefinition['options']); - } else { - $table->addColumn($columnName, $columnDefinition['type'], $columnDefinition['options']); - $messages[] = 'Added ' . $table->getName() . ', ' . $columnName . ' (' . $columnDefinition['type'] . ')'; - } - } - - if ($tableCreated) { - $table->setPrimaryKey(['id']); - } - return $messages; - } - - /** - * @return string[] - * - * @psalm-return non-empty-list - */ - public function createTables(): array { - $messages = []; - - foreach (TableSchema::TABLES as $tableName => $columns) { - $messages = array_merge($messages, $this->createTable($tableName, $columns)); - } - return $messages; - } -} diff --git a/lib/Migration/Version080300Date20250816201201.php b/lib/Migration/Version080300Date20250816201201.php new file mode 100644 index 0000000000..602f1db2f6 --- /dev/null +++ b/lib/Migration/Version080300Date20250816201201.php @@ -0,0 +1,164 @@ +output) { + if (is_array($message)) { + foreach ($message as $msg) { + $this->output->info($prefix . 'Polls - ' . $msg); + } + } else { + $this->output->info($prefix . 'Polls - ' . $message); + } + } + } + + /** + * This method is called before the schema change. + * All the existing calls are necessary to prepare the database for the migration. + * Main steps: + * 1. Make sure that no nullish values are used for poll_id and group_id in the share table + * 2. Remove all orphaned records which have no relation to a poll group (shares) or a poll (all) + * 3. Remove all duplicate records based on unique index definition + * 4. Tidy the watch table by removing all entries which are older than now + * + * @param IOutput $output + * @param \Closure $schemaClosure + * @param array $options + * @return void + */ + public function preSchemaChange(IOutput $output, \Closure $schemaClosure, array $options): void { + $this->output = $output; + $this->logInfo('Prepare migration'); + + // remove foreign keys and unique indices from the share table in preparation of fixing nullish values + $this->indexManager->createSchema(); // Let the indexManager use it's own schema + $message = $this->indexManager->removeUniqueIndicesFromTable(Share::TABLE); + $this->logInfo($message, 'preMigration: '); + $message = $this->indexManager->removeForeignKeysFromTable(Share::TABLE); + $this->logInfo($message, 'preMigration: '); + $this->indexManager->migrateToSchema(); + + // fix nullish values in poll_id and group_id and set 0 in case of null + $message = $this->tableManager->fixNullishShares(); + $this->logInfo($message, 'preMigration: '); + + // remove all orphaned records + $message = $this->tableManager->removeOrphaned(); + $this->logInfo($message, 'preMigration: '); + + // remove all duplicates + $this->tableManager->createSchema(); + $message = $this->tableManager->deleteAllDuplicates(); + $this->logInfo($message, 'preMigration: '); + $this->tableManager->migrateToSchema(); + + $message = $this->tableManager->tidyWatchTable(time()); + $this->logInfo($message, 'preMigration: '); + } + + /** + * This method is executing the actual schema change based on the definition of TableSchema + * $schemaClosure The `\Closure` returns an `ISchemaWrapper` + * @param IOutput $output + * @param \Closure $schemaClosure + * @param array $options + * @return ISchemaWrapper|null + */ + public function changeSchema(IOutput $output, \Closure $schemaClosure, array $options): ?ISchemaWrapper { + $this->output = $output; + $this->schema = $schemaClosure(); + $this->tableManager->setConnection($this->connection); + $this->tableManager->setSchema($this->schema); + + $message = $this->tableManager->createTables(); + $this->logInfo($message, 'runMigration: '); + + if (!($this->schema instanceof ISchemaWrapper)) { + return null; + } + + return $this->schema; + } + + /** + * This method is called after the schema change. + * It is used to perform any post-migration steps, such as migrating options to a hash. + * Main steps: + * 1. Ensure that option hashes are created correctly for options and votes + * + * @param IOutput $output + * @param \Closure $schemaClosure + * @param array $options + * @return void + */ + public function postSchemaChange(IOutput $output, \Closure $schemaClosure, array $options): void { + $this->output = $output; + $this->logInfo('Post migration steps'); + + $this->tableManager->createSchema(); + $message = $this->tableManager->migrateOptionsToHash(); + $this->logInfo($message, 'postMigration: '); + + $message = $this->tableManager->removeObsoleteTables(); + $this->logInfo($message, 'postMigration: '); + + $this->tableManager->createSchema(); + $message = $this->tableManager->removeObsoleteColumns(); + $this->tableManager->migrateToSchema(); + $this->logInfo($message, 'postMigration: '); + + + $this->indexManager->createSchema(); + + $message = $this->indexManager->createForeignKeyConstraints(); + $this->logInfo($message, 'postMigration: '); + + $message = $this->indexManager->createUniqueIndices(); + $this->logInfo($message, 'postMigration: '); + + // skip creating optional indices and leave it to 'occ db:add-missing-indices' + $this->indexManager->migrateToSchema(); + } +} diff --git a/package-lock.json b/package-lock.json index e4b19e66ed..a57c9d6d1a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "polls", - "version": "8.3.0-beta.1", + "version": "8.3.0-beta.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "polls", - "version": "8.3.0-beta.1", + "version": "8.3.0-beta.3", "license": "AGPL-3.0", "dependencies": { "@nextcloud/auth": "^2.5.1", diff --git a/package.json b/package.json index f1a1d4eda4..bf47a6f495 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "polls", - "version": "8.3.0-beta.1", + "version": "8.3.0-beta.3", "private": true, "description": "Polls app for nextcloud", "homepage": "https://github.com/nextcloud/polls#readme", diff --git a/psalm.xml b/psalm.xml index 1fa846a343..c55cd6e3ff 100644 --- a/psalm.xml +++ b/psalm.xml @@ -28,6 +28,13 @@ + + + + + + +