diff --git a/appinfo/routes.php b/appinfo/routes.php index 050db4d07d..af885ab2c3 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -62,6 +62,9 @@ ['name' => 'api1#deleteRowByView', 'url' => '/api/1/views/{viewId}/rows/{rowId}', 'verb' => 'DELETE'], ['name' => 'api1#updateRow', 'url' => '/api/1/rows/{rowId}', 'verb' => 'PUT'], ['name' => 'api1#deleteRow', 'url' => '/api/1/rows/{rowId}', 'verb' => 'DELETE'], + // -> relations + ['name' => 'api1#getRelations', 'url' => '/api/1/relations/{columnId}/{rowId}', 'verb' => 'GET'], + ['name' => 'api1#setRelations', 'url' => '/api/1/relations/{columnId}/{rowId}', 'verb' => 'PUT'], // -> import ['name' => 'api1#importInTable', 'url' => '/api/1/import/table/{tableId}', 'verb' => 'POST'], ['name' => 'api1#importInView', 'url' => '/api/1/import/views/{viewId}', 'verb' => 'POST'], @@ -135,6 +138,7 @@ ['name' => 'ApiColumns#createSelectionColumn', 'url' => '/api/2/columns/selection', 'verb' => 'POST'], ['name' => 'ApiColumns#createDatetimeColumn', 'url' => '/api/2/columns/datetime', 'verb' => 'POST'], ['name' => 'ApiColumns#createUsergroupColumn', 'url' => '/api/2/columns/usergroup', 'verb' => 'POST'], + ['name' => 'ApiColumns#createRelationColumn', 'url' => '/api/2/columns/relation', 'verb' => 'POST'], ['name' => 'ApiFavorite#create', 'url' => '/api/2/favorites/{nodeType}/{nodeId}', 'verb' => 'POST', 'requirements' => ['nodeType' => '(\d+)', 'nodeId' => '(\d+)']], ['name' => 'ApiFavorite#destroy', 'url' => '/api/2/favorites/{nodeType}/{nodeId}', 'verb' => 'DELETE', 'requirements' => ['nodeType' => '(\d+)', 'nodeId' => '(\d+)']], diff --git a/lib/Constants/ColumnType.php b/lib/Constants/ColumnType.php index 1be1f5ecdf..f296de2f74 100644 --- a/lib/Constants/ColumnType.php +++ b/lib/Constants/ColumnType.php @@ -15,4 +15,5 @@ enum ColumnType: string { case SELECTION = 'selection'; case DATETIME = 'datetime'; case PEOPLE = 'usergroup'; + case RELATION = 'relation'; } diff --git a/lib/Constants/ViewUpdatableParameters.php b/lib/Constants/ViewUpdatableParameters.php index f991af4525..26abc845d1 100644 --- a/lib/Constants/ViewUpdatableParameters.php +++ b/lib/Constants/ViewUpdatableParameters.php @@ -13,6 +13,7 @@ enum ViewUpdatableParameters: string { case TITLE = 'title'; case EMOJI = 'emoji'; case DESCRIPTION = 'description'; + case TYPE = 'type'; case SORT = 'sort'; case FILTER = 'filter'; case COLUMN_SETTINGS = 'columns'; diff --git a/lib/Controller/Api1Controller.php b/lib/Controller/Api1Controller.php index 0db4b1eb8b..ea08bf5f28 100644 --- a/lib/Controller/Api1Controller.php +++ b/lib/Controller/Api1Controller.php @@ -25,6 +25,7 @@ use OCA\Tables\ResponseDefinitions; use OCA\Tables\Service\ColumnService; use OCA\Tables\Service\ImportService; +use OCA\Tables\Service\RelationService; use OCA\Tables\Service\RowService; use OCA\Tables\Service\ShareService; use OCA\Tables\Service\TableService; @@ -69,6 +70,8 @@ class Api1Controller extends ApiController { protected LoggerInterface $logger; + private RelationService $relationService; + use Errors; @@ -85,6 +88,7 @@ public function __construct( LoggerInterface $logger, IL10N $l10N, ?string $userId, + RelationService $relationService, ) { parent::__construct(Application::APP_ID, $request); $this->tableService = $service; @@ -98,6 +102,7 @@ public function __construct( $this->v1Api = $v1Api; $this->logger = $logger; $this->l10N = $l10N; + $this->relationService = $relationService; } // Tables @@ -346,9 +351,9 @@ public function indexViews(int $tableId): DataResponse { #[CORS] #[RequirePermission(permission: Application::PERMISSION_MANAGE, type: Application::NODE_TYPE_TABLE, idParam: 'tableId')] #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] - public function createView(int $tableId, string $title, ?string $emoji): DataResponse { + public function createView(int $tableId, string $title, ?string $emoji, ?string $type = 'table'): DataResponse { try { - return new DataResponse($this->viewService->create($title, $emoji, $this->tableService->find($tableId))->jsonSerialize()); + return new DataResponse($this->viewService->create($title, $emoji, $this->tableService->find($tableId), null, $type)->jsonSerialize()); } catch (PermissionError $e) { $this->logger->warning('A permission error occurred: ' . $e->getMessage(), ['exception' => $e]); $message = ['message' => $e->getMessage()]; @@ -1729,4 +1734,39 @@ public function createTableColumn( return new DataResponse($message, Http::STATUS_BAD_REQUEST); } } + + // Relations + + /** + * @NoAdminRequired + */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[CORS] + public function getRelations(int $columnId, int $rowId): DataResponse { + try { + $linkedIds = $this->relationService->getLinkedRowIds($rowId, $columnId); + return new DataResponse($linkedIds); + } catch (\Exception $e) { + return new DataResponse(['message' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * @NoAdminRequired + */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[CORS] + public function setRelations(int $columnId, int $rowId, array $targetRowIds = []): DataResponse { + try { + $column = $this->columnService->find($columnId); + $userId = $this->userId; + $this->relationService->setLinks($columnId, $rowId, $targetRowIds, $userId, $column); + $linkedIds = $this->relationService->getLinkedRowIds($rowId, $columnId); + return new DataResponse($linkedIds); + } catch (\Exception $e) { + return new DataResponse(['message' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } } diff --git a/lib/Controller/ApiColumnsController.php b/lib/Controller/ApiColumnsController.php index fc73b40688..87050dae0a 100644 --- a/lib/Controller/ApiColumnsController.php +++ b/lib/Controller/ApiColumnsController.php @@ -412,4 +412,62 @@ public function createUsergroupColumn(int $baseNodeId, string $title, ?string $u ); return new DataResponse($column->jsonSerialize()); } + + /** + * [api v2] Create new relation column + * + * Links rows from one table to rows in another table + * + * @param int $baseNodeId Context of the column creation + * @param string $title Title + * @param int $relationTableId ID of the related table + * @param boolean $relationMultiple Whether multiple rows can be linked + * @param string $relationType Relation type: 'one-to-one', 'one-to-many', 'many-to-many' + * @param int|null $relationDisplayColumnId Column ID from target table to display + * @param string|null $description Description + * @param list|null $selectedViewIds View IDs where this columns + * should be added + * @param boolean $mandatory Is mandatory + * @param 'table'|'view' $baseNodeType Context type of the column creation + * @param array $customSettings Custom settings for the + * column + * + * + * @return DataResponse|DataResponse + * + * + * 200: Column created + * 403: No permission + * 404: Not found + * @throws InternalError + * @throws NotFoundError + * @throws PermissionError + * @throws BadRequestError + */ + #[NoAdminRequired] + #[RequirePermission(permission: Application::PERMISSION_MANAGE, typeParam: 'baseNodeType', idParam: 'baseNodeId')] + public function createRelationColumn(int $baseNodeId, string $title, int $relationTableId, ?bool $relationMultiple = false, string $relationType = 'many-to-many', ?int $relationDisplayColumnId = null, ?string $description = null, ?array $selectedViewIds = [], bool $mandatory = false, string $baseNodeType = 'table', array $customSettings = []): DataResponse { + $tableId = $baseNodeType === 'table' ? $baseNodeId : null; + $viewId = $baseNodeType === 'view' ? $baseNodeId : null; + $column = $this->service->create( + $this->userId, + $tableId, + $viewId, + new ColumnDto( + title: $title, + type: ColumnType::RELATION->value, + mandatory: $mandatory, + description: $description, + customSettings: json_encode($customSettings), + relationTableId: $relationTableId, + relationMultiple: $relationMultiple, + relationType: $relationType, + relationDisplayColumnId: $relationDisplayColumnId, + ), + $selectedViewIds + ); + return new DataResponse($column->jsonSerialize()); + } } diff --git a/lib/Controller/ViewController.php b/lib/Controller/ViewController.php index 2828dc4021..90375c7b1e 100644 --- a/lib/Controller/ViewController.php +++ b/lib/Controller/ViewController.php @@ -78,9 +78,9 @@ public function show(int $id): DataResponse { #[NoAdminRequired] #[RequirePermission(permission: Application::PERMISSION_MANAGE, type: Application::NODE_TYPE_TABLE, idParam: 'tableId')] - public function create(int $tableId, string $title, ?string $emoji): DataResponse { - return $this->handleError(function () use ($tableId, $title, $emoji) { - return $this->service->create($title, $emoji, $this->getTable($tableId, true)); + public function create(int $tableId, string $title, ?string $emoji, ?string $type = 'table'): DataResponse { + return $this->handleError(function () use ($tableId, $title, $emoji, $type) { + return $this->service->create($title, $emoji, $this->getTable($tableId, true), null, $type); }); } diff --git a/lib/Db/Column.php b/lib/Db/Column.php index 7ebcdacf35..746caf0eee 100644 --- a/lib/Db/Column.php +++ b/lib/Db/Column.php @@ -103,6 +103,7 @@ class Column extends EntitySuper implements JsonSerializable { public const TYPE_NUMBER = 'number'; public const TYPE_DATETIME = 'datetime'; public const TYPE_USERGROUP = 'usergroup'; + public const TYPE_RELATION = 'relation'; public const SUBTYPE_DATETIME_DATE = 'date'; public const SUBTYPE_DATETIME_TIME = 'time'; @@ -156,6 +157,13 @@ class Column extends EntitySuper implements JsonSerializable { protected ?bool $showUserStatus = null; protected ?string $customSettings = null; + // type relation + protected ?int $relationTableId = null; + protected ?bool $relationMultiple = null; + protected ?int $relationTargetColumnId = null; + protected ?string $relationType = null; + protected ?int $relationDisplayColumnId = null; + // virtual properties protected ?string $createdByDisplayName = null; protected ?string $lastEditByDisplayName = null; @@ -186,6 +194,13 @@ public function __construct() { $this->addType('showUserStatus', 'boolean'); $this->addType('customSettings', 'string'); + + // type relation + $this->addType('relationTableId', 'integer'); + $this->addType('relationMultiple', 'boolean'); + $this->addType('relationTargetColumnId', 'integer'); + $this->addType('relationType', 'string'); + $this->addType('relationDisplayColumnId', 'integer'); } public static function isValidMetaTypeId(int $metaTypeId): bool { @@ -225,6 +240,11 @@ public static function fromDto(ColumnDto $data): self { $column->setUsergroupSelectTeams($data->getUsergroupSelectTeams()); $column->setShowUserStatus($data->getShowUserStatus()); $column->setCustomSettings($data->getCustomSettings()); + $column->setRelationTableId($data->getRelationTableId()); + $column->setRelationMultiple($data->getRelationMultiple()); + $column->setRelationTargetColumnId($data->getRelationTargetColumnId()); + $column->setRelationType($data->getRelationType()); + $column->setRelationDisplayColumnId($data->getRelationDisplayColumnId()); return $column; } @@ -305,6 +325,13 @@ public function jsonSerialize(): array { 'usergroupSelectTeams' => $this->usergroupSelectTeams, 'showUserStatus' => $this->showUserStatus, 'customSettings' => $this->getCustomSettingsArray() ?: new \stdClass(), + + // type relation + 'relationTableId' => $this->getRelationTableId(), + 'relationMultiple' => $this->getRelationMultiple(), + 'relationTargetColumnId' => $this->getRelationTargetColumnId(), + 'relationType' => $this->getRelationType(), + 'relationDisplayColumnId' => $this->getRelationDisplayColumnId(), ]; } diff --git a/lib/Db/ColumnTypes/RelationColumnQB.php b/lib/Db/ColumnTypes/RelationColumnQB.php new file mode 100644 index 0000000000..dbeb3773b6 --- /dev/null +++ b/lib/Db/ColumnTypes/RelationColumnQB.php @@ -0,0 +1,12 @@ +addType('value', 'string'); + } + + public function jsonSerialize(): array { + return parent::jsonSerializePreparation($this->value); + } +} diff --git a/lib/Db/RowCellRelationMapper.php b/lib/Db/RowCellRelationMapper.php new file mode 100644 index 0000000000..7e3eafa758 --- /dev/null +++ b/lib/Db/RowCellRelationMapper.php @@ -0,0 +1,36 @@ + + */ +class RowCellRelationMapper extends RowCellMapperSuper { + use RowCellBulkFetchTrait; + + protected string $table = 'tables_row_cells_relation'; + + public function __construct(IDBConnection $db) { + parent::__construct($db, $this->table, RowCellRelation::class); + } + + public function filterValueToQueryParam(Column $column, mixed $value): mixed { + return $value ?? ''; + } + + public function applyDataToEntity(Column $column, RowCellSuper $cell, $data): void { + if (is_array($data)) { + $cell->setValue(json_encode($data)); + } else { + $cell->setValue($data); + } + } + + public function formatEntity(Column $column, RowCellSuper $cell) { + return json_decode($cell->getValue()); + } +} diff --git a/lib/Db/RowRelation.php b/lib/Db/RowRelation.php new file mode 100644 index 0000000000..a3073fdadd --- /dev/null +++ b/lib/Db/RowRelation.php @@ -0,0 +1,46 @@ +addType('relationColumnId', 'integer'); + $this->addType('sourceRowId', 'integer'); + $this->addType('targetRowId', 'integer'); + $this->addType('createdBy', 'string'); + $this->addType('createdAt', 'string'); + } + + public function jsonSerialize(): array { + return [ + 'id' => $this->getId(), + 'relationColumnId' => $this->getRelationColumnId(), + 'sourceRowId' => $this->getSourceRowId(), + 'targetRowId' => $this->getTargetRowId(), + 'createdBy' => $this->getCreatedBy(), + 'createdAt' => $this->getCreatedAt(), + ]; + } +} diff --git a/lib/Db/RowRelationMapper.php b/lib/Db/RowRelationMapper.php new file mode 100644 index 0000000000..75bfba0402 --- /dev/null +++ b/lib/Db/RowRelationMapper.php @@ -0,0 +1,92 @@ +db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('source_row_id', $qb->createNamedParameter($sourceRowId))) + ->andWhere($qb->expr()->eq('relation_column_id', $qb->createNamedParameter($columnId))); + return $this->findEntities($qb); + } + + /** + * Get all relations for a given target row and column + */ + public function findByTargetRowAndColumn(int $targetRowId, int $columnId): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('target_row_id', $qb->createNamedParameter($targetRowId))) + ->andWhere($qb->expr()->eq('relation_column_id', $qb->createNamedParameter($columnId))); + return $this->findEntities($qb); + } + + /** + * Get all relations for a column + */ + public function findByColumn(int $columnId): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('relation_column_id', $qb->createNamedParameter($columnId))); + return $this->findEntities($qb); + } + + /** + * Delete all relations for a given source row + */ + public function deleteBySourceRow(int $sourceRowId): void { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->getTableName()) + ->where($qb->expr()->eq('source_row_id', $qb->createNamedParameter($sourceRowId))); + $qb->executeStatement(); + } + + /** + * Delete all relations for a given target row + */ + public function deleteByTargetRow(int $targetRowId): void { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->getTableName()) + ->where($qb->expr()->eq('target_row_id', $qb->createNamedParameter($targetRowId))); + $qb->executeStatement(); + } + + /** + * Delete all relations for a column (when column is deleted) + */ + public function deleteByColumn(int $columnId): void { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->getTableName()) + ->where($qb->expr()->eq('relation_column_id', $qb->createNamedParameter($columnId))); + $qb->executeStatement(); + } + + /** + * Delete a specific relation link + */ + public function deleteLink(int $columnId, int $sourceRowId, int $targetRowId): void { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->getTableName()) + ->where($qb->expr()->eq('relation_column_id', $qb->createNamedParameter($columnId))) + ->andWhere($qb->expr()->eq('source_row_id', $qb->createNamedParameter($sourceRowId))) + ->andWhere($qb->expr()->eq('target_row_id', $qb->createNamedParameter($targetRowId))); + $qb->executeStatement(); + } +} diff --git a/lib/Db/View.php b/lib/Db/View.php index e3d87a9e63..4725e64e5c 100644 --- a/lib/Db/View.php +++ b/lib/Db/View.php @@ -45,6 +45,8 @@ * @method setEmoji(string $emoji) * @method getDescription(): string * @method setDescription(string $description) + * @method string getType() + * @method void setType(string $type) * @method getIsShared(): bool * @method setIsShared(bool $isShared) * @method getOnSharePermissions(): ?Permissions @@ -71,6 +73,7 @@ class View extends EntitySuper implements JsonSerializable { protected ?string $lastEditAt = null; protected ?string $emoji = null; protected ?string $description = null; + protected ?string $type = 'table'; protected ?string $columns = null; // json protected ?string $sort = null; // json protected ?string $filter = null; // json @@ -89,6 +92,7 @@ class View extends EntitySuper implements JsonSerializable { public function __construct() { $this->addType('id', 'integer'); $this->addType('tableId', 'integer'); + $this->addType('type', 'string'); } /** @@ -184,6 +188,7 @@ public function jsonSerialize(): array { 'tableId' => ($this->tableId || $this->tableId === 0) ? $this->tableId : -1, 'title' => $this->title ?: '', 'description' => $this->description, + 'type' => $this->getType(), 'emoji' => $this->emoji, 'ownership' => $this->ownership ?: '', 'createdBy' => $this->createdBy ?: '', diff --git a/lib/Dto/Column.php b/lib/Dto/Column.php index 5a321f1aee..d74d8758d6 100644 --- a/lib/Dto/Column.php +++ b/lib/Dto/Column.php @@ -34,6 +34,11 @@ public function __construct( private ?bool $usergroupSelectTeams = null, private ?bool $showUserStatus = null, private ?string $customSettings = null, + private ?int $relationTableId = null, + private ?bool $relationMultiple = null, + private ?int $relationTargetColumnId = null, + private ?string $relationType = null, + private ?int $relationDisplayColumnId = null, ) { } @@ -69,6 +74,11 @@ public static function createFromArray(array $data): self { usergroupSelectTeams: $data['usergroupSelectTeams'] ?? null, showUserStatus: $data['showUserStatus'] ?? null, customSettings: $customSettings, + relationTableId: $data['relationTableId'] ?? null, + relationMultiple: $data['relationMultiple'] ?? null, + relationTargetColumnId: $data['relationTargetColumnId'] ?? null, + relationType: $data['relationType'] ?? null, + relationDisplayColumnId: $data['relationDisplayColumnId'] ?? null, ); } @@ -171,4 +181,24 @@ public function getShowUserStatus(): ?bool { public function getCustomSettings(): ?string { return $this->customSettings; } + + public function getRelationTableId(): ?int { + return $this->relationTableId; + } + + public function getRelationMultiple(): ?bool { + return $this->relationMultiple; + } + + public function getRelationTargetColumnId(): ?int { + return $this->relationTargetColumnId; + } + + public function getRelationType(): ?string { + return $this->relationType; + } + + public function getRelationDisplayColumnId(): ?int { + return $this->relationDisplayColumnId; + } } diff --git a/lib/Helper/ColumnsHelper.php b/lib/Helper/ColumnsHelper.php index 385f7a1ed5..5452bf9d76 100644 --- a/lib/Helper/ColumnsHelper.php +++ b/lib/Helper/ColumnsHelper.php @@ -20,6 +20,7 @@ class ColumnsHelper { Column::TYPE_DATETIME, Column::TYPE_SELECTION, Column::TYPE_USERGROUP, + Column::TYPE_RELATION, ]; /** diff --git a/lib/Migration/Version001000Date20260314000000.php b/lib/Migration/Version001000Date20260314000000.php new file mode 100644 index 0000000000..3984ea6b19 --- /dev/null +++ b/lib/Migration/Version001000Date20260314000000.php @@ -0,0 +1,30 @@ +getTable('tables_views'); + if (!$table->hasColumn('type')) { + $table->addColumn('type', Types::STRING, [ + 'notnull' => true, + 'length' => 20, + 'default' => 'table', + ]); + } + + return $schema; + } +} diff --git a/lib/Migration/Version001000Date20260315000000.php b/lib/Migration/Version001000Date20260315000000.php new file mode 100644 index 0000000000..ce5925ad60 --- /dev/null +++ b/lib/Migration/Version001000Date20260315000000.php @@ -0,0 +1,115 @@ +getTable('tables_columns'); + if (!$columnsTable->hasColumn('relation_table_id')) { + $columnsTable->addColumn('relation_table_id', Types::INTEGER, [ + 'notnull' => false, + 'default' => null, + ]); + } + if (!$columnsTable->hasColumn('relation_multiple')) { + $columnsTable->addColumn('relation_multiple', Types::BOOLEAN, [ + 'notnull' => false, + 'default' => false, + ]); + } + if (!$columnsTable->hasColumn('relation_target_column_id')) { + $columnsTable->addColumn('relation_target_column_id', Types::INTEGER, [ + 'notnull' => false, + 'default' => null, + ]); + } + if (!$columnsTable->hasColumn('relation_type')) { + $columnsTable->addColumn('relation_type', Types::STRING, [ + 'notnull' => false, + 'length' => 20, + 'default' => 'many-to-many', + ]); + } + if (!$columnsTable->hasColumn('relation_display_column_id')) { + $columnsTable->addColumn('relation_display_column_id', Types::INTEGER, [ + 'notnull' => false, + 'default' => null, + ]); + } + + // Create join table for row relations + if (!$schema->hasTable('tables_row_relations')) { + $table = $schema->createTable('tables_row_relations'); + $table->addColumn('id', Types::INTEGER, [ + 'autoincrement' => true, + 'notnull' => true, + ]); + $table->addColumn('relation_column_id', Types::INTEGER, [ + 'notnull' => true, + ]); + $table->addColumn('source_row_id', Types::INTEGER, [ + 'notnull' => true, + ]); + $table->addColumn('target_row_id', Types::INTEGER, [ + 'notnull' => true, + ]); + $table->addColumn('created_by', Types::STRING, [ + 'notnull' => false, + 'length' => 64, + ]); + $table->addColumn('created_at', Types::DATETIME, [ + 'notnull' => false, + ]); + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['relation_column_id', 'source_row_id', 'target_row_id'], 'row_rel_col_src_tgt'); + $table->addIndex(['source_row_id'], 'row_rel_source'); + $table->addIndex(['target_row_id'], 'row_rel_target'); + $table->addIndex(['relation_column_id'], 'row_rel_column'); + } + + // Keep tables_row_cells_relation — Row2Mapper iterates all column types + // and queries their cell tables via UNION ALL. The table stays empty for + // relation columns since actual data lives in tables_row_relations. + if (!$schema->hasTable('tables_row_cells_relation')) { + $cellTable = $schema->createTable('tables_row_cells_relation'); + $cellTable->addColumn('id', Types::INTEGER, [ + 'autoincrement' => true, + 'notnull' => true, + ]); + $cellTable->addColumn('column_id', Types::INTEGER, [ + 'notnull' => true, + ]); + $cellTable->addColumn('row_id', Types::INTEGER, [ + 'notnull' => true, + ]); + $cellTable->addColumn('value', Types::TEXT, [ + 'notnull' => false, + ]); + $cellTable->addColumn('last_edit_by', Types::STRING, [ + 'notnull' => false, + 'length' => 64, + ]); + $cellTable->addColumn('last_edit_at', Types::DATETIME, [ + 'notnull' => false, + ]); + $cellTable->setPrimaryKey(['id']); + $cellTable->addIndex(['column_id'], 'row_cell_rel_col'); + $cellTable->addIndex(['row_id'], 'row_cell_rel_row'); + } + + return $schema; + } +} diff --git a/lib/Migration/Version001000Date20260315120000.php b/lib/Migration/Version001000Date20260315120000.php new file mode 100644 index 0000000000..848deaf261 --- /dev/null +++ b/lib/Migration/Version001000Date20260315120000.php @@ -0,0 +1,89 @@ +getTable('tables_columns'); + if (!$columnsTable->hasColumn('relation_table_id')) { + $columnsTable->addColumn('relation_table_id', Types::INTEGER, [ + 'notnull' => false, + 'default' => null, + ]); + } + if (!$columnsTable->hasColumn('relation_multiple')) { + $columnsTable->addColumn('relation_multiple', Types::BOOLEAN, [ + 'notnull' => false, + 'default' => false, + ]); + } + if (!$columnsTable->hasColumn('relation_target_column_id')) { + $columnsTable->addColumn('relation_target_column_id', Types::INTEGER, [ + 'notnull' => false, + 'default' => null, + ]); + } + if (!$columnsTable->hasColumn('relation_type')) { + $columnsTable->addColumn('relation_type', Types::STRING, [ + 'notnull' => false, + 'length' => 20, + 'default' => 'many-to-many', + ]); + } + if (!$columnsTable->hasColumn('relation_display_column_id')) { + $columnsTable->addColumn('relation_display_column_id', Types::INTEGER, [ + 'notnull' => false, + 'default' => null, + ]); + } + + // Create join table for row relations + if (!$schema->hasTable('tables_row_relations')) { + $table = $schema->createTable('tables_row_relations'); + $table->addColumn('id', Types::INTEGER, [ + 'autoincrement' => true, + 'notnull' => true, + ]); + $table->addColumn('relation_column_id', Types::INTEGER, [ + 'notnull' => true, + ]); + $table->addColumn('source_row_id', Types::INTEGER, [ + 'notnull' => true, + ]); + $table->addColumn('target_row_id', Types::INTEGER, [ + 'notnull' => true, + ]); + $table->addColumn('created_by', Types::STRING, [ + 'notnull' => false, + 'length' => 64, + ]); + $table->addColumn('created_at', Types::DATETIME, [ + 'notnull' => false, + ]); + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['relation_column_id', 'source_row_id', 'target_row_id'], 'row_rel_col_src_tgt'); + $table->addIndex(['source_row_id'], 'row_rel_source'); + $table->addIndex(['target_row_id'], 'row_rel_target'); + $table->addIndex(['relation_column_id'], 'row_rel_column'); + } + + // Keep tables_row_cells_relation — Row2Mapper iterates all column types + // and queries their cell tables. The table stays empty for relation columns + // since actual data lives in tables_row_relations join table. + + return $schema; + } +} diff --git a/lib/Model/ViewUpdateInput.php b/lib/Model/ViewUpdateInput.php index 5c1972f1d5..b85fcb02e7 100644 --- a/lib/Model/ViewUpdateInput.php +++ b/lib/Model/ViewUpdateInput.php @@ -26,6 +26,7 @@ public function __construct( protected readonly ?Title $title = null, protected readonly ?string $description = null, protected readonly ?Emoji $emoji = null, + protected readonly ?string $type = null, protected readonly ?ColumnSettings $columnSettings = null, protected readonly ?FilterSet $filterSet = null, protected readonly ?SortRuleSet $sortRuleSet = null, @@ -42,6 +43,9 @@ public function updateDetail(): Generator { if ($this->emoji) { yield ViewUpdatableParameters::EMOJI => $this->emoji; } + if ($this->type) { + yield ViewUpdatableParameters::TYPE => $this->type; + } if ($this->columnSettings) { yield ViewUpdatableParameters::COLUMN_SETTINGS => $this->columnSettings; } @@ -84,6 +88,7 @@ public static function fromInputArray(array $data): self { title: $data['title'] ? new Title($data['title']) : null, description: $data['description'] ?? null, emoji: $data['emoji'] ? new Emoji($data['emoji']) : null, + type: $data['type'] ?? null, columnSettings: $data['columnSettings'] ? ColumnSettings::createFromInputArray($data['columnSettings']) : null, filterSet: $data['filter'] ? FilterSet::createFromInputArray($data['filter']) : null, sortRuleSet: $data['sort'] ? SortRuleSet::createFromInputArray($data['sort']) : null, diff --git a/lib/Service/ColumnService.php b/lib/Service/ColumnService.php index 50b5caf334..ba1cc18a6d 100644 --- a/lib/Service/ColumnService.php +++ b/lib/Service/ColumnService.php @@ -272,6 +272,40 @@ public function create( $this->logger->error($e->getMessage(), ['exception' => $e]); throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); } + + // Auto-create reverse relation column on target table + if ($entity->getType() === 'relation' && $entity->getRelationTableId() !== null) { + // Build reverse column using fromDto to ensure all required fields have defaults + $reverseDto = new ColumnDto( + title: $table->getTitle() . ' (linked)', + type: 'relation', + subtype: '', + description: '', + relationTableId: $table->getId(), + relationType: $entity->getRelationType() ?? 'many-to-many', + relationTargetColumnId: $entity->getId(), + relationMultiple: $entity->getRelationMultiple(), + ); + $reverseColumn = Column::fromDto($reverseDto); + $reverseColumn->setTableId($entity->getRelationTableId()); + // Set required DB defaults for non-nullable columns + $reverseColumn->setNumberPrefix(''); + $reverseColumn->setNumberSuffix(''); + $reverseColumn->setNumberDecimals(0); + $reverseColumn->setOrderWeight(0); + $reverseColumn->setMandatory(false); + $this->updateMetadata($reverseColumn, $userId, true); + try { + $reverseColumn = $this->mapper->insert($reverseColumn); + + // Update the original column to point to the reverse column + $entity->setRelationTargetColumnId($reverseColumn->getId()); + $this->mapper->update($entity); + } catch (\OCP\DB\Exception $e) { + $this->logger->error('Failed to create reverse relation column: ' . $e->getMessage(), ['exception' => $e]); + } + } + if (isset($view) && $view) { // Add columns to view(s) $this->viewService->addColumnToView($view, $entity, $userId); @@ -363,6 +397,13 @@ public function update( $this->validateCustomSettings($columnDto->getCustomSettings()); $item->setCustomSettings($columnDto->getCustomSettings()); + if ($columnDto->getRelationTableId() !== null) { + $item->setRelationTableId($columnDto->getRelationTableId()); + } + if ($columnDto->getRelationMultiple() !== null) { + $item->setRelationMultiple($columnDto->getRelationMultiple()); + } + $this->updateMetadata($item, $userId); return $this->enhanceColumn($this->mapper->update($item)); } catch (Exception $e) { @@ -472,6 +513,24 @@ public function delete(int $id, bool $skipRowCleanup = false, ?string $userId = $this->viewService->deleteColumnDataFromViews($id, $table); } + // Clean up relation data if this is a relation column + if ($item->getType() === 'relation') { + // Delete the reverse column if it exists + $reverseColumnId = $item->getRelationTargetColumnId(); + if ($reverseColumnId) { + try { + $reverseColumn = $this->mapper->find($reverseColumnId); + // Clear reverse pointer to avoid infinite loop + $reverseColumn->setRelationTargetColumnId(null); + $this->mapper->update($reverseColumn); + // Then delete it + $this->mapper->delete($reverseColumn); + } catch (\Exception $e) { + // Reverse column may already be deleted + } + } + } + try { $this->mapper->delete($item); } catch (\OCP\DB\Exception $e) { diff --git a/lib/Service/ColumnTypes/RelationBusiness.php b/lib/Service/ColumnTypes/RelationBusiness.php new file mode 100644 index 0000000000..94c12918bc --- /dev/null +++ b/lib/Service/ColumnTypes/RelationBusiness.php @@ -0,0 +1,20 @@ +mapper->findBySourceRowAndColumn($sourceRowId, $columnId); + return array_map(fn(RowRelation $r) => $r->getTargetRowId(), $relations); + } + + /** + * Get reverse linked source row IDs for a target row + column + */ + public function getReverseLinkedRowIds(int $targetRowId, int $columnId): array { + $relations = $this->mapper->findByTargetRowAndColumn($targetRowId, $columnId); + return array_map(fn(RowRelation $r) => $r->getSourceRowId(), $relations); + } + + /** + * Set the linked rows for a source row + column (replaces existing) + */ + public function setLinks(int $columnId, int $sourceRowId, array $targetRowIds, string $userId, Column $column): void { + // Enforce relation type constraints + $relationType = $column->getRelationType() ?? 'many-to-many'; + + if ($relationType === 'one-to-one' && count($targetRowIds) > 1) { + $targetRowIds = [array_shift($targetRowIds)]; + } + + // Get existing links + $existing = $this->mapper->findBySourceRowAndColumn($sourceRowId, $columnId); + $existingTargetIds = array_map(fn(RowRelation $r) => $r->getTargetRowId(), $existing); + + // Delete removed links + foreach ($existing as $relation) { + if (!in_array($relation->getTargetRowId(), $targetRowIds)) { + $this->mapper->delete($relation); + } + } + + // Add new links + $now = date('Y-m-d H:i:s'); + foreach ($targetRowIds as $targetRowId) { + if (!in_array($targetRowId, $existingTargetIds)) { + $relation = new RowRelation(); + $relation->setRelationColumnId($columnId); + $relation->setSourceRowId($sourceRowId); + $relation->setTargetRowId((int)$targetRowId); + $relation->setCreatedBy($userId); + $relation->setCreatedAt($now); + $this->mapper->insert($relation); + } + } + } + + /** + * Remove a specific link + */ + public function removeLink(int $columnId, int $sourceRowId, int $targetRowId): void { + $this->mapper->deleteLink($columnId, $sourceRowId, $targetRowId); + } + + /** + * Clean up all relations when a row is deleted + */ + public function onRowDeleted(int $rowId): void { + $this->mapper->deleteBySourceRow($rowId); + $this->mapper->deleteByTargetRow($rowId); + } + + /** + * Clean up all relations when a column is deleted + */ + public function onColumnDeleted(int $columnId): void { + $this->mapper->deleteByColumn($columnId); + } +} diff --git a/lib/Service/ViewService.php b/lib/Service/ViewService.php index cc05fc5b81..7acaf9f6b0 100644 --- a/lib/Service/ViewService.php +++ b/lib/Service/ViewService.php @@ -193,7 +193,7 @@ public function findSharedViewsWithMe(?string $userId = null): array { * @throws InternalError * @throws PermissionError */ - public function create(string $title, ?string $emoji, Table $table, ?string $userId = null): View { + public function create(string $title, ?string $emoji, Table $table, ?string $userId = null, ?string $type = 'table'): View { /** @var string $userId */ $userId = $this->permissionsService->preCheckUserId($userId, false); // $userId is set @@ -209,6 +209,7 @@ public function create(string $title, ?string $emoji, Table $table, ?string $use $item->setEmoji($emoji); } $item->setDescription(''); + $item->setType($type ?? 'table'); $item->setTableId($table->getId()); $item->setCreatedBy($userId); $item->setLastEditBy($userId); diff --git a/src/modules/main/partials/ChartConfigBar.vue b/src/modules/main/partials/ChartConfigBar.vue new file mode 100644 index 0000000000..138acab8f1 --- /dev/null +++ b/src/modules/main/partials/ChartConfigBar.vue @@ -0,0 +1,128 @@ + + + + + + diff --git a/src/modules/main/partials/ChartView.vue b/src/modules/main/partials/ChartView.vue new file mode 100644 index 0000000000..88946c6cb3 --- /dev/null +++ b/src/modules/main/partials/ChartView.vue @@ -0,0 +1,180 @@ + + + + + + diff --git a/src/modules/main/partials/ColumnFormComponent.vue b/src/modules/main/partials/ColumnFormComponent.vue index be2f6a303f..2715748585 100644 --- a/src/modules/main/partials/ColumnFormComponent.vue +++ b/src/modules/main/partials/ColumnFormComponent.vue @@ -22,6 +22,7 @@ import DatetimeDateForm from '../../../shared/components/ncTable/partials/rowTyp import DatetimeTimeForm from '../../../shared/components/ncTable/partials/rowTypePartials/DatetimeTimeForm.vue' 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' export default { name: 'ColumnFormComponent', @@ -40,6 +41,7 @@ export default { DatetimeDateForm, DatetimeTimeForm, UsergroupForm, + RelationForm, }, props: { column: { diff --git a/src/modules/main/partials/ColumnTypeSelection.vue b/src/modules/main/partials/ColumnTypeSelection.vue index 71536b3c7c..a8f02cc2f2 100644 --- a/src/modules/main/partials/ColumnTypeSelection.vue +++ b/src/modules/main/partials/ColumnTypeSelection.vue @@ -19,6 +19,7 @@ +
{{ props.label }}
@@ -33,6 +34,7 @@ +
{{ props.label }}
@@ -50,6 +52,7 @@ import ProgressIcon from 'vue-material-design-icons/ArrowRightThin.vue' import SelectionIcon from 'vue-material-design-icons/FormSelect.vue' import DatetimeIcon from 'vue-material-design-icons/ClipboardTextClockOutline.vue' import ContactsIcon from 'vue-material-design-icons/ContactsOutline.vue' +import RelationIcon from 'vue-material-design-icons/LinkVariant.vue' import { NcSelect } from '@nextcloud/vue' export default { @@ -63,6 +66,7 @@ export default { TextLongIcon, NcSelect, ContactsIcon, + RelationIcon, }, props: { columnId: { @@ -86,6 +90,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') }, ], } }, diff --git a/src/modules/main/partials/ViewTabBar.vue b/src/modules/main/partials/ViewTabBar.vue new file mode 100644 index 0000000000..dd0e0a75f8 --- /dev/null +++ b/src/modules/main/partials/ViewTabBar.vue @@ -0,0 +1,106 @@ + + + + + + diff --git a/src/modules/main/sections/Table.vue b/src/modules/main/sections/Table.vue index 0fffd062da..0b3514e61b 100644 --- a/src/modules/main/sections/Table.vue +++ b/src/modules/main/sections/Table.vue @@ -6,12 +6,12 @@
- import TableDescription from './TableDescription.vue' import ElementTitle from './ElementTitle.vue' -import Dashboard from './Dashboard.vue' +import ViewTabBar from '../partials/ViewTabBar.vue' import DataTable from './DataTable.vue' import { mapState } from 'pinia' import { emit } from '@nextcloud/event-bus' import { useTablesStore } from '../../../store/store.js' +import permissionsMixin from '../../../shared/components/ncTable/mixins/permissionsMixin.js' export default { components: { TableDescription, ElementTitle, - Dashboard, + ViewTabBar, DataTable, }, + mixins: [permissionsMixin], + props: { - table: { - type: Object, - default: null, - }, - columns: { - type: Array, - default: null, - }, - rows: { - type: Array, - default: null, - }, - viewSetting: { - type: Object, - default: null, - }, + table: { type: Object, default: null }, + columns: { type: Array, default: null }, + rows: { type: Array, default: null }, + viewSetting: { type: Object, default: null }, }, data() { @@ -66,8 +57,8 @@ export default { }, computed: { ...mapState(useTablesStore, ['views']), - hasViews() { - return this.views.some(v => v.tableId === this.table.id) + tableViews() { + return this.views.filter(v => v.tableId === this.table.id) }, }, watch: { @@ -80,8 +71,11 @@ export default { }, methods: { + openView(view) { + this.$router.push('/view/' + parseInt(view.id)).catch(err => err) + }, createView() { - emit('tables:view:create', { tableId: this.table.id, viewSetting: this.viewSetting.length > 0 ? this.viewSetting : this.localViewSetting }) + emit('tables:view:create', { tableId: this.table.id, viewSetting: this.viewSetting?.length > 0 ? this.viewSetting : this.localViewSetting }) }, }, } diff --git a/src/modules/main/sections/View.vue b/src/modules/main/sections/View.vue index c227d01c9b..8b693fffc3 100644 --- a/src/modules/main/sections/View.vue +++ b/src/modules/main/sections/View.vue @@ -6,8 +6,21 @@
+
+ -