diff --git a/.scoper-production-dependencies b/.scoper-production-dependencies index e29620a89e..336650b97b 100644 --- a/.scoper-production-dependencies +++ b/.scoper-production-dependencies @@ -7,3 +7,5 @@ psr/http-client psr/http-factory psr/http-message psr/simple-cache +symfony/uid +symfony/polyfill-uuid diff --git a/appinfo/info.xml b/appinfo/info.xml index e1f28be62a..489ba0fd63 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -26,7 +26,7 @@ Share your tables and views with users and groups within your cloud. Have a good time and manage whatever you want. ]]> - 2.1.1 + 2.2.0-dev.0 AGPL-3.0-or-later Florian Steffens Tables diff --git a/composer.json b/composer.json index 489681ad53..059ed891e5 100644 --- a/composer.json +++ b/composer.json @@ -66,6 +66,7 @@ "require": { "phpoffice/phpspreadsheet": "^5.1", "ext-json": "*", - "bamarni/composer-bin-plugin": "^1.9.1" + "bamarni/composer-bin-plugin": "^1.9.1", + "symfony/uid": "^6.4" } } diff --git a/composer.lock b/composer.lock index 27c9e8da40..e404bb10a3 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b78c507d2c52ea02251a257651916b49", + "content-hash": "a23ff2aecc750aa400068e3f79dc35b8", "packages": [ { "name": "bamarni/composer-bin-plugin", @@ -642,6 +642,167 @@ "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" }, "time": "2021-10-29T13:26:27+00:00" + }, + { + "name": "symfony/polyfill-uuid", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-uuid.git", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-uuid": "*" + }, + "suggest": { + "ext-uuid": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Uuid\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for uuid functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/uid", + "version": "v6.4.32", + "source": { + "type": "git", + "url": "https://github.com/symfony/uid.git", + "reference": "6b973c385f00341b246f697d82dc01a09107acdd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/uid/zipball/6b973c385f00341b246f697d82dc01a09107acdd", + "reference": "6b973c385f00341b246f697d82dc01a09107acdd", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/polyfill-uuid": "^1.15" + }, + "require-dev": { + "symfony/console": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Uid\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to generate and represent UIDs", + "homepage": "https://symfony.com", + "keywords": [ + "UID", + "ulid", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/uid/tree/v6.4.32" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-12-23T15:07:59+00:00" } ], "packages-dev": [ diff --git a/lib/Db/Column.php b/lib/Db/Column.php index 7ebcdacf35..9ced1ff698 100644 --- a/lib/Db/Column.php +++ b/lib/Db/Column.php @@ -13,6 +13,7 @@ use OCA\Tables\Dto\Column as ColumnDto; use OCA\Tables\ResponseDefinitions; use OCA\Tables\Service\ValueObject\ViewColumnInformation; +use OCA\Tables\Vendor\Symfony\Component\Uid\Uuid; use ValueError; /** @@ -20,6 +21,7 @@ * * @psalm-import-type TablesColumn from ResponseDefinitions * + * @method string getUuid() * @method getTitle(): string * @method setTitle(string $title) * @method getTableId(): int @@ -59,13 +61,11 @@ * @method setTextDefault(?string $textDefault) * @method getTextAllowedPattern(): string * @method setTextAllowedPattern(?string $textAllowedPattern) - * @method getTextAllowedPattern(): ?string * @method getTextMaxLength(): int * @method setTextMaxLength(?int $textMaxLength) * @method getTextUnique(): bool * @method setTextUnique(?bool $textUnique) * @method getSelectionOptions(): string - * @method getSelectionDefault(): string * @method setSelectionOptions(?string $selectionOptionsArray) * @method setSelectionDefault(?string $selectionDefault) * @method getSelectionDefault(): ?string @@ -114,6 +114,7 @@ class Column extends EntitySuper implements JsonSerializable { public const META_ID_TITLE = 'id'; + protected ?string $uuid = null; protected ?string $title = null; protected ?int $tableId = null; protected ?string $createdBy = null; @@ -165,6 +166,7 @@ class Column extends EntitySuper implements JsonSerializable { public function __construct() { $this->addType('id', 'integer'); + $this->addType('uuid', 'string'); $this->addType('tableId', 'integer'); $this->addType('mandatory', 'boolean'); @@ -198,8 +200,23 @@ public static function isValidMetaTypeId(int $metaTypeId): bool { ], true); } + private function assignUuid(): void { + if ($this->uuid !== null) { + throw new \RuntimeException('This column already has a UUID, they are immutable'); + } + $this->uuid = Uuid::v7()->toRfc4122(); + } + + protected function setUuid(string $uuid): void { + if ($this->uuid !== null) { + throw new \RuntimeException('This column already has a UUID, they are immutable'); + } + $this->uuid = $uuid; + } + public static function fromDto(ColumnDto $data): self { $column = new self(); + $column->assignUuid(); $column->setTitle($data->getTitle()); $column->setType($data->getType()); $column->setSubtype($data->getSubtype() ?? ''); @@ -262,6 +279,7 @@ public function setSelectionOptionsArray(array $array):void { public function jsonSerialize(): array { return [ 'id' => $this->id, + 'uuid' => $this->uuid, 'tableId' => $this->tableId, 'title' => $this->title, 'createdBy' => $this->createdBy, diff --git a/lib/Migration/Version2020Date20260513185340.php b/lib/Migration/Version2020Date20260513185340.php new file mode 100644 index 0000000000..32766789b2 --- /dev/null +++ b/lib/Migration/Version2020Date20260513185340.php @@ -0,0 +1,96 @@ +hasTable(self::TARGET_TABLE)) { + return null; + } + + $columnsTable = $schema->getTable(self::TARGET_TABLE); + if (!$columnsTable->hasColumn(self::COL_UUID)) { + $columnsTable->addColumn(self::COL_UUID, Types::STRING, [ + 'notnull' => false, + 'default' => null, + 'length' => 36, + 'comment' => 'UUIDv7 identifier to support structural updates across instances', + ]); + } + + return $schema; + } + + #[Override] + public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { + + $qbUpdate = $this->db->getQueryBuilder(); + $qbUpdate->update(self::TARGET_TABLE) + ->set(self::COL_UUID, $qbUpdate->createParameter('columnUuid')) + ->where($qbUpdate->expr()->eq(self::COL_ID, $qbUpdate->createParameter('columnLocalId'))); + + $qbSelect = $this->db->getQueryBuilder(); + $qbSelect->select(self::COL_ID) + ->from(self::TARGET_TABLE); + $select = $qbSelect->executeQuery(); + + $writeBatches = 250; + $updates = 0; + + try { + $this->db->beginTransaction(); + while (($columnId = $select->fetchOne()) !== false) { + $qbUpdate->setParameters( + [ + 'columnLocalId' => (int)$columnId, + 'columnUuid' => Uuid::v7()->toRfc4122(), + ], + [ + Types::INTEGER, + Types::STRING, + ] + ); + $qbUpdate->executeStatement(); + $updates++; + if ($updates % $writeBatches === 0) { + $this->db->commit(); + $this->db->beginTransaction(); + } + } + $this->db->commit(); + } catch (\Exception $e) { + $this->db->rollBack(); + throw $e; + } + + $select->closeCursor(); + } +}