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
33 changes: 31 additions & 2 deletions lib/Controller/Api1Controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,10 @@
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\Attribute\RequestHeader;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\JSONResponse;
use OCP\Constants;
use OCP\IL10N;
use OCP\IRequest;
use Psr\Log\LoggerInterface;
Expand Down Expand Up @@ -1244,9 +1246,10 @@ public function indexTableRowsSimple(int $tableId, ?int $limit, ?int $offset): D
* @param int $tableId Table ID
* @param int|null $limit Limit
* @param int|null $offset Offset
* @return DataResponse<Http::STATUS_OK, list<TablesRow>, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}>
* @return DataResponse<Http::STATUS_OK, list<TablesRow>, array{}>|DataResponse<Http::STATUS_NOT_MODIFIED, array{}, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}>
*
* 200: Rows returned
* 304: Not modified
* 403: No permissions
* 404: Not found
*/
Expand All @@ -1255,13 +1258,39 @@ public function indexTableRowsSimple(int $tableId, ?int $limit, ?int $offset): D
#[CORS]
#[RequirePermission(permission: Application::PERMISSION_READ, type: Application::NODE_TYPE_TABLE, idParam: 'tableId')]
#[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)]
#[RequestHeader(name: 'if-modified-since', description: 'Respond with 304 Not Modified if the table has not been edited since this date')]
public function indexTableRows(int $tableId, ?int $limit, ?int $offset): DataResponse {
try {
return new DataResponse($this->rowService->formatRows($this->rowService->findAllByTable($tableId, $this->userId, $limit, $offset)));
$table = $this->tableService->find($tableId, true);
$lastEditAt = $table->getLastEditAt();
$lastModified = $lastEditAt !== null && $lastEditAt !== '' ? new \DateTime($lastEditAt) : null;

if ($lastModified !== null) {
$ifModifiedSince = trim($this->request->getHeader('if-modified-since'));
if ($ifModifiedSince !== '') {
$modifiedSince = \DateTime::createFromFormat(Constants::DATE_RFC7231, $ifModifiedSince);
if ($modifiedSince !== false && $lastModified->getTimestamp() <= $modifiedSince->getTimestamp()) {
$response = new DataResponse([], Http::STATUS_NOT_MODIFIED);
$response->setLastModified($lastModified);
return $response;
}
}
}

$rows = $this->rowService->findAllByTable($tableId, $this->userId, $limit, $offset);
$response = new DataResponse($this->rowService->formatRows($rows));
if ($lastModified !== null) {
$response->setLastModified($lastModified);
}
return $response;
} catch (PermissionError $e) {
$this->logger->warning('A permission error occurred: ' . $e->getMessage(), ['exception' => $e]);
$message = ['message' => $e->getMessage()];
return new DataResponse($message, Http::STATUS_FORBIDDEN);
} catch (NotFoundError $e) {
$this->logger->warning('A not found error occurred: ' . $e->getMessage(), ['exception' => $e]);
$message = ['message' => $e->getMessage()];
return new DataResponse($message, Http::STATUS_NOT_FOUND);
} catch (InternalError|Exception $e) {
$this->logger->error('An internal error or exception occurred: ' . $e->getMessage(), ['exception' => $e]);
$message = ['message' => $e->getMessage()];
Expand Down
16 changes: 16 additions & 0 deletions lib/Db/TableMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,22 @@ public function insert(Entity $entity): Table {
return $entity;
}

/**
* @throws Exception
*/
public function touch(int $id, ?string $userId = null, ?\DateTimeInterface $time = null): void {
$time ??= new \DateTime();
$qb = $this->db->getQueryBuilder();
$qb->update($this->table)
->set('last_edit_at', $qb->createNamedParameter($time->format('Y-m-d H:i:s'), IQueryBuilder::PARAM_STR))
->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)));
if ($userId !== null && $userId !== '') {
$qb->set('last_edit_by', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR));
}
$qb->executeStatement();
unset($this->cache[(string)$id]);
}

public function getDbConnection() {
return $this->db;
}
Expand Down
15 changes: 15 additions & 0 deletions lib/Service/ColumnService.php
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,8 @@ public function create(
author: $userId ?? $this->userId,
);

$this->touchTable($table->getId(), $userId ?? $this->userId);

return $this->enhanceColumn($entity);
}

Expand Down Expand Up @@ -442,6 +444,8 @@ public function update(
author: $userId ?? $this->userId,
);

$this->touchTable($updatedColumn->getTableId(), $userId ?? $this->userId);

return $this->enhanceColumn($updatedColumn);
} catch (\OCP\DB\Exception $e) {
$this->handleColumnPersistDbException($e, static::class . ' - ' . __FUNCTION__);
Expand Down Expand Up @@ -528,6 +532,17 @@ private function updateMetadata(Column $column, ?string $userId, bool $setCreate
}
}

/**
* Updates the table's last-modified metadata after a column change.
*/
private function touchTable(int $tableId, ?string $userId): void {
try {
$this->tableMapper->touch($tableId, $userId);
} catch (\OCP\DB\Exception $e) {
$this->logger->warning('Failed to update last modified timestamp for table ' . $tableId . ': ' . $e->getMessage(), ['exception' => $e]);
}
}

