Skip to content

Commit d406233

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

File tree

31 files changed

+1197
-25
lines changed

31 files changed

+1197
-25
lines changed

appinfo/routes.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@
5151
['name' => 'api1#updateColumn', 'url' => '/api/1/columns/{columnId}', 'verb' => 'PUT'],
5252
['name' => 'api1#getColumn', 'url' => '/api/1/columns/{columnId}', 'verb' => 'GET'],
5353
['name' => 'api1#deleteColumn', 'url' => '/api/1/columns/{columnId}', 'verb' => 'DELETE'],
54+
// -> relations
55+
['name' => 'api1#indexTableRelations', 'url' => '/api/1/tables/{tableId}/relations', 'verb' => 'GET'],
56+
['name' => 'api1#indexViewRelations', 'url' => '/api/1/views/{viewId}/relations', 'verb' => 'GET'],
5457
// -> rows
5558
['name' => 'api1#indexTableRowsSimple', 'url' => '/api/1/tables/{tableId}/rows/simple', 'verb' => 'GET'],
5659
['name' => 'api1#indexTableRows', 'url' => '/api/1/tables/{tableId}/rows', 'verb' => 'GET'],

lib/Controller/Api1Controller.php

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
use OCA\Tables\ResponseDefinitions;
2424
use OCA\Tables\Service\ColumnService;
2525
use OCA\Tables\Service\ImportService;
26+
use OCA\Tables\Service\RelationService;
2627
use OCA\Tables\Service\RowService;
2728
use OCA\Tables\Service\ShareService;
2829
use OCA\Tables\Service\TableService;
@@ -57,6 +58,7 @@ class Api1Controller extends ApiController {
5758
private RowService $rowService;
5859
private ImportService $importService;
5960
private ViewService $viewService;
61+
private RelationService $relationService;
6062
private ViewMapper $viewMapper;
6163
private IL10N $l10N;
6264

@@ -77,6 +79,7 @@ public function __construct(
7779
RowService $rowService,
7880
ImportService $importService,
7981
ViewService $viewService,
82+
RelationService $relationService,
8083
ViewMapper $viewMapper,
8184
V1Api $v1Api,
8285
LoggerInterface $logger,
@@ -90,6 +93,7 @@ public function __construct(
9093
$this->rowService = $rowService;
9194
$this->importService = $importService;
9295
$this->viewService = $viewService;
96+
$this->relationService = $relationService;
9397
$this->viewMapper = $viewMapper;
9498
$this->userId = $userId;
9599
$this->v1Api = $v1Api;
@@ -803,13 +807,77 @@ public function indexViewColumns(int $viewId): DataResponse {
803807
}
804808
}
805809

810+
/**
811+
* Get all relation data for a table
812+
*
813+
* @param int $tableId Table ID
814+
* @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{}>
815+
*
816+
* 200: Relation data returned
817+
* 403: No permissions
818+
* 404: Not found
819+
*/
820+
#[NoAdminRequired]
821+
#[NoCSRFRequired]
822+
#[CORS]
823+
#[RequirePermission(permission: Application::PERMISSION_READ, type: Application::NODE_TYPE_TABLE, idParam: 'tableId')]
824+
public function indexTableRelations(int $tableId): DataResponse {
825+
try {
826+
return new DataResponse($this->relationService->getRelationsForTable($tableId));
827+
} catch (PermissionError $e) {
828+
$this->logger->warning('A permission error occurred: ' . $e->getMessage(), ['exception' => $e]);
829+
$message = ['message' => $e->getMessage()];
830+
return new DataResponse($message, Http::STATUS_FORBIDDEN);
831+
} catch (InternalError $e) {
832+
$this->logger->error('An internal error or exception occurred: ' . $e->getMessage(), ['exception' => $e]);
833+
$message = ['message' => $e->getMessage()];
834+
return new DataResponse($message, Http::STATUS_INTERNAL_SERVER_ERROR);
835+
} catch (NotFoundError $e) {
836+
$this->logger->info('A not found error occurred: ' . $e->getMessage(), ['exception' => $e]);
837+
$message = ['message' => $e->getMessage()];
838+
return new DataResponse($message, Http::STATUS_NOT_FOUND);
839+
}
840+
}
841+
842+
/**
843+
* Get all relation data for a view
844+
*
845+
* @param int $viewId View ID
846+
* @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+
*
848+
* 200: Relation data returned
849+
* 403: No permissions
850+
* 404: Not found
851+
*/
852+
#[NoAdminRequired]
853+
#[NoCSRFRequired]
854+
#[CORS]
855+
#[RequirePermission(permission: Application::PERMISSION_READ, type: Application::NODE_TYPE_VIEW, idParam: 'viewId')]
856+
public function indexViewRelations(int $viewId): DataResponse {
857+
try {
858+
return new DataResponse($this->relationService->getRelationsForView($viewId));
859+
} catch (PermissionError $e) {
860+
$this->logger->warning('A permission error occurred: ' . $e->getMessage(), ['exception' => $e]);
861+
$message = ['message' => $e->getMessage()];
862+
return new DataResponse($message, Http::STATUS_FORBIDDEN);
863+
} catch (InternalError $e) {
864+
$this->logger->error('An internal error or exception occurred: ' . $e->getMessage(), ['exception' => $e]);
865+
$message = ['message' => $e->getMessage()];
866+
return new DataResponse($message, Http::STATUS_INTERNAL_SERVER_ERROR);
867+
} catch (NotFoundError $e) {
868+
$this->logger->info('A not found error occurred: ' . $e->getMessage(), ['exception' => $e]);
869+
$message = ['message' => $e->getMessage()];
870+
return new DataResponse($message, Http::STATUS_NOT_FOUND);
871+
}
872+
}
873+
806874
/**
807875
* Create a column
808876
*
809877
* @param int|null $tableId Table ID
810878
* @param int|null $viewId View ID
811879
* @param string $title Title
812-
* @param 'text'|'number'|'datetime'|'select'|'usergroup' $type Column main type
880+
* @param 'text'|'number'|'datetime'|'select'|'usergroup'|'relation' $type Column main type
813881
* @param string|null $subtype Column sub type
814882
* @param bool $mandatory Is the column mandatory
815883
* @param string|null $description Description
@@ -1572,7 +1640,7 @@ public function createTableShare(int $tableId, string $receiver, string $receive
15721640
*
15731641
* @param int $tableId Table ID
15741642
* @param string $title Title
1575-
* @param 'text'|'number'|'datetime'|'select'|'usergroup' $type Column main type
1643+
* @param 'text'|'number'|'datetime'|'select'|'usergroup'|'relation' $type Column main type
15761644
* @param string|null $subtype Column sub type
15771645
* @param bool $mandatory Is the column mandatory
15781646
* @param string|null $description Description

lib/Db/Column.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ class Column extends EntitySuper implements JsonSerializable {
100100
public const TYPE_NUMBER = 'number';
101101
public const TYPE_DATETIME = 'datetime';
102102
public const TYPE_USERGROUP = 'usergroup';
103+
public const TYPE_RELATION = 'relation';
103104

104105
public const SUBTYPE_DATETIME_DATE = 'date';
105106
public const SUBTYPE_DATETIME_TIME = 'time';

lib/Db/RowCellRelation.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
namespace OCA\Tables\Db;
10+
11+
/** @template-extends RowCellSuper<RowCellRelation> */
12+
class RowCellRelation extends RowCellSuper {
13+
protected ?int $value = null;
14+
15+
public function __construct() {
16+
parent::__construct();
17+
$this->addType('value', 'integer');
18+
}
19+
20+
public function jsonSerialize(): array {
21+
return parent::jsonSerializePreparation($this->value);
22+
}
23+
}

lib/Db/RowCellRelationMapper.php

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
namespace OCA\Tables\Db;
10+
11+
use OCP\DB\QueryBuilder\IQueryBuilder;
12+
use OCP\IDBConnection;
13+
14+
/** @template-extends RowCellMapperSuper<RowCellRelation, int|null, int|null> */
15+
class RowCellRelationMapper extends RowCellMapperSuper {
16+
protected string $table = 'tables_row_cells_relation';
17+
18+
public function __construct(IDBConnection $db) {
19+
parent::__construct($db, $this->table, RowCellRelation::class);
20+
}
21+
22+
/**
23+
* @inheritDoc
24+
*/
25+
public function hasMultipleValues(): bool {
26+
return false;
27+
}
28+
29+
/**
30+
* @inheritDoc
31+
*/
32+
public function getDbParamType() {
33+
return IQueryBuilder::PARAM_INT;
34+
}
35+
36+
public function formatRowData(Column $column, array $row) {
37+
$value = $row['value'];
38+
return (int)$value;
39+
}
40+
}

lib/Helper/ColumnsHelper.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ class ColumnsHelper {
1818
Column::TYPE_DATETIME,
1919
Column::TYPE_SELECTION,
2020
Column::TYPE_USERGROUP,
21+
Column::TYPE_RELATION,
2122
];
2223

2324
public function __construct(
@@ -30,6 +31,9 @@ public function resolveSearchValue(string $placeholder, string $userId, ?Column
3031
if (str_starts_with($placeholder, '@selection-id-')) {
3132
return substr($placeholder, 14);
3233
}
34+
if (str_starts_with($placeholder, '@relation-id-')) {
35+
return substr($placeholder, 13);
36+
}
3337

3438
$placeholderParts = explode(':', $placeholder, 2);
3539
$placeholderName = ltrim($placeholderParts[0], '@');
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
namespace OCA\Tables\Migration;
10+
11+
use Closure;
12+
use OCP\DB\ISchemaWrapper;
13+
use OCP\DB\Types;
14+
use OCP\Migration\IOutput;
15+
use OCP\Migration\SimpleMigrationStep;
16+
17+
class Version002001Date20260109000000 extends SimpleMigrationStep {
18+
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
19+
/** @var ISchemaWrapper $schema */
20+
$schema = $schemaClosure();
21+
22+
$changes = $this->createRowValueTable($schema, 'relation', Types::INTEGER);
23+
return $changes;
24+
}
25+
26+
private function createRowValueTable(ISchemaWrapper $schema, string $name, string $type): ?ISchemaWrapper {
27+
if (!$schema->hasTable('tables_row_cells_' . $name)) {
28+
$table = $schema->createTable('tables_row_cells_' . $name);
29+
$table->addColumn('id', Types::INTEGER, [
30+
'autoincrement' => true,
31+
'notnull' => true,
32+
]);
33+
$table->addColumn('column_id', Types::INTEGER, ['notnull' => true]);
34+
$table->addColumn('row_id', Types::INTEGER, ['notnull' => true]);
35+
$table->addColumn('value', $type, ['notnull' => false]);
36+
$table->addColumn('last_edit_at', Types::DATETIME, ['notnull' => true]);
37+
$table->addColumn('last_edit_by', Types::STRING, ['notnull' => true, 'length' => 64]);
38+
$table->addIndex(['column_id', 'row_id']);
39+
$table->addIndex(['column_id', 'value']);
40+
$table->setPrimaryKey(['id']);
41+
return $schema;
42+
}
43+
44+
return null;
45+
}
46+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<?php
2+
3+
/**
4+
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
8+
namespace OCA\Tables\Service\ColumnTypes;
9+
10+
use OCA\Tables\Db\Column;
11+
use OCA\Tables\Service\RelationService;
12+
use Psr\Log\LoggerInterface;
13+
14+
class RelationBusiness extends SuperBusiness implements IColumnTypeBusiness {
15+
16+
public function __construct(
17+
LoggerInterface $logger,
18+
private RelationService $relationService,
19+
) {
20+
parent::__construct($logger);
21+
}
22+
23+
/**
24+
* @param mixed $value (array|string|null)
25+
* @param Column|null $column
26+
* @return string
27+
*/
28+
public function parseValue($value, ?Column $column = null): string {
29+
if (!$column) {
30+
$this->logger->warning('No column given, but expected on ' . __FUNCTION__ . ' within ' . __CLASS__, ['exception' => new \Exception()]);
31+
return '';
32+
}
33+
34+
$relationData = $this->relationService->getRelationData($column);
35+
// try to find value by label
36+
$matchingRelation = array_filter($relationData, fn (array $relation) => $relation['label'] === $value);
37+
if (!empty($matchingRelation)) {
38+
return json_encode(reset($matchingRelation)['id']);
39+
}
40+
41+
// if not found, try to find by id
42+
if (is_numeric($value) && isset($relationData[$value])) {
43+
return json_encode($value);
44+
}
45+
46+
return '';
47+
}
48+
49+
/**
50+
* @param mixed $value (array|string|null)
51+
* @param Column|null $column
52+
* @return bool
53+
*/
54+
public function canBeParsed($value, ?Column $column = null): bool {
55+
if (!$column) {
56+
$this->logger->warning('No column given, but expected on ' . __FUNCTION__ . ' within ' . __CLASS__, ['exception' => new \Exception()]);
57+
return false;
58+
}
59+
if ($value === null) {
60+
return true;
61+
}
62+
63+
$relationData = $this->relationService->getRelationData($column);
64+
// try to find value by label
65+
$matchingRelation = array_filter($relationData, fn (array $relation) => $relation['label'] === $value);
66+
if (!empty($matchingRelation)) {
67+
return true;
68+
}
69+
// if not found, try to find by id
70+
if (is_numeric($value) && isset($relationData[$value])) {
71+
return true;
72+
}
73+
74+
return false;
75+
}
76+
}

lib/Service/ImportService.php

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,10 @@ private function getPreviewData(Worksheet $worksheet): array {
183183
$value = $cell->getValue();
184184
// $cellIterator`s index is based on 1, not 0.
185185
$colIndex = $cellIterator->getCurrentColumnIndex() - 1;
186+
if (!array_key_exists($colIndex, $this->columns)) {
187+
continue;
188+
}
189+
186190
$column = $this->columns[$colIndex];
187191

188192
if (!array_key_exists($colIndex, $columns)) {
@@ -370,12 +374,13 @@ private function loop(Worksheet $worksheet): void {
370374
*/
371375
private function parseValueByColumnType(string $value, Column $column): string {
372376
try {
377+
// fixme: add cache <columId, businessInstance>
373378
$businessClassName = 'OCA\Tables\Service\ColumnTypes\\';
374379
$businessClassName .= ucfirst($column->getType()) . ucfirst($column->getSubtype()) . 'Business';
375380
/** @var IColumnTypeBusiness $columnBusiness */
376381
$columnBusiness = Server::get($businessClassName);
377382
if (!$columnBusiness->canBeParsedDisplayValue($value, $column)) {
378-
$this->logger->warning('Value ' . $value . ' could not be parsed for column ' . $column->getTitle());
383+
$this->logger->warning('Value "' . $value . '" could not be parsed for column "' . $column->getTitle().'"');
379384
$this->countParsingErrors++;
380385
return '';
381386
}
@@ -439,7 +444,7 @@ private function upsertRow(Row $row): void {
439444
if (!$cell || $cell->getValue() === null) {
440445
$this->logger->info('Cell is empty while fetching rows data for importing.');
441446
if ($column->getMandatory()) {
442-
$this->logger->warning('Mandatory column was not set');
447+
$this->logger->warning('Mandatory column "'.$column->getTitle().'" was not set');
443448
$this->countErrors++;
444449
return;
445450
}

0 commit comments

Comments
 (0)