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
7 changes: 7 additions & 0 deletions lib/Activity/ActivityManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,10 @@ private function createEvent(string $objectType, Row2|Table|View|Column $object,
$objectTitle = $object->getTitle();
$table = $object;
} elseif ($object instanceof View) {
if ($object->isStandalone()) {
// activities are attached to a table, a view without one has no stream
return null;
}
$objectTitle = $object->getTitle();
$table = $this->tableMapper->find($object->getTableId());
} elseif ($object instanceof Row2) {
Expand Down Expand Up @@ -434,6 +438,9 @@ private function sendShareEventToUsers(IEvent $event, Row2|Table|View|Column $ob
}

$tableId = $object instanceof Table ? $object->getId() : $object->getTableId();
if ($tableId === null) {
return;
}
$table = $object instanceof Table ? $object : $this->tableMapper->find($tableId);
$tableOwner = $table->getOwnership();
$eventAuthor = $event->getAuthor();
Expand Down
1 change: 1 addition & 0 deletions lib/Constants/ViewUpdatableParameters.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ enum ViewUpdatableParameters: string {
case COLUMN_SETTINGS = 'columns';
case TECHNICAL_NAME = 'technicalName';
case SIDEBAR_ORDER = 'sidebarOrder';
case TYPE = 'type';
case LAYOUT = 'layout';
case VIEW_SETTINGS = 'viewSettings';
}
10 changes: 10 additions & 0 deletions lib/Db/Row2Mapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -768,6 +768,11 @@ private function parseEntities(IResult $result, array $sleeves): array {
* @throws InternalError
*/
public function isRowInViewPresent(int $rowId, View $view, string $userId): bool {
if ($view->isStandalone()) {
// A view without a table holds no rows. Federated views read their rows through the
// federation proxy, not here.
return false;
}
return in_array($rowId, $this->getWantedRowIds($userId, $view->getTableId(), $view->getFilterArray()));
}

Expand Down Expand Up @@ -1033,6 +1038,11 @@ public function countRowsForTables(array $tableIds): array {
* @return int
*/
public function countRowsForView(View $view, string $userId): int {
if ($view->isStandalone()) {
// A view without a table holds no rows. Federated views read their rows through the
// federation proxy, not here.
return 0;
}
$filter = $view->getFilterArray();

if (empty($filter)) {
Expand Down
39 changes: 36 additions & 3 deletions lib/Db/View.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,10 @@
* @method setTitle(string $title)
* @method getTechnicalName(): string
* @method setTechnicalName(?string $technicalName)
* @method getTableId(): int
* @method setTableId(int $tableId)
* @method getTableId(): ?int
* @method setTableId(?int $tableId)
* @method getType(): string
* @method setType(string $type)
* @method getColumns(): string
* @method setColumns(string $columns)
* @method getColumnSettings(): string
Expand Down Expand Up @@ -80,6 +82,10 @@
* @method setSidebarOrder(?int $sidebarOrder)
*/
class View extends EntitySuper implements JsonSerializable {
public const TYPE_TABLE = 'table';
public const TYPE_GRID = 'grid';
public const TYPES = [self::TYPE_TABLE, self::TYPE_GRID];

protected ?string $uuid = null;
protected ?string $title = null;
protected ?string $technicalName = null;
Expand All @@ -94,6 +100,7 @@ class View extends EntitySuper implements JsonSerializable {
protected ?string $columns = null; // json
protected ?string $sort = null; // json
protected ?string $filter = null; // json
protected ?string $type = null;
protected ?string $layout = null;
protected ?string $viewSettings = null; // json

Expand Down Expand Up @@ -224,6 +231,31 @@ public function setFilterArray(array $array):void {
$this->setFilter(\json_encode($array));
}

/**
* @return 'table'|'grid'
*/
public function getTypeOrDefault(): string {
return $this->type === self::TYPE_GRID ? self::TYPE_GRID : self::TYPE_TABLE;
}

/**
* A view of its own, such as a grid view: it has no table and is not a federated view,
* which has no local table either.
*/
public function isStandalone(): bool {
return $this->tableId === null && $this->externalId === null;
}

/**
* A view inherits its ownership from its table; a standalone view is owned by its creator.
*/
public function getOwnership(): ?string {
if ($this->ownership === null && $this->isStandalone()) {
return $this->createdBy;
}
return $this->ownership;
}

public function getLayoutNormalized(): string {
return ViewLayout::normalize($this->layout)->value;
}
Expand All @@ -248,7 +280,7 @@ public function jsonSerialize(): array {
'technicalName' => $this->technicalName,
'description' => $this->description,
'emoji' => $this->emoji,
'ownership' => $this->ownership ?: '',
'ownership' => $this->getOwnership() ?: '',
'createdBy' => $this->createdBy ?: '',
'createdAt' => $this->createdAt ?: '',
'lastEditBy' => $this->lastEditBy ?: '',
Expand All @@ -264,6 +296,7 @@ public function jsonSerialize(): array {
'ownerDisplayName' => $this->ownerDisplayName,
'isFederated' => $this->isFederated(),
'sidebarOrder' => $this->sidebarOrder,
'type' => $this->getTypeOrDefault(),
'layout' => $this->getLayoutNormalized(),
'viewSettings' => $this->getViewSettingsObject()->jsonSerialize(),
];
Expand Down
31 changes: 26 additions & 5 deletions lib/Db/ViewMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,24 @@ public function findMany(array $ids): array {
return $result;
}

/**
* Views that live without a table and belong to the given user. Federated views have no
* table either, so they are left out by their external id.
*
* @return View[]
* @throws Exception
*/
public function findAllStandaloneOwnedBy(string $userId): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->table)
->where($qb->expr()->isNull('table_id'))
->andWhere($qb->expr()->isNull('external_id'))
->andWhere($qb->expr()->eq('created_by', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR)))
->orderBy('id', 'ASC');
return $this->findEntities($qb);
}

public function findByExternalIdAndToken(int $externalId, string $shareToken): ?View {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
Expand Down Expand Up @@ -134,7 +152,7 @@ public function findAll(?int $tableId = null): array {
$qb = $this->db->getQueryBuilder();
$qb->select('v.*', 't.ownership')
->from($this->table, 'v')
->innerJoin('v', 'tables_tables', 't', 't.id = v.table_id');
->leftJoin('v', 'tables_tables', 't', 't.id = v.table_id');

if ($tableId !== null) {
$qb->where($qb->expr()->eq('v.table_id', $qb->createNamedParameter($tableId, IQueryBuilder::PARAM_INT)));
Expand Down Expand Up @@ -239,16 +257,19 @@ public function findIdToTitleMap(array $ids): array {
}

/**
* @throws DoesNotExistException
* @throws Exception
* @throws MultipleObjectsReturnedException
* @throws Exception
*/
public function findByUuid(string $uuid): ?View {
$qb = $this->db->getQueryBuilder();
$qb->select('v.*', 't.ownership')
->from($this->table, 'v')
->innerJoin('v', 'tables_tables', 't', 't.id = v.table_id')
->leftJoin('v', 'tables_tables', 't', 't.id = v.table_id')
->where($qb->expr()->eq('v.uuid', $qb->createNamedParameter($uuid)));
return $this->findEntity($qb);
try {
return $this->findEntity($qb);
} catch (DoesNotExistException) {
return null;
Comment thread
blizzz marked this conversation as resolved.
}
}
}
6 changes: 5 additions & 1 deletion lib/Federation/FederationProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,11 @@ private function buildNodeForFederationShare(ICloudFederationShare $share, array
$node->setLayout(self::layoutFromMetaData($metaData));
}

if (property_exists($node, 'uuid')) {
if ($node instanceof View) {
// Every proxy view gets its own uuid: a view's uuid is unique, and one view can be
// federated to several users on the same instance
$node->setUuid(null);
} elseif (property_exists($node, 'uuid')) {
$node->setUuid($metaData['uuid'] ?? null);
}

Expand Down
9 changes: 9 additions & 0 deletions lib/Listener/UserDeletedListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

use OCA\Tables\Errors\InternalError;
use OCA\Tables\Service\TableService;
use OCA\Tables\Service\ViewService;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\User\Events\BeforeUserDeletedEvent;
Expand All @@ -20,6 +21,7 @@
class UserDeletedListener implements IEventListener {
public function __construct(
private readonly TableService $tableService,
private readonly ViewService $viewService,
private readonly LoggerInterface $logger,
) {
}
Expand All @@ -41,5 +43,12 @@ public function handle(Event $event): void {
$this->logger->debug('tables for the deleted user removed');
} catch (InternalError) {
}

// views without a table belong to their creator, so they go with the user
try {
$this->viewService->deleteAllStandaloneOwnedBy($event->getUser()->getUID());
} catch (InternalError) {
// logged by the service
}
}
}
42 changes: 42 additions & 0 deletions lib/Migration/Version2400Date20261012000000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

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

namespace OCA\Tables\Migration;

use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\DB\Types;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
use Override;

/**
* Views get a type (table, grid). table_id is already nullable for federated views
* (Version2203Date20260821000000), so a view without a table needs no schema change.
*/
class Version2400Date20261012000000 extends SimpleMigrationStep {
#[Override]
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();

if ($schema->hasTable('tables_views')) {
$table = $schema->getTable('tables_views');
if (!$table->hasColumn('type')) {
$table->addColumn('type', Types::STRING, [
'notnull' => true,
'default' => 'table',
'length' => 16,
]);
}
}

return $schema;
}
}
91 changes: 91 additions & 0 deletions lib/Migration/Version2400Date20261013000000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php

declare(strict_types=1);

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

namespace OCA\Tables\Migration;

use Closure;
use OCA\Tables\Vendor\Symfony\Component\Uid\Uuid;
use OCP\DB\ISchemaWrapper;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
use Override;

/**
* A view's uuid is unique on its own, also for views without a table: the index on
* (table_id, uuid) does not hold when table_id is null, so it gives way to an index on uuid alone.
* Imports and federated proxy views used to keep the uuid of their source, so views that share
* one get a fresh uuid first; a local view keeps its own.
*/
class Version2400Date20261013000000 extends SimpleMigrationStep {
public function __construct(
private readonly IDBConnection $connection,
) {
}

#[Override]
public function preSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
$select = $this->connection->getQueryBuilder();
$select->select('id', 'uuid', 'external_id')
->from('tables_views')
->where($select->expr()->isNotNull('uuid'))
->orderBy('id', 'ASC');
$result = $select->executeQuery();
$rows = [];
while ($row = $result->fetchAssociative()) {
$rows[] = $row;
}
$result->closeCursor();

// A local view keeps its uuid; a federated proxy that copied it from its source gets a fresh one
usort($rows, static fn (array $a, array $b): int => [$a['external_id'] !== null, (int)$a['id']] <=> [$b['external_id'] !== null, (int)$b['id']]);
$seen = [];
$duplicateIds = [];
foreach ($rows as $row) {
if (isset($seen[$row['uuid']])) {
$duplicateIds[] = (int)$row['id'];
continue;
}
$seen[$row['uuid']] = true;
}

$update = $this->connection->getQueryBuilder();
$update->update('tables_views')
->set('uuid', $update->createParameter('uuid'))
->where($update->expr()->eq('id', $update->createParameter('id')));
foreach ($duplicateIds as $id) {
$update->setParameter('uuid', Uuid::v7()->toRfc4122());
$update->setParameter('id', $id, IQueryBuilder::PARAM_INT);
$update->executeStatement();
}
if ($duplicateIds !== []) {
$output->info(count($duplicateIds) . ' views got a fresh uuid because another view already used theirs');
}
}

#[Override]
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();

if ($schema->hasTable('tables_views')) {
$table = $schema->getTable('tables_views');
if (!$table->hasIndex('tables_views_uuid_only_uniq')) {
$table->addUniqueIndex(['uuid'], 'tables_views_uuid_only_uniq');
}
// The uuid-only index covers the old (table_id, uuid) one
if ($table->hasIndex('tables_views_uuid_uniq')) {
$table->dropIndex('tables_views_uuid_uniq');
}
}

return $schema;
}
}
Loading
Loading