/**
* @param int $id
* @param bool $skipRowCleanup
Expand Down
16 changes: 16 additions & 0 deletions lib/Service/RowService.php
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,7 @@ public function create(?int $tableId, ?int $viewId, RowDataInput|array $data, ?s
$row2->setData($data);
try {
$insertedRow = $this->row2Mapper->insert($row2, $this->userId);
$this->touchTable($tableId, $this->userId);
$this->attachAliasPayload($insertedRow, $columns);

$this->eventDispatcher->dispatchTyped(new RowAddedEvent($insertedRow));
Expand Down Expand Up @@ -704,6 +705,7 @@ public function updateSet(
}

$updatedRow = $this->row2Mapper->update($item, $this->userId);
$this->touchTable($item->getTableId(), $this->userId);
$this->attachAliasPayload($updatedRow, $columns);

$this->eventDispatcher->dispatchTyped(new RowUpdatedEvent($updatedRow, $previousData));
Expand Down Expand Up @@ -802,6 +804,7 @@ public function delete(int $id, ?int $viewId, string $userId, ?int $tableId = nu
try {
$columns = $this->loadColumnsForData($item->getData() ?? []);
$deletedRow = $this->row2Mapper->delete($item);
$this->touchTable($item->getTableId(), $userId);
$this->attachAliasPayload($item, $columns);

$event = new RowDeletedEvent($item, $item->getData());
Expand Down Expand Up @@ -843,6 +846,7 @@ public function deleteAllByTable(int $tableId, ?string $userId = null): void {
$columns = $this->columnMapper->findAllByTable($tableId);

$this->row2Mapper->deleteAllForTable($tableId, $columns);
$this->touchTable($tableId, $userId ?? $this->userId);
}

/**
Expand All @@ -858,6 +862,18 @@ public function deleteAllByTable(int $tableId, ?string $userId = null): void {
*/
public function deleteColumnDataFromRows(Column $column):void {
$this->row2Mapper->deleteDataForColumn($column);
$this->touchTable($column->getTableId(), $this->userId);
}

/**
* Updates the table's last-modified metadata.
*/
private function touchTable(int $tableId, ?string $userId): void {
try {
$this->tableMapper->touch($tableId, $userId);
} catch (Exception $e) {
$this->logger->warning('Failed to update last modified timestamp for table ' . $tableId . ': ' . $e->getMessage(), ['exception' => $e]);
}
}

/**
Expand Down
11 changes: 11 additions & 0 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -5194,6 +5194,14 @@
"format": "int64",
"nullable": true
}
},
{
"name": "if-modified-since",
"in": "header",
"description": "Respond with 304 Not Modified if the table has not been edited since this date",
"schema": {
"type": "string"
}
}
],
"responses": {
Expand All @@ -5210,6 +5218,9 @@
}
}
},
"304": {
"description": "Not modified"
},
"403": {
"description": "No permissions",
"content": {
Expand Down
12 changes: 11 additions & 1 deletion src/types/openapi/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3707,7 +3707,10 @@ export interface operations {
/** @description Offset */
readonly offset?: number | null;
};
readonly header?: never;
readonly header?: {
/** @description Respond with 304 Not Modified if the table has not been edited since this date */
readonly "if-modified-since"?: string;
};
readonly path: {
/** @description Table ID */
readonly tableId: number;
Expand All @@ -3725,6 +3728,13 @@ export interface operations {
readonly "application/json": readonly components["schemas"]["Row"][];
};
};
/** @description Not modified */
readonly 304: {
headers: {
readonly [name: string]: unknown;
};
content?: never;
};
/** @description Current user is not logged in */
readonly 401: {
headers: {
Expand Down
59 changes: 59 additions & 0 deletions tests/unit/Db/TableMapperTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

declare(strict_types=1);

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

namespace OCA\Tables\Tests\Unit\Db;

use OCA\Tables\Db\TableMapper;
use OCA\Tables\Helper\UserHelper;
use OCA\Tables\Tests\Unit\Database\DatabaseTestCase;

class TableMapperTest extends DatabaseTestCase {
private TableMapper $mapper;

protected function setUp(): void {
parent::setUp();
$this->cleanupTablesData();
$this->mapper = new TableMapper($this->connectionAdapter, $this->createMock(UserHelper::class));
}

protected function tearDown(): void {
$this->cleanupTablesData();
parent::tearDown();
}

public function testTouchUpdatesLastEditAtAndBy(): void {
$table = $this->createTestTable(['last_edit_at' => '2020-01-01 00:00:00', 'last_edit_by' => 'user1']);

$this->mapper->touch($table['id'], 'user2', new \DateTime('2030-05-01 12:00:00'));

$updated = $this->mapper->find($table['id']);
$this->assertSame('2030-05-01 12:00:00', $updated->getLastEditAt());
$this->assertSame('user2', $updated->getLastEditBy());
}

public function testTouchWithoutUserIdKeepsExistingLastEditBy(): void {
$table = $this->createTestTable(['last_edit_at' => '2020-01-01 00:00:00', 'last_edit_by' => 'user1']);

$this->mapper->touch($table['id'], null, new \DateTime('2030-05-01 12:00:00'));

$updated = $this->mapper->find($table['id']);
$this->assertSame('2030-05-01 12:00:00', $updated->getLastEditAt());
$this->assertSame('user1', $updated->getLastEditBy());
}

public function testTouchInvalidatesMapperCache(): void {
$table = $this->createTestTable(['last_edit_at' => '2020-01-01 00:00:00']);
$this->mapper->find($table['id']); // warms the cache

$this->mapper->touch($table['id'], 'user2', new \DateTime('2030-05-01 12:00:00'));

$updated = $this->mapper->find($table['id']);
$this->assertSame('2030-05-01 12:00:00', $updated->getLastEditAt());
}
}
Loading