diff --git a/lib/Controller/Api1Controller.php b/lib/Controller/Api1Controller.php index 240891f302..c9714902cb 100644 --- a/lib/Controller/Api1Controller.php +++ b/lib/Controller/Api1Controller.php @@ -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; @@ -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, array{}>|DataResponse + * @return DataResponse, array{}>|DataResponse|DataResponse * * 200: Rows returned + * 304: Not modified * 403: No permissions * 404: Not found */ @@ -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()]; diff --git a/lib/Db/TableMapper.php b/lib/Db/TableMapper.php index 5a9ad8d368..47a463455a 100644 --- a/lib/Db/TableMapper.php +++ b/lib/Db/TableMapper.php @@ -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; } diff --git a/lib/Service/ColumnService.php b/lib/Service/ColumnService.php index 52a84b03c1..799dee41b9 100644 --- a/lib/Service/ColumnService.php +++ b/lib/Service/ColumnService.php @@ -345,6 +345,8 @@ public function create( author: $userId ?? $this->userId, ); + $this->touchTable($table->getId(), $userId ?? $this->userId); + return $this->enhanceColumn($entity); } @@ -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__); @@ -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 diff --git a/lib/Service/RowService.php b/lib/Service/RowService.php index 95baa53324..043d31bfb8 100644 --- a/lib/Service/RowService.php +++ b/lib/Service/RowService.php @@ -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)); @@ -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)); @@ -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()); @@ -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); } /** @@ -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]); + } } /** diff --git a/openapi.json b/openapi.json index 412df8cf42..4e3c199706 100644 --- a/openapi.json +++ b/openapi.json @@ -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": { @@ -5210,6 +5218,9 @@ } } }, + "304": { + "description": "Not modified" + }, "403": { "description": "No permissions", "content": { diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 665abe8276..3166463cb3 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -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; @@ -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: { diff --git a/tests/unit/Db/TableMapperTest.php b/tests/unit/Db/TableMapperTest.php new file mode 100644 index 0000000000..0df858ee8c --- /dev/null +++ b/tests/unit/Db/TableMapperTest.php @@ -0,0 +1,59 @@ +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()); + } +}