From 2bcce2cb63fa8827fa6f9cf655ab0840ed303fc3 Mon Sep 17 00:00:00 2001 From: Arthur Schiwon Date: Wed, 13 May 2026 22:00:18 +0200 Subject: [PATCH 1/6] feat: add UUIDs to columns Signed-off-by: Arthur Schiwon --- .scoper-production-dependencies | 2 + composer.json | 3 +- composer.lock | 163 +++++++++++++++++- lib/Db/Column.php | 22 ++- .../Version2020Date20260513185340.php | 96 +++++++++++ 5 files changed, 282 insertions(+), 4 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/composer.json b/composer.json index 28c2f55eca..70c3ce0928 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 8a83391b95..3ce0d96fff 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": "46861b4f8ab5cf979b0b581545a099c5", + "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 52698e607f..6db76fbaef 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,7 @@ * * @psalm-import-type TablesColumn from ResponseDefinitions * + * @method string getUuid() * @method getTitle(): string * @method setTitle(string $title) * @method getTableId(): int @@ -58,13 +60,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 @@ -118,6 +118,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 ?int $tableId = null; protected ?string $createdBy = null; @@ -169,6 +170,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'); @@ -202,8 +204,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() ?? ''); @@ -266,6 +283,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(); + } +} From 06657d2c907d1940a6e68b2012b63572fb2731d8 Mon Sep 17 00:00:00 2001 From: Arthur Schiwon Date: Mon, 22 Jun 2026 17:56:30 +0200 Subject: [PATCH 2/6] fixup! feat: add UUIDs to columns --- appinfo/info.xml | 2 +- lib/Db/Column.php | 25 ++++++++++++++++--- .../Version2020Date20260513185340.php | 5 ++++ lib/ResponseDefinitions.php | 1 + lib/Service/ColumnService.php | 10 ++++++++ openapi.json | 4 +++ src/types/openapi/openapi.ts | 1 + 7 files changed, 44 insertions(+), 4 deletions(-) diff --git a/appinfo/info.xml b/appinfo/info.xml index 4f05ced258..94e609d38b 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/lib/Db/Column.php b/lib/Db/Column.php index 6db76fbaef..c5edeff9ce 100644 --- a/lib/Db/Column.php +++ b/lib/Db/Column.php @@ -20,7 +20,8 @@ * * @psalm-import-type TablesColumn from ResponseDefinitions * - * @method string getUuid() + * @method string|null getUuid() + * @method setUuid(?string $uuid) * @method getTitle(): string * @method setTitle(string $title) * @method getTableId(): int @@ -204,18 +205,36 @@ 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 assignUuid(): void { if ($this->uuid !== null) { throw new \RuntimeException('This column already has a UUID, they are immutable'); } - $this->uuid = Uuid::v7()->toRfc4122(); + $this->applyUuid(Uuid::v7()->toRfc4122()); } - protected function setUuid(string $uuid): void { + 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->assignUuid(); + return; + } + $this->applyUuid($uuid); + } + + private function applyUuid(string $uuid): void { $this->uuid = $uuid; + $this->markFieldUpdated('uuid'); } public static function fromDto(ColumnDto $data): self { diff --git a/lib/Migration/Version2020Date20260513185340.php b/lib/Migration/Version2020Date20260513185340.php index 32766789b2..b594268a52 100644 --- a/lib/Migration/Version2020Date20260513185340.php +++ b/lib/Migration/Version2020Date20260513185340.php @@ -22,6 +22,7 @@ class Version2020Date20260513185340 extends SimpleMigrationStep { private const TARGET_TABLE = 'tables_columns'; private const COL_ID = 'id'; private const COL_UUID = 'uuid'; + private const INDEX_NAME = 'tables_col_uuid_uniq'; public function __construct( private readonly IDBConnection $db, @@ -30,6 +31,7 @@ public function __construct( #[Override] public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /** @var ISchemaWrapper $schema */ $schema = $schemaClosure(); if (!$schema->hasTable(self::TARGET_TABLE)) { @@ -45,6 +47,9 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt '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; } diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 1dd4d5f6a8..c006a46ccb 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -117,6 +117,7 @@ * * @psalm-type TablesColumn = array{ * id: int, + * uuid: string, * title: string, * tableId: int, * createdBy: string, diff --git a/lib/Service/ColumnService.php b/lib/Service/ColumnService.php index 1f7322e8f1..0a1aeca418 100644 --- a/lib/Service/ColumnService.php +++ b/lib/Service/ColumnService.php @@ -25,6 +25,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; @@ -672,6 +673,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->setCreatedBy($table->getOwnership()); diff --git a/openapi.json b/openapi.json index b02bee3c14..76f66a1680 100644 --- a/openapi.json +++ b/openapi.json @@ -72,6 +72,7 @@ "type": "object", "required": [ "id", + "uuid", "title", "tableId", "createdBy", @@ -112,6 +113,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 e913176189..2d9ecbf65c 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -932,6 +932,7 @@ export type components = { readonly Column: { /** Format: int64 */ readonly id: number; + readonly uuid: string; readonly title: string; /** Format: int64 */ readonly tableId: number; From b8e7be784627f5907ec1608541c1d2ef53f4d459 Mon Sep 17 00:00:00 2001 From: Arthur Schiwon Date: Mon, 22 Jun 2026 15:12:00 +0200 Subject: [PATCH 3/6] 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 c5edeff9ce..76a0b45415 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; @@ -65,8 +66,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 @@ -193,6 +194,8 @@ public function __construct() { $this->addType('showUserStatus', 'boolean'); $this->addType('customSettings', 'string'); + + $this->addType('selectionOptions', 'string'); } public static function isValidMetaTypeId(int $metaTypeId): bool { @@ -255,8 +258,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()); @@ -282,18 +284,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..db916fd5fc --- /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_int($this->default)) { + $this->applyIntDefault(); + } elseif (is_string($this->default)) { + $this->applyStringDefault(); + } + } + + 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 applyStringDefault(): void { + // default value is a JSON string targeting multiple keys + + $workDefault = \json_decode($this->default(), true); + if (!is_array($workDefault)) { + $this->default = null; + return; + } + + $confirmedOptions = []; + foreach ($workDefault 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 0a1aeca418..92916900de 100644 --- a/lib/Service/ColumnService.php +++ b/lib/Service/ColumnService.php @@ -21,6 +21,7 @@ use OCA\Tables\Errors\NotFoundError; use OCA\Tables\Errors\PermissionError; use OCA\Tables\Helper\UserHelper; +use OCA\Tables\Model\SelectionOptions; use OCA\Tables\ResponseDefinitions; use OCA\Tables\Service\ValueObject\Title; use OCA\Tables\Service\ValueObject\ViewColumnInformation; @@ -75,6 +76,7 @@ public function __construct( } /** + * @return Column[] * @throws InternalError * @throws PermissionError */ @@ -142,7 +144,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 @@ -392,11 +394,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()); @@ -702,8 +703,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 9fa0d68b935c46cc6a78f58382519863f9657561 Mon Sep 17 00:00:00 2001 From: Arthur Schiwon Date: Mon, 29 Jun 2026 18:04:56 +0200 Subject: [PATCH 4/6] fixup! fix: standardize, ensure and require selection option value settings --- lib/Model/SelectionOptions.php | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/lib/Model/SelectionOptions.php b/lib/Model/SelectionOptions.php index db916fd5fc..e196c54059 100644 --- a/lib/Model/SelectionOptions.php +++ b/lib/Model/SelectionOptions.php @@ -30,10 +30,18 @@ public function __construct( } } } + + if (is_string($this->default)) { + $this->default = \json_decode($this->default(), true); + } + if (is_int($this->default)) { $this->applyIntDefault(); - } elseif (is_string($this->default)) { - $this->applyStringDefault(); + } elseif (is_bool($this->default)) { + // does not need any further handling + return; + } elseif (is_array($this->default)) { + $this->applyArrayDefault(); } } @@ -48,17 +56,9 @@ private function applyIntDefault(): void { $this->default = null; } - private function applyStringDefault(): void { - // default value is a JSON string targeting multiple keys - - $workDefault = \json_decode($this->default(), true); - if (!is_array($workDefault)) { - $this->default = null; - return; - } - + private function applyArrayDefault(): void { $confirmedOptions = []; - foreach ($workDefault as $defaultOption) { + foreach ($this->default as $defaultOption) { $normalizedDefaultOption = (int)$defaultOption; foreach ($this->selectionOptions as $selectionOption) { if ($selectionOption->key() === $normalizedDefaultOption) { From 9dfc340e9f5fbaa1846cd55c2da0bbc7a6ab2dd7 Mon Sep 17 00:00:00 2001 From: Arthur Schiwon Date: Mon, 29 Jun 2026 14:49:05 +0200 Subject: [PATCH 5/6] 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 70c3ce0928..1ca69df5ca 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 3c96380f7c74013d9515b222746162eb8d3b8d46 Mon Sep 17 00:00:00 2001 From: Arthur Schiwon Date: Mon, 6 Jul 2026 20:45:15 +0200 Subject: [PATCH 6/6] 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 76a0b45415..0952c0183d 100644 --- a/lib/Db/Column.php +++ b/lib/Db/Column.php @@ -242,7 +242,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->setType($data->getType()); $column->setSubtype($data->getSubtype() ?? ''); diff --git a/lib/Dto/Column.php b/lib/Dto/Column.php index 5a321f1aee..e4032b377e 100644 --- a/lib/Dto/Column.php +++ b/lib/Dto/Column.php @@ -34,6 +34,7 @@ public function __construct( private ?bool $usergroupSelectTeams = null, private ?bool $showUserStatus = null, private ?string $customSettings = null, + private ?string $uuid = null, ) { } @@ -69,9 +70,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; }