diff --git a/appinfo/info.xml b/appinfo/info.xml index b60dd96c5f..e5bbca409e 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.0.1 + 2.0.2 agpl Florian Steffens Tables diff --git a/lib/Controller/Api1Controller.php b/lib/Controller/Api1Controller.php index 0db4b1eb8b..75400d2da9 100644 --- a/lib/Controller/Api1Controller.php +++ b/lib/Controller/Api1Controller.php @@ -822,6 +822,7 @@ public function indexViewColumns(int $viewId): DataResponse { * @param int|null $tableId Table ID * @param int|null $viewId View ID * @param string $title Title + * @param string|null $technicalName Technical name of the column * @param 'text'|'number'|'datetime'|'select'|'usergroup' $type Column main type * @param string|null $subtype Column sub type * @param bool $mandatory Is the column mandatory @@ -863,6 +864,7 @@ public function createColumn( ?int $tableId, ?int $viewId, string $title, + ?string $technicalName, string $type, ?string $subtype, bool $mandatory, @@ -901,6 +903,7 @@ public function createColumn( $viewId, new ColumnDto( title: $title, + technicalName: $technicalName, type: ColumnType::from($type)->value, subtype: $subtype, mandatory: $mandatory, @@ -956,6 +959,7 @@ public function createColumn( * * @param int $columnId Column ID that will be updated * @param string|null $title Title + * @param string|null $technicalName Technical name of the column * @param string|null $subtype Column sub type * @param bool $mandatory Is the column mandatory * @param string|null $description Description @@ -993,6 +997,7 @@ public function createColumn( public function updateColumn( int $columnId, ?string $title, + ?string $technicalName, ?string $subtype, ?bool $mandatory, ?string $description, @@ -1028,6 +1033,7 @@ public function updateColumn( $this->userId, new ColumnDto( title: $title, + technicalName: $technicalName, subtype: $subtype, mandatory: $mandatory, description: $description, @@ -1600,6 +1606,7 @@ public function createTableShare(int $tableId, string $receiver, string $receive * * @param int $tableId Table ID * @param string $title Title + * @param string|null $technicalName Technical name of the column * @param 'text'|'number'|'datetime'|'select'|'usergroup' $type Column main type * @param string|null $subtype Column sub type * @param bool $mandatory Is the column mandatory @@ -1641,6 +1648,7 @@ public function createTableShare(int $tableId, string $receiver, string $receive public function createTableColumn( int $tableId, string $title, + ?string $technicalName, string $type, ?string $subtype, bool $mandatory, @@ -1679,6 +1687,7 @@ public function createTableColumn( null, new ColumnDto( title: $title, + technicalName: $technicalName, type: ColumnType::from($type)->value, subtype: $subtype, mandatory: $mandatory, diff --git a/lib/Controller/ApiColumnsController.php b/lib/Controller/ApiColumnsController.php index fc73b40688..10e3a296e7 100644 --- a/lib/Controller/ApiColumnsController.php +++ b/lib/Controller/ApiColumnsController.php @@ -113,6 +113,7 @@ public function show(int $id): DataResponse { * * @param int $baseNodeId Context of the column creation * @param string $title Title + * @param string|null $technicalName Technical name of the column * @param boolean $mandatory Is mandatory * @param 'table'|'view' $baseNodeType Context type of the column creation * @param float|null $numberDefault Default value for new rows @@ -144,7 +145,7 @@ public function show(int $id): DataResponse { */ #[NoAdminRequired] #[RequirePermission(permission: Application::PERMISSION_MANAGE, typeParam: 'baseNodeType', idParam: 'baseNodeId')] - public function createNumberColumn(int $baseNodeId, string $title, ?float $numberDefault, ?int $numberDecimals, ?string $numberPrefix, ?string $numberSuffix, ?float $numberMin, ?float $numberMax, ?string $subtype = null, ?string $description = null, ?array $selectedViewIds = [], bool $mandatory = false, string $baseNodeType = 'table', array $customSettings = []): DataResponse { + public function createNumberColumn(int $baseNodeId, string $title, ?string $technicalName, ?float $numberDefault, ?int $numberDecimals, ?string $numberPrefix, ?string $numberSuffix, ?float $numberMin, ?float $numberMax, ?string $subtype = null, ?string $description = null, ?array $selectedViewIds = [], bool $mandatory = false, string $baseNodeType = 'table', array $customSettings = []): DataResponse { $tableId = $baseNodeType === 'table' ? $baseNodeId : null; $viewId = $baseNodeType === 'view' ? $baseNodeId : null; $column = $this->service->create( @@ -153,6 +154,7 @@ public function createNumberColumn(int $baseNodeId, string $title, ?float $numbe $viewId, new ColumnDto( title: $title, + technicalName: $technicalName, type: ColumnType::NUMBER->value, subtype: $subtype, mandatory: $mandatory, @@ -177,6 +179,7 @@ public function createNumberColumn(int $baseNodeId, string $title, ?float $numbe * * @param int $baseNodeId Context of the column creation * @param string $title Title + * @param string|null $technicalName Technical name of the column * @param string|null $textDefault Default * @param string|null $textAllowedPattern Allowed regex pattern * @param int|null $textMaxLength Max raw text length @@ -207,7 +210,7 @@ public function createNumberColumn(int $baseNodeId, string $title, ?float $numbe */ #[NoAdminRequired] #[RequirePermission(permission: Application::PERMISSION_MANAGE, typeParam: 'baseNodeType', idParam: 'baseNodeId')] - public function createTextColumn(int $baseNodeId, string $title, ?string $textDefault, ?string $textAllowedPattern, ?int $textMaxLength, ?bool $textUnique = false, ?string $subtype = null, ?string $description = null, ?array $selectedViewIds = [], bool $mandatory = false, string $baseNodeType = 'table', array $customSettings = []): DataResponse { + public function createTextColumn(int $baseNodeId, string $title, ?string $technicalName, ?string $textDefault, ?string $textAllowedPattern, ?int $textMaxLength, ?bool $textUnique = false, ?string $subtype = null, ?string $description = null, ?array $selectedViewIds = [], bool $mandatory = false, string $baseNodeType = 'table', array $customSettings = []): DataResponse { $tableId = $baseNodeType === 'table' ? $baseNodeId : null; $viewId = $baseNodeType === 'view' ? $baseNodeId : null; $column = $this->service->create( @@ -216,6 +219,7 @@ public function createTextColumn(int $baseNodeId, string $title, ?string $textDe $viewId, new ColumnDto( title: $title, + technicalName: $technicalName, type: ColumnType::TEXT->value, subtype: $subtype, mandatory: $mandatory, @@ -238,6 +242,7 @@ public function createTextColumn(int $baseNodeId, string $title, ?string $textDe * * @param int $baseNodeId Context of the column creation * @param string $title Title + * @param string|null $technicalName Technical name of the column * @param string $selectionOptions Json array{id: int, label: string} with * options that can be selected, eg [{"id": 1, "label": "first"},{"id": * 2, "label": "second"}] @@ -270,7 +275,7 @@ public function createTextColumn(int $baseNodeId, string $title, ?string $textDe */ #[NoAdminRequired] #[RequirePermission(permission: Application::PERMISSION_MANAGE, typeParam: 'baseNodeType', idParam: 'baseNodeId')] - public function createSelectionColumn(int $baseNodeId, string $title, string $selectionOptions, ?string $selectionDefault, ?string $subtype = null, ?string $description = null, ?array $selectedViewIds = [], bool $mandatory = false, string $baseNodeType = 'table', array $customSettings = []): DataResponse { + public function createSelectionColumn(int $baseNodeId, string $title, ?string $technicalName, string $selectionOptions, ?string $selectionDefault, ?string $subtype = null, ?string $description = null, ?array $selectedViewIds = [], bool $mandatory = false, string $baseNodeType = 'table', array $customSettings = []): DataResponse { $tableId = $baseNodeType === 'table' ? $baseNodeId : null; $viewId = $baseNodeType === 'view' ? $baseNodeId : null; $column = $this->service->create( @@ -279,6 +284,7 @@ public function createSelectionColumn(int $baseNodeId, string $title, string $se $viewId, new ColumnDto( title: $title, + technicalName: $technicalName, type: ColumnType::SELECTION->value, subtype: $subtype, mandatory: $mandatory, @@ -299,6 +305,7 @@ public function createSelectionColumn(int $baseNodeId, string $title, string $se * * @param int $baseNodeId Context of the column creation * @param string $title Title + * @param string|null $technicalName Technical name of the column * @param 'today'|'now'|null $datetimeDefault For a subtype 'date' you can * set 'today'. For a main type or subtype 'time' you can set to 'now'. * @@ -328,7 +335,7 @@ public function createSelectionColumn(int $baseNodeId, string $title, string $se */ #[NoAdminRequired] #[RequirePermission(permission: Application::PERMISSION_MANAGE, typeParam: 'baseNodeType', idParam: 'baseNodeId')] - public function createDatetimeColumn(int $baseNodeId, string $title, ?string $datetimeDefault, ?string $subtype = null, ?string $description = null, ?array $selectedViewIds = [], bool $mandatory = false, string $baseNodeType = 'table', array $customSettings = []): DataResponse { + public function createDatetimeColumn(int $baseNodeId, string $title, ?string $technicalName, ?string $datetimeDefault, ?string $subtype = null, ?string $description = null, ?array $selectedViewIds = [], bool $mandatory = false, string $baseNodeType = 'table', array $customSettings = []): DataResponse { $tableId = $baseNodeType === 'table' ? $baseNodeId : null; $viewId = $baseNodeType === 'view' ? $baseNodeId : null; $column = $this->service->create( @@ -337,6 +344,7 @@ public function createDatetimeColumn(int $baseNodeId, string $title, ?string $da $viewId, new ColumnDto( title: $title, + technicalName: $technicalName, type: ColumnType::DATETIME->value, subtype: $subtype, mandatory: $mandatory, @@ -354,6 +362,7 @@ public function createDatetimeColumn(int $baseNodeId, string $title, ?string $da * * @param int $baseNodeId Context of the column creation * @param string $title Title + * @param string|null $technicalName Technical name of the column * @param string|null $usergroupDefault Json array{id: string, type: int}, * eg [{"id": "admin", "type": 0}, {"id": "user1", "type": 0}] * @param boolean $usergroupMultipleItems Whether you can select multiple @@ -388,7 +397,7 @@ public function createDatetimeColumn(int $baseNodeId, string $title, ?string $da */ #[NoAdminRequired] #[RequirePermission(permission: Application::PERMISSION_MANAGE, typeParam: 'baseNodeType', idParam: 'baseNodeId')] - public function createUsergroupColumn(int $baseNodeId, string $title, ?string $usergroupDefault, ?bool $usergroupMultipleItems = null, ?bool $usergroupSelectUsers = null, ?bool $usergroupSelectGroups = null, ?bool $usergroupSelectTeams = null, ?bool $showUserStatus = null, ?string $description = null, ?array $selectedViewIds = [], bool $mandatory = false, string $baseNodeType = 'table', array $customSettings = []): DataResponse { + public function createUsergroupColumn(int $baseNodeId, string $title, ?string $technicalName, ?string $usergroupDefault, ?bool $usergroupMultipleItems = null, ?bool $usergroupSelectUsers = null, ?bool $usergroupSelectGroups = null, ?bool $usergroupSelectTeams = null, ?bool $showUserStatus = null, ?string $description = null, ?array $selectedViewIds = [], bool $mandatory = false, string $baseNodeType = 'table', array $customSettings = []): DataResponse { $tableId = $baseNodeType === 'table' ? $baseNodeId : null; $viewId = $baseNodeType === 'view' ? $baseNodeId : null; $column = $this->service->create( @@ -397,6 +406,7 @@ public function createUsergroupColumn(int $baseNodeId, string $title, ?string $u $viewId, new ColumnDto( title: $title, + technicalName: $technicalName, type: ColumnType::PEOPLE->value, mandatory: $mandatory, description: $description, diff --git a/lib/Db/Column.php b/lib/Db/Column.php index 7ebcdacf35..9f111f5a53 100644 --- a/lib/Db/Column.php +++ b/lib/Db/Column.php @@ -22,6 +22,8 @@ * * @method getTitle(): string * @method setTitle(string $title) + * @method getTechnicalName(): string + * @method setTechnicalName(?string $technicalName) * @method getTableId(): int * @method setTableId(int $tableId) * @method getCreatedBy(): string @@ -115,6 +117,7 @@ class Column extends EntitySuper implements JsonSerializable { public const META_ID_TITLE = 'id'; protected ?string $title = null; + protected ?string $technicalName = null; protected ?int $tableId = null; protected ?string $createdBy = null; protected ?string $createdAt = null; @@ -201,6 +204,7 @@ public static function isValidMetaTypeId(int $metaTypeId): bool { public static function fromDto(ColumnDto $data): self { $column = new self(); $column->setTitle($data->getTitle()); + $column->setTechnicalName($data->getTechnicalName()); $column->setType($data->getType()); $column->setSubtype($data->getSubtype() ?? ''); $column->setMandatory($data->isMandatory() ?? false); @@ -264,6 +268,7 @@ public function jsonSerialize(): array { 'id' => $this->id, 'tableId' => $this->tableId, 'title' => $this->title, + 'technicalName' => $this->technicalName, 'createdBy' => $this->createdBy, 'createdByDisplayName' => $this->createdByDisplayName, 'createdAt' => $this->createdAt, diff --git a/lib/Db/Row2.php b/lib/Db/Row2.php index dc24f7b335..2c210aad95 100644 --- a/lib/Db/Row2.php +++ b/lib/Db/Row2.php @@ -23,6 +23,8 @@ class Row2 implements JsonSerializable { private ?string $lastEditBy = null; private ?string $lastEditAt = null; private ?array $data = []; + /** @var array */ + private array $dataByAlias = []; private array $changedColumnIds = []; // collect column ids that have changed after $loaded = true private bool $loaded = false; // set to true if model is loaded, after that changed column ids will be collected @@ -73,6 +75,20 @@ public function getData(): ?array { return $this->data; } + /** + * @return array + */ + public function getDataByAlias(): array { + return $this->dataByAlias; + } + + /** + * @param array $dataByAlias + */ + public function setDataByAlias(array $dataByAlias): void { + $this->dataByAlias = $dataByAlias; + } + /** * @param RowDataInput|list $data * @return void @@ -145,6 +161,7 @@ public function jsonSerialize(): array { 'lastEditBy' => $this->lastEditBy, 'lastEditAt' => $this->lastEditAt, 'data' => $this->data, + 'dataByAlias' => $this->dataByAlias, ]; } diff --git a/lib/Dto/Column.php b/lib/Dto/Column.php index 5a321f1aee..7119e9c6dc 100644 --- a/lib/Dto/Column.php +++ b/lib/Dto/Column.php @@ -10,6 +10,7 @@ class Column { public function __construct( private ?string $title = null, + private ?string $technicalName = null, private ?string $type = null, private ?string $subtype = null, private ?bool $mandatory = null, @@ -45,6 +46,7 @@ public static function createFromArray(array $data): self { return new self( title: $data['title'] ?? null, + technicalName: $data['technicalName'] ?? null, type: $data['type'] ?? null, subtype: $data['subtype'] ?? null, mandatory: $data['mandatory'] ?? null, @@ -88,6 +90,10 @@ public function getTitle(): ?string { return $this->title; } + public function getTechnicalName(): ?string { + return $this->technicalName; + } + public function getDescription(): ?string { return $this->description; } diff --git a/lib/Migration/Version1000Date20260327000000.php b/lib/Migration/Version1000Date20260327000000.php new file mode 100644 index 0000000000..9369c5f76f --- /dev/null +++ b/lib/Migration/Version1000Date20260327000000.php @@ -0,0 +1,84 @@ +connection = $connection; + } + + #[Override] + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + if (!$schema->hasTable('tables_columns')) { + return null; + } + + $table = $schema->getTable('tables_columns'); + if (!$table->hasColumn('technical_name')) { + $table->addColumn('technical_name', Types::STRING, [ + 'notnull' => false, + 'length' => 200, + ]); + } + + if (!$table->hasIndex('tables_columns_table_tech_name_uq')) { + $table->addUniqueIndex(['table_id', 'technical_name'], 'tables_columns_table_tech_name_uq'); + } + + return $schema; + } + + #[Override] + public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { + $selectQb = $this->connection->getQueryBuilder(); + $selectQb->select('id') + ->from('tables_columns') + ->where( + $selectQb->expr()->orX( + $selectQb->expr()->isNull('technical_name'), + $selectQb->expr()->eq('technical_name', $selectQb->createNamedParameter('')), + ) + ); + + $result = $selectQb->executeQuery(); + $updatedCount = 0; + + try { + while ($row = $result->fetchAssociative()) { + $columnId = (int)$row['id']; + + $updateQb = $this->connection->getQueryBuilder(); + $updateQb->update('tables_columns') + ->set('technical_name', $updateQb->createNamedParameter('column_' . $columnId, IQueryBuilder::PARAM_STR)) + ->where($updateQb->expr()->eq('id', $updateQb->createNamedParameter($columnId, IQueryBuilder::PARAM_INT))); + + $updatedCount += $updateQb->executeStatement(); + } + } finally { + $result->closeCursor(); + } + + $output->info('Version1000Date20260327000000: backfilled technical_name for ' . $updatedCount . ' columns.'); + } +} diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 4ab08bdcd0..7688a3543a 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -21,6 +21,7 @@ * ownership: string, * ownerDisplayName: string|null, * createdBy: string, + * dataByAlias: array, * createdAt: string, * lastEditBy: string, * lastEditAt: string, @@ -81,6 +82,7 @@ * lastEditBy: string, * lastEditAt: string, * data: ?array{columnId: int, value: mixed}, + * dataByAlias: array, * } * * @psalm-type TablesPublicRow = array{ @@ -88,6 +90,7 @@ * createdAt: string, * lastEditAt: string, * data: ?array{columnId: int, value: mixed}, + * dataByAlias: array, * } * * @psalm-type TablesLinkShare = array{ @@ -115,6 +118,7 @@ * @psalm-type TablesColumn = array{ * id: int, * title: string, + * technicalName: string, * tableId: int, * createdBy: string, * createdByDisplayName: string, @@ -160,6 +164,7 @@ * @psalm-type TablesPublicColumn = array{ * id: int, * title: string, + * technicalName: string, * createdAt: string, * lastEditAt: string, * type: string, diff --git a/lib/Service/ColumnService.php b/lib/Service/ColumnService.php index 0c211c0d7f..62bef14dc5 100644 --- a/lib/Service/ColumnService.php +++ b/lib/Service/ColumnService.php @@ -270,6 +270,9 @@ public function create( $item = Column::fromDto($columnDto); $item->setTitle($newTitle); $item->setTableId($table->getId()); + if ($item->getTechnicalName() !== null) { + $this->assertTechnicalNameUnique($table->getId(), $item->getTechnicalName()); + } $this->updateMetadata($item, $userId, true); try { @@ -278,6 +281,15 @@ public function create( $this->logger->error($e->getMessage(), ['exception' => $e]); throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); } + if ($entity->getTechnicalName() === null || $entity->getTechnicalName() === '') { + $entity->setTechnicalName($this->buildDefaultTechnicalName($entity->getId())); + try { + $entity = $this->mapper->update($entity); + } catch (Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); + } + } if (isset($view) && $view) { // Add columns to view(s) $this->viewService->addColumnToView($view, $entity, $userId); @@ -327,6 +339,10 @@ public function update( if ($columnDto->getTitle() !== null) { $item->setTitle($columnDto->getTitle()); } + if ($columnDto->getTechnicalName() !== null) { + $this->assertTechnicalNameUnique($item->getTableId(), $columnDto->getTechnicalName(), $item->getId()); + $item->setTechnicalName($columnDto->getTechnicalName()); + } if ($columnDto->getType() !== null) { $item->setType($columnDto->getType()); } @@ -617,6 +633,7 @@ public function importColumn(Table $table, array $column): int { $item = new Column(); $item->setTableId($table->getId()); $item->setTitle($column['title']); + $item->setTechnicalName($column['technicalName'] ?? null); $item->setCreatedBy($table->getOwnership()); $item->setCreatedAt($column['createdAt']); $item->setLastEditBy($table->getOwnership()); @@ -648,10 +665,41 @@ public function importColumn(Table $table, array $column): int { try { $newColumn = $this->mapper->insert($item); + if ($newColumn->getTechnicalName() === null || $newColumn->getTechnicalName() === '') { + $newColumn->setTechnicalName($this->buildDefaultTechnicalName($newColumn->getId())); + $newColumn = $this->mapper->update($newColumn); + } } catch (\Exception $e) { $this->logger->error('importColumn insert error: ' . $e->getMessage()); throw new InternalError('importColumn insert error: ' . $e->getMessage()); } return $newColumn->getId(); } + + private function buildDefaultTechnicalName(int $columnId): string { + return 'column_' . $columnId; + } + + /** + * @throws BadRequestError + * @throws InternalError + */ + private function assertTechnicalNameUnique(int $tableId, string $technicalName, ?int $excludeCurrentColumnId = null): void { + try { + $columns = $this->mapper->findAllByTable($tableId); + } catch (Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); + } + + foreach ($columns as $column) { + if ($excludeCurrentColumnId !== null && $column->getId() === $excludeCurrentColumnId) { + continue; + } + + if ($column->getTechnicalName() === $technicalName) { + throw new BadRequestError('Technical name must be unique in the table.'); + } + } + } } diff --git a/lib/Service/RowService.php b/lib/Service/RowService.php index 954d943bec..6733c9d119 100644 --- a/lib/Service/RowService.php +++ b/lib/Service/RowService.php @@ -72,12 +72,13 @@ public function formatRows(array $rows): array { /** * @param Row2[] $rows - * @return TablesPublicRow[] + * @psalm-return TablesPublicRow[] */ public function formatRowsForPublicShare(array $rows): array { return array_map(static function (Row2 $row): array { $rowData = $row->jsonSerialize(); unset($rowData['tableId'], $rowData['createdBy'], $rowData['lastEditBy']); + /** @var TablesPublicRow $rowData */ return $rowData; }, $rows); } @@ -97,7 +98,9 @@ public function findAllByTable(int $tableId, string $userId, ?int $limit = null, $tableColumns = $this->columnMapper->findAllByTable($tableId); $showColumnIds = array_map(fn (Column $column) => $column->getId(), $tableColumns); - return $this->row2Mapper->findAll($showColumnIds, $tableId, $limit, $offset, null, null, $userId); + $rows = $this->row2Mapper->findAll($showColumnIds, $tableId, $limit, $offset, null, null, $userId); + $this->attachAliasPayloads($rows, $tableColumns); + return $rows; } else { throw new PermissionError('no read access to table id = ' . $tableId); } @@ -122,8 +125,10 @@ public function findAllByView(int $viewId, string $userId, ?int $limit = null, ? try { if ($this->permissionsService->canReadRowsByElementId($viewId, 'view', $userId)) { $view = $this->viewMapper->find($viewId); - - return $this->row2Mapper->findAll($view->getColumnIds(), $view->getTableId(), $limit, $offset, $view->getFilterArray(), $view->getSortArray(), $userId); + $viewColumns = $this->columnMapper->findAll($view->getColumnIds()); + $rows = $this->row2Mapper->findAll($view->getColumnIds(), $view->getTableId(), $limit, $offset, $view->getFilterArray(), $view->getSortArray(), $userId); + $this->attachAliasPayloads($rows, $viewColumns); + return $rows; } else { throw new PermissionError('no read access to view id = ' . $viewId); } @@ -159,6 +164,7 @@ public function find(int $rowId): Row2 { throw new NotFoundError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); } + $this->attachAliasPayload($row, $columns); return $row; } @@ -244,7 +250,9 @@ public function create(?int $tableId, ?int $viewId, RowDataInput|array $data): R author: $this->userId, ); - return $this->filterRowResult($view, $insertedRow); + $insertedRow = $this->filterRowResult($view, $insertedRow); + $this->attachAliasPayload($insertedRow, $columns); + return $insertedRow; } catch (InternalError|Exception $e) { $this->logger->error($e->getMessage(), ['exception' => $e]); throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); @@ -660,7 +668,9 @@ public function updateSet( ); } - return $this->filterRowResult($view ?? null, $updatedRow); + $updatedRow = $this->filterRowResult($view ?? null, $updatedRow); + $this->attachAliasPayload($updatedRow, $columns); + return $updatedRow; } /** @@ -735,7 +745,9 @@ public function delete(int $id, ?int $viewId, string $userId): Row2 { author: $this->userId, ); - return $this->filterRowResult($view ?? null, $deletedRow); + $deletedRow = $this->filterRowResult($view ?? null, $deletedRow); + $this->attachAliasPayload($deletedRow); + return $deletedRow; } catch (Exception $e) { $this->logger->error($e->getMessage(), ['exception' => $e]); throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); @@ -838,6 +850,72 @@ private function filterRowResult(?View $view, Row2 $row): Row2 { return $row; } + /** + * @param Row2[] $rows + * @param Column[]|null $columns + */ + private function attachAliasPayloads(array $rows, ?array $columns = null): void { + foreach ($rows as $row) { + $this->attachAliasPayload($row, $columns); + } + } + + /** + * @param Column[]|null $columns + */ + private function attachAliasPayload(Row2 $row, ?array $columns = null): void { + $data = $row->getData() ?? []; + if ($data === []) { + $row->setDataByAlias([]); + return; + } + + $columns ??= $this->loadColumnsForData($data); + $aliasByColumnId = $this->buildAliasByColumnId($columns); + + $dataByAlias = []; + foreach ($data as $cell) { + $columnId = (int)$cell['columnId']; + $alias = $aliasByColumnId[$columnId] ?? ('column_' . $columnId); + $dataByAlias[$alias] = [ + 'columnId' => $columnId, + 'value' => $cell['value'], + ]; + } + + $row->setDataByAlias($dataByAlias); + } + + /** + * Loads Column entities for the column IDs found in a row's data cells, + * skipping meta column IDs (<= 0). + * + * @param array $data + * @return Column[] + */ + private function loadColumnsForData(array $data): array { + $columnIds = array_values(array_unique(array_map(static fn (array $cell): int => (int)$cell['columnId'], $data))); + $columnIds = array_values(array_filter($columnIds, static fn (int $id): bool => $id > 0)); + return $columnIds === [] ? [] : $this->columnMapper->findAll($columnIds); + } + + /** + * Builds a map of column ID => technical name for alias resolution. + * + * @param Column[] $columns + * @return array + */ + private function buildAliasByColumnId(array $columns): array { + $aliasByColumnId = []; + foreach ($columns as $column) { + $technicalName = $column->getTechnicalName(); + if ($technicalName !== null && $technicalName !== '') { + $aliasByColumnId[$column->getId()] = $technicalName; + } + } + return $aliasByColumnId; + } + /** * @param Table $table * @param array $row diff --git a/lib/Validation/ColumnDtoValidator.php b/lib/Validation/ColumnDtoValidator.php index c0cb8f58e2..13a11f65e7 100644 --- a/lib/Validation/ColumnDtoValidator.php +++ b/lib/Validation/ColumnDtoValidator.php @@ -11,10 +11,26 @@ use OCA\Tables\Errors\BadRequestError; class ColumnDtoValidator { + private const TECHNICAL_NAME_MAX_LENGTH = 200; + + /** + * reserved keys to avoid confusion with meta fields and common row keys. + */ + private const RESERVED_TECHNICAL_NAMES = [ + 'id', + 'created_by', + 'created_at', + 'last_edit_by', + 'last_edit_at', + 'data', + ]; + /** * @throws BadRequestError */ public function validate(ColumnDto $columnDto): void { + $this->validateTechnicalName($columnDto); + $textMaxLength = $columnDto->getTextMaxLength(); if ($textMaxLength !== null && $textMaxLength < 0) { throw new BadRequestError('Maximum text length must be greater than or equal to 0.'); @@ -26,4 +42,30 @@ public function validate(ColumnDto $columnDto): void { throw new BadRequestError('Minimum number must be less than or equal to maximum number.'); } } + + /** + * @throws BadRequestError + */ + private function validateTechnicalName(ColumnDto $columnDto): void { + $technicalName = $columnDto->getTechnicalName(); + if ($technicalName === null) { + return; + } + + if ($technicalName === '') { + throw new BadRequestError('Technical name must not be empty.'); + } + + if (strlen($technicalName) > self::TECHNICAL_NAME_MAX_LENGTH) { + throw new BadRequestError('Technical name must be maximum ' . self::TECHNICAL_NAME_MAX_LENGTH . ' characters long.'); + } + + if (in_array($technicalName, self::RESERVED_TECHNICAL_NAMES, true)) { + throw new BadRequestError('Technical name is reserved.'); + } + + if (preg_match('/^[a-z][a-z0-9_]*$/', $technicalName) !== 1) { + throw new BadRequestError('Technical name must start with a letter and contain only lowercase letters, digits, and underscores.'); + } + } } diff --git a/openapi.json b/openapi.json index 0f452fb652..ac560c9a41 100644 --- a/openapi.json +++ b/openapi.json @@ -73,6 +73,7 @@ "required": [ "id", "title", + "technicalName", "tableId", "createdBy", "createdByDisplayName", @@ -115,6 +116,9 @@ "title": { "type": "string" }, + "technicalName": { + "type": "string" + }, "tableId": { "type": "integer", "format": "int64" @@ -422,6 +426,7 @@ "required": [ "id", "title", + "technicalName", "createdAt", "lastEditAt", "type", @@ -459,6 +464,9 @@ "title": { "type": "string" }, + "technicalName": { + "type": "string" + }, "createdAt": { "type": "string" }, @@ -590,7 +598,8 @@ "id", "createdAt", "lastEditAt", - "data" + "data", + "dataByAlias" ], "properties": { "id": { @@ -619,6 +628,25 @@ "type": "object" } } + }, + "dataByAlias": { + "type": "object", + "additionalProperties": { + "type": "object", + "required": [ + "columnId", + "value" + ], + "properties": { + "columnId": { + "type": "integer", + "format": "int64" + }, + "value": { + "type": "object" + } + } + } } } }, @@ -631,7 +659,8 @@ "createdAt", "lastEditBy", "lastEditAt", - "data" + "data", + "dataByAlias" ], "properties": { "id": { @@ -670,6 +699,25 @@ "type": "object" } } + }, + "dataByAlias": { + "type": "object", + "additionalProperties": { + "type": "object", + "required": [ + "columnId", + "value" + ], + "properties": { + "columnId": { + "type": "integer", + "format": "int64" + }, + "value": { + "type": "object" + } + } + } } } }, @@ -855,6 +903,7 @@ "ownership", "ownerDisplayName", "createdBy", + "dataByAlias", "createdAt", "lastEditBy", "lastEditAt", @@ -895,6 +944,25 @@ "createdBy": { "type": "string" }, + "dataByAlias": { + "type": "object", + "additionalProperties": { + "type": "object", + "required": [ + "columnId", + "value" + ], + "properties": { + "columnId": { + "type": "integer", + "format": "int64" + }, + "value": { + "type": "object" + } + } + } + }, "createdAt": { "type": "string" }, @@ -3630,6 +3698,11 @@ "type": "string", "description": "Title" }, + "technicalName": { + "type": "string", + "nullable": true, + "description": "Technical name of the column" + }, "type": { "type": "string", "enum": [ @@ -4058,6 +4131,11 @@ "type": "string", "description": "Title" }, + "technicalName": { + "type": "string", + "nullable": true, + "description": "Technical name of the column" + }, "type": { "type": "string", "enum": [ @@ -4348,6 +4426,11 @@ "nullable": true, "description": "Title" }, + "technicalName": { + "type": "string", + "nullable": true, + "description": "Technical name of the column" + }, "subtype": { "type": "string", "nullable": true, @@ -8463,6 +8546,11 @@ "type": "string", "description": "Title" }, + "technicalName": { + "type": "string", + "nullable": true, + "description": "Technical name of the column" + }, "numberDefault": { "type": "number", "format": "double", @@ -8779,6 +8867,11 @@ "type": "string", "description": "Title" }, + "technicalName": { + "type": "string", + "nullable": true, + "description": "Technical name of the column" + }, "textDefault": { "type": "string", "nullable": true, @@ -9084,6 +9177,11 @@ "type": "string", "description": "Title" }, + "technicalName": { + "type": "string", + "nullable": true, + "description": "Technical name of the column" + }, "selectionOptions": { "type": "string", "description": "Json array{id: int, label: string} with options that can be selected, eg [{\"id\": 1, \"label\": \"first\"},{\"id\": 2, \"label\": \"second\"}]" @@ -9375,6 +9473,11 @@ "type": "string", "description": "Title" }, + "technicalName": { + "type": "string", + "nullable": true, + "description": "Technical name of the column" + }, "datetimeDefault": { "type": "string", "nullable": true, @@ -9665,6 +9768,11 @@ "type": "string", "description": "Title" }, + "technicalName": { + "type": "string", + "nullable": true, + "description": "Technical name of the column" + }, "usergroupDefault": { "type": "string", "nullable": true, diff --git a/src/modules/modals/CreateColumn.vue b/src/modules/modals/CreateColumn.vue index 91c70d8a17..6f94f7c228 100644 --- a/src/modules/modals/CreateColumn.vue +++ b/src/modules/modals/CreateColumn.vue @@ -12,10 +12,12 @@
@@ -172,6 +174,7 @@ export default { numberPrefix: '', numberSuffix: '', selectedViews: [], + technicalName: '', mandatory: false, numberDefault: null, numberMin: 0, @@ -197,6 +200,7 @@ export default { typeMissingError: false, widthInvalidError: false, titleMissingError: false, + technicalNameInvalidError: false, typeOptions: [ { id: 'text', label: t('tables', 'Text') }, { id: 'text-link', label: t('tables', 'Link') }, @@ -292,6 +296,9 @@ export default { if (!this.column.title) { showInfo(t('tables', 'Please insert a title for the new column.')) this.titleMissingError = true + } else if (!this.isTechnicalNameValid()) { + showError(t('tables', 'Cannot save column. Technical name must start with a lowercase letter and only contain lowercase letters, numbers, and underscores.')) + this.technicalNameInvalidError = true } else if (this.column.customSettings?.width && (this.column.customSettings?.width < COLUMN_WIDTH_MIN || this.column.customSettings?.width > COLUMN_WIDTH_MAX)) { showError(t('tables', 'Cannot save column. Column width must be between {min} and {max}.', { min: COLUMN_WIDTH_MIN, max: COLUMN_WIDTH_MAX })) @@ -321,10 +328,12 @@ export default { this.$emit('close') }, prepareSubmitData() { + const technicalName = this.normalizeTechnicalName(this.column.technicalName) const data = { type: this.column.type, subtype: this.column.subtype, title: this.column.title, + technicalName, description: this.column.description, selectedViewIds: this.column.selectedViews.map(view => view.id), mandatory: this.column.mandatory, @@ -398,6 +407,7 @@ export default { type: this.column.type, subtype: this.column.subtype, title: this.column.title, + technicalName: this.column.technicalName, description: this.column.description, selectedViews: this.column.selectedViews, mandatory: this.column.mandatory, @@ -424,6 +434,7 @@ export default { } if (mainForm) { this.column.title = '' + this.column.technicalName = '' this.column.description = '' this.column.mandatory = false } @@ -435,9 +446,22 @@ export default { this.column.selectedViews = [] } this.titleMissingError = false + this.technicalNameInvalidError = false this.widthInvalidError = false this.typeMissingError = false }, + normalizeTechnicalName(technicalName) { + const normalized = technicalName?.trim() + return normalized === '' ? null : normalized + }, + isTechnicalNameValid() { + const technicalName = this.normalizeTechnicalName(this.column.technicalName) + if (technicalName === null) { + return true + } + + return /^[a-z][a-z0-9_]*$/.test(technicalName) + }, }, } diff --git a/src/modules/modals/EditColumn.vue b/src/modules/modals/EditColumn.vue index e5981514a4..76e4e628e7 100644 --- a/src/modules/modals/EditColumn.vue +++ b/src/modules/modals/EditColumn.vue @@ -12,10 +12,12 @@
@@ -125,6 +127,7 @@ export default { editColumn: JSON.parse(JSON.stringify(this.column)), deleteId: null, editErrorTitle: false, + technicalNameInvalidError: false, widthInvalidError: false, canSave: true, // used to avoid saving an incorrect config } @@ -171,6 +174,12 @@ export default { return } + if (!this.isTechnicalNameValid()) { + showError(t('tables', 'Cannot save column. Technical name must start with a lowercase letter and only contain lowercase letters, numbers, and underscores.')) + this.technicalNameInvalidError = true + return + } + if (this.editColumn.customSettings?.width && (this.editColumn.customSettings?.width < COLUMN_WIDTH_MIN || this.editColumn.customSettings?.width > COLUMN_WIDTH_MAX)) { showError(t('tables', 'Cannot save column. Column width must be between {min} and {max}.', { min: COLUMN_WIDTH_MIN, max: COLUMN_WIDTH_MAX })) @@ -187,6 +196,7 @@ export default { this.editColumn = null this.deleteId = null this.editErrorTitle = false + this.technicalNameInvalidError = false this.widthInvalidError = false }, async updateLocalColumn() { @@ -203,6 +213,7 @@ export default { delete data.createdBy delete data.lastEditAt delete data.lastEditBy + data.technicalName = this.normalizeTechnicalName(data.technicalName) data.customSettings = { width: data.customSettings.width } console.debug('this column data will be send', data) const res = await this.updateColumn({ @@ -216,6 +227,18 @@ export default { showSuccess(t('tables', 'The column "{column}" was updated.', { column: this.editColumn.title })) } }, + normalizeTechnicalName(technicalName) { + const normalized = technicalName?.trim() + return normalized === '' ? null : normalized + }, + isTechnicalNameValid() { + const technicalName = this.normalizeTechnicalName(this.editColumn.technicalName) + if (technicalName === null) { + return false + } + + return /^[a-z][a-z0-9_]*$/.test(technicalName) + }, }, } diff --git a/src/shared/components/ncTable/mixins/columnClass.js b/src/shared/components/ncTable/mixins/columnClass.js index 87604acb69..d055f4e032 100644 --- a/src/shared/components/ncTable/mixins/columnClass.js +++ b/src/shared/components/ncTable/mixins/columnClass.js @@ -18,6 +18,7 @@ export class AbstractColumn { this.mandatory = data.mandatory this.tableId = data.tableId this.title = data.title + this.technicalName = data.technicalName this.description = data.description this.createdByDisplayName = data.createdByDisplayName this.viewColumnInformation = data.viewColumnInformation diff --git a/src/shared/components/ncTable/partials/columnTypePartials/forms/MainForm.vue b/src/shared/components/ncTable/partials/columnTypePartials/forms/MainForm.vue index 030a878a1e..274e553167 100644 --- a/src/shared/components/ncTable/partials/columnTypePartials/forms/MainForm.vue +++ b/src/shared/components/ncTable/partials/columnTypePartials/forms/MainForm.vue @@ -12,6 +12,17 @@
+ +
+ {{ t('tables', 'Technical name') }} +
+
+ +
+
{{ t('tables', 'Description') }} @@ -98,6 +109,10 @@ export default { type: Boolean, default: null, }, + technicalName: { + type: String, + default: null, + }, selectedViews: { type: Array, default: null, @@ -110,6 +125,10 @@ export default { type: Boolean, default: false, }, + technicalNameInvalidError: { + type: Boolean, + default: false, + }, editColumn: { type: Boolean, default: false, @@ -137,6 +156,10 @@ export default { get() { return this.description }, set(description) { this.$emit('update:description', description) }, }, + localTechnicalName: { + get() { return this.technicalName }, + set(technicalName) { this.$emit('update:technicalName', technicalName) }, + }, localMandatory: { get() { return this.mandatory }, set(mandatory) { this.$emit('update:mandatory', mandatory) }, diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 14fff1e485..545b813cbc 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -914,6 +914,7 @@ export type components = { /** Format: int64 */ readonly id: number; readonly title: string; + readonly technicalName: string; /** Format: int64 */ readonly tableId: number; readonly createdBy: string; @@ -1019,6 +1020,7 @@ export type components = { /** Format: int64 */ readonly id: number; readonly title: string; + readonly technicalName: string; readonly createdAt: string; readonly lastEditAt: string; readonly type: string; @@ -1074,6 +1076,13 @@ export type components = { readonly columnId: number; readonly value: Record; } | null; + readonly dataByAlias: { + readonly [key: string]: { + /** Format: int64 */ + readonly columnId: number; + readonly value: Record; + }; + }; }; readonly Row: { /** Format: int64 */ @@ -1089,6 +1098,13 @@ export type components = { readonly columnId: number; readonly value: Record; } | null; + readonly dataByAlias: { + readonly [key: string]: { + /** Format: int64 */ + readonly columnId: number; + readonly value: Record; + }; + }; }; readonly Share: { /** Format: int64 */ @@ -1146,6 +1162,13 @@ export type components = { readonly ownership: string; readonly ownerDisplayName: string | null; readonly createdBy: string; + readonly dataByAlias: { + readonly [key: string]: { + /** Format: int64 */ + readonly columnId: number; + readonly value: Record; + }; + }; readonly createdAt: string; readonly lastEditBy: string; readonly lastEditAt: string; @@ -2666,6 +2689,8 @@ export interface operations { readonly "application/json": { /** @description Title */ readonly title: string; + /** @description Technical name of the column */ + readonly technicalName?: string | null; /** * @description Column main type * @enum {string} @@ -2926,6 +2951,8 @@ export interface operations { readonly viewId?: number | null; /** @description Title */ readonly title: string; + /** @description Technical name of the column */ + readonly technicalName?: string | null; /** * @description Column main type * @enum {string} @@ -3182,6 +3209,8 @@ export interface operations { readonly "application/json": { /** @description Title */ readonly title?: string | null; + /** @description Technical name of the column */ + readonly technicalName?: string | null; /** @description Column sub type */ readonly subtype?: string | null; /** @description Is the column mandatory */ @@ -5156,6 +5185,8 @@ export interface operations { readonly baseNodeId: number; /** @description Title */ readonly title: string; + /** @description Technical name of the column */ + readonly technicalName?: string | null; /** * Format: double * @description Default value for new rows @@ -5316,6 +5347,8 @@ export interface operations { readonly baseNodeId: number; /** @description Title */ readonly title: string; + /** @description Technical name of the column */ + readonly technicalName?: string | null; /** @description Default */ readonly textDefault?: string | null; /** @description Allowed regex pattern */ @@ -5466,6 +5499,8 @@ export interface operations { readonly baseNodeId: number; /** @description Title */ readonly title: string; + /** @description Technical name of the column */ + readonly technicalName?: string | null; /** @description Json array{id: int, label: string} with options that can be selected, eg [{"id": 1, "label": "first"},{"id": 2, "label": "second"}] */ readonly selectionOptions: string; /** @description Json int|list for default selected option(s), eg 5 or ["1", "8"] */ @@ -5606,6 +5641,8 @@ export interface operations { readonly baseNodeId: number; /** @description Title */ readonly title: string; + /** @description Technical name of the column */ + readonly technicalName?: string | null; /** * @description For a subtype 'date' you can set 'today'. For a main type or subtype 'time' you can set to 'now'. * @enum {string|null} @@ -5747,6 +5784,8 @@ export interface operations { readonly baseNodeId: number; /** @description Title */ readonly title: string; + /** @description Technical name of the column */ + readonly technicalName?: string | null; /** @description Json array{id: string, type: int}, eg [{"id": "admin", "type": 0}, {"id": "user1", "type": 0}] */ readonly usergroupDefault?: string | null; /** diff --git a/tests/integration/features/APIv1.feature b/tests/integration/features/APIv1.feature index 0c37c8beb6..f53a737955 100644 --- a/tests/integration/features/APIv1.feature +++ b/tests/integration/features/APIv1.feature @@ -522,3 +522,23 @@ Feature: APIv1 Then view "general-tasks" has exactly the following rows | task | | New general task | + + @api1 @columns @rows + Scenario: Column technicalName is returned and dataByAlias maps alias to row values + Given table "Alias test" with emoji "🔖" exists for user "participant1" as "base1" + Then column "name" exists with following properties + | type | text | + | subtype | line | + | mandatory | 0 | + | technicalName | name | + Then column "score" exists with following properties + | type | number | + | mandatory | 0 | + | technicalName | score | + Then row exists with following values + | name | check1 | + | score | 42 | + Then the last created row has the following dataByAlias + | name | check1 | + | score | 42 | + Then user "participant1" deletes table with keyword "Alias test" diff --git a/tests/integration/features/bootstrap/FeatureContext.php b/tests/integration/features/bootstrap/FeatureContext.php index 1a8f45b9c7..9624aab08c 100644 --- a/tests/integration/features/bootstrap/FeatureContext.php +++ b/tests/integration/features/bootstrap/FeatureContext.php @@ -2890,4 +2890,27 @@ public function viewHasExactRows(string $viewName, TableNode $expectedRows): voi ); } } + + /** + * @Then the last created row has the following dataByAlias + * + * @param TableNode $table + */ + public function theLastCreatedRowHasTheFollowingDataByAlias(TableNode $table): void { + $this->sendRequest( + 'GET', + '/apps/tables/api/1/rows/' . $this->rowId, + ); + + $row = $this->getDataFromResponse($this->response); + Assert::assertEquals(200, $this->response->getStatusCode()); + $dataByAlias = $row['dataByAlias']; + + foreach ($table->getRows() as [$alias, $expectedValue]) { + Assert::assertArrayHasKey($alias, $dataByAlias, "Alias '$alias' not found in dataByAlias"); + Assert::assertEquals($expectedValue, (string)$dataByAlias[$alias]['value'], "Value mismatch for alias '$alias'"); + $columnId = $this->collectionManager->getByAlias('column', $alias)['id']; + Assert::assertEquals($columnId, $dataByAlias[$alias]['columnId'], "columnId mismatch for alias '$alias'"); + } + } } diff --git a/tests/unit/Service/LegacyRowMapperTest.php b/tests/unit/Service/LegacyRowMapperTest.php index bce9a926ce..7795782d36 100644 --- a/tests/unit/Service/LegacyRowMapperTest.php +++ b/tests/unit/Service/LegacyRowMapperTest.php @@ -137,6 +137,8 @@ public function testMigrateLegacyRow() { $data2 = $row2->getData(); self::assertTrue($data === $data2); - self::assertTrue($legacyRow->jsonSerialize() === $row2->jsonSerialize()); + $row2Serialized = $row2->jsonSerialize(); + unset($row2Serialized['dataByAlias']); + self::assertTrue($legacyRow->jsonSerialize() === $row2Serialized); } } diff --git a/tests/unit/Validation/ColumnDtoValidatorTest.php b/tests/unit/Validation/ColumnDtoValidatorTest.php new file mode 100644 index 0000000000..921d28389d --- /dev/null +++ b/tests/unit/Validation/ColumnDtoValidatorTest.php @@ -0,0 +1,139 @@ +validator = new ColumnDtoValidator(); + } + + private function dto(?string $technicalName, ?int $textMaxLength = null, ?float $numberMin = null, ?float $numberMax = null): ColumnDto { + return new ColumnDto( + technicalName: $technicalName, + textMaxLength: $textMaxLength, + numberMin: $numberMin, + numberMax: $numberMax, + ); + } + + public function testNullTechnicalNameIsAccepted(): void { + $this->expectNotToPerformAssertions(); + $this->validator->validate($this->dto(null)); + } + + public function testEmptyTechnicalNameIsRejected(): void { + $this->expectException(BadRequestError::class); + $this->expectExceptionMessageMatches('/must not be empty/i'); + $this->validator->validate($this->dto('')); + } + + public function testTechnicalNameExactly200CharsIsAccepted(): void { + $this->expectNotToPerformAssertions(); + $this->validator->validate($this->dto('tes' . str_repeat('t', 196))); + } + + public function testTechnicalNameOver200CharsIsRejected(): void { + $this->expectException(BadRequestError::class); + $this->expectExceptionMessageMatches('/maximum.*200/i'); + $this->validator->validate($this->dto('test' . str_repeat('t', 197))); + } + + /** + * @dataProvider reservedNameProvider + */ + #[DataProvider('reservedNameProvider')] + public function testReservedTechnicalNamesAreRejected(string $name): void { + $this->expectException(BadRequestError::class); + $this->expectExceptionMessageMatches('/reserved/i'); + $this->validator->validate($this->dto($name)); + } + + public static function reservedNameProvider(): array { + return [ + ['id'], + ['created_by'], + ['created_at'], + ['last_edit_by'], + ['last_edit_at'], + ['data'], + ]; + } + + /** + * @dataProvider invalidFormatProvider + */ + #[DataProvider('invalidFormatProvider')] + public function testInvalidFormatIsRejected(string $name): void { + $this->expectException(BadRequestError::class); + $this->expectExceptionMessageMatches('/start with a letter|lowercase|underscores/i'); + $this->validator->validate($this->dto($name)); + } + + public static function invalidFormatProvider(): array { + return [ + ['1starts_with_digit'], + ['_starts_with_underscore'], + ['Has_Uppercase'], + ['has space'], + ['has-dash'], + ['has.dot'], + ]; + } + + /** + * @dataProvider validFormatProvider + */ + #[DataProvider('validFormatProvider')] + public function testValidFormatIsAccepted(string $name): void { + $this->expectNotToPerformAssertions(); + $this->validator->validate($this->dto($name)); + } + + public static function validFormatProvider(): array { + return [ + ['customer_name'], + ['field1'], + ['abc'], + ['a123_xyz'], + ['column_42'], + ]; + } + + public function testNegativeTextMaxLengthIsRejected(): void { + $this->expectException(BadRequestError::class); + $this->expectExceptionMessageMatches('/maximum text length/i'); + $this->validator->validate($this->dto(null, textMaxLength: -1)); + } + + public function testTextMaxLengthZeroIsAccepted(): void { + $this->expectNotToPerformAssertions(); + $this->validator->validate($this->dto(null, textMaxLength: 0)); + } + + public function testNumberMinGreaterThanMaxIsRejected(): void { + $this->expectException(BadRequestError::class); + $this->expectExceptionMessageMatches('/minimum number must be less/i'); + $this->validator->validate($this->dto(null, numberMin: 10.0, numberMax: 5.0)); + } + + public function testValidNumberRangeIsAccepted(): void { + $this->expectNotToPerformAssertions(); + $this->validator->validate($this->dto(null, numberMin: 0.0, numberMax: 100.0)); + } +}