Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/Constants/ColumnType.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@ enum ColumnType: string {
case DATETIME = 'datetime';
case PEOPLE = 'usergroup';
case RELATION = 'relation';
case RELATION_LOOKUP = 'relation_lookup';
}
12 changes: 8 additions & 4 deletions lib/Controller/Api1Controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -844,7 +844,8 @@ public function indexViewColumns(int $viewId): DataResponse {
* Get all relation data for a table
*
* @param int $tableId Table ID
* @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{}>
* @return DataResponse<Http::STATUS_OK, TablesRelationData, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}>
* @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{}>
*
* 200: Relation data returned
* 403: No permissions
Expand All @@ -854,6 +855,7 @@ public function indexViewColumns(int $viewId): DataResponse {
#[NoCSRFRequired]
#[CORS]
#[RequirePermission(permission: Application::PERMISSION_READ, type: Application::NODE_TYPE_TABLE, idParam: 'tableId')]
#[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)]
public function indexTableRelations(int $tableId): DataResponse {
try {
return new DataResponse($this->relationService->getRelationsForTable($tableId));
Expand All @@ -876,7 +878,8 @@ public function indexTableRelations(int $tableId): DataResponse {
* Get all relation data for a view
*
* @param int $viewId View ID
* @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{}>
* @return DataResponse<Http::STATUS_OK, TablesRelationData, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}>
* @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{}>
*
* 200: Relation data returned
* 403: No permissions
Expand All @@ -886,6 +889,7 @@ public function indexTableRelations(int $tableId): DataResponse {
#[NoCSRFRequired]
#[CORS]
#[RequirePermission(permission: Application::PERMISSION_READ, type: Application::NODE_TYPE_VIEW, idParam: 'viewId')]
#[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)]
public function indexViewRelations(int $viewId): DataResponse {
try {
return new DataResponse($this->relationService->getRelationsForView($viewId));
Expand All @@ -910,7 +914,7 @@ public function indexViewRelations(int $viewId): DataResponse {
* @param int|null $tableId Table ID
* @param int|null $viewId View ID
* @param string $title Title
* @param 'text'|'number'|'datetime'|'select'|'usergroup'|'relation' $type Column main type
* @param 'text'|'number'|'datetime'|'select'|'usergroup'|'relation'|'relation_lookup' $type Column main type
* @param string|null $technicalName Technical name of the column
* @param string|null $subtype Column sub type
* @param bool $mandatory Is the column mandatory
Expand Down Expand Up @@ -1694,7 +1698,7 @@ public function createTableShare(int $tableId, string $receiver, string $receive
*
* @param int $tableId Table ID
* @param string $title Title
* @param 'text'|'number'|'datetime'|'select'|'usergroup'|'relation' $type Column main type
* @param 'text'|'number'|'datetime'|'select'|'usergroup'|'relation'|'relation_lookup' $type Column main type
* @param string|null $technicalName Technical name of the column
* @param string|null $subtype Column sub type
* @param bool $mandatory Is the column mandatory
Expand Down
15 changes: 15 additions & 0 deletions lib/Db/Column.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -352,6 +353,20 @@ public function getCustomSettingsArray(): array {
return json_decode($this->customSettings, true) ?: [];
}

/**
* @template T
* @param class-string<T> $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
*/
Expand Down
51 changes: 51 additions & 0 deletions lib/Db/Row2Mapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -60,6 +61,9 @@ public function delete(Row2 $row): Row2 {
$this->db->beginTransaction();
try {
foreach ($this->columnsHelper->columns as $columnType) {
if ($this->isVirtualColumn($columnType)) {
continue;
}
$this->getCellMapperFromType($columnType)->deleteAllForRow($row->getId());
}
$this->rowSleeveMapper->deleteById($row->getId());
Expand Down Expand Up @@ -219,6 +223,8 @@ private function getRows(array $rowIds, array $columnIds): array {
private function getRowsChunk(array $rowIds, array $columnIds): array {
$qb = $this->db->getQueryBuilder();

$columnIds = $this->addRelationColumnIdsForLookupColumns($columnIds);

$qbSqlForColumnTypes = null;
foreach ($this->columnsHelper->columns as $columnType) {
$qbTmp = $this->db->getQueryBuilder();
Expand All @@ -234,6 +240,9 @@ private function getRowsChunk(array $rowIds, array $columnIds): array {
$qbTmp->selectAlias($qbTmp->createFunction('NULL'), 'value_type');
}

if ($this->isVirtualColumn($columnType)) {
continue;
}
$qbTmp
->from('tables_row_cells_' . $columnType)
->where($qb->expr()->in('column_id', $qb->createNamedParameter($columnIds, IQueryBuilder::PARAM_INT_ARRAY, ':columnIds')))
Expand Down Expand Up @@ -839,6 +848,10 @@ private function insertCell(int $rowId, int $columnId, $value, ?string $lastEdit
throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage());
}

if ($this->isVirtualColumn($column->getType())) {
return;
}

// insert new cell
$cellMapper = $this->getCellMapper($column);

Expand Down Expand Up @@ -877,6 +890,10 @@ private function insertCell(int $rowId, int $columnId, $value, ?string $lastEdit
* @throws InternalError
*/
private function updateCell(RowCellSuper $cell, RowCellMapperSuper $mapper, $value, Column $column): void {
if ($this->isVirtualColumn($column->getType())) {
return;
}

$this->getCellMapper($column)->applyDataToEntity($column, $cell, $value);
$this->updateMetaData($cell);
$mapper->updateWrapper($cell);
Expand All @@ -887,6 +904,9 @@ private function updateCell(RowCellSuper $cell, RowCellMapperSuper $mapper, $val
*/
private function insertOrUpdateCell(int $rowId, int $columnId, $value): void {
$column = $this->columnMapper->find($columnId);
if ($this->isVirtualColumn($column->getType())) {
return;
}
$cellMapper = $this->getCellMapper($column);
try {
if ($cellMapper->hasMultipleValues()) {
Expand All @@ -913,6 +933,9 @@ private function getCellMapper(Column $column): RowCellMapperSuper {
}

private function getCellMapperFromType(string $columnType): RowCellMapperSuper {
if ($this->isVirtualColumn($columnType)) {
throw new InternalError('Virtual columns do not have cell mappers');
}
$cellMapperClassName = 'OCA\Tables\Db\RowCell' . ucfirst($columnType) . 'Mapper';
/** @var RowCellMapperSuper $cellMapper */
try {
Expand All @@ -934,6 +957,9 @@ private function getColumnDbParamType(Column $column): int {
* @throws InternalError
*/
public function deleteDataForColumn(Column $column): void {
if ($this->isVirtualColumn($column->getType())) {
return;
}
try {
$this->getCellMapper($column)->deleteAllForColumn($column->getId());
} catch (Exception $e) {
Expand Down Expand Up @@ -1002,6 +1028,9 @@ private function getFormattedDefaultValue(Column $column) {
case Column::TYPE_USERGROUP:
$defaultValue = $this->getCellMapper($column)->filterValueToQueryParam($column, $column->getUsergroupDefault());
break;
case Column::TYPE_RELATION_LOOKUP:
$defaultValue = null;
break;
}
return $defaultValue;
}
Expand Down Expand Up @@ -1029,4 +1058,26 @@ private function sortRowsByIds(array $rows, array $wantedRowIds): array {

return $sortedRows;
}

public function isVirtualColumn(string $columnType): bool {
return $columnType === Column::TYPE_RELATION_LOOKUP;
}

/**
* @param int[] $columnIds
* @return int[]
*/
public function addRelationColumnIdsForLookupColumns(array $columnIds): array {
$allColumns = $this->columnMapper->findAll($columnIds);
foreach ($allColumns as $column) {
if ($column->getType() !== Column::TYPE_RELATION_LOOKUP) {
continue;
}

$settings = $column->getCustomSettingsObject(RelationLookupSettings::class);
$columnIds[] = $settings->relationColumnId;
}
return array_values(array_unique(array_filter($columnIds)));
}

}
23 changes: 23 additions & 0 deletions lib/Dto/RelationLookupSettings.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Tables\Dto;

readonly class RelationLookupSettings {
public function __construct(
public int $relationColumnId,
public int $targetColumnId,
) {
}

public static function fromArray(array $data): self {
return new self(
relationColumnId: (int)$data['relationColumnId'],
targetColumnId: (int)$data['targetColumnId'],
);
}
}
29 changes: 29 additions & 0 deletions lib/Dto/RelationSettings.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Tables\Dto;

readonly class RelationSettings {
public function __construct(
public string $relationType,
public int $targetId,
public int $labelColumn,
) {
}

public static function fromArray(array $data): self {
return new self(
relationType: $data['relationType'],
targetId: (int)$data['targetId'],
labelColumn: (int)$data['labelColumn'],
);
}

public function isView(): bool {
return $this->relationType === 'view';
}
}
1 change: 1 addition & 0 deletions lib/Helper/ColumnsHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class ColumnsHelper {
Column::TYPE_SELECTION,
Column::TYPE_USERGROUP,
Column::TYPE_RELATION,
Column::TYPE_RELATION_LOOKUP,
];

/**
Expand Down
2 changes: 2 additions & 0 deletions lib/ResponseDefinitions.php
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,8 @@
* notify-column: bool,
* notify-row: bool,
* }
*
* @psalm-type TablesRelationData = list<array{column: ?TablesColumn, values: list<array{id: int, value: mixed}>}>
*/
class ResponseDefinitions {
}
5 changes: 5 additions & 0 deletions lib/Service/ColumnService.php
Original file line number Diff line number Diff line change
Expand Up @@ -655,6 +655,11 @@ public function findOrCreateColumnsByTitleForTableAsArray(?int $tableId, ?int $v
foreach ($titles as $title) {
$i++;
foreach ($allColumns as $column) {
// Skip matching columns with type relation_lookup
if ($column->getType() === Column::TYPE_RELATION_LOOKUP) {
continue;
}

if ($column->getTitle() === $title) {
$result[$i] = $column;
$countMatchingColumns++;
Expand Down
8 changes: 4 additions & 4 deletions lib/Service/ColumnTypes/RelationBusiness.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -34,7 +34,7 @@ public function parseValue($value, ?Column $column = null): string {

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

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

// Try to find value by label first
$matchingRelation = array_filter($relationData, fn (array $relation) => $relation['label'] === $value);
$matchingRelation = array_filter($relationData, fn (array $relation) => $relation['value'] === $value);
if (!empty($matchingRelation)) {
return;
}
Expand Down
58 changes: 58 additions & 0 deletions lib/Service/ColumnTypes/RelationLookupBusiness.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Tables\Service\ColumnTypes;

use OCA\Tables\Db\Column;

class RelationLookupBusiness extends SuperBusiness {
/**
* @param mixed $value (array|string|null)
* @param Column|null $column
* @return string
*/
public function parseValue($value, ?Column $column = null): string {
// Relation lookup is a virtual column, values come from the related table
// No parsing needed for input as this column is read-only
return '';
}

/**
* @param mixed $value (array|string|null)
* @param Column|null $column
* @return bool
*/
public function canBeParsed($value, ?Column $column = null): bool {
// Virtual column, cannot be set directly
return false;
}

public function validateValue(mixed $value, Column $column, string $userId, int $tableId, ?int $rowId): void {
// Virtual column, validation not applicable
// Values are derived from the relation column
}

/**
* @param mixed $value
* @param Column $column
* @return bool
*/
public function canBeParsedDisplayValue($value, Column $column): bool {
// Virtual column, display values come from relation data
return false;
}

/**
* @param mixed $value
* @param Column $column
* @return string
*/
public function parseDisplayValue($value, Column $column): string {
// Virtual column, display values come from relation data
return '';
}
}
Loading
Loading