Skip to content

Commit e3d186a

Browse files
committed
feat: add relation lookup column type
Signed-off-by: Kostiantyn Miakshyn <molodchick@gmail.com>
1 parent a539783 commit e3d186a

40 files changed

Lines changed: 1674 additions & 192 deletions

lib/Constants/ColumnType.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,5 @@ enum ColumnType: string {
1616
case DATETIME = 'datetime';
1717
case PEOPLE = 'usergroup';
1818
case RELATION = 'relation';
19+
case RELATION_LOOKUP = 'relation_lookup';
1920
}

lib/Controller/Api1Controller.php

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -844,7 +844,8 @@ public function indexViewColumns(int $viewId): DataResponse {
844844
* Get all relation data for a table
845845
*
846846
* @param int $tableId Table ID
847-
* @return DataResponse<Http::STATUS_OK, array<string, array<string, array{id: int, label: string}>>, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}>
847+
* @return DataResponse<Http::STATUS_OK, TablesRelationData, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}>
848+
* @psalm-return DataResponse<Http::STATUS_OK, list<array{column: ?TablesColumn, values: list<array{id: int, value: mixed}>}>, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}>
848849
*
849850
* 200: Relation data returned
850851
* 403: No permissions
@@ -854,6 +855,7 @@ public function indexViewColumns(int $viewId): DataResponse {
854855
#[NoCSRFRequired]
855856
#[CORS]
856857
#[RequirePermission(permission: Application::PERMISSION_READ, type: Application::NODE_TYPE_TABLE, idParam: 'tableId')]
858+
#[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)]
857859
public function indexTableRelations(int $tableId): DataResponse {
858860
try {
859861
return new DataResponse($this->relationService->getRelationsForTable($tableId));
@@ -876,7 +878,8 @@ public function indexTableRelations(int $tableId): DataResponse {
876878
* Get all relation data for a view
877879
*
878880
* @param int $viewId View ID
879-
* @return DataResponse<Http::STATUS_OK, array<string, array<string, array{id: int, label: string}>>, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}>
881+
* @return DataResponse<Http::STATUS_OK, TablesRelationData, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}>
882+
* @psalm-return DataResponse<Http::STATUS_OK, list<array{column: ?TablesColumn, values: list<array{id: int, value: mixed}>}>, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}>
880883
*
881884
* 200: Relation data returned
882885
* 403: No permissions
@@ -886,6 +889,7 @@ public function indexTableRelations(int $tableId): DataResponse {
886889
#[NoCSRFRequired]
887890
#[CORS]
888891
#[RequirePermission(permission: Application::PERMISSION_READ, type: Application::NODE_TYPE_VIEW, idParam: 'viewId')]
892+
#[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)]
889893
public function indexViewRelations(int $viewId): DataResponse {
890894
try {
891895
return new DataResponse($this->relationService->getRelationsForView($viewId));
@@ -910,7 +914,7 @@ public function indexViewRelations(int $viewId): DataResponse {
910914
* @param int|null $tableId Table ID
911915
* @param int|null $viewId View ID
912916
* @param string $title Title
913-
* @param 'text'|'number'|'datetime'|'select'|'usergroup'|'relation' $type Column main type
917+
* @param 'text'|'number'|'datetime'|'select'|'usergroup'|'relation'|'relation_lookup' $type Column main type
914918
* @param string|null $technicalName Technical name of the column
915919
* @param string|null $subtype Column sub type
916920
* @param bool $mandatory Is the column mandatory
@@ -1694,7 +1698,7 @@ public function createTableShare(int $tableId, string $receiver, string $receive
16941698
*
16951699
* @param int $tableId Table ID
16961700
* @param string $title Title
1697-
* @param 'text'|'number'|'datetime'|'select'|'usergroup'|'relation' $type Column main type
1701+
* @param 'text'|'number'|'datetime'|'select'|'usergroup'|'relation'|'relation_lookup' $type Column main type
16981702
* @param string|null $technicalName Technical name of the column
16991703
* @param string|null $subtype Column sub type
17001704
* @param bool $mandatory Is the column mandatory

lib/Db/Column.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ class Column extends EntitySuper implements JsonSerializable {
107107
public const TYPE_DATETIME = 'datetime';
108108
public const TYPE_USERGROUP = 'usergroup';
109109
public const TYPE_RELATION = 'relation';
110+
public const TYPE_RELATION_LOOKUP = 'relation_lookup';
110111

111112
public const SUBTYPE_DATETIME_DATE = 'date';
112113
public const SUBTYPE_DATETIME_TIME = 'time';
@@ -352,6 +353,20 @@ public function getCustomSettingsArray(): array {
352353
return json_decode($this->customSettings, true) ?: [];
353354
}
354355

356+
/**
357+
* @template T
358+
* @param class-string<T> $className
359+
* @return T
360+
* @throws \InvalidArgumentException
361+
*/
362+
public function getCustomSettingsObject(string $className): mixed {
363+
$array = $this->getCustomSettingsArray();
364+
if (method_exists($className, 'fromArray')) {
365+
return $className::fromArray($array);
366+
}
367+
throw new \InvalidArgumentException("Class $className must have a fromArray method");
368+
}
369+
355370
/**
356371
* @throws ValueError
357372
*/

lib/Db/Row2Mapper.php

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use DateTime;
1111
use DateTimeImmutable;
1212
use OCA\Tables\Constants\UsergroupType;
13+
use OCA\Tables\Dto\RelationLookupSettings;
1314
use OCA\Tables\Errors\InternalError;
1415
use OCA\Tables\Errors\NotFoundError;
1516
use OCA\Tables\Helper\ColumnsHelper;
@@ -60,6 +61,9 @@ public function delete(Row2 $row): Row2 {
6061
$this->db->beginTransaction();
6162
try {
6263
foreach ($this->columnsHelper->columns as $columnType) {
64+
if ($this->isVirtualColumn($columnType)) {
65+
continue;
66+
}
6367
$this->getCellMapperFromType($columnType)->deleteAllForRow($row->getId());
6468
}
6569
$this->rowSleeveMapper->deleteById($row->getId());
@@ -219,6 +223,8 @@ private function getRows(array $rowIds, array $columnIds): array {
219223
private function getRowsChunk(array $rowIds, array $columnIds): array {
220224
$qb = $this->db->getQueryBuilder();
221225

226+
$columnIds = $this->addRelationColumnIdsForLookupColumns($columnIds);
227+
222228
$qbSqlForColumnTypes = null;
223229
foreach ($this->columnsHelper->columns as $columnType) {
224230
$qbTmp = $this->db->getQueryBuilder();
@@ -234,6 +240,9 @@ private function getRowsChunk(array $rowIds, array $columnIds): array {
234240
$qbTmp->selectAlias($qbTmp->createFunction('NULL'), 'value_type');
235241
}
236242

243+
if ($this->isVirtualColumn($columnType)) {
244+
continue;
245+
}
237246
$qbTmp
238247
->from('tables_row_cells_' . $columnType)
239248
->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
839848
throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage());
840849
}
841850

851+
if ($this->isVirtualColumn($column->getType())) {
852+
return;
853+
}
854+
842855
// insert new cell
843856
$cellMapper = $this->getCellMapper($column);
844857

@@ -877,6 +890,10 @@ private function insertCell(int $rowId, int $columnId, $value, ?string $lastEdit
877890
* @throws InternalError
878891
*/
879892
private function updateCell(RowCellSuper $cell, RowCellMapperSuper $mapper, $value, Column $column): void {
893+
if ($this->isVirtualColumn($column->getType())) {
894+
return;
895+
}
896+
880897
$this->getCellMapper($column)->applyDataToEntity($column, $cell, $value);
881898
$this->updateMetaData($cell);
882899
$mapper->updateWrapper($cell);
@@ -887,6 +904,9 @@ private function updateCell(RowCellSuper $cell, RowCellMapperSuper $mapper, $val
887904
*/
888905
private function insertOrUpdateCell(int $rowId, int $columnId, $value): void {
889906
$column = $this->columnMapper->find($columnId);
907+
if ($this->isVirtualColumn($column->getType())) {
908+
return;
909+
}
890910
$cellMapper = $this->getCellMapper($column);
891911
try {
892912
if ($cellMapper->hasMultipleValues()) {
@@ -913,6 +933,9 @@ private function getCellMapper(Column $column): RowCellMapperSuper {
913933
}
914934

915935
private function getCellMapperFromType(string $columnType): RowCellMapperSuper {
936+
if ($this->isVirtualColumn($columnType)) {
937+
throw new InternalError('Virtual columns do not have cell mappers');
938+
}
916939
$cellMapperClassName = 'OCA\Tables\Db\RowCell' . ucfirst($columnType) . 'Mapper';
917940
/** @var RowCellMapperSuper $cellMapper */
918941
try {
@@ -934,6 +957,9 @@ private function getColumnDbParamType(Column $column): int {
934957
* @throws InternalError
935958
*/
936959
public function deleteDataForColumn(Column $column): void {
960+
if ($this->isVirtualColumn($column->getType())) {
961+
return;
962+
}
937963
try {
938964
$this->getCellMapper($column)->deleteAllForColumn($column->getId());
939965
} catch (Exception $e) {
@@ -1002,6 +1028,9 @@ private function getFormattedDefaultValue(Column $column) {
10021028
case Column::TYPE_USERGROUP:
10031029
$defaultValue = $this->getCellMapper($column)->filterValueToQueryParam($column, $column->getUsergroupDefault());
10041030
break;
1031+
case Column::TYPE_RELATION_LOOKUP:
1032+
$defaultValue = null;
1033+
break;
10051034
}
10061035
return $defaultValue;
10071036
}
@@ -1029,4 +1058,26 @@ private function sortRowsByIds(array $rows, array $wantedRowIds): array {
10291058

10301059
return $sortedRows;
10311060
}
1061+
1062+
public function isVirtualColumn(string $columnType): bool {
1063+
return $columnType === Column::TYPE_RELATION_LOOKUP;
1064+
}
1065+
1066+
/**
1067+
* @param int[] $columnIds
1068+
* @return int[]
1069+
*/
1070+
public function addRelationColumnIdsForLookupColumns(array $columnIds): array {
1071+
$allColumns = $this->columnMapper->findAll($columnIds);
1072+
foreach ($allColumns as $column) {
1073+
if ($column->getType() !== Column::TYPE_RELATION_LOOKUP) {
1074+
continue;
1075+
}
1076+
1077+
$settings = $column->getCustomSettingsObject(RelationLookupSettings::class);
1078+
$columnIds[] = $settings->relationColumnId;
1079+
}
1080+
return array_values(array_unique(array_filter($columnIds)));
1081+
}
1082+
10321083
}

lib/Dto/RelationLookupSettings.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
/**
4+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
8+
namespace OCA\Tables\Dto;
9+
10+
readonly class RelationLookupSettings {
11+
public function __construct(
12+
public int $relationColumnId,
13+
public int $targetColumnId,
14+
) {
15+
}
16+
17+
public static function fromArray(array $data): self {
18+
return new self(
19+
relationColumnId: (int)$data['relationColumnId'],
20+
targetColumnId: (int)$data['targetColumnId'],
21+
);
22+
}
23+
}

lib/Dto/RelationSettings.php

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
/**
4+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
8+
namespace OCA\Tables\Dto;
9+
10+
readonly class RelationSettings {
11+
public function __construct(
12+
public string $relationType,
13+
public int $targetId,
14+
public int $labelColumn,
15+
) {
16+
}
17+
18+
public static function fromArray(array $data): self {
19+
return new self(
20+
relationType: $data['relationType'],
21+
targetId: (int)$data['targetId'],
22+
labelColumn: (int)$data['labelColumn'],
23+
);
24+
}
25+
26+
public function isView(): bool {
27+
return $this->relationType === 'view';
28+
}
29+
}

lib/Helper/ColumnsHelper.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ class ColumnsHelper {
2121
Column::TYPE_SELECTION,
2222
Column::TYPE_USERGROUP,
2323
Column::TYPE_RELATION,
24+
Column::TYPE_RELATION_LOOKUP,
2425
];
2526

2627
/**

lib/ResponseDefinitions.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,8 @@
238238
* notify-column: bool,
239239
* notify-row: bool,
240240
* }
241+
*
242+
* @psalm-type TablesRelationData = list<array{column: ?TablesColumn, values: list<array{id: int, value: mixed}>}>
241243
*/
242244
class ResponseDefinitions {
243245
}

lib/Service/ColumnService.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -655,6 +655,11 @@ public function findOrCreateColumnsByTitleForTableAsArray(?int $tableId, ?int $v
655655
foreach ($titles as $title) {
656656
$i++;
657657
foreach ($allColumns as $column) {
658+
// Skip matching columns with type relation_lookup
659+
if ($column->getType() === Column::TYPE_RELATION_LOOKUP) {
660+
continue;
661+
}
662+
658663
if ($column->getTitle() === $title) {
659664
$result[$i] = $column;
660665
$countMatchingColumns++;

lib/Service/ColumnTypes/RelationBusiness.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
use OCA\Tables\Service\RelationService;
1313
use Psr\Log\LoggerInterface;
1414

15-
class RelationBusiness extends SuperBusiness implements IColumnTypeBusiness {
15+
class RelationBusiness extends SuperBusiness {
1616

1717
public function __construct(
1818
LoggerInterface $logger,
@@ -34,7 +34,7 @@ public function parseValue($value, ?Column $column = null): string {
3434

3535
$relationData = $this->relationService->getRelationData($column);
3636
// try to find value by label
37-
$matchingRelation = array_filter($relationData, fn (array $relation) => $relation['label'] === $value);
37+
$matchingRelation = array_filter($relationData, fn (array $relation) => $relation['value'] === $value);
3838
if (!empty($matchingRelation)) {
3939
return json_encode(reset($matchingRelation)['id']);
4040
}
@@ -63,7 +63,7 @@ public function canBeParsed($value, ?Column $column = null): bool {
6363

6464
$relationData = $this->relationService->getRelationData($column);
6565
// try to find value by label
66-
$matchingRelation = array_filter($relationData, fn (array $relation) => $relation['label'] === $value);
66+
$matchingRelation = array_filter($relationData, fn (array $relation) => $relation['value'] === $value);
6767
if (!empty($matchingRelation)) {
6868
return true;
6969
}
@@ -83,7 +83,7 @@ public function validateValue(mixed $value, Column $column, string $userId, int
8383
$relationData = $this->relationService->getRelationData($column);
8484

8585
// Try to find value by label first
86-
$matchingRelation = array_filter($relationData, fn (array $relation) => $relation['label'] === $value);
86+
$matchingRelation = array_filter($relationData, fn (array $relation) => $relation['value'] === $value);
8787
if (!empty($matchingRelation)) {
8888
return;
8989
}

0 commit comments

Comments
 (0)