diff --git a/lib/Constants/ColumnType.php b/lib/Constants/ColumnType.php index f296de2f74..aa189bf000 100644 --- a/lib/Constants/ColumnType.php +++ b/lib/Constants/ColumnType.php @@ -16,4 +16,5 @@ enum ColumnType: string { case DATETIME = 'datetime'; case PEOPLE = 'usergroup'; case RELATION = 'relation'; + case RELATION_LOOKUP = 'relation_lookup'; } diff --git a/lib/Controller/Api1Controller.php b/lib/Controller/Api1Controller.php index 6e53050fd7..01f62a29b0 100644 --- a/lib/Controller/Api1Controller.php +++ b/lib/Controller/Api1Controller.php @@ -844,7 +844,8 @@ public function indexViewColumns(int $viewId): DataResponse { * Get all relation data for a table * * @param int $tableId Table ID - * @return DataResponse>, array{}>|DataResponse + * @return DataResponse|DataResponse + * @psalm-return DataResponse}>, array{}>|DataResponse * * 200: Relation data returned * 403: No permissions @@ -854,6 +855,7 @@ public function indexViewColumns(int $viewId): DataResponse { #[NoCSRFRequired] #[CORS] #[RequirePermission(permission: Application::PERMISSION_READ, type: Application::NODE_TYPE_TABLE, idParam: 'tableId')] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] public function indexTableRelations(int $tableId): DataResponse { try { return new DataResponse($this->relationService->getRelationsForTable($tableId)); @@ -876,7 +878,8 @@ public function indexTableRelations(int $tableId): DataResponse { * Get all relation data for a view * * @param int $viewId View ID - * @return DataResponse>, array{}>|DataResponse + * @return DataResponse|DataResponse + * @psalm-return DataResponse}>, array{}>|DataResponse * * 200: Relation data returned * 403: No permissions @@ -886,6 +889,7 @@ public function indexTableRelations(int $tableId): DataResponse { #[NoCSRFRequired] #[CORS] #[RequirePermission(permission: Application::PERMISSION_READ, type: Application::NODE_TYPE_VIEW, idParam: 'viewId')] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] public function indexViewRelations(int $viewId): DataResponse { try { return new DataResponse($this->relationService->getRelationsForView($viewId)); @@ -910,7 +914,7 @@ public function indexViewRelations(int $viewId): DataResponse { * @param int|null $tableId Table ID * @param int|null $viewId View ID * @param string $title Title - * @param 'text'|'number'|'datetime'|'select'|'usergroup'|'relation' $type Column main type + * @param 'text'|'number'|'datetime'|'select'|'usergroup'|'relation'|'relation_lookup' $type Column main type * @param string|null $technicalName Technical name of the column * @param string|null $subtype Column sub type * @param bool $mandatory Is the column mandatory @@ -1694,7 +1698,7 @@ public function createTableShare(int $tableId, string $receiver, string $receive * * @param int $tableId Table ID * @param string $title Title - * @param 'text'|'number'|'datetime'|'select'|'usergroup'|'relation' $type Column main type + * @param 'text'|'number'|'datetime'|'select'|'usergroup'|'relation'|'relation_lookup' $type Column main type * @param string|null $technicalName Technical name of the column * @param string|null $subtype Column sub type * @param bool $mandatory Is the column mandatory diff --git a/lib/Db/Column.php b/lib/Db/Column.php index 2940ed19d4..54a702736a 100644 --- a/lib/Db/Column.php +++ b/lib/Db/Column.php @@ -107,6 +107,7 @@ class Column extends EntitySuper implements JsonSerializable { public const TYPE_DATETIME = 'datetime'; public const TYPE_USERGROUP = 'usergroup'; public const TYPE_RELATION = 'relation'; + public const TYPE_RELATION_LOOKUP = 'relation_lookup'; public const SUBTYPE_DATETIME_DATE = 'date'; public const SUBTYPE_DATETIME_TIME = 'time'; @@ -352,6 +353,20 @@ public function getCustomSettingsArray(): array { return json_decode($this->customSettings, true) ?: []; } + /** + * @template T + * @param class-string $className + * @return T + * @throws \InvalidArgumentException + */ + public function getCustomSettingsObject(string $className): mixed { + $array = $this->getCustomSettingsArray(); + if (method_exists($className, 'fromArray')) { + return $className::fromArray($array); + } + throw new \InvalidArgumentException("Class $className must have a fromArray method"); + } + /** * @throws ValueError */ diff --git a/lib/Db/Row2Mapper.php b/lib/Db/Row2Mapper.php index 7ec48500f9..5f83b5b197 100644 --- a/lib/Db/Row2Mapper.php +++ b/lib/Db/Row2Mapper.php @@ -10,6 +10,7 @@ use DateTime; use DateTimeImmutable; use OCA\Tables\Constants\UsergroupType; +use OCA\Tables\Dto\RelationLookupSettings; use OCA\Tables\Errors\InternalError; use OCA\Tables\Errors\NotFoundError; use OCA\Tables\Helper\ColumnsHelper; @@ -60,6 +61,9 @@ public function delete(Row2 $row): Row2 { $this->db->beginTransaction(); try { foreach ($this->columnsHelper->columns as $columnType) { + if ($this->isVirtualColumn($columnType)) { + continue; + } $this->getCellMapperFromType($columnType)->deleteAllForRow($row->getId()); } $this->rowSleeveMapper->deleteById($row->getId()); @@ -219,6 +223,8 @@ private function getRows(array $rowIds, array $columnIds): array { private function getRowsChunk(array $rowIds, array $columnIds): array { $qb = $this->db->getQueryBuilder(); + $columnIds = $this->addRelationColumnIdsForLookupColumns($columnIds); + $qbSqlForColumnTypes = null; foreach ($this->columnsHelper->columns as $columnType) { $qbTmp = $this->db->getQueryBuilder(); @@ -234,6 +240,9 @@ private function getRowsChunk(array $rowIds, array $columnIds): array { $qbTmp->selectAlias($qbTmp->createFunction('NULL'), 'value_type'); } + if ($this->isVirtualColumn($columnType)) { + continue; + } $qbTmp ->from('tables_row_cells_' . $columnType) ->where($qb->expr()->in('column_id', $qb->createNamedParameter($columnIds, IQueryBuilder::PARAM_INT_ARRAY, ':columnIds'))) @@ -839,6 +848,10 @@ private function insertCell(int $rowId, int $columnId, $value, ?string $lastEdit throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); } + if ($this->isVirtualColumn($column->getType())) { + return; + } + // insert new cell $cellMapper = $this->getCellMapper($column); @@ -877,6 +890,10 @@ private function insertCell(int $rowId, int $columnId, $value, ?string $lastEdit * @throws InternalError */ private function updateCell(RowCellSuper $cell, RowCellMapperSuper $mapper, $value, Column $column): void { + if ($this->isVirtualColumn($column->getType())) { + return; + } + $this->getCellMapper($column)->applyDataToEntity($column, $cell, $value); $this->updateMetaData($cell); $mapper->updateWrapper($cell); @@ -887,6 +904,9 @@ private function updateCell(RowCellSuper $cell, RowCellMapperSuper $mapper, $val */ private function insertOrUpdateCell(int $rowId, int $columnId, $value): void { $column = $this->columnMapper->find($columnId); + if ($this->isVirtualColumn($column->getType())) { + return; + } $cellMapper = $this->getCellMapper($column); try { if ($cellMapper->hasMultipleValues()) { @@ -913,6 +933,9 @@ private function getCellMapper(Column $column): RowCellMapperSuper { } private function getCellMapperFromType(string $columnType): RowCellMapperSuper { + if ($this->isVirtualColumn($columnType)) { + throw new InternalError('Virtual columns do not have cell mappers'); + } $cellMapperClassName = 'OCA\Tables\Db\RowCell' . ucfirst($columnType) . 'Mapper'; /** @var RowCellMapperSuper $cellMapper */ try { @@ -934,6 +957,9 @@ private function getColumnDbParamType(Column $column): int { * @throws InternalError */ public function deleteDataForColumn(Column $column): void { + if ($this->isVirtualColumn($column->getType())) { + return; + } try { $this->getCellMapper($column)->deleteAllForColumn($column->getId()); } catch (Exception $e) { @@ -1002,6 +1028,9 @@ private function getFormattedDefaultValue(Column $column) { case Column::TYPE_USERGROUP: $defaultValue = $this->getCellMapper($column)->filterValueToQueryParam($column, $column->getUsergroupDefault()); break; + case Column::TYPE_RELATION_LOOKUP: + $defaultValue = null; + break; } return $defaultValue; } @@ -1029,4 +1058,26 @@ private function sortRowsByIds(array $rows, array $wantedRowIds): array { return $sortedRows; } + + public function isVirtualColumn(string $columnType): bool { + return $columnType === Column::TYPE_RELATION_LOOKUP; + } + + /** + * @param int[] $columnIds + * @return int[] + */ + public function addRelationColumnIdsForLookupColumns(array $columnIds): array { + $allColumns = $this->columnMapper->findAll($columnIds); + foreach ($allColumns as $column) { + if ($column->getType() !== Column::TYPE_RELATION_LOOKUP) { + continue; + } + + $settings = $column->getCustomSettingsObject(RelationLookupSettings::class); + $columnIds[] = $settings->relationColumnId; + } + return array_values(array_unique(array_filter($columnIds))); + } + } diff --git a/lib/Dto/RelationLookupSettings.php b/lib/Dto/RelationLookupSettings.php new file mode 100644 index 0000000000..8826979280 --- /dev/null +++ b/lib/Dto/RelationLookupSettings.php @@ -0,0 +1,23 @@ +relationType === 'view'; + } +} diff --git a/lib/Helper/ColumnsHelper.php b/lib/Helper/ColumnsHelper.php index f926d2ba3c..022008e03a 100644 --- a/lib/Helper/ColumnsHelper.php +++ b/lib/Helper/ColumnsHelper.php @@ -21,6 +21,7 @@ class ColumnsHelper { Column::TYPE_SELECTION, Column::TYPE_USERGROUP, Column::TYPE_RELATION, + Column::TYPE_RELATION_LOOKUP, ]; /** diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 0e502ec8de..25b84ab248 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -238,6 +238,8 @@ * notify-column: bool, * notify-row: bool, * } + * + * @psalm-type TablesRelationData = list}> */ class ResponseDefinitions { } diff --git a/lib/Service/ColumnService.php b/lib/Service/ColumnService.php index 341c1f13c3..3e9d9c947e 100644 --- a/lib/Service/ColumnService.php +++ b/lib/Service/ColumnService.php @@ -655,6 +655,11 @@ public function findOrCreateColumnsByTitleForTableAsArray(?int $tableId, ?int $v foreach ($titles as $title) { $i++; foreach ($allColumns as $column) { + // Skip matching columns with type relation_lookup + if ($column->getType() === Column::TYPE_RELATION_LOOKUP) { + continue; + } + if ($column->getTitle() === $title) { $result[$i] = $column; $countMatchingColumns++; diff --git a/lib/Service/ColumnTypes/RelationBusiness.php b/lib/Service/ColumnTypes/RelationBusiness.php index 7ca0a73ba8..41dc23ce5a 100644 --- a/lib/Service/ColumnTypes/RelationBusiness.php +++ b/lib/Service/ColumnTypes/RelationBusiness.php @@ -12,7 +12,7 @@ use OCA\Tables\Service\RelationService; use Psr\Log\LoggerInterface; -class RelationBusiness extends SuperBusiness implements IColumnTypeBusiness { +class RelationBusiness extends SuperBusiness { public function __construct( LoggerInterface $logger, @@ -34,7 +34,7 @@ public function parseValue($value, ?Column $column = null): string { $relationData = $this->relationService->getRelationData($column); // try to find value by label - $matchingRelation = array_filter($relationData, fn (array $relation) => $relation['label'] === $value); + $matchingRelation = array_filter($relationData, fn (array $relation) => $relation['value'] === $value); if (!empty($matchingRelation)) { return json_encode(reset($matchingRelation)['id']); } @@ -63,7 +63,7 @@ public function canBeParsed($value, ?Column $column = null): bool { $relationData = $this->relationService->getRelationData($column); // try to find value by label - $matchingRelation = array_filter($relationData, fn (array $relation) => $relation['label'] === $value); + $matchingRelation = array_filter($relationData, fn (array $relation) => $relation['value'] === $value); if (!empty($matchingRelation)) { return true; } @@ -83,7 +83,7 @@ public function validateValue(mixed $value, Column $column, string $userId, int $relationData = $this->relationService->getRelationData($column); // Try to find value by label first - $matchingRelation = array_filter($relationData, fn (array $relation) => $relation['label'] === $value); + $matchingRelation = array_filter($relationData, fn (array $relation) => $relation['value'] === $value); if (!empty($matchingRelation)) { return; } diff --git a/lib/Service/ColumnTypes/RelationLookupBusiness.php b/lib/Service/ColumnTypes/RelationLookupBusiness.php new file mode 100644 index 0000000000..d7373bfa69 --- /dev/null +++ b/lib/Service/ColumnTypes/RelationLookupBusiness.php @@ -0,0 +1,58 @@ + Cache for relation data */ private array $cacheRelationData = []; @@ -25,7 +34,8 @@ public function __construct( private ViewMapper $viewMapper, private Row2Mapper $row2Mapper, private ColumnService $columnService, - private ?string $userId, + private IUserSession $userSession, + private LoggerInterface $logger, ) { } @@ -33,7 +43,7 @@ public function __construct( * Get all relation data for a table * * @param int $tableId - * @return array Relation data grouped by column ID + * @return array}> Relation data grouped by column ID * @throws InternalError * @throws NotFoundError * @throws PermissionError @@ -42,18 +52,14 @@ public function getRelationsForTable(int $tableId): array { // Check table permissions through ColumnService $columns = $this->columnService->findAllByTable($tableId); - $relationColumns = array_filter($columns, function ($column) { - return $column->getType() === Column::TYPE_RELATION; - }); - - return $this->getRelationsForColumns($relationColumns); + return $this->getRelationsForColumnList($columns); } /** * Get all relation data for a view * * @param int $viewId - * @return array Relation data grouped by column ID + * @return array}> Relation data grouped by column ID * @throws InternalError * @throws NotFoundError * @throws PermissionError @@ -62,30 +68,71 @@ public function getRelationsForView(int $viewId): array { // Check view permissions through ColumnService $columns = $this->columnService->findAllByView($viewId); - $relationColumns = array_filter($columns, function ($column) { - return $column->getType() === Column::TYPE_RELATION; - }); + return $this->getRelationsForColumnList($columns); + } + + /** + * Get relation data for a specific column + * + * @param Column $column + * @return array Indexed per row id + */ + public function getRelationData(Column $column): array { + if ($column->getType() !== Column::TYPE_RELATION) { + return []; + } + + try { + return $this->fetchRelationValuesForTarget($column); + } catch (\InvalidArgumentException $e) { + $this->logger->warning('Invalid relation settings for column ' . $column->getId() . ': ' . $e->getMessage()); + return []; + } + } + + /** + * Get relation data for a list of columns + * + * @param Column[] $columns + * @return array}> Relation data grouped by column ID + * @throws InternalError + */ + private function getRelationsForColumnList(array $columns): array { + $relationColumns = array_filter($columns, fn (Column $column) => $column->getType() === Column::TYPE_RELATION); + $relationData = $this->fetchRelationColumns($relationColumns); + + $lookupColumns = array_filter($columns, fn (Column $column) => $column->getType() === Column::TYPE_RELATION_LOOKUP); + $relationLookupData = $this->fetchRelationLookupColumns($lookupColumns); - return $this->getRelationsForColumns($relationColumns); + return array_map( + fn (array $data) => ['column' => null, 'values' => $data], + $relationData + ) + $relationLookupData; } /** - * Get relation data for specific columns + * Fetch relation data for relation columns * - * @param Column[] $relationColumns - * @return array Relation data grouped by column ID + * @param Column[] $columns + * @return array> Relation data, indexed per column ID and row ID * @throws InternalError */ - private function getRelationsForColumns(array $relationColumns): array { - // Group columns by their target (relationType + targetId + labelColumn) + private function fetchRelationColumns(array $columns): array { $result = []; - $groupedColumns = $this->groupColumnsByTarget($relationColumns); - foreach ($groupedColumns as $target => $columns) { - $relationData = $this->getRelationDataForTarget($target, $columns[0]); + $fetchedTargets = []; - // Assign the same data to all columns with this target - foreach ($columns as $column) { - $result[$column->getId()] = $relationData; + foreach ($columns as $column) { + try { + $settings = $column->getCustomSettingsObject(RelationSettings::class); + $targetKey = $this->buildRelationCacheKey($settings->relationType, $settings->targetId, $settings->labelColumn); + + if (!isset($fetchedTargets[$targetKey])) { + $fetchedTargets[$targetKey] = $this->fetchRelationValuesForTarget($column); + } + + $result[$column->getId()] = $fetchedTargets[$targetKey]; + } catch (\InvalidArgumentException $e) { + $this->logger->warning('Invalid relation settings for column ' . $column->getId() . ': ' . $e->getMessage()); } } @@ -93,121 +140,272 @@ private function getRelationsForColumns(array $relationColumns): array { } /** - * Group relation columns by their target configuration + * Fetch relation data for relation lookup columns * * @param Column[] $columns - * @return array + * @return array}> Relation data grouped by column ID + * @throws InternalError */ - private function groupColumnsByTarget(array $columns): array { - $groups = []; + private function fetchRelationLookupColumns(array $columns): array { + $result = []; + + // Batch fetch all needed columns to avoid duplicate queries + $columnIdsToFetch = $this->collectColumnIdsForLookup($columns); + $columnsMap = $this->fetchColumnsByIds($columnIdsToFetch); foreach ($columns as $column) { - $settings = $column->getCustomSettingsArray(); - if (empty($settings['relationType']) || empty($settings['targetId']) || empty($settings['labelColumn'])) { - continue; - } + try { + $settings = $column->getCustomSettingsObject(RelationLookupSettings::class); + $relationColumn = $columnsMap[$settings->relationColumnId] ?? null; + $targetColumn = $columnsMap[$settings->targetColumnId] ?? null; + + if (!$relationColumn || !$targetColumn) { + continue; + } - $target = sprintf('%s_%s_%s', $settings['relationType'], $settings['targetId'], $settings['labelColumn']); - if (!isset($groups[$target])) { - $groups[$target] = []; + $lookupData = $this->fetchLookupValues($settings, $relationColumn); + $result[$column->getId()] = [ + 'column' => $targetColumn->jsonSerialize(), + 'values' => $lookupData, + ]; + } catch (\InvalidArgumentException $e) { + $this->logger->warning('Invalid relation lookup settings for column ' . $column->getId() . ': ' . $e->getMessage()); } - $groups[$target][] = $column; } - return $groups; + return $result; + } + + private function getCurrentUserId(): string { + $user = $this->userSession->getUser(); + return $user ? $user->getUID() : 'anonymous'; } /** - * Get relation data for a specific column + * Fetch relation values for a specific target * * @param Column $column - * @return array Indexed per row id + * @return array Indexed per row id + * @throws InternalError */ - public function getRelationData(Column $column): array { - if ($column->getType() !== Column::TYPE_RELATION) { - return []; + private function fetchRelationValuesForTarget(Column $column): array { + $settings = $column->getCustomSettingsObject(RelationSettings::class); + + $cacheKey = $this->buildRelationCacheKey($settings->relationType, $settings->targetId, $settings->labelColumn); + if (isset($this->cacheRelationData[$cacheKey])) { + return $this->cacheRelationData[$cacheKey]; } - $settings = $column->getCustomSettingsArray(); - if (empty($settings['relationType']) || empty($settings['targetId']) || empty($settings['labelColumn'])) { + try { + $targetColumn = $this->columnMapper->find($settings->labelColumn); + $rows = $this->fetchRowsForTarget($settings, [$settings->labelColumn]); + } catch (DoesNotExistException $e) { + $this->cacheRelationData[$cacheKey] = []; return []; } - $target = sprintf('%s_%s_%s', $settings['relationType'], $settings['targetId'], $settings['labelColumn']); + $result = $this->buildRelationValues($rows, $targetColumn, $settings->labelColumn); + $this->cacheRelationData[$cacheKey] = $result; + + return $result; + } - return $this->getRelationDataForTarget($target, $column); + /** + * Build a cache key for relation data + */ + private function buildRelationCacheKey(string $relationType, int $targetId, int $labelColumn): string { + return sprintf('%s_%d_%d_%s', $relationType, $targetId, $labelColumn, $this->getCurrentUserId()); } /** - * Get relation data for a specific target + * Collect all column IDs needed for lookup processing * - * @param string $target - * @param Column $column - * @return array Indexed per row id - * @throws InternalError + * @param Column[] $columns + * @return int[] */ - private function getRelationDataForTarget(string $target, Column $column): array { - // Check cache first - $cacheKey = $target . '_' . ($this->userId ?? 'anonymous'); - if (isset($this->cacheRelationData[$cacheKey])) { - return $this->cacheRelationData[$cacheKey]; + private function collectColumnIdsForLookup(array $columns): array { + $columnIds = []; + + foreach ($columns as $column) { + $settings = $column->getCustomSettingsObject(RelationLookupSettings::class); + $columnIds[] = $settings->targetColumnId; + $columnIds[] = $settings->relationColumnId; } - $settings = $column->getCustomSettingsArray(); - if (empty($settings[Column::RELATION_TYPE]) || empty($settings[Column::RELATION_TARGET_ID]) || empty($settings[Column::RELATION_LABEL_COLUMN])) { - $this->cacheRelationData[$cacheKey] = []; + return array_unique($columnIds); + } + + /** + * Fetch multiple columns by their IDs in a single batch + * + * @param int[] $columnIds + * @return array + */ + private function fetchColumnsByIds(array $columnIds): array { + if (empty($columnIds)) { return []; } - $isView = $settings[Column::RELATION_TYPE] === 'view'; - $targetId = $settings[Column::RELATION_TARGET_ID] ?? null; - try { - $targetColumn = $this->columnMapper->find($settings[Column::RELATION_LABEL_COLUMN]); - if ($isView) { - $view = $this->viewMapper->find($targetId); - $rows = $this->row2Mapper->findAll( - [$targetColumn->getId()], - $view->getTableId(), - null, - null, - $view->getFilterArray(), - $view->getSortArray(), - $this->userId - ); - } else { - $rows = $this->row2Mapper->findAll( - [$targetColumn->getId()], - $targetId, - null, - null, - null, - null, - $this->userId - ); + $columns = $this->columnMapper->findAll($columnIds); + $result = []; + foreach ($columns as $column) { + $result[$column->getId()] = $column; } + return $result; + } catch (\Exception $e) { + $this->logger->warning('Failed to fetch columns: ' . $e->getMessage()); + return []; + } + } + + /** + * Fetch lookup values for a relation lookup column + * + * @param RelationLookupSettings $settings + * @param Column $relationColumn + * @return array + */ + private function fetchLookupValues(RelationLookupSettings $settings, Column $relationColumn): array { + $relationSettings = $relationColumn->getCustomSettingsObject(RelationSettings::class); + + $cacheKey = $this->buildRelationCacheKey($relationSettings->relationType, $relationSettings->targetId, $settings->targetColumnId); + if (isset($this->cacheRelationData[$cacheKey])) { + return $this->cacheRelationData[$cacheKey]; + } + + try { + // Fetch both relation label column and lookup target column in a single query + $rows = $this->fetchRowsForTarget($relationSettings, [$relationSettings->labelColumn, $settings->targetColumnId]); + $lookupData = $this->buildLookupValues($rows, $relationSettings->labelColumn, $settings->targetColumnId); + + $this->cacheRelationData[$cacheKey] = $lookupData; + return $lookupData; } catch (DoesNotExistException $e) { - $this->cacheRelationData[$cacheKey] = []; + $this->logger->warning('Failed to fetch lookup data: ' . $e->getMessage()); return []; } + } + + /** + * Fetch rows for a relation target + * + * @param RelationSettings $settings + * @param int[] $columnIds + * @return Row2[] + * @throws DoesNotExistException + */ + private function fetchRowsForTarget(RelationSettings $settings, array $columnIds): array { + if ($settings->isView()) { + $view = $this->viewMapper->find($settings->targetId); + return $this->row2Mapper->findAll( + $columnIds, + $view->getTableId(), + null, + null, + $view->getFilterArray(), + $view->getSortArray(), + $this->getCurrentUserId() + ); + } + return $this->row2Mapper->findAll( + $columnIds, + $settings->targetId, + null, + null, + null, + null, + $this->getCurrentUserId() + ); + } + + /** + * Build relation values from rows + * + * @param Row2[] $rows + * @param Column $column + * @param int $labelColumnId + * @return array + */ + private function buildRelationValues(array $rows, Column $column, int $labelColumnId): array { $result = []; + foreach ($rows as $row) { $data = $row->getData(); - $displayFieldData = array_filter($data, function ($item) use ($settings) { - return $item['columnId'] === (int)$settings[Column::RELATION_LABEL_COLUMN]; - }); + $displayFieldData = array_filter($data, fn ($item) => $item['columnId'] === $labelColumnId); $value = reset($displayFieldData)['value'] ?? null; - // Structure compatible with Row2 format: {id: int, label: string} $rowId = (int)$row->getId(); $result[$rowId] = [ 'id' => $rowId, - 'label' => (string)$value, + 'value' => $this->formatValue($column, $value), ]; } - $this->cacheRelationData[$cacheKey] = $result; return $result; } + + /** + * Build lookup values from rows containing both relation label and target columns + * + * @param Row2[] $rows + * @param int $relationLabelColumnId + * @param int $targetColumnId + * @return array + */ + private function buildLookupValues(array $rows, int $relationLabelColumnId, int $targetColumnId): array { + $result = []; + + foreach ($rows as $row) { + $data = $row->getData(); + $rowId = (int)$row->getId(); + + // Find the relation label value + $relationLabelData = array_filter($data, fn ($item) => $item['columnId'] === $relationLabelColumnId); + $relationLabelValue = reset($relationLabelData)['value'] ?? null; + + // Find the target column value + $targetData = array_filter($data, fn ($item) => $item['columnId'] === $targetColumnId); + $targetValue = reset($targetData)['value'] ?? null; + + // Only include if relation label exists (meaning this row is referenced) + if ($relationLabelValue !== null && $relationLabelValue !== '') { + $result[$rowId] = [ + 'id' => $rowId, + 'value' => $targetValue, + ]; + } + } + + return $result; + } + + /** + * Format a cell value for display as a relation label. + * Relation lookup only supports text-line and number columns. + */ + private function formatValue(Column $column, mixed $value): string { + if ($value === null || $value === '' || $value === []) { + return ''; + } + + return match ($column->getType()) { + Column::TYPE_TEXT => trim(strip_tags((string)$value)), + Column::TYPE_NUMBER => $this->formatNumberValue($column, $value), + default => (string)$value, + }; + } + + private function formatNumberValue(Column $column, mixed $value): string { + if ($value === null || $value === '') { + return ''; + } + if (!is_numeric($value)) { + return (string)$value; + } + $decimals = $column->getNumberDecimals() ?? 0; + $formatted = number_format((float)$value, $decimals, '.', ''); + return $column->getNumberPrefix() . $formatted . $column->getNumberSuffix(); + } } diff --git a/lib/Service/RowService.php b/lib/Service/RowService.php index 252e428118..d01381377a 100644 --- a/lib/Service/RowService.php +++ b/lib/Service/RowService.php @@ -393,6 +393,10 @@ private function cleanupAndValidateData(RowDataInput $data, array $columns, ?int $column = $this->getColumnFromColumnsArray($columnId, $columns); + if ($column && $this->row2Mapper->isVirtualColumn($column->getType())) { + continue; + } + if ($column) { $this->validateColumnValueLimits($column, $entry['value']); $columnBusiness = $this->columnsHelper->getColumnBusinessObject($column); @@ -919,6 +923,8 @@ private function filterRowResult(?View $view, Row2 $row): Row2 { } $columnIds = $view->getColumnIds(); + $columnIds = $this->row2Mapper->addRelationColumnIdsForLookupColumns($columnIds); + $row->filterDataByColumns($columnIds); $row->filterDataByAliasByColumns($columnIds); diff --git a/openapi.json b/openapi.json index 0234b5c0f6..29437de912 100644 --- a/openapi.json +++ b/openapi.json @@ -673,6 +673,45 @@ } } }, + "RelationData": { + "type": "array", + "items": { + "type": "object", + "required": [ + "column", + "values" + ], + "properties": { + "column": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/Column" + } + ] + }, + "values": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "value" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "value": { + "type": "object" + } + } + } + } + } + } + }, "Row": { "type": "object", "required": [ @@ -3848,7 +3887,8 @@ "datetime", "select", "usergroup", - "relation" + "relation", + "relation_lookup" ], "description": "Column main type" }, @@ -4282,7 +4322,8 @@ "datetime", "select", "usergroup", - "relation" + "relation", + "relation_lookup" ], "description": "Column main type" }, @@ -4995,6 +5036,314 @@ } } }, + "/index.php/apps/tables/api/1/tables/{tableId}/relations": { + "get": { + "operationId": "api1-index-table-relations", + "summary": "Get all relation data for a table", + "description": "This endpoint allows CORS requests", + "tags": [ + "api1" + ], + "security": [ + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "tableId", + "in": "path", + "description": "Table ID", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "Relation data returned", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/RelationData" + }, + { + "type": "array", + "items": { + "type": "object", + "required": [ + "column", + "values" + ], + "properties": { + "column": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/Column" + } + ] + }, + "values": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "value" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "value": { + "type": "object" + } + } + } + } + } + } + } + ] + } + } + } + }, + "403": { + "description": "No permissions", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/index.php/apps/tables/api/1/views/{viewId}/relations": { + "get": { + "operationId": "api1-index-view-relations", + "summary": "Get all relation data for a view", + "description": "This endpoint allows CORS requests", + "tags": [ + "api1" + ], + "security": [ + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "viewId", + "in": "path", + "description": "View ID", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "Relation data returned", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/RelationData" + }, + { + "type": "array", + "items": { + "type": "object", + "required": [ + "column", + "values" + ], + "properties": { + "column": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/Column" + } + ] + }, + "values": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "value" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "value": { + "type": "object" + } + } + } + } + } + } + } + ] + } + } + } + }, + "403": { + "description": "No permissions", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + }, "/index.php/apps/tables/api/1/tables/{tableId}/rows/simple": { "get": { "operationId": "api1-index-table-rows-simple", diff --git a/src/modules/main/partials/ColumnFormComponent.vue b/src/modules/main/partials/ColumnFormComponent.vue index 491ae29b7c..37aedd10ea 100644 --- a/src/modules/main/partials/ColumnFormComponent.vue +++ b/src/modules/main/partials/ColumnFormComponent.vue @@ -23,6 +23,7 @@ import DatetimeTimeForm from '../../../shared/components/ncTable/partials/rowTyp import TextRichForm from '../../../shared/components/ncTable/partials/rowTypePartials/TextRichForm.vue' import UsergroupForm from '../../../shared/components/ncTable/partials/rowTypePartials/UsergroupForm.vue' import RelationForm from '../../../shared/components/ncTable/partials/rowTypePartials/RelationForm.vue' +import RelationLookupForm from '../../../shared/components/ncTable/partials/rowTypePartials/RelationLookupForm.vue' export default { name: 'ColumnFormComponent', @@ -42,6 +43,7 @@ export default { DatetimeTimeForm, UsergroupForm, RelationForm, + RelationLookupForm, }, props: { column: { @@ -58,7 +60,7 @@ export default { ], data() { return { - value_data: this.value, + value_data: this.getValueForColumn(), } }, computed: { @@ -79,10 +81,13 @@ export default { this.$emit('update:value', this.value_data) }, value() { - this.value_data = this.value + this.value_data = this.getValueForColumn() }, }, methods: { + getValueForColumn() { + return this.column.getValueForForm(this.value) + }, snakeToCamel(str) { str = str.toLowerCase().replace(/([-_][a-z])/g, group => group diff --git a/src/modules/main/partials/ColumnTypeSelection.vue b/src/modules/main/partials/ColumnTypeSelection.vue index 9f861c91ab..a237c6dabe 100644 --- a/src/modules/main/partials/ColumnTypeSelection.vue +++ b/src/modules/main/partials/ColumnTypeSelection.vue @@ -20,6 +20,7 @@ +
{{ props.label }}
@@ -35,6 +36,7 @@ +
{{ props.label }}
@@ -94,6 +96,7 @@ export default { { id: 'datetime', label: t('tables', 'Date and time') }, { id: 'usergroup', label: t('tables', 'Users and groups') }, { id: 'relation', label: t('tables', 'Relation') }, + { id: 'relation_lookup', label: t('tables', 'Relation lookup') }, ], } }, diff --git a/src/modules/main/partials/editViewPartials/filter/FilterEntry.vue b/src/modules/main/partials/editViewPartials/filter/FilterEntry.vue index f9612f5809..1fe291d8d8 100644 --- a/src/modules/main/partials/editViewPartials/filter/FilterEntry.vue +++ b/src/modules/main/partials/editViewPartials/filter/FilterEntry.vue @@ -215,10 +215,10 @@ export default { // Get relations from DataStore using the column's tableId const dataStore = useDataStore() const columnRelations = dataStore.getRelations(this.selectedColumn.id) - Object.values(columnRelations).forEach(item => { + Object.entries(columnRelations.values || {}).forEach(([, item]) => { options.push({ id: '@relation-id-' + item.id, - label: item.label, + label: item.value, }) }) diff --git a/src/modules/modals/CreateColumn.vue b/src/modules/modals/CreateColumn.vue index b6095ffd7f..e50a0504c0 100644 --- a/src/modules/modals/CreateColumn.vue +++ b/src/modules/modals/CreateColumn.vue @@ -127,6 +127,7 @@ import TextRichForm from '../../shared/components/ncTable/partials/columnTypePar import { ColumnTypes } from '../../shared/components/ncTable/mixins/columnHandler.js' import UsergroupForm from '../../shared/components/ncTable/partials/columnTypePartials/forms/UsergroupForm.vue' import RelationForm from '../../shared/components/ncTable/partials/columnTypePartials/forms/RelationForm.vue' +import RelationLookupForm from '../../shared/components/ncTable/partials/columnTypePartials/forms/RelationLookupForm.vue' import { useTablesStore } from '../../store/store.js' import { useDataStore } from '../../store/data.js' import { mapActions } from 'pinia' @@ -155,6 +156,7 @@ export default { SelectionMultiForm, UsergroupForm, RelationForm, + RelationLookupForm, }, props: { showModal: { @@ -333,6 +335,10 @@ export default { showInfo(t('tables', 'Please select a target.')) } else if (this.column.type === ColumnTypes.Relation && !this.column.customSettings?.labelColumn) { showInfo(t('tables', 'Please select a label for relation selection.')) + } else if (this.column.type === ColumnTypes.RelationLookup && !this.column.customSettings?.relationColumnId) { + showInfo(t('tables', 'Please select a relation column.')) + } else if (this.column.type === ColumnTypes.RelationLookup && !this.column.customSettings?.targetColumnId) { + showInfo(t('tables', 'Please select a target column.')) } else { this.column.title = title this.$emit('save', this.prepareSubmitData()) @@ -409,6 +415,9 @@ export default { data.customSettings.relationType = this.column.customSettings.relationType data.customSettings.targetId = this.column.customSettings.targetId data.customSettings.labelColumn = this.column.customSettings.labelColumn + } else if (this.column.type === ColumnTypes.RelationLookup) { + data.customSettings.relationColumnId = this.column.customSettings.relationColumnId + data.customSettings.targetColumnId = this.column.customSettings.targetColumnId } return data }, diff --git a/src/modules/modals/CreateRow.vue b/src/modules/modals/CreateRow.vue index 58b8b644e0..370e5bc448 100644 --- a/src/modules/modals/CreateRow.vue +++ b/src/modules/modals/CreateRow.vue @@ -9,15 +9,15 @@ data-cy="createRowModal" @closing="actionCancel">