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
108 changes: 108 additions & 0 deletions lib/BackgroundJob/BackfillRowUuids.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
<?php

declare(strict_types=1);

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

namespace OCA\Tables\BackgroundJob;

use OCA\Tables\AppInfo\Application;
use OCA\Tables\Vendor\Symfony\Component\Uid\Uuid;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\IJobList;
use OCP\BackgroundJob\TimedJob;
use OCP\DB\Exception;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IAppConfig;
use OCP\IDBConnection;
use Psr\Log\LoggerInterface;

class BackfillRowUuids extends TimedJob {

private const BATCH_SIZE = 500;
private const TIME_BUDGET = 5;
private const SQLITE_BATCH_PAUSE_MICROSECONDS = 10_000;
private const CURSOR_KEY = 'row_uuid_backfill_cursor';

public function __construct(
ITimeFactory $time,
private readonly IDBConnection $connection,
private readonly IAppConfig $config,
private readonly IJobList $jobList,
private readonly LoggerInterface $logger,
) {
parent::__construct($time);
$this->setInterval(60);
$this->setAllowParallelRuns(false);
}

public function run(mixed $argument): void {
$deadline = $this->time->getTime() + self::TIME_BUDGET;
$cursor = $this->config->getValueInt(Application::APP_ID, self::CURSOR_KEY, 0, true);
$select = $this->connection->getQueryBuilder();
$select->select('id')->from('tables_row_sleeves')
->where($select->expr()->isNull('uuid'))
->andWhere($select->expr()->gt('id', $select->createParameter('cursor')))
->orderBy('id', 'ASC')->setMaxResults(self::BATCH_SIZE);
$update = $this->connection->getQueryBuilder();
$update->update('tables_row_sleeves')->set('uuid', $update->createParameter('uuid'))
->where($update->expr()->eq('id', $update->createParameter('id')))
->andWhere($update->expr()->isNull('uuid'));

try {
do {
$select->setParameter('cursor', $cursor, IQueryBuilder::PARAM_INT);
$result = $select->executeQuery();
$ids = array_map('intval', $result->fetchFirstColumn());
$result->closeCursor();
if ($ids === []) {
if ($cursor === 0) {
$this->jobList->remove(self::class);
$this->config->deleteKey(Application::APP_ID, self::CURSOR_KEY);
return;
}
$cursor = 0;
continue;
}

$cursor = $this->processBatch($ids, $update, $deadline);
if ($this->connection->getDatabaseProvider() === IDBConnection::PLATFORM_SQLITE && $this->time->getTime() < $deadline) {
// Yield SQLite's single writer between committed batches.
usleep(self::SQLITE_BATCH_PAUSE_MICROSECONDS);
}
} while ($this->time->getTime() < $deadline);
// A lagging cursor only repeats a NULL scan, so one write per run spares the shared config cache.
$this->config->setValueInt(Application::APP_ID, self::CURSOR_KEY, $cursor, true);
} catch (\Throwable $e) {
$this->logger->error('Row UUID backfill will retry on its next run.', ['exception' => $e]);
}
}

/** @param non-empty-list<int> $ids */
private function processBatch(array $ids, IQueryBuilder $update, int $deadline): int {
for ($attempt = 0; ; $attempt++) {
$this->connection->beginTransaction();
try {
foreach ($ids as $id) {
$update->setParameter('id', $id, IQueryBuilder::PARAM_INT);
$update->setParameter('uuid', Uuid::v7()->toRfc4122());
$update->executeStatement();
if ($this->time->getTime() >= $deadline) {
break;
}
}
$this->connection->commit();
return $id;
} catch (\Throwable $e) {
$this->connection->rollBack();
// Only uuid changes in this statement, so any unique violation is a UUID collision.
if (!$e instanceof Exception || $e->getReason() !== Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION || $attempt >= 3) {
throw $e;
}
}
}
}
}
109 changes: 95 additions & 14 deletions lib/Controller/RowOCSController.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use OCA\Tables\Helper\ConversionHelper;
use OCA\Tables\Middleware\Attribute\RequirePermission;
use OCA\Tables\Model\RowDataInput;
use OCA\Tables\Model\RowIdentifier;
use OCA\Tables\ResponseDefinitions;
use OCA\Tables\Service\FederationService;
use OCA\Tables\Service\RowService;
Expand Down Expand Up @@ -87,11 +88,11 @@ public function createRow(string $nodeCollection, int $nodeId, mixed $data): Dat
try {
if ($tableId !== null && $this->federationService->isNodeFederated($tableId, 'table')) {
$table = $this->tableService->find($nodeId, true);
return new DataResponse($this->federationService->createRow($table, $data));
return new DataResponse($this->federationService->createRow($table, $data) + ['uuid' => null]);
}
if ($viewId !== null && $this->federationService->isNodeFederated($viewId, 'view')) {
$view = $this->viewService->find($nodeId, false, $this->userId);
return new DataResponse($this->federationService->createRow($view, $data));
return new DataResponse($this->federationService->createRow($view, $data) + ['uuid' => null]);
}
return new DataResponse($this->rowService->create($tableId, $viewId, $newRowData)->jsonSerialize());
} catch (BadRequestError $e) {
Expand All @@ -105,13 +106,49 @@ public function createRow(string $nodeCollection, int $nodeId, mixed $data): Dat
}
}

