From f7b345fc1747e83e96ca7b6a4597a5124c58f825 Mon Sep 17 00:00:00 2001 From: Arthur Schiwon Date: Wed, 13 May 2026 22:00:18 +0200 Subject: [PATCH 1/9] feat: add UUIDs to columns Signed-off-by: Arthur Schiwon --- .scoper-production-dependencies | 2 + appinfo/info.xml | 2 +- composer.json | 3 +- composer.lock | 163 +++++++++++++++++- lib/Db/Column.php | 34 +++- .../Version2020Date20260513185340.php | 101 +++++++++++ lib/ResponseDefinitions.php | 1 + lib/Service/ColumnService.php | 10 ++ openapi.json | 4 + src/types/openapi/openapi.ts | 1 + 10 files changed, 316 insertions(+), 5 deletions(-) create mode 100644 lib/Migration/Version2020Date20260513185340.php diff --git a/.scoper-production-dependencies b/.scoper-production-dependencies index 9d95f3ede6..2c54d256d7 100644 --- a/.scoper-production-dependencies +++ b/.scoper-production-dependencies @@ -4,3 +4,5 @@ maennchen/zipstream-php markbaker/complex markbaker/matrix psr/simple-cache +symfony/uid +symfony/polyfill-uuid diff --git a/appinfo/info.xml b/appinfo/info.xml index 51e1c3b75b..8a494f0a04 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.2.0 + 2.2.1-dev.0 AGPL-3.0-or-later Nextcloud GmbH and Nextcloud contributors Tables diff --git a/composer.json b/composer.json index 087b3cb81c..8fdb0bbb9b 100644 --- a/composer.json +++ b/composer.json @@ -65,6 +65,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 e5ccf2b536..7191562108 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": "1413a5746651fb36bb5390cf28852b35", + "content-hash": "a23ff2aecc750aa400068e3f79dc35b8", "packages": [ { "name": "bamarni/composer-bin-plugin", @@ -486,6 +486,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 0f1058b1cc..0b3b92f38a 100644 --- a/lib/Db/Column.php +++ b/lib/Db/Column.php @@ -12,6 +12,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; /** @@ -19,6 +20,8 @@ * * @psalm-import-type TablesColumn from ResponseDefinitions * + * @method string|null getUuid() + * @method setUuid(?string $uuid) * @method getTitle(): string * @method setTitle(string $title) * @method getTechnicalName(): string @@ -60,13 +63,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 @@ -120,6 +121,7 @@ class Column extends EntitySuper implements JsonSerializable { public const RELATION_TARGET_ID = 'targetId'; public const RELATION_LABEL_COLUMN = 'labelColumn'; + protected ?string $uuid = null; protected ?string $title = null; protected ?string $technicalName = null; protected ?int $tableId = null; @@ -172,6 +174,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'); @@ -205,8 +208,34 @@ public static function isValidMetaTypeId(int $metaTypeId): bool { ], true); } + public function setter(string $name, array $args): void { + if ($name === 'uuid') { + $this->setOrAssignUuid((string)$args[0]); + return; + } + + parent::setter($name, $args); + } + + private function setOrAssignUuid(?string $uuid): void { + if ($this->uuid !== null) { + throw new \RuntimeException('This column already has a UUID, they are immutable'); + } + if ($uuid === null) { + $this->applyUuid(Uuid::v7()->toRfc4122()); + return; + } + $this->applyUuid($uuid); + } + + private function applyUuid(string $uuid): void { + $this->uuid = $uuid; + $this->markFieldUpdated('uuid'); + } + public static function fromDto(ColumnDto $data): self { $column = new self(); + $column->assignUuid(); $column->setTitle($data->getTitle()); $column->setTechnicalName($data->getTechnicalName()); $column->setType($data->getType()); @@ -270,6 +299,7 @@ public function setSelectionOptionsArray(array $array):void { public function jsonSerialize(): array { return [ 'id' => $this->id, + 'uuid' => $this->uuid, 'tableId' => $this->tableId, 'title' => $this->title, 'technicalName' => $this->technicalName, diff --git a/lib/Migration/Version2020Date20260513185340.php b/lib/Migration/Version2020Date20260513185340.php new file mode 100644 index 0000000000..b594268a52 --- /dev/null +++ b/lib/Migration/Version2020Date20260513185340.php @@ -0,0 +1,101 @@ +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', + ]); + } + if (!$columnsTable->hasUniqueConstraint(self::INDEX_NAME)) { + $columnsTable->addUniqueIndex(['table_id', self::COL_UUID], self::INDEX_NAME); + } + + 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(); + } +} diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index c9c5b2cb21..35dafa82ea 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -120,6 +120,7 @@ * * @psalm-type TablesColumn = array{ * id: int, + * uuid: string, * title: string, * technicalName: string, * tableId: int, diff --git a/lib/Service/ColumnService.php b/lib/Service/ColumnService.php index d86b94ee72..c618e3559c 100644 --- a/lib/Service/ColumnService.php +++ b/lib/Service/ColumnService.php @@ -27,6 +27,7 @@ use OCA\Tables\Service\ValueObject\Title; use OCA\Tables\Service\ValueObject\ViewColumnInformation; use OCA\Tables\Validation\ColumnDtoValidator; +use OCA\Tables\Vendor\Symfony\Component\Uid\Uuid; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\MultipleObjectsReturnedException; use OCP\IL10N; @@ -745,6 +746,15 @@ private function enhanceColumns(?array $columns, ?View $view = null): array { */ public function importColumn(Table $table, array $column): int { $item = new Column(); + if (isset($column['uuid'])) { + $uuid = (string)$column['uuid']; + if ($uuid === '') { + $uuid = null; + } elseif (!Uuid::isValid($uuid)) { + throw new \InvalidArgumentException('Invalid UUID provided'); + } + } + $item->setUuid($uuid ?? null); $item->setTableId($table->getId()); $item->setTitle($column['title']); $item->setTechnicalName($column['technicalName'] ?? null); diff --git a/openapi.json b/openapi.json index d6dd3d8252..8e38a23348 100644 --- a/openapi.json +++ b/openapi.json @@ -72,6 +72,7 @@ "type": "object", "required": [ "id", + "uuid", "title", "technicalName", "tableId", @@ -113,6 +114,9 @@ "type": "integer", "format": "int64" }, + "uuid": { + "type": "string" + }, "title": { "type": "string" }, diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 4029c46b72..0a55354602 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -983,6 +983,7 @@ export type components = { readonly Column: { /** Format: int64 */ readonly id: number; + readonly uuid: string; readonly title: string; readonly technicalName: string; /** Format: int64 */ From d3657ac52989b5989937e928ad5614bd15a21b35 Mon Sep 17 00:00:00 2001 From: Arthur Schiwon Date: Mon, 22 Jun 2026 15:12:00 +0200 Subject: [PATCH 2/9] fix: standardize, ensure and require selection option value settings Signed-off-by: Arthur Schiwon --- lib/Analytics/AnalyticsDatasource.php | 12 +- lib/Db/Column.php | 29 ++-- lib/Model/SelectionOption.php | 51 +++++++ lib/Model/SelectionOptions.php | 138 ++++++++++++++++++ lib/Service/ColumnService.php | 16 +- lib/Service/ColumnTypes/SelectionBusiness.php | 22 +-- .../ColumnTypes/SelectionMultiBusiness.php | 19 ++- .../ColumnTypes/SelectionBusinessTest.php | 18 ++- 8 files changed, 249 insertions(+), 56 deletions(-) create mode 100644 lib/Model/SelectionOption.php create mode 100644 lib/Model/SelectionOptions.php diff --git a/lib/Analytics/AnalyticsDatasource.php b/lib/Analytics/AnalyticsDatasource.php index 170f35ddc8..9963c9f8d6 100644 --- a/lib/Analytics/AnalyticsDatasource.php +++ b/lib/Analytics/AnalyticsDatasource.php @@ -298,9 +298,9 @@ private function formatSelectionValue(Column $column, mixed $value): mixed { return ''; } - foreach ($column->getSelectionOptionsArray() as $option) { - if ((int)$option['id'] === (int)$value) { - return $option['label']; + foreach ($column->getSelectionOptionsCollection() as $option) { + if ($option->key() === (int)$value) { + return $option->label(); } } @@ -318,9 +318,9 @@ private function isMultiSelection(Column $column): bool { private function getSelectionLabels(Column $column, array $values): array { $labels = []; foreach ($values as $value) { - foreach ($column->getSelectionOptionsArray() as $option) { - if ((int)$option['id'] === (int)$value) { - $labels[] = $option['label']; + foreach ($column->getSelectionOptionsCollection() as $option) { + if ($option->key() === (int)$value) { + $labels[] = $option->label(); break; } } diff --git a/lib/Db/Column.php b/lib/Db/Column.php index 0b3b92f38a..81af6dcc3d 100644 --- a/lib/Db/Column.php +++ b/lib/Db/Column.php @@ -10,6 +10,7 @@ use JsonSerializable; use OCA\Tables\Constants\ColumnType; use OCA\Tables\Dto\Column as ColumnDto; +use OCA\Tables\Model\SelectionOptions; use OCA\Tables\ResponseDefinitions; use OCA\Tables\Service\ValueObject\ViewColumnInformation; use OCA\Tables\Vendor\Symfony\Component\Uid\Uuid; @@ -67,8 +68,8 @@ * @method setTextMaxLength(?int $textMaxLength) * @method getTextUnique(): bool * @method setTextUnique(?bool $textUnique) - * @method getSelectionOptions(): string - * @method setSelectionOptions(?string $selectionOptionsArray) + * @method string getSelectionOptions() + * @method setSelectionOptions(?string $selectionOptions) * @method setSelectionDefault(?string $selectionDefault) * @method getSelectionDefault(): ?string * @method getDatetimeDefault(): string @@ -196,6 +197,8 @@ public function __construct() { $this->addType('showUserStatus', 'boolean'); $this->addType('customSettings', 'string'); + + $this->addType('selectionOptions', 'string'); } public static function isValidMetaTypeId(int $metaTypeId): bool { @@ -252,8 +255,7 @@ public static function fromDto(ColumnDto $data): self { $column->setNumberDecimals($data->getNumberDecimals()); $column->setNumberPrefix($data->getNumberPrefix() ?? ''); $column->setNumberSuffix($data->getNumberSuffix() ?? ''); - $column->setSelectionOptions($data->getSelectionOptions()); - $column->setSelectionDefault($data->getSelectionDefault()); + $column->setSelectionOptionsCollection(SelectionOptions::createFromInputJsonString($data->getSelectionOptions() ?? '[]', $data->getSelectionDefault())); $column->setDatetimeDefault($data->getDatetimeDefault()); $column->setUsergroupDefault($data->getUsergroupDefault()); $column->setUsergroupMultipleItems($data->getUsergroupMultipleItems()); @@ -279,18 +281,17 @@ public function setUsergroupDefaultArray(array $array):void { $this->setUsergroup($json); } - public function getSelectionOptionsArray(): array { - $options = $this->getSelectionOptions(); - if ($options !== '' && $options !== null && $options !== 'null') { - return \json_decode($options, true); - } else { - return []; - } + public function getSelectionOptionsCollection(): SelectionOptions { + return SelectionOptions::createFromInputJsonString($this->getSelectionOptions() ?? '[]', $this->getSelectionDefault()); } - public function setSelectionOptionsArray(array $array):void { - $json = \json_encode($array); - $this->setSelectionOptions($json); + public function setSelectionOptionsCollection(SelectionOptions $selectionOptions): void { + $this->setSelectionOptions(json_encode($selectionOptions->jsonSerialize())); + $this->setSelectionDefault($selectionOptions->defaultSerialized()); + } + + public function getSelectionOptionsArray(): ?array { + return $this->getSelectionOptionsCollection()->jsonSerialize(); } /** diff --git a/lib/Model/SelectionOption.php b/lib/Model/SelectionOption.php new file mode 100644 index 0000000000..d85aa94420 --- /dev/null +++ b/lib/Model/SelectionOption.php @@ -0,0 +1,51 @@ +key; + } + + public function label(): string { + return $this->label; + } + + #[\Override] + public function jsonSerialize(): array { + return [ + 'id' => $this->key, + 'label' => $this->label, + ]; + } +} diff --git a/lib/Model/SelectionOptions.php b/lib/Model/SelectionOptions.php new file mode 100644 index 0000000000..e196c54059 --- /dev/null +++ b/lib/Model/SelectionOptions.php @@ -0,0 +1,138 @@ + + */ +class SelectionOptions implements JsonSerializable, Iterator { + + public function __construct( + /** @var SelectionOption[]|null */ + private ?array $selectionOptions, + private mixed $default, + ) { + if ($this->selectionOptions !== null) { + // `check` subtype has options set to null + foreach ($this->selectionOptions as $selectionOption) { + if (!$selectionOption instanceof SelectionOption) { + throw new \InvalidArgumentException('Provided selectionOption must be an instance of SelectionOption'); + } + } + } + + if (is_string($this->default)) { + $this->default = \json_decode($this->default(), true); + } + + if (is_int($this->default)) { + $this->applyIntDefault(); + } elseif (is_bool($this->default)) { + // does not need any further handling + return; + } elseif (is_array($this->default)) { + $this->applyArrayDefault(); + } + } + + private function applyIntDefault(): void { + // default value targets a specific key + foreach ($this->selectionOptions as $selectionOption) { + if ($selectionOption->key() === $this->default) { + return; + } + } + // if the default is not available anymore, we pragmatically unset it. + $this->default = null; + } + + private function applyArrayDefault(): void { + $confirmedOptions = []; + foreach ($this->default as $defaultOption) { + $normalizedDefaultOption = (int)$defaultOption; + foreach ($this->selectionOptions as $selectionOption) { + if ($selectionOption->key() === $normalizedDefaultOption) { + $confirmedOptions[] = $normalizedDefaultOption; + continue 2; + } + // if the default is not available anymore, we pragmatically ignore it. + } + } + $this->default = $confirmedOptions; + } + + public static function createFromInputArray(?array $data, null|bool|int|string $default): self { + if ($data !== null) { + $selectionOptions = []; + foreach ($data as $inputSelectionOption) { + $selectionOptions[] = SelectionOption::createFromInputArray($inputSelectionOption); + } + } + // `check` subtype has null as options + return new self($selectionOptions ?? null, $default); + } + + public static function createFromInputJsonString(?string $data, null|bool|int|string $default): self { + if ($data !== null && $data !== 'null') { + $inputArray = \json_decode($data === '' ? '[]' : $data, true); + if (!is_array($inputArray)) { + throw new \InvalidArgumentException('Provided selectionOption is not a valid JSON string'); + } + } else { + // `check` subtype has "null" as options + $inputArray = null; + } + return self::createFromInputArray($inputArray, $default); + } + + public function default(): mixed { + return $this->default; + } + + public function defaultSerialized(): string { + return \json_encode($this->default()); + } + + #[\Override] + public function jsonSerialize(): ?array { + if ($this->selectionOptions === null) { + return null; + } + return array_map(static fn (SelectionOption $so) => $so->jsonSerialize(), $this->selectionOptions); + } + + #[\Override] + public function current(): SelectionOption { + return current($this->selectionOptions); + } + + #[\Override] + public function next(): void { + next($this->selectionOptions); + } + + #[\Override] + public function key(): ?int { + return key($this->selectionOptions); + } + + #[\Override] + public function valid(): bool { + return $this->key() !== null; + } + + #[\Override] + public function rewind(): void { + reset($this->selectionOptions); + } +} diff --git a/lib/Service/ColumnService.php b/lib/Service/ColumnService.php index c618e3559c..341c1f13c3 100644 --- a/lib/Service/ColumnService.php +++ b/lib/Service/ColumnService.php @@ -22,6 +22,7 @@ use OCA\Tables\Errors\NotFoundError; use OCA\Tables\Errors\PermissionError; use OCA\Tables\Helper\UserHelper; +use OCA\Tables\Model\SelectionOptions; use OCA\Tables\Notification\NotificationHelper; use OCA\Tables\ResponseDefinitions; use OCA\Tables\Service\ValueObject\Title; @@ -85,6 +86,7 @@ public function __construct( } /** + * @return Column[] * @throws InternalError * @throws PermissionError */ @@ -152,7 +154,7 @@ public function findAllByManagedView(View $view, string $userId): array { /** * @param int $viewId * @param string|null $userId - * @return array + * @return Column[] * @throws NotFoundError * @throws PermissionError * @throws InternalError @@ -429,11 +431,10 @@ public function update( $item->setNumberMin($columnDto->getNumberMin()); $item->setNumberMax($columnDto->getNumberMax()); $item->setNumberDecimals($columnDto->getNumberDecimals()); - if ($columnDto->getSelectionOptions() !== null) { - $item->setSelectionOptions($columnDto->getSelectionOptions()); - } - if ($columnDto->getSelectionDefault() !== null) { - $item->setSelectionDefault($columnDto->getSelectionDefault()); + if ($columnDto->getSelectionOptions() !== null || $columnDto->getSelectionDefault() !== null) { + $item->setSelectionOptionsCollection(SelectionOptions::createFromInputJsonString( + $columnDto->getSelectionOptions(), $columnDto->getSelectionDefault()) + ); } $item->setDatetimeDefault($columnDto->getDatetimeDefault()); @@ -776,8 +777,7 @@ public function importColumn(Table $table, array $column): int { $item->setTextAllowedPattern($column['textAllowedPattern']); $item->setTextMaxLength($column['textMaxLength']); $item->setTextUnique($column['textUnique']); - $item->setSelectionOptions(json_encode($column['selectionOptions'])); - $item->setSelectionDefault($column['selectionDefault']); + $item->setSelectionOptionsCollection(SelectionOptions::createFromInputArray($column['selectionOptions'], $column['selectionDefault'])); $item->setDatetimeDefault($column['datetimeDefault']); $item->setUsergroupDefault(json_encode($column['usergroupDefault'])); $item->setUsergroupMultipleItems($column['usergroupMultipleItems']); diff --git a/lib/Service/ColumnTypes/SelectionBusiness.php b/lib/Service/ColumnTypes/SelectionBusiness.php index 3fd6f19c76..423478d6ec 100644 --- a/lib/Service/ColumnTypes/SelectionBusiness.php +++ b/lib/Service/ColumnTypes/SelectionBusiness.php @@ -22,9 +22,9 @@ public function parseValue($value, Column $column): string { return ''; } - foreach ($column->getSelectionOptionsArray() as $option) { - if ($option['id'] === $intValue) { - return json_encode((string)$option['id']); + foreach ($column->getSelectionOptionsCollection() as $option) { + if ($option->key() === $intValue) { + return json_encode((string)$option->key()); } } @@ -32,9 +32,9 @@ public function parseValue($value, Column $column): string { } public function parseDisplayValue($value, Column $column): string { - foreach ($column->getSelectionOptionsArray() as $option) { - if ($option['label'] === $value) { - return json_encode($option['id']); + foreach ($column->getSelectionOptionsCollection() as $option) { + if ($option->label() === $value) { + return json_encode($option->key()); } } @@ -46,7 +46,7 @@ public function parseDisplayValue($value, Column $column): string { * @param Column $column * @return bool */ - public function canBeParsed($value, Column $column): bool { + public function canBeParsed(mixed $value, Column $column): bool { if ($value === null) { return true; } @@ -56,8 +56,8 @@ public function canBeParsed($value, Column $column): bool { return false; } - foreach ($column->getSelectionOptionsArray() as $option) { - if ($option['id'] === $intValue) { + foreach ($column->getSelectionOptionsCollection() as $option) { + if ($option->key() === $intValue) { return true; } } @@ -70,8 +70,8 @@ public function canBeParsedDisplayValue($value, Column $column): bool { return true; } - foreach ($column->getSelectionOptionsArray() as $option) { - if ($option['label'] === $value) { + foreach ($column->getSelectionOptionsCollection() as $option) { + if ($option->label() === $value) { return true; } } diff --git a/lib/Service/ColumnTypes/SelectionMultiBusiness.php b/lib/Service/ColumnTypes/SelectionMultiBusiness.php index b8799a6613..edf7b6c813 100644 --- a/lib/Service/ColumnTypes/SelectionMultiBusiness.php +++ b/lib/Service/ColumnTypes/SelectionMultiBusiness.php @@ -8,10 +8,11 @@ namespace OCA\Tables\Service\ColumnTypes; use OCA\Tables\Db\Column; +use OCA\Tables\Model\SelectionOptions; class SelectionMultiBusiness extends SuperBusiness { - private array $options = []; + private SelectionOptions $options; /** * @param mixed $value (array|string|null) @@ -23,7 +24,7 @@ public function parseValue($value, Column $column): string { return json_encode([]); } - $this->options = $column->getSelectionOptionsArray(); + $this->options = $column->getSelectionOptionsCollection(); $wasString = false; if (is_string($value)) { @@ -48,20 +49,18 @@ public function parseValue($value, Column $column): string { * @param int|string|null $value int assume as option ID, string assumes a label * @return int|null return always the option ID or null */ - private function getOptionIdForValue($value): ?int { + private function getOptionIdForValue(mixed $value): ?int { if ($value === null) { return null; } foreach ($this->options as $option) { if (is_int($value)) { - if ($option['id'] === $value) { - return $option['id']; - } - } else { - if ($option['label'] === $value) { - return $option['id']; + if ($option->key() === $value) { + return $option->key(); } + } elseif ($option->label() === $value) { + return $option->key(); } } return null; @@ -77,7 +76,7 @@ public function canBeParsed($value, Column $column): bool { return true; } - $this->options = $column->getSelectionOptionsArray(); + $this->options = $column->getSelectionOptionsCollection(); $wasString = false; if (is_string($value)) { diff --git a/tests/unit/Service/ColumnTypes/SelectionBusinessTest.php b/tests/unit/Service/ColumnTypes/SelectionBusinessTest.php index babf703852..7fa416c7dd 100644 --- a/tests/unit/Service/ColumnTypes/SelectionBusinessTest.php +++ b/tests/unit/Service/ColumnTypes/SelectionBusinessTest.php @@ -8,6 +8,8 @@ namespace OCA\Tables\Service\ColumnTypes; use OCA\Tables\Db\Column; +use OCA\Tables\Model\SelectionOption; +use OCA\Tables\Model\SelectionOptions; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; @@ -21,14 +23,16 @@ public function setUp(): void { $this->logger = $this->createMock(LoggerInterface::class); $this->selectionBusiness = new SelectionBusiness($this->logger); + $selectionOptions = new SelectionOptions([ + new SelectionOption(1, 'Option 1'), + new SelectionOption(2, 'Option 2'), + new SelectionOption(3, 'Option 3'), + new SelectionOption(4, '1'), + ], null); + $this->column = $this->createMock(Column::class); - $this->column->method('getSelectionOptionsArray') - ->willReturn([ - ['id' => 1, 'label' => 'Option 1'], - ['id' => 2, 'label' => 'Option 2'], - ['id' => 3, 'label' => 'Option 3'], - ['id' => 4, 'label' => '1'], - ]); + $this->column->method('getSelectionOptionsCollection') + ->willReturn($selectionOptions); } public function parseValueProvider(): array { From 3bf46a48b87fbaa0656f6d948db06de8a3855782 Mon Sep 17 00:00:00 2001 From: Arthur Schiwon Date: Mon, 29 Jun 2026 14:49:05 +0200 Subject: [PATCH 3/9] ci(phpunit): run tests with current php interpreter Signed-off-by: Arthur Schiwon --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 8fdb0bbb9b..7df33aea7f 100644 --- a/composer.json +++ b/composer.json @@ -39,7 +39,7 @@ "lint": "find . -name \\*.php -not -path './vendor/*' -not -path './build/*' -print0 | xargs -0 -n1 php -l", "cs:check": "php-cs-fixer fix --dry-run --diff", "cs:fix": "php-cs-fixer fix", - "psalm": "./vendor/bin/psalm.phar --show-info=false --no-cache", + "psalm": "@php ./vendor/bin/psalm.phar --show-info=false --no-cache", "psalm:update-baseline": "./vendor/bin/psalm.phar --update-baseline", "psalm:fix": "./vendor/bin/psalm.phar --no-cache --alter --issues=InvalidReturnType,InvalidNullableReturnType,MismatchingDocblockParamType,MismatchingDocblockReturnType,MissingParamType,InvalidFalsableReturnType", "psalm:fix:dry": "./vendor/bin/psalm.phar --no-cache --alter --issues=InvalidReturnType,InvalidNullableReturnType,MismatchingDocblockParamType,MismatchingDocblockReturnType,MissingParamType,InvalidFalsableReturnType --dry-run", From 90863d97072cb7356fa5cc987386384c367438c2 Mon Sep 17 00:00:00 2001 From: Arthur Schiwon Date: Mon, 6 Jul 2026 20:45:15 +0200 Subject: [PATCH 4/9] fix(API): take over UUID when importing a scheme Signed-off-by: Arthur Schiwon --- lib/Controller/ApiTablesController.php | 7 ++++++- lib/Db/Column.php | 2 +- lib/Dto/Column.php | 6 ++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/lib/Controller/ApiTablesController.php b/lib/Controller/ApiTablesController.php index 7ad79e84de..b94a07d622 100644 --- a/lib/Controller/ApiTablesController.php +++ b/lib/Controller/ApiTablesController.php @@ -22,6 +22,7 @@ use OCA\Tables\Service\ColumnService; use OCA\Tables\Service\TableService; use OCA\Tables\Service\ViewService; +use OCA\Tables\Vendor\Symfony\Component\Uid\Uuid; use OCP\App\IAppManager; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\NoAdminRequired; @@ -153,6 +154,9 @@ public function createFromScheme(string $title, string $emoji, string $descripti $table = $this->service->create($title, 'custom', $emoji, $description); $colMap = []; foreach ($columns as $column) { + if (isset($column['uuid']) && !Uuid::isValid($column['uuid'])) { + throw new \InvalidArgumentException('Invalid UUID provided'); + } $col = $this->columnService->create( $this->userId, $table->getId(), @@ -182,7 +186,8 @@ public function createFromScheme(string $title, string $emoji, string $descripti usergroupSelectGroups: $column['usergroupSelectGroups'], usergroupSelectTeams: $column['usergroupSelectTeams'], showUserStatus: $column['showUserStatus'], - customSettings: empty($column['customSettings']) ? null : json_encode($column['customSettings']) + customSettings: empty($column['customSettings']) ? null : json_encode($column['customSettings']), + uuid: $column['uuid'] ?? null, ) ); $colMap[$column['id']] = $col->getId(); diff --git a/lib/Db/Column.php b/lib/Db/Column.php index 81af6dcc3d..66c2aa7cfb 100644 --- a/lib/Db/Column.php +++ b/lib/Db/Column.php @@ -238,7 +238,7 @@ private function applyUuid(string $uuid): void { public static function fromDto(ColumnDto $data): self { $column = new self(); - $column->assignUuid(); + $column->setOrAssignUuid($data->getUuid()); $column->setTitle($data->getTitle()); $column->setTechnicalName($data->getTechnicalName()); $column->setType($data->getType()); diff --git a/lib/Dto/Column.php b/lib/Dto/Column.php index 7119e9c6dc..70984f8a17 100644 --- a/lib/Dto/Column.php +++ b/lib/Dto/Column.php @@ -35,6 +35,7 @@ public function __construct( private ?bool $usergroupSelectTeams = null, private ?bool $showUserStatus = null, private ?string $customSettings = null, + private ?string $uuid = null, ) { } @@ -71,9 +72,14 @@ public static function createFromArray(array $data): self { usergroupSelectTeams: $data['usergroupSelectTeams'] ?? null, showUserStatus: $data['showUserStatus'] ?? null, customSettings: $customSettings, + uuid: $data['uuid'] ?? null, ); } + public function getUuid(): ?string { + return $this->uuid; + } + public function getType(): ?string { return $this->type; } From 58f7381d27f8bb39950e480a7a930164804000b0 Mon Sep 17 00:00:00 2001 From: Arthur Schiwon Date: Fri, 10 Jul 2026 21:48:10 +0200 Subject: [PATCH 5/9] enh: set UUID for selection options on migration Signed-off-by: Arthur Schiwon --- lib/Db/Column.php | 4 +- .../Version2020Date20260513185340.php | 58 ++++++++++++++++--- lib/Model/SelectionOption.php | 4 +- lib/Model/SelectionOptions.php | 8 +-- 4 files changed, 58 insertions(+), 16 deletions(-) diff --git a/lib/Db/Column.php b/lib/Db/Column.php index 66c2aa7cfb..2940ed19d4 100644 --- a/lib/Db/Column.php +++ b/lib/Db/Column.php @@ -255,7 +255,7 @@ public static function fromDto(ColumnDto $data): self { $column->setNumberDecimals($data->getNumberDecimals()); $column->setNumberPrefix($data->getNumberPrefix() ?? ''); $column->setNumberSuffix($data->getNumberSuffix() ?? ''); - $column->setSelectionOptionsCollection(SelectionOptions::createFromInputJsonString($data->getSelectionOptions() ?? '[]', $data->getSelectionDefault())); + $column->setSelectionOptionsCollection(SelectionOptions::createFromInputJsonString($data->getSelectionOptions() ?? '[]', $data->getSelectionDefault(), true)); $column->setDatetimeDefault($data->getDatetimeDefault()); $column->setUsergroupDefault($data->getUsergroupDefault()); $column->setUsergroupMultipleItems($data->getUsergroupMultipleItems()); @@ -282,7 +282,7 @@ public function setUsergroupDefaultArray(array $array):void { } public function getSelectionOptionsCollection(): SelectionOptions { - return SelectionOptions::createFromInputJsonString($this->getSelectionOptions() ?? '[]', $this->getSelectionDefault()); + return SelectionOptions::createFromInputJsonString($this->getSelectionOptions() ?? '[]', $this->getSelectionDefault(), true); } public function setSelectionOptionsCollection(SelectionOptions $selectionOptions): void { diff --git a/lib/Migration/Version2020Date20260513185340.php b/lib/Migration/Version2020Date20260513185340.php index b594268a52..7892b58db6 100644 --- a/lib/Migration/Version2020Date20260513185340.php +++ b/lib/Migration/Version2020Date20260513185340.php @@ -12,6 +12,7 @@ use Closure; use OCA\Tables\Vendor\Symfony\Component\Uid\Uuid; use OCP\DB\ISchemaWrapper; +use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\DB\Types; use OCP\IDBConnection; use OCP\Migration\IOutput; @@ -22,6 +23,7 @@ class Version2020Date20260513185340 extends SimpleMigrationStep { private const TARGET_TABLE = 'tables_columns'; private const COL_ID = 'id'; private const COL_UUID = 'uuid'; + private const COL_SELECTION_OPTIONS = 'selection_options'; private const INDEX_NAME = 'tables_col_uuid_uniq'; public function __construct( @@ -54,16 +56,52 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt return $schema; } + private function applyColumnOptionsUpdateIfNecessary(IQueryBuilder $query, int $columnId, ?string $rawSelectionOptions): void { + $columnSelectionOptions = trim($rawSelectionOptions ?? ''); + if ($columnSelectionOptions === '') { + return; + } + + $selectionOptions = \json_decode($columnSelectionOptions, true); + if (!is_array($selectionOptions) || empty($selectionOptions)) { + return; + } + + foreach ($selectionOptions as &$selectionOption) { + if (!isset($selectionOption['uuid'])) { + $selectionOption['uuid'] = Uuid::v7()->toRfc4122(); + } + } + + $updatedSelectionOptions = json_encode($selectionOptions); + unset($selectionOption); + $query->setParameters( + [ + 'columnLocalId' => $columnId, + 'columnSelectionOptions' => $updatedSelectionOptions, + ], + [ + Types::INTEGER, + Types::TEXT, + ] + ); + $query->executeStatement(); + } + #[Override] public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { + $qbColUuidUpdate = $this->db->getQueryBuilder(); + $qbColUuidUpdate->update(self::TARGET_TABLE) + ->set(self::COL_UUID, $qbColUuidUpdate->createParameter('columnUuid')) + ->where($qbColUuidUpdate->expr()->eq(self::COL_ID, $qbColUuidUpdate->createParameter('columnLocalId'))); - $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'))); + $qbColOptionsUuidUpdate = $this->db->getQueryBuilder(); + $qbColOptionsUuidUpdate->update(self::TARGET_TABLE) + ->set(self::COL_SELECTION_OPTIONS, $qbColOptionsUuidUpdate->createParameter('columnSelectionOptions')) + ->where($qbColOptionsUuidUpdate->expr()->eq(self::COL_ID, $qbColOptionsUuidUpdate->createParameter('columnLocalId'))); $qbSelect = $this->db->getQueryBuilder(); - $qbSelect->select(self::COL_ID) + $qbSelect->select(self::COL_ID, self::COL_SELECTION_OPTIONS) ->from(self::TARGET_TABLE); $select = $qbSelect->executeQuery(); @@ -72,8 +110,9 @@ public function postSchemaChange(IOutput $output, Closure $schemaClosure, array try { $this->db->beginTransaction(); - while (($columnId = $select->fetchOne()) !== false) { - $qbUpdate->setParameters( + while (($columnData = $select->fetchAssociative()) !== false) { + $columnId = $columnData[self::COL_ID]; + $qbColUuidUpdate->setParameters( [ 'columnLocalId' => (int)$columnId, 'columnUuid' => Uuid::v7()->toRfc4122(), @@ -83,7 +122,10 @@ public function postSchemaChange(IOutput $output, Closure $schemaClosure, array Types::STRING, ] ); - $qbUpdate->executeStatement(); + $qbColUuidUpdate->executeStatement(); + + $this->applyColumnOptionsUpdateIfNecessary($qbColOptionsUuidUpdate, (int)$columnId, $columnData[self::COL_SELECTION_OPTIONS]); + $updates++; if ($updates % $writeBatches === 0) { $this->db->commit(); diff --git a/lib/Model/SelectionOption.php b/lib/Model/SelectionOption.php index d85aa94420..d36de25e73 100644 --- a/lib/Model/SelectionOption.php +++ b/lib/Model/SelectionOption.php @@ -17,7 +17,7 @@ public function __construct( ) { } - public static function createFromInputArray(array $data): self { + public static function createFromInputArray(array $data, bool $allowPassingUuid = false): self { if (!isset($data['id']) || !is_numeric($data['id'])) { throw new \InvalidArgumentException('Only integer keys are allowed for options'); } @@ -26,7 +26,7 @@ public static function createFromInputArray(array $data): self { throw new \InvalidArgumentException('Option label is missing'); } - if (isset($data['uuid'])) { + if (isset($data['uuid']) && !$allowPassingUuid) { throw new \InvalidArgumentException('It is forbidden to set the Uuid from external'); } diff --git a/lib/Model/SelectionOptions.php b/lib/Model/SelectionOptions.php index e196c54059..9e4dc3f444 100644 --- a/lib/Model/SelectionOptions.php +++ b/lib/Model/SelectionOptions.php @@ -71,18 +71,18 @@ private function applyArrayDefault(): void { $this->default = $confirmedOptions; } - public static function createFromInputArray(?array $data, null|bool|int|string $default): self { + public static function createFromInputArray(?array $data, null|bool|int|string $default, bool $allowPassingUuid = false): self { if ($data !== null) { $selectionOptions = []; foreach ($data as $inputSelectionOption) { - $selectionOptions[] = SelectionOption::createFromInputArray($inputSelectionOption); + $selectionOptions[] = SelectionOption::createFromInputArray($inputSelectionOption, $allowPassingUuid); } } // `check` subtype has null as options return new self($selectionOptions ?? null, $default); } - public static function createFromInputJsonString(?string $data, null|bool|int|string $default): self { + public static function createFromInputJsonString(?string $data, null|bool|int|string $default, bool $allowPassingUuid = false): self { if ($data !== null && $data !== 'null') { $inputArray = \json_decode($data === '' ? '[]' : $data, true); if (!is_array($inputArray)) { @@ -92,7 +92,7 @@ public static function createFromInputJsonString(?string $data, null|bool|int|st // `check` subtype has "null" as options $inputArray = null; } - return self::createFromInputArray($inputArray, $default); + return self::createFromInputArray($inputArray, $default, $allowPassingUuid); } public function default(): mixed { From 8143e745e7703bc51af2219a13857c0eb2b23839 Mon Sep 17 00:00:00 2001 From: Arthur Schiwon Date: Fri, 17 Jul 2026 16:57:56 +0200 Subject: [PATCH 6/9] fix(Export): also return uuid for selection options Signed-off-by: Arthur Schiwon --- lib/Model/SelectionOption.php | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/lib/Model/SelectionOption.php b/lib/Model/SelectionOption.php index d36de25e73..d5d80eb7d5 100644 --- a/lib/Model/SelectionOption.php +++ b/lib/Model/SelectionOption.php @@ -10,6 +10,7 @@ namespace OCA\Tables\Model; class SelectionOption implements \JsonSerializable { + private string $uuid; public function __construct( private readonly int $key, @@ -17,6 +18,9 @@ public function __construct( ) { } + /** + * @psalm-param array{id: int|string, label: string, uuid?: string} $data + */ public static function createFromInputArray(array $data, bool $allowPassingUuid = false): self { if (!isset($data['id']) || !is_numeric($data['id'])) { throw new \InvalidArgumentException('Only integer keys are allowed for options'); @@ -30,7 +34,11 @@ public static function createFromInputArray(array $data, bool $allowPassingUuid throw new \InvalidArgumentException('It is forbidden to set the Uuid from external'); } - return new self((int)$data['id'], $data['label']); + $instance = new self((int)$data['id'], $data['label']); + if (isset($data['uuid'])) { + $instance->setUuid($data['uuid']); + } + return $instance; } public function key(): int { @@ -41,11 +49,20 @@ public function label(): string { return $this->label; } + protected function setUuid(string $uuid): void { + $this->uuid = $uuid; + } + #[\Override] public function jsonSerialize(): array { - return [ + $out = [ 'id' => $this->key, 'label' => $this->label, ]; + /** @psalm-suppress RedundantPropertyInitializationCheck */ + if (isset($this->uuid)) { + $out['uuid'] = $this->uuid; + } + return $out; } } From 16029da19c47a871a03cdec8ad92a18a1a374b8b Mon Sep 17 00:00:00 2001 From: Arthur Schiwon Date: Mon, 20 Jul 2026 15:33:53 +0200 Subject: [PATCH 7/9] enh: UUIDs for Views Signed-off-by: Arthur Schiwon --- lib/Db/View.php | 30 +++++++ .../Version2020Date20260513185340.php | 86 +++++++++++++++---- lib/Service/ViewService.php | 3 + 3 files changed, 104 insertions(+), 15 deletions(-) diff --git a/lib/Db/View.php b/lib/Db/View.php index 34783f76ef..ad6637129c 100644 --- a/lib/Db/View.php +++ b/lib/Db/View.php @@ -15,6 +15,7 @@ use OCA\Tables\Model\SortRuleSet; use OCA\Tables\ResponseDefinitions; use OCA\Tables\Service\ValueObject\ViewColumnInformation; +use OCA\Tables\Vendor\Symfony\Component\Uid\Uuid; /** * @psalm-suppress PropertyNotSetInConstructor @@ -23,6 +24,8 @@ * * @method getId(): int * @method setId(int $id) + * @method string getUuid() + * @method setUuid(?string $uuid) * @method getTitle(): string * @method setTitle(string $title) * @method getTableId(): int @@ -63,6 +66,7 @@ * @method setOwnership(string $ownership) */ class View extends EntitySuper implements JsonSerializable { + protected ?string $uuid = null; protected ?string $title = null; protected ?int $tableId = null; protected ?string $createdBy = null; @@ -88,9 +92,34 @@ class View extends EntitySuper implements JsonSerializable { public function __construct() { $this->addType('id', 'integer'); + $this->addType('uuid', 'string'); $this->addType('tableId', 'integer'); } + public function setter(string $name, array $args): void { + if ($name === 'uuid') { + $this->setOrAssignUuid($args[0]); + return; + } + parent::setter($name, $args); + } + + private function setOrAssignUuid(?string $uuid): void { + if ($this->uuid !== null) { + throw new \RuntimeException('This view already has a UUID, they are immutable'); + } + if ($uuid === null) { + $this->applyUuid(Uuid::v7()->toRfc4122()); + return; + } + $this->applyUuid($uuid); + } + + private function applyUuid(string $uuid): void { + $this->uuid = $uuid; + $this->markFieldUpdated('uuid'); + } + /** * @psalm-suppress MismatchingDocblockReturnType * @return int[] @@ -181,6 +210,7 @@ private function getSharePermissions(): ?Permissions { public function jsonSerialize(): array { $serialisedJson = [ 'id' => $this->id, + 'uuid' => $this->uuid, 'tableId' => ($this->tableId || $this->tableId === 0) ? $this->tableId : -1, 'title' => $this->title ?: '', 'description' => $this->description, diff --git a/lib/Migration/Version2020Date20260513185340.php b/lib/Migration/Version2020Date20260513185340.php index 7892b58db6..067989d001 100644 --- a/lib/Migration/Version2020Date20260513185340.php +++ b/lib/Migration/Version2020Date20260513185340.php @@ -20,11 +20,13 @@ use Override; class Version2020Date20260513185340 extends SimpleMigrationStep { - private const TARGET_TABLE = 'tables_columns'; + private const TARGET_TABLE_COLUMNS = 'tables_columns'; + private const TARGET_TABLE_VIEWS = 'tables_views'; private const COL_ID = 'id'; private const COL_UUID = 'uuid'; private const COL_SELECTION_OPTIONS = 'selection_options'; - private const INDEX_NAME = 'tables_col_uuid_uniq'; + private const INDEX_NAME_COLUMNS = 'tables_col_uuid_uniq'; + private const INDEX_NAME_VIEWS = 'tables_views_uuid_uniq'; public function __construct( private readonly IDBConnection $db, @@ -36,24 +38,28 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt /** @var ISchemaWrapper $schema */ $schema = $schemaClosure(); - if (!$schema->hasTable(self::TARGET_TABLE)) { - return null; - } + $this->addUuidColumnToTable($schema, self::TARGET_TABLE_COLUMNS, self::INDEX_NAME_COLUMNS); + $this->addUuidColumnToTable($schema, self::TARGET_TABLE_VIEWS, self::INDEX_NAME_VIEWS); + + return $schema; + } - $columnsTable = $schema->getTable(self::TARGET_TABLE); - if (!$columnsTable->hasColumn(self::COL_UUID)) { - $columnsTable->addColumn(self::COL_UUID, Types::STRING, [ + private function addUuidColumnToTable(ISchemaWrapper $schema, string $tableName, string $indexName): void { + if (!$schema->hasTable($tableName)) { + return; + } + $targetTable = $schema->getTable($tableName); + if (!$targetTable->hasColumn(self::COL_UUID)) { + $targetTable->addColumn(self::COL_UUID, Types::STRING, [ 'notnull' => false, 'default' => null, 'length' => 36, 'comment' => 'UUIDv7 identifier to support structural updates across instances', ]); } - if (!$columnsTable->hasUniqueConstraint(self::INDEX_NAME)) { - $columnsTable->addUniqueIndex(['table_id', self::COL_UUID], self::INDEX_NAME); + if (!$targetTable->hasUniqueConstraint($indexName)) { + $targetTable->addUniqueIndex(['table_id', self::COL_UUID], $indexName); } - - return $schema; } private function applyColumnOptionsUpdateIfNecessary(IQueryBuilder $query, int $columnId, ?string $rawSelectionOptions): void { @@ -90,19 +96,24 @@ private function applyColumnOptionsUpdateIfNecessary(IQueryBuilder $query, int $ #[Override] public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { + $this->fillColumnUuids(); + $this->fillViewUuids(); + } + + private function fillColumnUuids(): void { $qbColUuidUpdate = $this->db->getQueryBuilder(); - $qbColUuidUpdate->update(self::TARGET_TABLE) + $qbColUuidUpdate->update(self::TARGET_TABLE_COLUMNS) ->set(self::COL_UUID, $qbColUuidUpdate->createParameter('columnUuid')) ->where($qbColUuidUpdate->expr()->eq(self::COL_ID, $qbColUuidUpdate->createParameter('columnLocalId'))); $qbColOptionsUuidUpdate = $this->db->getQueryBuilder(); - $qbColOptionsUuidUpdate->update(self::TARGET_TABLE) + $qbColOptionsUuidUpdate->update(self::TARGET_TABLE_COLUMNS) ->set(self::COL_SELECTION_OPTIONS, $qbColOptionsUuidUpdate->createParameter('columnSelectionOptions')) ->where($qbColOptionsUuidUpdate->expr()->eq(self::COL_ID, $qbColOptionsUuidUpdate->createParameter('columnLocalId'))); $qbSelect = $this->db->getQueryBuilder(); $qbSelect->select(self::COL_ID, self::COL_SELECTION_OPTIONS) - ->from(self::TARGET_TABLE); + ->from(self::TARGET_TABLE_COLUMNS); $select = $qbSelect->executeQuery(); $writeBatches = 250; @@ -140,4 +151,49 @@ public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $select->closeCursor(); } + + private function fillViewUuids(): void { + $qbColUuidUpdate = $this->db->getQueryBuilder(); + $qbColUuidUpdate->update(self::TARGET_TABLE_VIEWS) + ->set(self::COL_UUID, $qbColUuidUpdate->createParameter('columnUuid')) + ->where($qbColUuidUpdate->expr()->eq(self::COL_ID, $qbColUuidUpdate->createParameter('columnLocalId'))); + + $qbSelect = $this->db->getQueryBuilder(); + $qbSelect->select(self::COL_ID) + ->from(self::TARGET_TABLE_VIEWS); + $select = $qbSelect->executeQuery(); + + $writeBatches = 250; + $updates = 0; + + try { + $this->db->beginTransaction(); + while (($columnData = $select->fetchAssociative()) !== false) { + $columnId = $columnData[self::COL_ID]; + $qbColUuidUpdate->setParameters( + [ + 'columnLocalId' => (int)$columnId, + 'columnUuid' => Uuid::v7()->toRfc4122(), + ], + [ + Types::INTEGER, + Types::STRING, + ] + ); + $qbColUuidUpdate->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(); + } } diff --git a/lib/Service/ViewService.php b/lib/Service/ViewService.php index 490cf01496..c1fc24c79a 100644 --- a/lib/Service/ViewService.php +++ b/lib/Service/ViewService.php @@ -33,6 +33,7 @@ use OCA\Tables\Model\ViewUpdateInput; use OCA\Tables\ResponseDefinitions; use OCA\Tables\Service\ValueObject\ViewColumnInformation; +use OCA\Tables\Vendor\Symfony\Component\Uid\Uuid; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\MultipleObjectsReturnedException; use OCP\EventDispatcher\IEventDispatcher; @@ -209,6 +210,7 @@ public function create(string $title, ?string $emoji, Table $table, ?string $use $time = new DateTime(); $item = new View(); + $item->setUuid(null); $item->setTitle($title); if ($emoji) { $item->setEmoji($emoji); @@ -634,6 +636,7 @@ public function addColumnToView(View $view, Column $column, ?string $userId = nu */ public function importView(int $tableId, array $view, string $userId): void { $item = new View(); + $item->setUuid((isset($view['uuid']) && Uuid::isValid($view['uuid'])) ? $view['uuid'] : null); $item->setTableId($tableId); $item->setTitle($view['title']); $item->setEmoji($view['emoji']); From a1ede47877ef125c5c33bbb1ccac232c1a78f3a8 Mon Sep 17 00:00:00 2001 From: Arthur Schiwon Date: Tue, 21 Jul 2026 22:40:15 +0200 Subject: [PATCH 8/9] ci: show nextcloud log on playwright failure Signed-off-by: Arthur Schiwon --- .github/workflows/playwright.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index eb03c49707..ae3b2fbd46 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -54,6 +54,16 @@ jobs: env: SERVER_BRANCH: ${{ matrix.server-versions }} + - name: Show logs + if: failure() + run: | + for id in $(docker ps -aq); do + docker container inspect "$id" --format '=== Logs for container {{.Name}} ===' + docker logs "$id" >> nextcloud.log + done + echo '=== Nextcloud server logs ===' + docker exec nextcloud-e2e-test-server_tables cat data/nextcloud.log + - name: Upload test results uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: always() From 66eee665591700c08ae81b5afea4ee7e0fbea7b9 Mon Sep 17 00:00:00 2001 From: Arthur Schiwon Date: Tue, 21 Jul 2026 23:19:18 +0200 Subject: [PATCH 9/9] ci(playwright): also install php prod deps Signed-off-by: Arthur Schiwon --- .github/workflows/playwright.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index ae3b2fbd46..c215d6151b 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -40,6 +40,9 @@ jobs: cache: 'npm' node-version-file: 'package.json' + - name: Set up dependencies + run: composer i --no-dev + - name: Install dependencies run: npm ci