From afb8e87d5994dc1b5f24361a5479148ae08a94e0 Mon Sep 17 00:00:00 2001 From: Kostiantyn Miakshyn Date: Mon, 19 Jan 2026 17:45:44 +0100 Subject: [PATCH 1/2] feat: add relation lookup column type Signed-off-by: Kostiantyn Miakshyn --- lib/Constants/ColumnType.php | 1 + lib/Controller/Api1Controller.php | 12 +- lib/Db/Column.php | 15 + lib/Db/Row2Mapper.php | 282 +++++++------ lib/Dto/RelationLookupSettings.php | 23 ++ lib/Dto/RelationSettings.php | 29 ++ lib/Helper/ColumnsHelper.php | 1 + lib/ResponseDefinitions.php | 2 + lib/Service/ColumnService.php | 5 + lib/Service/ColumnTypes/RelationBusiness.php | 8 +- .../ColumnTypes/RelationLookupBusiness.php | 58 +++ lib/Service/RelationService.php | 382 ++++++++++++++---- lib/Service/RowService.php | 6 + openapi.json | 353 +++++++++++++++- .../main/partials/ColumnFormComponent.vue | 9 +- .../main/partials/ColumnTypeSelection.vue | 3 + .../editViewPartials/filter/FilterEntry.vue | 4 +- src/modules/modals/CreateColumn.vue | 9 + src/modules/modals/CreateRow.vue | 10 +- src/modules/modals/EditColumn.vue | 2 + src/modules/modals/EditRow.vue | 16 +- src/modules/modals/ImportPreview.vue | 5 +- src/shared/components/ncTable/NcTable.vue | 7 +- .../components/ncTable/mixins/columnClass.js | 21 + .../ncTable/mixins/columnHandler.js | 1 + .../components/ncTable/mixins/columnParser.js | 2 + .../ncTable/mixins/columnsTypes/relation.js | 6 +- .../mixins/columnsTypes/relationLookup.js | 139 +++++++ .../ncTable/mixins/exportTableMixin.js | 46 ++- .../components/ncTable/partials/TableCell.vue | 83 ++++ .../ncTable/partials/TableCellRelation.vue | 20 +- .../partials/TableCellRelationLookup.vue | 82 ++++ .../components/ncTable/partials/TableRow.vue | 3 +- .../forms/RelationLookupForm.vue | 158 ++++++++ .../partials/rowTypePartials/RelationForm.vue | 7 +- .../rowTypePartials/RelationLookupForm.vue | 31 ++ src/store/data.js | 1 + src/types/openapi/openapi.ts | 198 ++++++++- src/views/ContentReferenceWidget.vue | 14 +- tests/integration/features/APIv1.feature | 59 +++ .../features/bootstrap/FeatureContext.php | 83 +++- 41 files changed, 1932 insertions(+), 264 deletions(-) create mode 100644 lib/Dto/RelationLookupSettings.php create mode 100644 lib/Dto/RelationSettings.php create mode 100644 lib/Service/ColumnTypes/RelationLookupBusiness.php create mode 100644 src/shared/components/ncTable/mixins/columnsTypes/relationLookup.js create mode 100644 src/shared/components/ncTable/partials/TableCell.vue create mode 100644 src/shared/components/ncTable/partials/TableCellRelationLookup.vue create mode 100644 src/shared/components/ncTable/partials/columnTypePartials/forms/RelationLookupForm.vue create mode 100644 src/shared/components/ncTable/partials/rowTypePartials/RelationLookupForm.vue 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 240891f302..6243abbc32 100644 --- a/lib/Controller/Api1Controller.php +++ b/lib/Controller/Api1Controller.php @@ -828,7 +828,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 @@ -838,6 +839,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)); @@ -860,7 +862,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 @@ -870,6 +873,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)); @@ -894,7 +898,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 @@ -1678,7 +1682,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 a039dd33c7..f357f9483e 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'; @@ -354,6 +355,20 @@ public function getCustomSettingsArray(): array { return json_decode((string)$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 a2979afbda..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; @@ -32,15 +33,23 @@ class Row2Mapper { private const DB_CHUNK_SIZE = 1_000; - public function __construct( - private ?string $userId, - private IDBConnection $db, - private LoggerInterface $logger, - protected UserHelper $userHelper, - private RowSleeveMapper $rowSleeveMapper, - private ColumnsHelper $columnsHelper, - protected ColumnMapper $columnMapper, - ) { + private RowSleeveMapper $rowSleeveMapper; + private ?string $userId; + private IDBConnection $db; + private LoggerInterface $logger; + protected UserHelper $userHelper; + protected ColumnMapper $columnMapper; + + private ColumnsHelper $columnsHelper; + + public function __construct(?string $userId, IDBConnection $db, LoggerInterface $logger, UserHelper $userHelper, RowSleeveMapper $rowSleeveMapper, ColumnsHelper $columnsHelper, ColumnMapper $columnMapper) { + $this->rowSleeveMapper = $rowSleeveMapper; + $this->userId = $userId; + $this->db = $db; + $this->logger = $logger; + $this->userHelper = $userHelper; + $this->columnsHelper = $columnsHelper; + $this->columnMapper = $columnMapper; } /** @@ -52,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()); @@ -59,7 +71,7 @@ public function delete(Row2 $row): Row2 { } catch (Throwable $e) { $this->db->rollBack(); $this->logger->error($e->getMessage(), ['exception' => $e]); - throw new Exception(static::class . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); + throw new Exception(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); } return $row; @@ -83,12 +95,12 @@ public function find(int $id, array $columns): Row2 { if (count($rows) === 0) { $e = new Exception('Wanted row not found.'); $this->logger->error($e->getMessage(), ['exception' => $e]); - throw new NotFoundError(static::class . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); + throw new NotFoundError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); } $e = new Exception('Too many results for one wanted row.'); $this->logger->error($e->getMessage(), ['exception' => $e]); - throw new InternalError(static::class . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); + throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); } /** @@ -99,7 +111,7 @@ public function findNextId(int $offsetId = -1): ?int { $rowSleeve = $this->rowSleeveMapper->findNext($offsetId); } catch (MultipleObjectsReturnedException|Exception $e) { $this->logger->error($e->getMessage(), ['exception' => $e]); - throw new InternalError(static::class . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); + throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); } catch (DoesNotExistException) { return null; } @@ -150,10 +162,10 @@ private function getWantedRowIds(string $userId, int $tableId, ?array $filter = $result = $this->db->executeQuery($qb->getSQL(), $qb->getParameters(), $qb->getParameterTypes()); } catch (Exception $e) { $this->logger->error($e->getMessage(), ['exception' => $e]); - throw new InternalError(static::class . ' - ' . __FUNCTION__ . ': ' . $e->getMessage(), ); + throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage(), ); } - return array_map(fn (array $item) => $item['id'], $result->fetchAllAssociative()); + return array_map(fn (array $item) => $item['id'], $result->fetchAll()); } /** @@ -180,7 +192,7 @@ public function findAll(array $showColumnIds, int $tableId, ?int $limit = null, return $this->sortRowsByIds($rows, $wantedRowIdsArray); } catch (DoesNotExistException $e) { $this->logger->error($e->getMessage(), ['exception' => $e]); - throw new InternalError(static::class . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); + throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); } } @@ -211,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(); @@ -226,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'))) @@ -249,14 +266,14 @@ private function getRowsChunk(array $rowIds, array $columnIds): array { $result = $qb->executeQuery(); } catch (Exception $e) { $this->logger->error($e->getMessage(), ['exception' => $e]); - throw new InternalError(static::class . ' - ' . __FUNCTION__ . ': ' . $e->getMessage(), ); + throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage(), ); } try { $sleeves = $this->rowSleeveMapper->findMultiple($rowIds); } catch (Exception $e) { $this->logger->error($e->getMessage(), ['exception' => $e]); - throw new InternalError(static::class . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); + throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); } return $this->parseEntities($result, $sleeves); @@ -342,7 +359,7 @@ private function addSortQueryForMultipleSleeveFinder(IQueryBuilder $qb, string $ private function replacePlaceholderValues(array &$filters, string $userId): void { foreach ($filters as &$filterGroup) { foreach ($filterGroup as &$filter) { - if (str_starts_with((string)$filter['value'], '@')) { + if (str_starts_with($filter['value'], '@')) { $columnId = (int)($filter['columnId'] ?? 0); $column = $columnId > 0 ? $this->columnMapper->find($columnId) : null; $filter['value'] = $this->columnsHelper->resolveSearchValue($filter['value'], $userId, $column); @@ -397,7 +414,7 @@ private function getFilter(IQueryBuilder $qb, array $filterGroup): array { } else { $e = new Exception('Needed column (' . $columnId . ') not found.'); $this->logger->error($e->getMessage(), ['exception' => $e]); - throw new InternalError(static::class . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); + throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); } $filterExpressions[] = $sql; } @@ -413,7 +430,7 @@ private function getFilterExpression(IQueryBuilder $qb, Column $column, string $ $value = $this->getCellMapper($column)->filterValueToQueryParam($column, $value); } catch (DoesNotExistException $e) { $this->logger->error('Cannot filter, because the column does not exist', ['exception' => $e]); - throw new InternalError(static::class . '::' . __FUNCTION__ . ': Cannot filter, because the column does not exist'); + throw new InternalError(get_class($this) . '::' . __FUNCTION__ . ': Cannot filter, because the column does not exist'); } // We try to match the requested value against the default before building the query @@ -433,12 +450,12 @@ private function getFilterExpression(IQueryBuilder $qb, Column $column, string $ switch ($operator) { case 'begins-with': - $includeDefault = str_starts_with((string)($defaultValue ?? ''), (string)$value); - $filterExpression = $qb->expr()->iLike('value', $qb->createNamedParameter($this->db->escapeLikeParameter($value) . '%', $paramType)); + $includeDefault = str_starts_with((string)($defaultValue ?? ''), $value); + $filterExpression = $qb->expr()->like('value', $qb->createNamedParameter($this->db->escapeLikeParameter($value) . '%', $paramType)); break; case 'ends-with': - $includeDefault = str_ends_with((string)($defaultValue ?? ''), (string)$value); - $filterExpression = $qb->expr()->iLike('value', $qb->createNamedParameter('%' . $this->db->escapeLikeParameter($value), $paramType)); + $includeDefault = str_ends_with((string)($defaultValue ?? ''), $value); + $filterExpression = $qb->expr()->like('value', $qb->createNamedParameter('%' . $this->db->escapeLikeParameter($value), $paramType)); break; case 'contains': $filterExpressions = []; @@ -465,18 +482,18 @@ private function getFilterExpression(IQueryBuilder $qb, Column $column, string $ break; } - $includeDefault = str_contains((string)($defaultValue ?? ''), (string)$value); + $includeDefault = str_contains((string)($defaultValue ?? ''), $value); if ($column->getType() === 'selection' && $column->getSubtype() === 'multi') { $value = str_replace(['"', '\''], '', $value); $filterExpression = $qb2->expr()->orX( - $qb->expr()->iLike('value', $qb->createNamedParameter('[' . $this->db->escapeLikeParameter($value) . ']')), - $qb->expr()->iLike('value', $qb->createNamedParameter('[' . $this->db->escapeLikeParameter($value) . ',%')), - $qb->expr()->iLike('value', $qb->createNamedParameter('%,' . $this->db->escapeLikeParameter($value) . ']%')), - $qb->expr()->iLike('value', $qb->createNamedParameter('%,' . $this->db->escapeLikeParameter($value) . ',%')) + $qb->expr()->like('value', $qb->createNamedParameter('[' . $this->db->escapeLikeParameter($value) . ']')), + $qb->expr()->like('value', $qb->createNamedParameter('[' . $this->db->escapeLikeParameter($value) . ',%')), + $qb->expr()->like('value', $qb->createNamedParameter('%,' . $this->db->escapeLikeParameter($value) . ']%')), + $qb->expr()->like('value', $qb->createNamedParameter('%,' . $this->db->escapeLikeParameter($value) . ',%')) ); break; } - $filterExpression = $qb->expr()->iLike('value', $qb->createNamedParameter('%' . $this->db->escapeLikeParameter($value) . '%', $paramType)); + $filterExpression = $qb->expr()->like('value', $qb->createNamedParameter('%' . $this->db->escapeLikeParameter($value) . '%', $paramType)); break; case 'does-not-contain': if (is_array($value) && $column->getType() === Column::TYPE_USERGROUP) { @@ -510,18 +527,18 @@ private function getFilterExpression(IQueryBuilder $qb, Column $column, string $ $qb->expr()->notIn('sl3.id', $qb->createFunction($qb2->getSQL())) ); } - $includeDefault = !str_contains((string)($defaultValue ?? ''), (string)$value); + $includeDefault = !str_contains((string)($defaultValue ?? ''), $value); if ($column->getType() === 'selection' && $column->getSubtype() === 'multi') { $value = str_replace(['"', '\''], '', $value); $filterExpression = $qb2->expr()->andX( - $this->notILike($qb, 'value', $qb->createNamedParameter('[' . $this->db->escapeLikeParameter($value) . ']')), - $this->notILike($qb, 'value', $qb->createNamedParameter('[' . $this->db->escapeLikeParameter($value) . ',%')), - $this->notILike($qb, 'value', $qb->createNamedParameter('%,' . $this->db->escapeLikeParameter($value) . ']%')), - $this->notILike($qb, 'value', $qb->createNamedParameter('%,' . $this->db->escapeLikeParameter($value) . ',%')) + $qb->expr()->notLike('value', $qb->createNamedParameter('[' . $this->db->escapeLikeParameter($value) . ']')), + $qb->expr()->notLike('value', $qb->createNamedParameter('[' . $this->db->escapeLikeParameter($value) . ',%')), + $qb->expr()->notLike('value', $qb->createNamedParameter('%,' . $this->db->escapeLikeParameter($value) . ']%')), + $qb->expr()->notLike('value', $qb->createNamedParameter('%,' . $this->db->escapeLikeParameter($value) . ',%')) ); break; } - $filterExpression = $this->notILike($qb, 'value', $qb->createNamedParameter('%' . $this->db->escapeLikeParameter($value) . '%', $paramType)); + $filterExpression = $qb->expr()->notLike('value', $qb->createNamedParameter('%' . $this->db->escapeLikeParameter($value) . '%', $paramType)); break; case 'is-equal': $includeDefault = $defaultValue === $value; @@ -590,28 +607,24 @@ private function getMetaFilterExpression(IQueryBuilder $qb, int $columnId, strin $qb2->select('id'); $qb2->from('tables_row_sleeves'); - match ($columnId) { - Column::TYPE_META_ID => $qb2->where($this->getSqlOperator($operator, $qb, 'id', (int)$value, IQueryBuilder::PARAM_INT)), - Column::TYPE_META_CREATED_BY => $qb2->where($this->getSqlOperator($operator, $qb, 'created_by', $value, IQueryBuilder::PARAM_STR)), - Column::TYPE_META_CREATED_AT => $qb2->where($this->getSqlOperator($operator, $qb, 'created_at', new DateTimeImmutable($value), IQueryBuilder::PARAM_DATE)), - Column::TYPE_META_UPDATED_BY => $qb2->where($this->getSqlOperator($operator, $qb, 'last_edit_by', $value, IQueryBuilder::PARAM_STR)), - Column::TYPE_META_UPDATED_AT => $qb2->where($this->getSqlOperator($operator, $qb, 'last_edit_at', new DateTimeImmutable($value), IQueryBuilder::PARAM_DATE)), - default => $qb2, - }; - return $qb2; - } - - /** - * Helper method to use notILike if available, otherwise fall back to not(iLike(...)) - */ - private function notILike(IQueryBuilder $qb, string $column, \OCP\DB\QueryBuilder\IParameter $parameter): string { - $expr = $qb->expr(); - // Nextcloud v35 introduced shortcut method - if (method_exists($expr, 'notILike')) { - return $expr->notILike($column, $parameter); + switch ($columnId) { + case Column::TYPE_META_ID: + $qb2->where($this->getSqlOperator($operator, $qb, 'id', (int)$value, IQueryBuilder::PARAM_INT)); + break; + case Column::TYPE_META_CREATED_BY: + $qb2->where($this->getSqlOperator($operator, $qb, 'created_by', $value, IQueryBuilder::PARAM_STR)); + break; + case Column::TYPE_META_CREATED_AT: + $qb2->where($this->getSqlOperator($operator, $qb, 'created_at', new DateTimeImmutable($value), IQueryBuilder::PARAM_DATE)); + break; + case Column::TYPE_META_UPDATED_BY: + $qb2->where($this->getSqlOperator($operator, $qb, 'last_edit_by', $value, IQueryBuilder::PARAM_STR)); + break; + case Column::TYPE_META_UPDATED_AT: + $qb2->where($this->getSqlOperator($operator, $qb, 'last_edit_at', new DateTimeImmutable($value), IQueryBuilder::PARAM_DATE)); + break; } - - return 'NOT (' . $expr->iLike($column, $parameter) . ')'; + return $qb2; } /** @@ -624,20 +637,32 @@ private function notILike(IQueryBuilder $qb, string $column, \OCP\DB\QueryBuilde * @throws InternalError */ private function getSqlOperator(string $operator, IQueryBuilder $qb, string $columnName, $value, $paramType): string { - return match ($operator) { - 'begins-with' => $qb->expr()->iLike($columnName, $qb->createNamedParameter($this->db->escapeLikeParameter($value) . '%', $paramType)), - 'ends-with' => $qb->expr()->iLike($columnName, $qb->createNamedParameter('%' . $this->db->escapeLikeParameter($value), $paramType)), - 'contains' => $qb->expr()->iLike($columnName, $qb->createNamedParameter('%' . $this->db->escapeLikeParameter($value) . '%', $paramType)), - 'does-not-contain' => $this->notILike($qb, $columnName, $qb->createNamedParameter('%' . $this->db->escapeLikeParameter($value) . '%', $paramType)), - 'is-equal' => $qb->expr()->eq($columnName, $qb->createNamedParameter($value, $paramType)), - 'is-not-equal' => $qb->expr()->neq($columnName, $qb->createNamedParameter($value, $paramType)), - 'is-greater-than' => $qb->expr()->gt($columnName, $qb->createNamedParameter($value, $paramType)), - 'is-greater-than-or-equal' => $qb->expr()->gte($columnName, $qb->createNamedParameter($value, $paramType)), - 'is-lower-than' => $qb->expr()->lt($columnName, $qb->createNamedParameter($value, $paramType)), - 'is-lower-than-or-equal' => $qb->expr()->lte($columnName, $qb->createNamedParameter($value, $paramType)), - 'is-empty' => $qb->expr()->isNull($columnName), - default => throw new InternalError('Operator ' . $operator . ' is not supported.'), - }; + switch ($operator) { + case 'begins-with': + return $qb->expr()->like($columnName, $qb->createNamedParameter('%' . $this->db->escapeLikeParameter($value), $paramType)); + case 'ends-with': + return $qb->expr()->like($columnName, $qb->createNamedParameter($this->db->escapeLikeParameter($value) . '%', $paramType)); + case 'contains': + return $qb->expr()->like($columnName, $qb->createNamedParameter('%' . $this->db->escapeLikeParameter($value) . '%', $paramType)); + case 'does-not-contain': + return $qb->expr()->notLike($columnName, $qb->createNamedParameter('%' . $this->db->escapeLikeParameter($value) . '%', $paramType)); + case 'is-equal': + return $qb->expr()->eq($columnName, $qb->createNamedParameter($value, $paramType)); + case 'is-not-equal': + return $qb->expr()->neq($columnName, $qb->createNamedParameter($value, $paramType)); + case 'is-greater-than': + return $qb->expr()->gt($columnName, $qb->createNamedParameter($value, $paramType)); + case 'is-greater-than-or-equal': + return $qb->expr()->gte($columnName, $qb->createNamedParameter($value, $paramType)); + case 'is-lower-than': + return $qb->expr()->lt($columnName, $qb->createNamedParameter($value, $paramType)); + case 'is-lower-than-or-equal': + return $qb->expr()->lte($columnName, $qb->createNamedParameter($value, $paramType)); + case 'is-empty': + return $qb->expr()->isNull($columnName); + default: + throw new InternalError('Operator ' . $operator . ' is not supported.'); + } } /** @@ -663,7 +688,7 @@ private function parseEntities(IResult $result, array $sleeves): array { $keyToRowId = []; $cellMapperCache = []; - while ($rowData = $result->fetchAssociative()) { + while ($rowData = $result->fetch()) { if (!isset($rowData['row_id'], $rows[$rowData['row_id']])) { break; } @@ -753,7 +778,7 @@ public function update(Row2 $row, ?string $userId = null): Row2 { $this->rowSleeveMapper->update($sleeve); } catch (DoesNotExistException|MultipleObjectsReturnedException|Exception $e) { $this->logger->error($e->getMessage(), ['exception' => $e]); - throw new InternalError(static::class . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); + throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); } $this->columnMapper->preloadColumns(array_column($changedCells, 'columnId')); @@ -820,7 +845,11 @@ private function insertCell(int $rowId, int $columnId, $value, ?string $lastEdit $column = $this->columnMapper->find($columnId); } catch (DoesNotExistException $e) { $this->logger->error('Can not insert cell, because the given column-id is not known', ['exception' => $e]); - throw new InternalError(static::class . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); + throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); + } + + if ($this->isVirtualColumn($column->getType())) { + return; } // insert new cell @@ -861,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); @@ -871,10 +904,13 @@ 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()) { - $this->atomic(function () use ($cellMapper, $rowId, $columnId, $value): void { + $this->atomic(function () use ($cellMapper, $rowId, $columnId, $value) { // For a usergroup field with mutiple values, each is inserted as a new cell // we need to delete all previous cells for this row and column, otherwise we get duplicates $cellMapper->deleteAllForColumnAndRow($columnId, $rowId); @@ -888,7 +924,7 @@ private function insertOrUpdateCell(int $rowId, int $columnId, $value): void { $this->insertCell($rowId, $columnId, $value); } catch (MultipleObjectsReturnedException|Exception $e) { $this->logger->error($e->getMessage(), ['exception' => $e]); - throw new InternalError(static::class . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); + throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); } } @@ -897,13 +933,16 @@ 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 { return Server::get($cellMapperClassName); } catch (NotFoundExceptionInterface|ContainerExceptionInterface $e) { $this->logger->error($e->getMessage(), ['exception' => $e]); - throw new InternalError(static::class . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); + throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); } } @@ -918,11 +957,14 @@ 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) { $this->logger->error($e->getMessage(), ['exception' => $e]); - throw new InternalError(static::class . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); + throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); } } @@ -950,14 +992,6 @@ public function countRowsForTable(int $tableId): int { return $this->rowSleeveMapper->countRows($tableId); } - /** - * @param int[] $tableIds - * @return array - */ - public function countRowsForTables(array $tableIds): array { - return $this->rowSleeveMapper->countRowsByTableIds($tableIds); - } - /** * @param View $view * @param string $userId @@ -965,41 +999,39 @@ public function countRowsForTables(array $tableIds): array { */ public function countRowsForView(View $view, string $userId): int { $filter = $view->getFilterArray(); - - if (empty($filter)) { - return $this->countRowsForTable($view->getTableId()); - } - try { - $this->columnMapper->preloadColumns($view->getColumnsArray(), $filter); + $this->columnMapper->preloadColumns($view->getColumnsArray(), $filter, $view->getSortArray()); - $qb = $this->db->getQueryBuilder(); - $qb->select($qb->func()->count('*', 'counter')) - ->from('tables_row_sleeves', 'sleeves') - ->where($qb->expr()->eq('table_id', $qb->createNamedParameter($view->getTableId(), IQueryBuilder::PARAM_INT))); - - $this->addFilterToQuery($qb, $filter, $userId); - - $result = $this->db->executeQuery($qb->getSQL(), $qb->getParameters(), $qb->getParameterTypes()); - $count = (int)$result->fetchOne(); - $result->closeCursor(); - return $count; - } catch (InternalError|Exception $e) { + $rowIds = $this->getWantedRowIds($userId, $view->getTableId(), $filter); + } catch (InternalError $e) { $this->logger->error($e->getMessage(), ['exception' => $e]); - return 0; + $rowIds = []; } + return count($rowIds); } private function getFormattedDefaultValue(Column $column) { $defaultValue = null; - $defaultValue = match ($column->getType()) { - Column::TYPE_SELECTION => $this->getCellMapper($column)->filterValueToQueryParam($column, $column->getSelectionDefault()), - Column::TYPE_DATETIME => $this->getCellMapper($column)->filterValueToQueryParam($column, $column->getDatetimeDefault()), - Column::TYPE_NUMBER => $this->getCellMapper($column)->filterValueToQueryParam($column, $column->getNumberDefault()), - Column::TYPE_TEXT => $this->getCellMapper($column)->filterValueToQueryParam($column, $column->getTextDefault()), - Column::TYPE_USERGROUP => $this->getCellMapper($column)->filterValueToQueryParam($column, $column->getUsergroupDefault()), - default => $defaultValue, - }; + switch ($column->getType()) { + case Column::TYPE_SELECTION: + $defaultValue = $this->getCellMapper($column)->filterValueToQueryParam($column, $column->getSelectionDefault()); + break; + case Column::TYPE_DATETIME: + $defaultValue = $this->getCellMapper($column)->filterValueToQueryParam($column, $column->getDatetimeDefault()); + break; + case Column::TYPE_NUMBER: + $defaultValue = $this->getCellMapper($column)->filterValueToQueryParam($column, $column->getNumberDefault()); + break; + case Column::TYPE_TEXT: + $defaultValue = $this->getCellMapper($column)->filterValueToQueryParam($column, $column->getTextDefault()); + break; + case Column::TYPE_USERGROUP: + $defaultValue = $this->getCellMapper($column)->filterValueToQueryParam($column, $column->getUsergroupDefault()); + break; + case Column::TYPE_RELATION_LOOKUP: + $defaultValue = null; + break; + } return $defaultValue; } @@ -1026,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 2ebe0e254d..48e3c80d6b 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 b5a7de2450..ac24cace2c 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -243,6 +243,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 52a84b03c1..3dd9a7c4c8 100644 --- a/lib/Service/ColumnService.php +++ b/lib/Service/ColumnService.php @@ -631,6 +631,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 f072904cd5..c000453036 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, @@ -35,7 +35,7 @@ public function parseValue($value, ?Column $column = null): string|false { $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']); } @@ -64,7 +64,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; } @@ -84,7 +84,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 = []; public function __construct( - private readonly ColumnMapper $columnMapper, - private readonly ViewMapper $viewMapper, - private readonly Row2Mapper $row2Mapper, - private readonly ColumnService $columnService, - private readonly ?string $userId, + private ColumnMapper $columnMapper, + private ViewMapper $viewMapper, + private Row2Mapper $row2Mapper, + private ColumnService $columnService, + 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,16 +52,14 @@ public function getRelationsForTable(int $tableId): array { // Check table permissions through ColumnService $columns = $this->columnService->findAllByTable($tableId); - $relationColumns = array_filter($columns, fn ($column) => $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 @@ -60,28 +68,71 @@ public function getRelationsForView(int $viewId): array { // Check view permissions through ColumnService $columns = $this->columnService->findAllByView($viewId); - $relationColumns = array_filter($columns, fn ($column) => $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()); } } @@ -89,119 +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; } - } catch (DoesNotExistException) { - $this->cacheRelationData[$cacheKey] = []; + 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->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, fn ($item) => $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 95baa53324..44055d45e2 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); @@ -938,6 +942,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 412df8cf42..86380f15c0 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": [ @@ -3870,7 +3909,8 @@ "datetime", "select", "usergroup", - "relation" + "relation", + "relation_lookup" ], "description": "Column main type" }, @@ -4304,7 +4344,8 @@ "datetime", "select", "usergroup", - "relation" + "relation", + "relation_lookup" ], "description": "Column main type" }, @@ -5017,6 +5058,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 3d54a6e720..25017e6213 100644 --- a/src/modules/modals/CreateRow.vue +++ b/src/modules/modals/CreateRow.vue @@ -9,15 +9,15 @@ data-cy="createRowModal" @closing="actionCancel">