/**
* [api v2] Read a local row in a table or view
*
* @param 'tables'|'views' $nodeCollection The targeted node collection
* @param int $nodeId The table or view identifier
* @param string $rowId A positive decimal row ID or a hyphenated UUIDv7
* @return DataResponse<Http::STATUS_OK, TablesRow, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND|Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}>
*
* 200: Row returned
* 400: Invalid identifier or unsupported federated operation
* 403: No permissions
* 404: Not found in the requested node
* 500: Internal error
*/
#[NoAdminRequired]
#[RequirePermission(permission: Application::PERMISSION_READ, typeParam: 'nodeCollection')]
#[ApiRoute(verb: 'GET', url: '/api/2/{nodeCollection}/{nodeId}/rows/{rowId}', requirements: ['nodeCollection' => '(tables|views)', 'nodeId' => '(\d+)'])]
public function getRow(string $nodeCollection, int $nodeId, string $rowId): DataResponse {
$iNodeType = ConversionHelper::stringNodeType2Const($nodeCollection);
try {
$identifier = $this->parseRowIdentifier($rowId);
if ($this->federationService->isNodeFederated($nodeId, ConversionHelper::constNodeType2String($iNodeType))) {
throw new BadRequestError('Single-row reads are not supported on federated nodes.');
}
return new DataResponse($this->rowService->findByIdentifier($identifier, $iNodeType, $nodeId, $this->userId)->jsonSerialize());
} catch (BadRequestError $e) {
return $this->handleBadRequestError($e);
} catch (PermissionError $e) {
return $this->handlePermissionError($e);
} catch (NotFoundError $e) {
return $this->handleNotFoundError($e);
} catch (\Exception $e) {
return $this->handleError($e);
}
}

