Skip to content

Commit 154fdbd

Browse files
committed
Feat: Add technicalName to views
Signed-off-by: Kostiantyn Miakshyn <molodchick@gmail.com>
1 parent e505892 commit 154fdbd

14 files changed

Lines changed: 417 additions & 46 deletions

File tree

‎appinfo/info.xml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ Share your tables and views with users and groups within your cloud.
2626
Have a good time and manage whatever you want.
2727
2828
]]></description>
29-
<version>2.2.1</version>
29+
<version>2.2.2-dev.0</version>
3030
<licence>AGPL-3.0-or-later</licence>
3131
<author>Nextcloud GmbH and Nextcloud contributors</author>
3232
<namespace>Tables</namespace>

‎lib/Constants/ViewUpdatableParameters.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,5 @@ enum ViewUpdatableParameters: string {
1616
case SORT = 'sort';
1717
case FILTER = 'filter';
1818
case COLUMN_SETTINGS = 'columns';
19+
case TECHNICAL_NAME = 'technicalName';
1920
}

‎lib/Controller/Api1Controller.php‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -348,10 +348,12 @@ public function indexViews(int $tableId): DataResponse {
348348
* @param int $tableId Table ID that will hold the view
349349
* @param string $title Title for the view
350350
* @param string|null $emoji Emoji for the view
351+
* @param string|null $technicalName Technical name for the view
351352
*
352-
* @return DataResponse<Http::STATUS_OK, TablesView, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}>
353+
* @return DataResponse<Http::STATUS_OK, TablesView, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}>
353354
*
354355
* 200: View created
356+
* 400: Bad request
355357
* 403: No permissions
356358
* 404: Not found
357359
*/
@@ -360,13 +362,17 @@ public function indexViews(int $tableId): DataResponse {
360362
#[CORS]
361363
#[RequirePermission(permission: Application::PERMISSION_MANAGE, type: Application::NODE_TYPE_TABLE, idParam: 'tableId')]
362364
#[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)]
363-
public function createView(int $tableId, string $title, ?string $emoji): DataResponse {
365+
public function createView(int $tableId, string $title, ?string $emoji, ?string $technicalName = null): DataResponse {
364366
try {
365-
return new DataResponse($this->viewService->create($title, $emoji, $this->tableService->find($tableId))->jsonSerialize());
367+
return new DataResponse($this->viewService->create($title, $emoji, $this->tableService->find($tableId), null, $technicalName)->jsonSerialize());
366368
} catch (PermissionError $e) {
367369
$this->logger->warning('A permission error occurred: ' . $e->getMessage(), ['exception' => $e]);
368370
$message = ['message' => $e->getMessage()];
369371
return new DataResponse($message, Http::STATUS_FORBIDDEN);
372+
} catch (BadRequestError $e) {
373+
$this->logger->warning('A bad request error occurred: ' . $e->getMessage(), ['exception' => $e]);
374+
$message = ['message' => $e->getMessage()];
375+
return new DataResponse($message, Http::STATUS_BAD_REQUEST);
370376
} catch (InternalError|Exception $e) {
371377
$this->logger->error('An internal error or exception occurred: ' . $e->getMessage(), ['exception' => $e]);
372378
$message = ['message' => $e->getMessage()];
@@ -444,6 +450,10 @@ public function updateView(int $viewId, array $data): DataResponse {
444450
$this->logger->warning('An invalid request occurred: ' . $e->getMessage(), ['exception' => $e]);
445451
$message = ['message' => $e->getMessage()];
446452
return new DataResponse($message, Http::STATUS_BAD_REQUEST);
453+
} catch (BadRequestError $e) {
454+
$this->logger->warning('A bad request error occurred: ' . $e->getMessage(), ['exception' => $e]);
455+
$message = ['message' => $e->getMessage()];
456+
return new DataResponse($message, Http::STATUS_BAD_REQUEST);
447457
} catch (InternalError|Exception $e) {
448458
$this->logger->error('An internal error or exception occurred: ' . $e->getMessage(), ['exception' => $e]);
449459
$message = ['message' => $e->getMessage()];

‎lib/Db/View.php‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
* @method setUuid(?string $uuid)
2929
* @method getTitle(): string
3030
* @method setTitle(string $title)
31+
* @method getTechnicalName(): string
32+
* @method setTechnicalName(?string $technicalName)
3133
* @method getTableId(): int
3234
* @method setTableId(int $tableId)
3335
* @method getColumns(): string
@@ -68,6 +70,7 @@
6870
class View extends EntitySuper implements JsonSerializable {
6971
protected ?string $uuid = null;
7072
protected ?string $title = null;
73+
protected ?string $technicalName = null;
7174
protected ?int $tableId = null;
7275
protected ?string $createdBy = null;
7376
protected ?string $createdAt = null;
@@ -213,6 +216,7 @@ public function jsonSerialize(): array {
213216
'uuid' => $this->uuid,
214217
'tableId' => ($this->tableId || $this->tableId === 0) ? $this->tableId : -1,
215218
'title' => $this->title ?: '',
219+
'technicalName' => $this->technicalName,
216220
'description' => $this->description,
217221
'emoji' => $this->emoji,
218222
'ownership' => $this->ownership ?: '',
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
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+
10+
namespace OCA\Tables\Migration;
11+
12+
use Closure;
13+
use OCP\DB\ISchemaWrapper;
14+
use OCP\DB\Types;
15+
use OCP\IDBConnection;
16+
use OCP\Migration\IOutput;
17+
use OCP\Migration\SimpleMigrationStep;
18+
use Override;
19+
20+
class Version2200Date20260722000000 extends SimpleMigrationStep {
21+
private IDBConnection $connection;
22+
23+
public function __construct(IDBConnection $connection) {
24+
$this->connection = $connection;
25+
}
26+
27+
#[Override]
28+
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
29+
/** @var ISchemaWrapper $schema */
30+
$schema = $schemaClosure();
31+
32+
$table = $schema->getTable('tables_views');
33+
if (!$table->hasColumn('technical_name')) {
34+
$table->addColumn('technical_name', Types::STRING, [
35+
'notnull' => false,
36+
'length' => 200,
37+
]);
38+
}
39+
40+
if (!$table->hasIndex('tables_views_table_tech_name_uq')) {
41+
$table->addUniqueIndex(['table_id', 'technical_name'], 'tables_views_table_tech_name_uq');
42+
}
43+
44+
return $schema;
45+
}
46+
47+
#[Override]
48+
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
49+
$qb = $this->connection->getQueryBuilder();
50+
$qb->update('tables_views')
51+
->set('technical_name', $qb->createFunction("CONCAT('view_', id)"))
52+
->where(
53+
$qb->expr()->orX(
54+
$qb->expr()->isNull('technical_name'),
55+
$qb->expr()->eq('technical_name', $qb->createNamedParameter('')),
56+
)
57+
);
58+
59+
$updatedCount = $qb->executeStatement();
60+
61+
$output->info('Version2200Date20260708000000: backfilled technical_name for ' . $updatedCount . ' views.');
62+
}
63+
}

‎lib/Model/ViewUpdateInput.php‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ class ViewUpdateInput {
2424

2525
public function __construct(
2626
protected readonly ?Title $title = null,
27+
protected readonly ?string $technicalName = null,
2728
protected readonly ?string $description = null,
2829
protected readonly ?Emoji $emoji = null,
2930
protected readonly ?ColumnSettings $columnSettings = null,
@@ -36,6 +37,9 @@ public function updateDetail(): Generator {
3637
if ($this->title) {
3738
yield ViewUpdatableParameters::TITLE => $this->title;
3839
}
40+
if ($this->technicalName !== null) {
41+
yield ViewUpdatableParameters::TECHNICAL_NAME => $this->technicalName;
42+
}
3943
if ($this->description) {
4044
yield ViewUpdatableParameters::DESCRIPTION => $this->description;
4145
}
@@ -58,6 +62,7 @@ public function updateDetail(): Generator {
5862
* title?: string,
5963
* emoji?: string,
6064
* description?: string,
65+
* technicalName?: string,
6166
* columns?: list<int>,
6267
* columnSettings?: list<array{columnId?: int, order?: int, readonly?: bool, mandatory?: bool}>,
6368
* sort?: list<array{columnId: int, mode: 'ASC'|'DESC'}>,
@@ -82,6 +87,7 @@ public static function fromInputArray(array $data): self {
8287

8388
return new self(
8489
title: ($data['title'] ?? null) ? new Title($data['title']) : null,
90+
technicalName: $data['technicalName'] ?? null,
8591
description: $data['description'] ?? null,
8692
emoji: ($data['emoji'] ?? null) ? new Emoji($data['emoji']) : null,
8793
columnSettings: ($data['columnSettings'] ?? null) ? ColumnSettings::createViewSettingsFromInputArray($data['columnSettings']) : null,

‎lib/ResponseDefinitions.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
* @psalm-type TablesView = array{
1717
* id: int,
1818
* title: string,
19+
* technicalName: string|null,
1920
* emoji: string|null,
2021
* tableId: int,
2122
* ownership: string,

‎lib/Service/RowService.php‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ public function findAllByView(int $viewId, string $userId, ?int $limit = null, ?
131131
if ($this->permissionsService->canReadRowsByElementId($viewId, 'view', $userId)) {
132132
$view = $this->viewMapper->find($viewId);
133133

134-
return $this->row2Mapper->findAll(
134+
$rows = $this->row2Mapper->findAll(
135135
$view->getColumnIds(),
136136
$view->getTableId(),
137137
$limit,
@@ -141,6 +141,9 @@ public function findAllByView(int $viewId, string $userId, ?int $limit = null, ?
141141
$this->resolveFilterUserId($userId, $view),
142142
);
143143

144+
$viewColumns = $this->columnMapper->findAll($view->getColumnIds());
145+
$this->attachAliasPayloads($rows, $viewColumns);
146+
return $rows;
144147
} else {
145148
throw new PermissionError('no read access to view id = ' . $viewId);
146149
}

‎lib/Service/ViewService.php‎

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
use OCA\Tables\Db\Table;
2222
use OCA\Tables\Db\View;
2323
use OCA\Tables\Db\ViewMapper;
24+
use OCA\Tables\Errors\BadRequestError;
2425
use OCA\Tables\Errors\InternalError;
2526
use OCA\Tables\Errors\NotFoundError;
2627
use OCA\Tables\Errors\PermissionError;
@@ -195,11 +196,13 @@ public function findSharedViewsWithMe(?string $userId = null): array {
195196
* @param string|null $emoji
196197
* @param Table $table
197198
* @param string|null $userId
199+
* @param string|null $technicalName
198200
* @return View
199201
* @throws InternalError
200202
* @throws PermissionError
203+
* @throws BadRequestError
201204
*/
202-
public function create(string $title, ?string $emoji, Table $table, ?string $userId = null): View {
205+
public function create(string $title, ?string $emoji, Table $table, ?string $userId = null, ?string $technicalName = null): View {
203206
/** @var string $userId */
204207
$userId = $this->permissionsService->preCheckUserId($userId, false); // $userId is set
205208

@@ -221,15 +224,26 @@ public function create(string $title, ?string $emoji, Table $table, ?string $use
221224
$item->setLastEditBy($userId);
222225
$item->setCreatedAt($time->format('Y-m-d H:i:s'));
223226
$item->setLastEditAt($time->format('Y-m-d H:i:s'));
227+
if ($technicalName !== null) {
228+
$this->assertTechnicalNameValid($technicalName);
229+
$item->setTechnicalName($technicalName);
230+
}
224231
// ownership is not stored with the record, but it might be necessary upon
225232
// further interaction with the view in the running process, as the instance
226233
// is cached now. The ownership is always inherited from the table.
227234
$item->setOwnership($table->getOwnership());
228235
try {
229236
$newItem = $this->mapper->insert($item);
230237
} catch (\OCP\DB\Exception $e) {
231-
$this->logger->error($e->getMessage());
232-
throw new InternalError($e->getMessage());
238+
$this->handleViewPersistDbException($e, 'create view insert error');
239+
}
240+
if ($newItem->getTechnicalName() === null || $newItem->getTechnicalName() === '') {
241+
$newItem->setTechnicalName($this->buildDefaultTechnicalName($newItem->getId()));
242+
try {
243+
$newItem = $this->mapper->update($newItem);
244+
} catch (\OCP\DB\Exception $e) {
245+
$this->handleViewPersistDbException($e, 'create view update error');
246+
}
233247
}
234248

235249
$this->activityManager->triggerEvent(
@@ -267,6 +281,10 @@ public function update(int $id, ViewUpdateInput $data, ?string $userId = null, b
267281
$this->assertInputColumnsAreValid($view, $userId, $value);
268282
}
269283

284+
if ($parameter === ViewUpdatableParameters::TECHNICAL_NAME) {
285+
$this->assertTechnicalNameValid($value);
286+
}
287+
270288
if ($value instanceof JsonSerializable) {
271289
$insertableValue = json_encode($value);
272290
}
@@ -289,8 +307,10 @@ public function update(int $id, ViewUpdateInput $data, ?string $userId = null, b
289307
subject: ActivityManager::SUBJECT_VIEW_UPDATE,
290308
);
291309
return $view;
292-
} catch (InvalidArgumentException $e) {
310+
} catch (BadRequestError|InvalidArgumentException $e) {
293311
throw $e;
312+
} catch (\OCP\DB\Exception $e) {
313+
$this->handleViewPersistDbException($e, 'update view error');
294314
} catch (Exception $e) {
295315
$this->logger->error($e->getMessage(), ['exception' => $e]);
296316
throw new InternalError($e->getMessage());
@@ -640,6 +660,11 @@ public function importView(int $tableId, array $view, string $userId): void {
640660
$item->setTableId($tableId);
641661
$item->setTitle($view['title']);
642662
$item->setEmoji($view['emoji']);
663+
$technicalName = $view['technicalName'] ?? null;
664+
if ($technicalName !== null) {
665+
$this->assertTechnicalNameValid($technicalName);
666+
$item->setTechnicalName($technicalName);
667+
}
643668
$item->setCreatedBy($userId);
644669
$item->setCreatedAt($view['createdAt']);
645670
$item->setLastEditBy($userId);
@@ -650,9 +675,44 @@ public function importView(int $tableId, array $view, string $userId): void {
650675
$item->setFilter(json_encode($view['filter']));
651676
try {
652677
$this->mapper->insert($item);
678+
if ($item->getTechnicalName() === null || $item->getTechnicalName() === '') {
679+
$item->setTechnicalName($this->buildDefaultTechnicalName($item->getId()));
680+
$this->mapper->update($item);
681+
}
682+
} catch (\OCP\DB\Exception $e) {
683+
$this->handleViewPersistDbException($e, 'importView insert error');
653684
} catch (\Exception $e) {
654685
$this->logger->error('userMigrationImport insert error: ' . $e->getMessage());
655686
throw new InternalError('userMigrationImport insert error: ' . $e->getMessage());
656687
}
657688
}
689+
690+
private function buildDefaultTechnicalName(int $viewId): string {
691+
return 'view_' . $viewId;
692+
}
693+
694+
/**
695+
* @throws BadRequestError
696+
*/
697+
private function assertTechnicalNameValid(string $technicalName): void {
698+
if (strlen($technicalName) > 200) {
699+
throw new BadRequestError('Technical name must not exceed 200 characters.');
700+
}
701+
if (!preg_match('/^[a-z][a-z0-9_]*$/', $technicalName)) {
702+
throw new BadRequestError('Technical name must start with a lowercase letter and contain only lowercase letters, numbers, and underscores.');
703+
}
704+
}
705+
706+
/**
707+
* @throws BadRequestError
708+
* @throws InternalError
709+
*/
710+
private function handleViewPersistDbException(\OCP\DB\Exception $e, string $context): never {
711+
if ($e->getReason() === \OCP\DB\Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
712+
throw new BadRequestError('Technical name must be unique in the table.');
713+
}
714+
715+
$this->logger->error($e->getMessage(), ['exception' => $e]);
716+
throw new InternalError($context . ': ' . $e->getMessage());
717+
}
658718
}

‎openapi.json‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -970,6 +970,7 @@
970970
"required": [
971971
"id",
972972
"title",
973+
"technicalName",
973974
"emoji",
974975
"tableId",
975976
"ownership",
@@ -998,6 +999,10 @@
998999
"title": {
9991000
"type": "string"
10001001
},
1002+
"technicalName": {
1003+
"type": "string",
1004+
"nullable": true
1005+
},
10011006
"emoji": {
10021007
"type": "string",
10031008
"nullable": true
@@ -2010,6 +2015,12 @@
20102015
"type": "string",
20112016
"nullable": true,
20122017
"description": "Emoji for the view"
2018+
},
2019+
"technicalName": {
2020+
"type": "string",
2021+
"nullable": true,
2022+
"default": null,
2023+
"description": "Technical name for the view"
20132024
}
20142025
}
20152026
}
@@ -2039,6 +2050,24 @@
20392050
}
20402051
}
20412052
},
2053+
"400": {
2054+
"description": "Bad request",
2055+
"content": {
2056+
"application/json": {
2057+
"schema": {
2058+
"type": "object",
2059+
"required": [
2060+
"message"
2061+
],
2062+
"properties": {
2063+
"message": {
2064+
"type": "string"
2065+
}
2066+
}
2067+
}
2068+
}
2069+
}
2070+
},
20422071
"403": {
20432072
"description": "No permissions",
20442073
"content": {

0 commit comments

Comments
 (0)