/**
* [api v2] Update a row in a table or a view
*
* @param 'tables'|'views' $nodeCollection Indicates whether to update a row on a table or view
* @param int $nodeId The identifier of the targeted table or view
* @param int $rowId The identifier of the row to update
* @param string|array<string, mixed> $data An array containing the column identifiers and their values
* @param string $rowId A positive decimal row ID or a hyphenated UUIDv7
* @param string|list<array{columnId: int|string, value: mixed}> $data A list of cells containing columnId and value
* @return DataResponse<Http::STATUS_OK, TablesRow, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND|Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}>
*
* 200: Row updated
Expand All @@ -123,7 +160,7 @@ public function createRow(string $nodeCollection, int $nodeId, mixed $data): Dat
#[NoAdminRequired]
#[RequirePermission(permission: Application::PERMISSION_UPDATE, typeParam: 'nodeCollection')]
#[ApiRoute(verb: 'PUT', url: '/api/2/{nodeCollection}/{nodeId}/rows/{rowId}', requirements: ['nodeCollection' => '(tables|views)', 'nodeId' => '(\d+)'])]
public function updateRow(string $nodeCollection, int $nodeId, int $rowId, mixed $data): DataResponse {
public function updateRow(string $nodeCollection, int $nodeId, string $rowId, mixed $data): DataResponse {
if (is_string($data)) {
$data = json_decode($data, true);
}
Expand All @@ -138,15 +175,20 @@ public function updateRow(string $nodeCollection, int $nodeId, int $rowId, mixed
$viewId = $nodeId;
}
try {
$identifier = $this->parseRowIdentifier($rowId);
$rowData = $this->parseRowData($data);
if ($tableId !== null && $this->federationService->isNodeFederated($tableId, 'table')) {
$federatedRowId = $this->requireNumericRowId($identifier);
$table = $this->tableService->find($nodeId, true);
return new DataResponse($this->federationService->updateRow($table, $rowId, $data));
return new DataResponse($this->federationService->updateRow($table, $federatedRowId, $data) + ['uuid' => null]);
}
if ($viewId !== null && $this->federationService->isNodeFederated($viewId, 'view')) {
$federatedRowId = $this->requireNumericRowId($identifier);
$view = $this->viewService->find($nodeId, false, $this->userId);
return new DataResponse($this->federationService->updateRow($view, $rowId, $data));
return new DataResponse($this->federationService->updateRow($view, $federatedRowId, $data) + ['uuid' => null]);
}
return new DataResponse($this->rowService->updateSet($rowId, $viewId, $data, $this->userId, $tableId)->jsonSerialize());
$localRowId = $this->rowService->resolveRowId($identifier, $iNodeType, $nodeId, $this->userId);
return new DataResponse($this->rowService->updateRowData($localRowId, $viewId, $rowData, $this->userId, $tableId)->jsonSerialize());
} catch (BadRequestError $e) {
return $this->handleBadRequestError($e);
} catch (NotFoundError $e) {
Expand All @@ -163,18 +205,19 @@ public function updateRow(string $nodeCollection, int $nodeId, int $rowId, mixed
*
* @param 'tables'|'views' $nodeCollection Indicates whether to delete a row on a table or view
* @param int $nodeId The identifier of the targeted table or view
* @param int $rowId The identifier of the row to delete
* @return DataResponse<Http::STATUS_OK, TablesRow, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND|Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}>
* @param string $rowId A positive decimal row ID or a hyphenated UUIDv7
* @return DataResponse<Http::STATUS_OK, TablesRow, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND|Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}>
*
* 200: Row deleted
* 400: Invalid identifier or unsupported federated operation
* 403: No permissions
* 404: Not found
* 500: Internal error
*/
#[NoAdminRequired]
#[RequirePermission(permission: Application::PERMISSION_DELETE, typeParam: 'nodeCollection')]
#[ApiRoute(verb: 'DELETE', url: '/api/2/{nodeCollection}/{nodeId}/rows/{rowId}', requirements: ['nodeCollection' => '(tables|views)', 'nodeId' => '(\d+)'])]
public function deleteRow(string $nodeCollection, int $nodeId, int $rowId): DataResponse {
public function deleteRow(string $nodeCollection, int $nodeId, string $rowId): DataResponse {
$iNodeType = ConversionHelper::stringNodeType2Const($nodeCollection);
$tableId = $viewId = null;
if ($iNodeType === Application::NODE_TYPE_TABLE) {
Expand All @@ -183,15 +226,21 @@ public function deleteRow(string $nodeCollection, int $nodeId, int $rowId): Data
$viewId = $nodeId;
}
try {
$identifier = $this->parseRowIdentifier($rowId);
if ($tableId !== null && $this->federationService->isNodeFederated($tableId, 'table')) {
$federatedRowId = $this->requireNumericRowId($identifier);
$table = $this->tableService->find($nodeId, true);
return new DataResponse($this->federationService->deleteRow($table, $rowId));
return new DataResponse($this->federationService->deleteRow($table, $federatedRowId) + ['uuid' => null]);
}
if ($viewId !== null && $this->federationService->isNodeFederated($viewId, 'view')) {
$federatedRowId = $this->requireNumericRowId($identifier);
$view = $this->viewService->find($nodeId, false, $this->userId);
return new DataResponse($this->federationService->deleteRow($view, $rowId));
return new DataResponse($this->federationService->deleteRow($view, $federatedRowId) + ['uuid' => null]);
}
return new DataResponse($this->rowService->delete($rowId, $viewId, $this->userId, $tableId)->jsonSerialize());
$localRowId = $this->rowService->resolveRowId($identifier, $iNodeType, $nodeId, $this->userId);
return new DataResponse($this->rowService->delete($localRowId, $viewId, $this->userId, $tableId)->jsonSerialize());
} catch (BadRequestError $e) {
return $this->handleBadRequestError($e);
} catch (NotFoundError $e) {
return $this->handleNotFoundError($e);
} catch (PermissionError $e) {
Expand All @@ -200,4 +249,36 @@ public function deleteRow(string $nodeCollection, int $nodeId, int $rowId): Data
return $this->handleError($e);
}
}

/**
* @throws BadRequestError
*/
private function parseRowIdentifier(string $rowId): RowIdentifier {
try {
return RowIdentifier::fromString($rowId);
} catch (\InvalidArgumentException $e) {
throw new BadRequestError($e->getMessage(), 0, $e);
}
}

/**
* @throws BadRequestError
*/
private function parseRowData(array $data): RowDataInput {
try {
return RowDataInput::createFromInputArray($data);
} catch (\InvalidArgumentException $e) {
throw new BadRequestError($e->getMessage(), 0, $e);
}
}

/**
* @throws BadRequestError
*/
private function requireNumericRowId(RowIdentifier $identifier): int {
if (!is_int($identifier->value)) {
throw new BadRequestError('Federated rows do not support UUID addressing.');
}
return $identifier->value;
}
}
10 changes: 10 additions & 0 deletions lib/Db/Row2.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
*/
class Row2 implements JsonSerializable {
private ?int $id = null;
private ?string $uuid = null;
private ?int $tableId = null;
private ?string $createdBy = null;
private ?string $createdAt = null;
Expand All @@ -36,6 +37,14 @@ public function setId(int $id): void {
$this->id = $id;
}

public function getUuid(): ?string {
return $this->uuid;
}

public function setUuid(?string $uuid): void {
$this->uuid = $uuid;
}

public function getTableId(): ?int {
return $this->tableId;
}
Expand Down Expand Up @@ -165,6 +174,7 @@ public function filterDataByAliasByColumns(array $columns): array {
public function jsonSerialize(): array {
return [
'id' => $this->id,
'uuid' => $this->uuid,
'tableId' => $this->tableId,
'createdBy' => $this->createdBy,
'createdAt' => $this->createdAt,
Expand Down
Loading