From dceee48a341172ad3d422dbe3ad4919760c42214 Mon Sep 17 00:00:00 2001 From: Arthur Schiwon Date: Tue, 28 Jan 2025 22:05:42 +0100 Subject: [PATCH 01/11] feat(Api): add v2 OCS Api to get table/view rows - also changes Api Route definition to attribute Signed-off-by: Arthur Schiwon --- REUSE.toml | 6 + appinfo/routes.php | 2 - lib/Activity/ChangeSet.php | 4 +- lib/Analytics/AnalyticsDatasource.php | 2 +- lib/Controller/RowOCSController.php | 192 ++++++++++- lib/Db/LegacyRowMapper.php | 2 +- lib/Db/Row2Mapper.php | 2 +- lib/Db/RowCellMapperSuper.php | 5 +- lib/Db/RowCellSuper.php | 2 +- lib/Db/RowQuery.php | 77 +++++ lib/Model/FilterInput.php | 15 + lib/Model/RowDataInput.php | 5 +- lib/Service/ImportService.php | 5 +- lib/Service/RowService.php | 77 +++++ .../ValueObject/ColumnOrderInformation.php | 2 +- tests/integration/features/RowOCS.feature | 298 ++++++++++++++++++ .../features/bootstrap/FeatureContext.php | 107 +++++++ vendor-bin/rector/composer.json | 1 + vendor-bin/rector/composer.lock | 18 ++ 19 files changed, 807 insertions(+), 15 deletions(-) create mode 100644 lib/Db/RowQuery.php create mode 100644 lib/Model/FilterInput.php create mode 100644 tests/integration/features/RowOCS.feature create mode 100644 vendor-bin/rector/composer.json create mode 100644 vendor-bin/rector/composer.lock diff --git a/REUSE.toml b/REUSE.toml index 519b115aaf..1b25e862b1 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -29,6 +29,12 @@ precedence = "aggregate" SPDX-FileCopyrightText = "2025 Nextcloud GmbH and Nextcloud contributors" SPDX-License-Identifier = "AGPL-3.0-or-later" +[[annotations]] +path = ["vendor-bin/rector/composer.json", "vendor-bin/rector/composer.lock"] +precedence = "aggregate" +SPDX-FileCopyrightText = "2025 Nextcloud GmbH and Nextcloud contributors" +SPDX-License-Identifier = "AGPL-3.0-or-later" + [[annotations]] path = ["img/app-dark.svg", "img/app.svg", "img/view.svg", "img/view-dark.svg", "img/material/*.svg"] precedence = "aggregate" diff --git a/appinfo/routes.php b/appinfo/routes.php index b92ee2f831..9e19e1d604 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -156,8 +156,6 @@ ['name' => 'Context#transfer', 'url' => '/api/2/contexts/{contextId}/transfer', 'verb' => 'PUT'], ['name' => 'Context#updateContentOrder', 'url' => '/api/2/contexts/{contextId}/pages/{pageId}', 'verb' => 'PUT'], - ['name' => 'RowOCS#createRow', 'url' => '/api/2/{nodeCollection}/{nodeId}/rows', 'verb' => 'POST', 'requirements' => ['nodeCollection' => '(tables|views)', 'nodeId' => '(\d+)']], - ['name' => 'Config#getTableConfig', 'url' => '/api/2/config/table/{id}', 'verb' => 'GET'], ['name' => 'Config#getViewConfig', 'url' => '/api/2/config/view/{id}', 'verb' => 'GET'], ['name' => 'Config#setValue', 'url' => '/api/2/config/{key}', 'verb' => 'POST'], diff --git a/lib/Activity/ChangeSet.php b/lib/Activity/ChangeSet.php index 4ec67cde9d..ea850a537a 100644 --- a/lib/Activity/ChangeSet.php +++ b/lib/Activity/ChangeSet.php @@ -24,11 +24,11 @@ public function __construct( } } - public function setBefore($before) { + public function setBefore(Entity $before) { $this->before = clone $before; } - public function setAfter($after) { + public function setAfter(Entity $after) { $this->after = clone $after; } diff --git a/lib/Analytics/AnalyticsDatasource.php b/lib/Analytics/AnalyticsDatasource.php index 9963c9f8d6..0d0ee5e29d 100644 --- a/lib/Analytics/AnalyticsDatasource.php +++ b/lib/Analytics/AnalyticsDatasource.php @@ -338,7 +338,7 @@ private function formatBooleanValue(mixed $value): string { return ''; } - private function formatTextValue(Column $column, mixed $value): string { + private function formatTextValue(Column $column, string $value): string { if ($value === null || $value === '') { return ''; } diff --git a/lib/Controller/RowOCSController.php b/lib/Controller/RowOCSController.php index 60ef07efaf..ad72d120b6 100644 --- a/lib/Controller/RowOCSController.php +++ b/lib/Controller/RowOCSController.php @@ -1,5 +1,7 @@ $data An array containing the column identifiers and their values - * @return DataResponse|DataResponse + * @param string|array $data An array containing the column + * identifiers and their values + * @return DataResponse|DataResponse * * 200: Row returned * 400: Invalid request parameters @@ -55,6 +67,7 @@ public function __construct( */ #[NoAdminRequired] #[RequirePermission(permission: Application::PERMISSION_CREATE, typeParam: 'nodeCollection')] + #[ApiRoute(verb: 'POST', url: '/api/2/{nodeCollection}/{nodeId}/rows', requirements: ['nodeCollection' => '(tables|views)', 'nodeId' => '(\d+)'])] public function createRow(string $nodeCollection, int $nodeId, mixed $data): DataResponse { if (is_string($data)) { $data = json_decode($data, true); @@ -88,4 +101,177 @@ public function createRow(string $nodeCollection, int $nodeId, mixed $data): Dat return $this->handleError($e); } } + + /** + * [api v2] Get a number of rows from a table or view + * + * Both `filter` and `sort` are passed as JSON encoded strings. + * + * The filter is a list of filter groups, each group being a list of single + * filter definitions. Definitions within a group are AND-connected, while + * the groups themselves are OR-connected. + * + * When reading from a view, the provided filter is added to each of the + * view's existing filter groups, so the view's base rules are always + * enforced. + * + * A provided sort order overrides the view's default sort order. The view's + * default sort order is only used when no sort order is provided. + * + * @param 'tables'|'views' $nodeCollection Indicates whether to read from a table or a view + * @psalm-param int<0,max> $nodeId The ID of the table or view + * @psalm-param ?int<1,500> $limit Number of rows to return between 1 and 500, fetches all by default (optional) + * @psalm-param ?int<0,max> $offset Offset of the rows to be returned (optional) + * @param ?string $filter JSON encoded list of filter groups. Definitions within a group are AND-connected, groups are OR-connected, e.g. `[[{"columnId":1,"operator":"contains","value":"foo"}]]` (optional) + * @param ?string $sort JSON encoded list of sort rules, e.g. `[{"columnId":1,"mode":"ASC"}]` (optional) + * @return DataResponse, array{}>|DataResponse + * + * 200: Rows returned + * 400: Invalid request parameters + * 403: No permissions + * 404: Not found + * 500: Internal error + */ + #[NoAdminRequired] + #[RequirePermission(permission: Application::PERMISSION_READ, typeParam: 'nodeCollection')] + #[ApiRoute( + verb: 'GET', + url: '/api/2/{nodeCollection}/{nodeId}/rows', + requirements: ['nodeCollection' => '(tables|views)', 'nodeId' => '(\\d+)'] + )] + public function getRows(string $nodeCollection, int $nodeId, ?int $limit = null, ?int $offset = null, ?string $filter = null, ?string $sort = null): DataResponse { + try { + if (($limit !== null && ($limit <= 0 || $limit > 500)) + || ($offset !== null && $offset < 0) + ) { + throw new InvalidArgumentException('Offset or limit parameter is out of bounds'); + } + + $queryData = new RowQuery( + nodeType: $nodeCollection === 'tables' ? Application::NODE_TYPE_TABLE : Application::NODE_TYPE_VIEW, + nodeId: $nodeId, + ); + $queryData->setLimit($limit) + ->setOffset($offset) + // the provided filter is set here; any filter defined on a view + // is merged in on the service level + ->setFilter($this->parseFilter($filter)) + ->setSort($this->parseSort($sort)) + ->setUserId($this->userId); + + $rows = $this->rowService->findAllByQuery($queryData); + return new DataResponse($this->rowService->formatRows($rows)); + } catch (DoesNotExistException $e) { + return $this->handleNotFoundError(new NotFoundError($e->getMessage(), $e->getCode(), $e)); + } catch (MultipleObjectsReturnedException|InvalidArgumentException $e) { + return $this->handleBadRequestError(new BadRequestError($e->getMessage(), $e->getCode(), $e)); + } catch (InternalError|Exception $e) { + return $this->handleError($e); + } + } + + /** + * Decode and validate the JSON encoded filter parameter. + * + * @return list>|null + * @throws InvalidArgumentException + */ + protected function parseFilter(?string $filter): ?array { + if ($filter === null || $filter === '') { + return null; + } + $decoded = json_decode($filter, true); + if (!is_array($decoded)) { + throw new InvalidArgumentException('Invalid filter supplied'); + } + foreach ($decoded as $filterGroup) { + if (!is_array($filterGroup)) { + throw new InvalidArgumentException('Invalid filter supplied'); + } + foreach ($filterGroup as $singleFilter) { + $this->assertFilterValue($singleFilter); + } + } + return $decoded; + } + + /** + * Decode and validate the JSON encoded sort parameter. + * + * @return list|null + * @throws InvalidArgumentException + */ + protected function parseSort(?string $sort): ?array { + if ($sort === null || $sort === '') { + return null; + } + $decoded = json_decode($sort, true); + if (!is_array($decoded)) { + throw new InvalidArgumentException('Invalid sort data supplied'); + } + foreach ($decoded as $singleSortRule) { + $this->assertSortValue($singleSortRule); + } + return $decoded; + } + + /** + * @throws InvalidArgumentException + */ + protected function assertFilterValue(mixed $filter): void { + if (!is_array($filter) + || !isset($filter['columnId'], $filter['operator'], $filter['value']) + || count($filter) !== 3 + ) { + throw new InvalidArgumentException('Invalid filter supplied'); + } + // values higher than PHP_INT_MAX will be capped to PHP_INT_MAX on cast, + // checking it roughly is sufficient. + // the lower value boundary is the lowest meta column id in \OCA\Tables\Db\Column + $maxDigits = strlen((string)PHP_INT_MAX); + if (!is_numeric($filter['columnId']) + || (int)$filter['columnId'] < -5 + || !preg_match('/^-?\\d{0,' . $maxDigits . '}$/', (string)$filter['columnId']) + ) { + throw new InvalidArgumentException(sprintf('Invalid column id supplied: %s', (string)$filter['columnId'])); + } + if (!in_array($filter['operator'], [ + 'begins-with', + 'ends-with', + 'contains', + 'is-equal', + 'is-greater-than', + 'is-greater-than-or-equal', + 'is-lower-than', + 'is-lower-than-or-equal', + 'is-empty', + ], true)) { + throw new InvalidArgumentException('Invalid filter operator supplied'); + } + } + + /** + * @throws InvalidArgumentException + */ + protected function assertSortValue(mixed $sort): void { + if (!is_array($sort) + || !isset($sort['columnId'], $sort['mode']) + || count($sort) !== 2 + ) { + throw new InvalidArgumentException('Invalid sort data supplied'); + } + // values higher than PHP_INT_MAX will be capped to PHP_INT_MAX on cast, + // checking it roughly is sufficient. + // the lower value boundary is the lowest meta column id in \OCA\Tables\Db\Column + $maxDigits = strlen((string)PHP_INT_MAX); + if (!is_numeric($sort['columnId']) + || (int)$sort['columnId'] < -5 + || !preg_match('/^-?\\d{0,' . $maxDigits . '}$/', (string)$sort['columnId']) + ) { + throw new InvalidArgumentException('Invalid column id supplied'); + } + if ($sort['mode'] !== 'DESC' && $sort['mode'] !== 'ASC') { + throw new InvalidArgumentException('Invalid sort mode supplied'); + } + } } diff --git a/lib/Db/LegacyRowMapper.php b/lib/Db/LegacyRowMapper.php index 95f04a2bb7..b39859ca07 100644 --- a/lib/Db/LegacyRowMapper.php +++ b/lib/Db/LegacyRowMapper.php @@ -82,7 +82,7 @@ public function find(int $id): LegacyRow { return $this->findEntity($qb); } - private function buildFilterByColumnType($qb, array $filter, string $filterId): ?IQueryFunction { + private function buildFilterByColumnType(IQueryBuilder $qb, array $filter, string $filterId): ?IQueryFunction { try { $columnQbClassName = 'OCA\Tables\Db\ColumnTypes\\'; $type = explode('-', $filter['columnType'])[0]; diff --git a/lib/Db/Row2Mapper.php b/lib/Db/Row2Mapper.php index 7ec48500f9..b8dffd2ec8 100644 --- a/lib/Db/Row2Mapper.php +++ b/lib/Db/Row2Mapper.php @@ -161,7 +161,7 @@ private function getWantedRowIds(string $userId, int $tableId, ?array $filter = throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage(), ); } - return array_map(fn (array $item) => $item['id'], $result->fetchAll()); + return array_map(static fn (array $item) => $item['id'], $result->fetchAll()); } /** diff --git a/lib/Db/RowCellMapperSuper.php b/lib/Db/RowCellMapperSuper.php index be377c5d87..460e2095e8 100644 --- a/lib/Db/RowCellMapperSuper.php +++ b/lib/Db/RowCellMapperSuper.php @@ -41,7 +41,10 @@ public function formatRowData(Column $column, array $row) { * Transform value from a filter rule to the actual query parameter used * for constructing the view filter query */ - public function filterValueToQueryParam(Column $column, mixed $value): mixed { + /** + * @param array|float|null|string $value + */ + public function filterValueToQueryParam(Column $column, array|string|float|null $value): mixed { return $value; } diff --git a/lib/Db/RowCellSuper.php b/lib/Db/RowCellSuper.php index 29c3e366bb..58fc632fa6 100644 --- a/lib/Db/RowCellSuper.php +++ b/lib/Db/RowCellSuper.php @@ -60,7 +60,7 @@ public function setColumnIdWrapper(int $columnId) { $this->setColumnId($columnId); } - public function setValueWrapper($value) { + public function setValueWrapper(array|float|null $value) { $this->setValue($value); } } diff --git a/lib/Db/RowQuery.php b/lib/Db/RowQuery.php new file mode 100644 index 0000000000..c307071b93 --- /dev/null +++ b/lib/Db/RowQuery.php @@ -0,0 +1,77 @@ +nodeType; + } + + public function getNodeId(): int { + return $this->nodeId; + } + + public function getUserId(): ?string { + return $this->userId; + } + + public function setUserId(?string $userId): self { + $this->userId = $userId; + return $this; + } + + public function getLimit(): ?int { + return $this->limit; + } + + public function setLimit(?int $limit): self { + $this->limit = $limit; + return $this; + } + + public function getOffset(): ?int { + return $this->offset; + } + + public function setOffset(?int $offset): self { + $this->offset = $offset; + return $this; + } + + public function getFilter(): ?array { + return $this->filter; + } + + public function setFilter(?array $filter): self { + $this->filter = $filter; + return $this; + } + + public function getSort(): ?array { + return $this->sort; + } + + public function setSort(?array $sort): self { + $this->sort = $sort; + return $this; + } +} diff --git a/lib/Model/FilterInput.php b/lib/Model/FilterInput.php new file mode 100644 index 0000000000..ec96cd3c57 --- /dev/null +++ b/lib/Model/FilterInput.php @@ -0,0 +1,15 @@ +getParam('filter', '[]'); + $this->filter = json_decode($value, true) ?? []; + } +} diff --git a/lib/Model/RowDataInput.php b/lib/Model/RowDataInput.php index dbef7fde0b..21f54cd2f9 100644 --- a/lib/Model/RowDataInput.php +++ b/lib/Model/RowDataInput.php @@ -24,7 +24,10 @@ class RowDataInput implements ArrayAccess, Iterator { /** @psalm-var array */ protected array $data = []; - public function add(int $columnId, mixed $value): self { + /** + * @param array|float|int|null|string $value + */ + public function add(int $columnId, array|string|int|float|null $value): self { $this->data[] = [self::DATA_KEY => $columnId, self::DATA_VAL => $value]; return $this; } diff --git a/lib/Service/ImportService.php b/lib/Service/ImportService.php index 42122ff45d..d241b96194 100644 --- a/lib/Service/ImportService.php +++ b/lib/Service/ImportService.php @@ -698,7 +698,10 @@ private function upsertRow(Row $row, array $columnBusinesses): void { } } - private function valueToDateTimeImmutable(mixed $value): ?DateTimeImmutable { + /** + * @param null|string $value + */ + private function valueToDateTimeImmutable(string|null $value): ?DateTimeImmutable { if ( $value === false || $value === null diff --git a/lib/Service/RowService.php b/lib/Service/RowService.php index 252e428118..60007fb425 100644 --- a/lib/Service/RowService.php +++ b/lib/Service/RowService.php @@ -8,10 +8,12 @@ namespace OCA\Tables\Service; use OCA\Tables\Activity\ActivityManager; +use OCA\Tables\AppInfo\Application; use OCA\Tables\Db\Column; use OCA\Tables\Db\ColumnMapper; use OCA\Tables\Db\Row2; use OCA\Tables\Db\Row2Mapper; +use OCA\Tables\Db\RowQuery; use OCA\Tables\Db\Table; use OCA\Tables\Db\TableMapper; use OCA\Tables\Db\View; @@ -85,6 +87,81 @@ public function formatRowsForPublicShare(array $rows): array { }, $rows); } + /** + * Fetch rows for a table or view, applying the given filter and sort rules. + * + * When reading from a view, the provided filter is added to each of the + * view's filter groups so that the view's base rules are always enforced. + * A provided sort order overrides the view's default sort order; the view + * default is only used when no sort order is given. + * + * @return Row2[] + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + * @throws InternalError + */ + public function findAllByQuery(RowQuery $rowQuery): array { + $tableId = $rowQuery->getNodeId(); + $userId = $rowQuery->getUserId() ?? ''; + $filter = $rowQuery->getFilter(); + $sort = $rowQuery->getSort(); + + if ($rowQuery->getNodeType() === Application::NODE_TYPE_VIEW) { + $view = $this->viewMapper->find($rowQuery->getNodeId()); + $tableId = $view->getTableId(); + $showColumnIds = $view->getColumnIds(); + + $filter = $this->mergeFilterWithViewFilter($filter, $view->getFilterArray()); + + if ($sort === null) { + $sort = $view->getSortArray(); + } + + $userId = $this->resolveFilterUserId($userId, $view); + } else { + $tableColumns = $this->columnMapper->findAllByTable($tableId); + $showColumnIds = array_map(static fn (Column $column) => $column->getId(), $tableColumns); + } + + return $this->row2Mapper->findAll( + $showColumnIds, + $tableId, + $rowQuery->getLimit(), + $rowQuery->getOffset(), + $filter, + $sort, + $userId, + ); + } + + /** + * Combine a user supplied filter with a view's base filter. + * + * A filter is a list of OR-connected groups, each group being a list of + * AND-connected conditions. To enforce the view's rules while also applying + * the user's filter, the user's conditions are appended to every base + * group, resulting in (group AND userFilter) OR ... When the view has no + * base filter, the user's filter is used as-is. + * + * @param list>|null $filter + * @param list> $viewFilter + * @return list> + */ + private function mergeFilterWithViewFilter(?array $filter, array $viewFilter): array { + if ($filter === null || $filter === []) { + return $viewFilter; + } + if ($viewFilter === []) { + return $filter; + } + $userConditions = array_merge(...$filter); + $merged = []; + foreach ($viewFilter as $group) { + $merged[] = array_merge($group, $userConditions); + } + return $merged; + } + /** * @param int $tableId * @param string $userId diff --git a/lib/Service/ValueObject/ColumnOrderInformation.php b/lib/Service/ValueObject/ColumnOrderInformation.php index e7d0a12945..f0b4b4192b 100644 --- a/lib/Service/ValueObject/ColumnOrderInformation.php +++ b/lib/Service/ValueObject/ColumnOrderInformation.php @@ -84,7 +84,7 @@ public function jsonSerialize(): array { ]; } - protected function ensureType(string $offset, mixed $value): int|bool { + protected function ensureType(string $offset, bool|int $value): int|bool { return match ($offset) { self::KEY_ID, self::KEY_ORDER => (int)$value, diff --git a/tests/integration/features/RowOCS.feature b/tests/integration/features/RowOCS.feature new file mode 100644 index 0000000000..1a71c84d3d --- /dev/null +++ b/tests/integration/features/RowOCS.feature @@ -0,0 +1,298 @@ +# SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +Feature: RowOCS + Background: + Given user "participant1-v2" exists + Given user "participant2-v2" exists + Given user "participant3-v2" exists + Given table "Table 1 via api v2" with emoji "👋" exists for user "participant1-v2" as "t1" via v2 + And column "one" exists with following properties + | type | text | + | subtype | line | + | mandatory | 0 | + And column "two" exists with following properties + | type | number | + | mandatory | 1 | + | numberDefault | 10 | + And column "three" exists with following properties + | type | selection | + | subtype | check | + | mandatory | 1 | + And column "four" exists with following properties + | type | datetime | + | subtype | date | + | mandatory | 0 | + And column "five" exists with following properties + | type | usergroup | + | mandatory | 1 | + | usergroupMultipleItems | true | + | usergroupSelectUsers | true | + | usergroupSelectGroups | false | + | usergroupSelectTeams | false | + And using "table" "t1" + And user "participant1-v2" creates row "r1" with following values: + | one | Row one | + | two | 1600 | + | three | true | + | four | 2025-01-01 | + | five | [{"id": "alice", "type": 0}] | + And user "participant1-v2" creates row "r2" with following values: + | one | Row two | + | two | 1604 | + | three | false | + | four | 2025-01-12 | + | five | [{"id": "bob", "type": 0},{"id": "clarence", "type": 0}] | + And user "participant1-v2" creates row "r3" with following values: + | one | Row three | + | two | 1628 | + | three | true | + | four | 2025-01-23 | + | five | [{"id": "dany", "type": 0}] | + And user "participant1-v2" creates row "r4" with following values: + | one | Row four | + | two | 1669 | + | three | false | + | four | 2025-02-03 | + | five | [{"id": "elias", "type": 0},{"id": "fran", "type": 0}] | + And user "participant1-v2" creates row "r5" with following values: + | one | Row five | + | two | 1711 | + | three | true | + | four | 2025-02-14 | + | five | [{"id": "george", "type": 0},{"id": "hannah", "type": 0},{"id": "ines", "type": 0}] | + And user "participant1-v2" creates row "r6" with following values: + | one | Row six | + | two | 1729 | + | three | true | + | four | 2025-02-25 | + | five | [{"id": "jamie", "type": 0}] | + And user "participant1-v2" creates row "r7" with following values: + | one | Row seven | + | two | 1794 | + | three | false | + | four | 2025-03-08 | + | five | [{"id": "kate", "type": 0},{"id": "lena", "type": 0}] | + And user "participant1-v2" creates row "r8" with following values: + | one | Row eight | + | two | 1827 | + | three | false | + | four | 2025-03-19 | + | five | [{"id": "moe", "type": 0}] | + And user "participant1-v2" creates row "r9" with following values: + | one | Row nine | + | two | 1924 | + | three | true | + | four | 2025-03-30 | + | five | [{"id": "nora", "type": 0}] | + And user "participant1-v2" creates row "r10" with following values: + | one | Row ten | + | two | 1994 | + | three | true | + | four | 2025-04-10 | + | five | [{"id": "otto", "type": 0},{"id": "pierre", "type": 0}] | + And user "participant1-v2" creates row "r11" with following values: + | one | Row eleven | + | two | 2006 | + | three | true | + | four | 2025-04-21 | + | five | [{"id": "quinn", "type": 0},{"id": "roberta", "type": 0}] | + And user "participant1-v2" creates row "r12" with following values: + | one | Row twelve | + | two | 2023 | + | three | false | + | four | 2025-05-05 | + | five | [{"id": "samir", "type": 0},{"id": "teresa", "type": 0}] | + And user "participant1-v2" creates row "r13" with following values: + | one | Row thirteen | + | two | 2061 | + | three | false | + | four | 2025-05-16 | + | five | [{"id": "udai", "type": 0},{"id": "vera", "type": 0},{"id": "xuan", "type": 0}] | + And user "participant1-v2" creates row "r14" with following values: + | one | Row fourteen | + | two | 2083 | + | three | false | + | four | 2025-05-27 | + | five | [{"id": "yvonne", "type": 0},{"id": "zara", "type": 0},{"id": "ahmad", "type": 0}] | + And user "participant1-v2" creates row "r15" with following values: + | one | Row fifteen | + | two | 2137 | + | three | true | + | four | 2025-06-07 | + | five | [{"id": "bertram", "type": 0}] | + And user "participant1-v2" shares table with user "participant2-v2" + And user "participant1-v2" create view "v1" with emoji "⚡️" for "t1" as "v1" + And user "participant1-v2" shares view "v1" with "participant3-v2" + + @tables + Scenario: Get all rows from a table + Given as user "participant2-v2" + When the current user fetches all rows from "table" "t1" + Then the reported status is 200 + And 15 rows have been loaded + + @views + Scenario: Get all rows from a view + Given as user "participant3-v2" + When the current user fetches all rows from "view" "v1" + Then the reported status is 200 + And 15 rows have been loaded + + @tables @views + Scenario Outline: Get rows from a table or view with an out-of-bounds offset + Given as user "" + When the current user fetches rows from "" "" with those parameters + | offset | | + Then the reported status is + And rows have been loaded + + Examples: + | user | type | alias | offset | responseCode | rowsReturned | + | participant2-v2 | table | t1 | -1 | 400 | 0 | + | participant3-v2 | view | v1 | -1 | 400 | 0 | + | participant2-v2 | table | t1 | 200 | 200 | 0 | + | participant3-v2 | view | v1 | 200 | 200 | 0 | + + @tables @views + Scenario Outline: Get rows from a table or view with an offset + Given as user "" + When the current user fetches rows from "" "" with those parameters + | offset | 5 | + Then the reported status is 200 + And 10 rows have been loaded + And rows "r1,r2,r3,r4,r5" are not included in the response + + Examples: + | user | type | alias | + | participant2-v2 | table | t1 | + | participant3-v2 | view | v1 | + + @tables @views + Scenario Outline: Get rows from a table or view with an out-of-bounds limit + Given as user "" + When the current user fetches rows from "" "" with those parameters + | limit | | + Then the reported status is + And rows have been loaded + + Examples: + | user | type | alias | limit | responseCode | rowsReturned | + | participant2-v2 | table | t1 | -1 | 400 | 0 | + | participant3-v2 | view | v1 | -1 | 400 | 0 | + | participant2-v2 | table | t1 | 0 | 400 | 0 | + | participant3-v2 | view | v1 | 0 | 400 | 0 | + | participant2-v2 | table | t1 | 555 | 400 | 0 | + | participant3-v2 | view | v1 | 555 | 400 | 0 | + + @tables @views + Scenario Outline: Get rows from a table or view with a limit + Given as user "" + When the current user fetches rows from "" "" with those parameters + | limit | 5 | + Then the reported status is 200 + And 5 rows have been loaded + And rows "r6,r7,r8,r9,r10,r11,r12,r13,r14,r15" are not included in the response + + Examples: + | user | type | alias | + | participant2-v2 | table | t1 | + | participant3-v2 | view | v1 | + + @tables @views + Scenario Outline: Get rows from a table or view with a text filter + Given as user "" + When the current user fetches rows from "" "" with those parameters + | filter | one,contains,t | + Then the reported status is 200 + And 8 rows have been loaded + And rows "r2,r3,r8,r10,r12,r13,r14,r15" are included in the response + And rows "r1,r4,r5,r6,r7,r9,r11" are not included in the response + + Examples: + | user | type | alias | + | participant2-v2 | table | t1 | + | participant3-v2 | view | v1 | + + @tables @views + Scenario Outline: Get rows from a table or view with a numeric filter + Given as user "" + When the current user fetches rows from "" "" with those parameters + | filter | two,is-greater-than,2000 | + Then the reported status is 200 + And 5 rows have been loaded + And rows "r11,r12,r13,r14,r15" are included in the response + And rows "r1,r2,r3,r4,r5,r6,r7,r8,r9,r10" are not included in the response + + Examples: + | user | type | alias | + | participant2-v2 | table | t1 | + | participant3-v2 | view | v1 | + + @tables @views + Scenario Outline: Get rows from a table or view sorted descending + Given as user "" + When the current user fetches rows from "" "" with those parameters + | sort | two,DESC | + | limit | 3 | + Then the reported status is 200 + And 3 rows have been loaded + And the rows are returned in the order "r15,r14,r13" + + Examples: + | user | type | alias | + | participant2-v2 | table | t1 | + | participant3-v2 | view | v1 | + + @tables @views + Scenario Outline: Get rows from a table or view sorted ascending + Given as user "" + When the current user fetches rows from "" "" with those parameters + | sort | two,ASC | + | limit | 3 | + Then the reported status is 200 + And 3 rows have been loaded + And the rows are returned in the order "r1,r2,r3" + + Examples: + | user | type | alias | + | participant2-v2 | table | t1 | + | participant3-v2 | view | v1 | + + @tables @views + Scenario Outline: Reject an invalid filter operator + Given as user "" + When the current user fetches rows from "" "" with those parameters + | filter | one,not-an-operator,t | + Then the reported status is 400 + + Examples: + | user | type | alias | + | participant2-v2 | table | t1 | + | participant3-v2 | view | v1 | + + @tables @views + Scenario Outline: Reject an invalid sort mode + Given as user "" + When the current user fetches rows from "" "" with those parameters + | sort | two,SIDEWAYS | + Then the reported status is 400 + + Examples: + | user | type | alias | + | participant2-v2 | table | t1 | + | participant3-v2 | view | v1 | + + @views + Scenario: A view's base filter is combined with the request filter + Given user "participant1-v2" create view "v2" with emoji "🔎" for "t1" as "v2" + And user "participant1-v2" sets filter to view "v2" + | column | operator | value | + | two | is-greater-than | 1700 | + And user "participant1-v2" shares view "v2" with "participant3-v2" + And as user "participant3-v2" + When the current user fetches rows from "view" "v2" with those parameters + | filter | one,contains,t | + Then the reported status is 200 + And 6 rows have been loaded + And rows "r8,r10,r12,r13,r14,r15" are included in the response + And rows "r2,r3" are not included in the response diff --git a/tests/integration/features/bootstrap/FeatureContext.php b/tests/integration/features/bootstrap/FeatureContext.php index 2def946228..8bd4c548ef 100644 --- a/tests/integration/features/bootstrap/FeatureContext.php +++ b/tests/integration/features/bootstrap/FeatureContext.php @@ -3284,6 +3284,113 @@ public function viewHasExactRows(string $viewName, TableNode $expectedRows): voi } } + /** + * @When the current user fetches all rows from :nodeType :nodeId + */ + public function theCurrentUserFetchesAllRowsFromCollection(string $nodeType, string $nodeAlias): void { + $nodeId = $this->collectionManager->getByAlias($nodeType, $nodeAlias)['id']; + $this->sendOcsRequest('GET', sprintf('/apps/tables/api/2/%ss/%d/rows', $nodeType, $nodeId)); + } + + /** + * @When the current user fetches rows from :nodeType :nodeId with those parameters + */ + public function theCurrentUserFetchesRowsFromWithThoseParameters(string $nodeType, string $nodeAlias, TableNode $parameters): void { + $query = ''; + foreach ($parameters->getRows() as $row) { + $parameterName = $row[0]; + if ($parameterName === 'filter') { + // `,,` is turned into a single + // filter group holding a single filter definition + [$columnAlias, $operator, $value] = explode(',', $row[1]); + $columnId = $this->collectionManager->getByAlias('column', $columnAlias)['id']; + $parameterValue = json_encode([ // all filter groups + [ // single filter group + [ // single filter definition + 'columnId' => $columnId, + 'operator' => $operator, + 'value' => $value, + ], + ], + ]); + } elseif ($parameterName === 'sort') { + // `,` is turned into a single sort rule + [$columnAlias, $mode] = explode(',', $row[1]); + $columnId = $this->collectionManager->getByAlias('column', $columnAlias)['id']; + $parameterValue = json_encode([ + [ + 'columnId' => $columnId, + 'mode' => $mode, + ], + ]); + } else { + $parameterValue = $row[1]; + } + $query .= $parameterName . '=' . urlencode($parameterValue) . '&'; + } + $nodeId = $this->collectionManager->getByAlias($nodeType, $nodeAlias)['id']; + $this->sendOcsRequest('GET', sprintf('/apps/tables/api/2/%ss/%d/rows?%s', $nodeType, $nodeId, $query)); + } + + /** + * @Given :numberOfRows rows have been loaded + */ + public function rowsHaveBeenLoaded(int $numberOfRows): void { + $responseData = $this->getDataFromResponse($this->response)['ocs']['data']; + // do not count the error message, if present + unset($responseData['message']); + Assert::assertCount($numberOfRows, $responseData); + $returnedIDs = []; + foreach ($responseData as $row) { + $returnedIDs[] = $this->collectionManager->getById('row', $row['id']); + } + $this->collectionManager->register($returnedIDs, 'returnedRowIDs', 0); + } + + /** + * @Given rows :rowAliasList are not included in the response + */ + public function rowsAreNotIncludedInTheResponse(string $rowAliasList): void { + $unexpectedRowAliases = array_map('trim', explode(',', $rowAliasList)); + $returnedRowIds = $this->collectionManager->getById('returnedRowIDs', 0); + foreach ($unexpectedRowAliases as $unexpectedRowAlias) { + $row = $this->collectionManager->getByAlias('row', $unexpectedRowAlias); + Assert::assertNotContains($row['id'], $returnedRowIds); + } + } + + /** + * @Given rows :rowAliasList are included in the response + */ + public function rowsAreIncludedInTheResponse(string $rowAliasList): void { + $unexpectedRowAliases = array_map('trim', explode(',', $rowAliasList)); + $returnedRowIds = $this->collectionManager->getById('returnedRowIDs', 0); + foreach ($unexpectedRowAliases as $unexpectedRowAlias) { + $row = $this->collectionManager->getByAlias('row', $unexpectedRowAlias); + Assert::assertContains($row['id'], $returnedRowIds); + } + } + + /** + * @Then the rows are returned in the order :rowAliasList + */ + public function theRowsAreReturnedInTheOrder(string $rowAliasList): void { + $expectedRowAliases = array_map('trim', explode(',', $rowAliasList)); + $responseData = $this->getDataFromResponse($this->response)['ocs']['data']; + // do not count the error message, if present + unset($responseData['message']); + $responseData = array_values($responseData); + Assert::assertCount(count($expectedRowAliases), $responseData); + foreach ($expectedRowAliases as $index => $expectedRowAlias) { + $expectedRow = $this->collectionManager->getByAlias('row', $expectedRowAlias); + Assert::assertSame( + $expectedRow['id'], + $responseData[$index]['id'], + sprintf('Row at position %d does not match the expected row "%s"', $index, $expectedRowAlias), + ); + } + } + /** * @Then the last created row has the following dataByAlias * diff --git a/vendor-bin/rector/composer.json b/vendor-bin/rector/composer.json new file mode 100644 index 0000000000..9e26dfeeb6 --- /dev/null +++ b/vendor-bin/rector/composer.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/vendor-bin/rector/composer.lock b/vendor-bin/rector/composer.lock new file mode 100644 index 0000000000..ba8d41762c --- /dev/null +++ b/vendor-bin/rector/composer.lock @@ -0,0 +1,18 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "d751713988987e9331980363e24189ce", + "packages": [], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": {}, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} From e1ac28b85501b5109843130436deb838e86c9be8 Mon Sep 17 00:00:00 2001 From: Kostiantyn Miakshyn Date: Sun, 23 Aug 2026 21:30:07 +0200 Subject: [PATCH 02/11] Move filtes/sorging/pagination to backend, add reload button Signed-off-by: Kostiantyn Miakshyn --- lib/Constants/FilterOperator.php | 1 + lib/Controller/PublicRowOCSController.php | 74 ++++++- lib/Controller/RowOCSController.php | 144 ++++---------- lib/Db/Row2Mapper.php | 91 ++++++++- lib/Db/RowCellSelectionMapper.php | 3 + lib/Db/RowQuery.php | 66 +++++++ lib/Service/RowService.php | 36 ++++ lib/Service/ValueObject/Filter.php | 2 +- src/modules/main/partials/TableView.vue | 5 + src/modules/main/sections/DataTable.vue | 5 + src/modules/main/sections/MainWrapper.vue | 185 +++++++++++++++++- src/modules/main/sections/PublicElement.vue | 22 ++- .../main/sections/PublicMainWrapper.vue | 159 +++++++++++++-- src/modules/main/sections/Table.vue | 6 +- src/modules/main/sections/View.vue | 5 + src/shared/components/ncTable/NcTable.vue | 152 +------------- .../ncTable/partials/TableHeader.vue | 1 + .../ncTable/sections/CustomTable.vue | 7 + .../components/ncTable/sections/Options.vue | 28 ++- .../ncTable/sections/PaginationBlock.vue | 28 ++- src/store/data.js | 147 +++++++++++++- 21 files changed, 855 insertions(+), 312 deletions(-) diff --git a/lib/Constants/FilterOperator.php b/lib/Constants/FilterOperator.php index 1bf49d8228..5b7665fea4 100644 --- a/lib/Constants/FilterOperator.php +++ b/lib/Constants/FilterOperator.php @@ -12,6 +12,7 @@ enum FilterOperator: string { case BEGINS_WITH = 'begins-with'; case ENDS_WITH = 'ends-with'; case CONTAINS = 'contains'; + case CONTAINS_ITEM = 'contains-item'; case DOES_NOT_CONTAIN = 'does-not-contain'; case IS_EQUAL = 'is-equal'; case IS_NOT_EQUAL = 'is-not-equal'; diff --git a/lib/Controller/PublicRowOCSController.php b/lib/Controller/PublicRowOCSController.php index 9ac88b6768..6a753ce1c9 100644 --- a/lib/Controller/PublicRowOCSController.php +++ b/lib/Controller/PublicRowOCSController.php @@ -8,13 +8,12 @@ namespace OCA\Tables\Controller; -use OCA\Tables\AppInfo\Application; use OCA\Tables\Db\Row2Mapper; +use OCA\Tables\Db\RowQuery; use OCA\Tables\Errors\BadRequestError; use OCA\Tables\Errors\InternalError; use OCA\Tables\Errors\NotFoundError; use OCA\Tables\Errors\PermissionError; -use OCA\Tables\Helper\ConversionHelper; use OCA\Tables\Middleware\Attribute\AssertShareAccessIsAccessible; use OCA\Tables\Model\RowDataInput; use OCA\Tables\ResponseDefinitions; @@ -67,7 +66,7 @@ public function __construct( #[ApiRoute(verb: 'GET', url: '/api/2/public/{token}/rows', requirements: ['token' => '[a-zA-Z0-9]{16}'])] #[OpenAPI] #[AnonRateLimit(limit: 20, period: 30)] - public function getRows(string $token, ?int $limit, ?int $offset): DataResponse { + public function getRows(string $token, ?int $limit, ?int $offset, ?string $filter = null, ?string $sort = null, ?string $search = null): DataResponse { try { $shareToken = new ShareToken($token); $share = $this->shareService->findByToken($shareToken); @@ -79,13 +78,18 @@ public function getRows(string $token, ?int $limit, ?int $offset): DataResponse $limit = $limit !== null ? max(0, min(500, $limit)) : null; $offset = $offset !== null ? max(0, $offset) : null; - $nodeType = ConversionHelper::stringNodeType2Const($share->getNodeType()); - if ($nodeType === Application::NODE_TYPE_TABLE) { - $rows = $this->rowService->findAllByTable($share->getNodeId(), '', $limit, $offset); - } elseif ($nodeType === Application::NODE_TYPE_VIEW) { - $rows = $this->rowService->findAllByView($share->getNodeId(), '', $limit, $offset); - } + $queryData = RowQuery::buildFromInput( + nodeType: $share->getNodeType(), + nodeId: $share->getNodeId(), + userId: '', + limit: $limit, + offset: $offset, + filter: $filter, + sort: $sort, + search: $search, + ); + $rows = $this->rowService->findAllByQuery($queryData); $formattedRows = $this->rowService->formatRowsForPublicShare($rows); return new DataResponse($formattedRows); } catch (PermissionError $e) { @@ -99,6 +103,57 @@ public function getRows(string $token, ?int $limit, ?int $offset): DataResponse } } + /** + * [api v2] Count rows from a link share + * + * @param string $token The share token + * @param string|null $filter Optional: a JSON encoded filter parameter + * @param string|null $sort Optional: a JSON encoded sort parameter + * @param string|null $search Optional: a search string + * @return DataResponse|DataResponse + * + * 200: Count is returned + * 400: Invalid request parameters + * 403: No permissions + * 404: Not found + * 500: Internal error + */ + #[PublicPage] + #[AssertShareAccessIsAccessible] + #[ApiRoute(verb: 'GET', url: '/api/2/public/{token}/rows/count', requirements: ['token' => '[a-zA-Z0-9]{16}'])] + #[OpenAPI] + #[AnonRateLimit(limit: 20, period: 30)] + public function countRows(string $token, ?string $filter = null, ?string $sort = null, ?string $search = null): DataResponse { + try { + $shareToken = new ShareToken($token); + $share = $this->shareService->findByToken($shareToken); + + if (!$share->getPermissionRead()) { + return $this->handlePermissionError(new PermissionError('No read permission on this share')); + } + + $queryData = RowQuery::buildFromInput( + nodeType: $share->getNodeType(), + nodeId: $share->getNodeId(), + userId: '', + filter: $filter, + sort: $sort, + search: $search, + ); + + $count = $this->rowService->countByQuery($queryData); + return new DataResponse(['count' => $count]); + } catch (PermissionError $e) { + return $this->handlePermissionError($e); + } catch (InternalError $e) { + return $this->handleError($e); + } catch (NotFoundError $e) { + return $this->handleNotFoundError($e); + } catch (BadRequestError $e) { + return $this->handleBadRequestError($e); + } + } + /** * [api v2] Create a row in a link share * @@ -259,4 +314,5 @@ public function deleteRow(string $token, int $rowId): DataResponse { return $this->handleError($e); } } + } diff --git a/lib/Controller/RowOCSController.php b/lib/Controller/RowOCSController.php index ad72d120b6..2738c2de90 100644 --- a/lib/Controller/RowOCSController.php +++ b/lib/Controller/RowOCSController.php @@ -139,7 +139,7 @@ public function createRow(string $nodeCollection, int $nodeId, mixed $data): Dat url: '/api/2/{nodeCollection}/{nodeId}/rows', requirements: ['nodeCollection' => '(tables|views)', 'nodeId' => '(\\d+)'] )] - public function getRows(string $nodeCollection, int $nodeId, ?int $limit = null, ?int $offset = null, ?string $filter = null, ?string $sort = null): DataResponse { + public function getRows(string $nodeCollection, int $nodeId, ?int $limit = null, ?int $offset = null, ?string $filter = null, ?string $sort = null, ?string $search = null): DataResponse { try { if (($limit !== null && ($limit <= 0 || $limit > 500)) || ($offset !== null && $offset < 0) @@ -147,17 +147,16 @@ public function getRows(string $nodeCollection, int $nodeId, ?int $limit = null, throw new InvalidArgumentException('Offset or limit parameter is out of bounds'); } - $queryData = new RowQuery( - nodeType: $nodeCollection === 'tables' ? Application::NODE_TYPE_TABLE : Application::NODE_TYPE_VIEW, + $queryData = RowQuery::buildFromInput( + nodeType: $nodeCollection, nodeId: $nodeId, + userId: $this->userId, + limit: $limit, + offset: $offset, + filter: $filter, + sort: $sort, + search: $search, ); - $queryData->setLimit($limit) - ->setOffset($offset) - // the provided filter is set here; any filter defined on a view - // is merged in on the service level - ->setFilter($this->parseFilter($filter)) - ->setSort($this->parseSort($sort)) - ->setUserId($this->userId); $rows = $this->rowService->findAllByQuery($queryData); return new DataResponse($this->rowService->formatRows($rows)); @@ -170,108 +169,33 @@ public function getRows(string $nodeCollection, int $nodeId, ?int $limit = null, } } - /** - * Decode and validate the JSON encoded filter parameter. - * - * @return list>|null - * @throws InvalidArgumentException - */ - protected function parseFilter(?string $filter): ?array { - if ($filter === null || $filter === '') { - return null; - } - $decoded = json_decode($filter, true); - if (!is_array($decoded)) { - throw new InvalidArgumentException('Invalid filter supplied'); - } - foreach ($decoded as $filterGroup) { - if (!is_array($filterGroup)) { - throw new InvalidArgumentException('Invalid filter supplied'); - } - foreach ($filterGroup as $singleFilter) { - $this->assertFilterValue($singleFilter); - } - } - return $decoded; - } - - /** - * Decode and validate the JSON encoded sort parameter. - * - * @return list|null - * @throws InvalidArgumentException - */ - protected function parseSort(?string $sort): ?array { - if ($sort === null || $sort === '') { - return null; - } - $decoded = json_decode($sort, true); - if (!is_array($decoded)) { - throw new InvalidArgumentException('Invalid sort data supplied'); - } - foreach ($decoded as $singleSortRule) { - $this->assertSortValue($singleSortRule); - } - return $decoded; - } + #[NoAdminRequired] + #[RequirePermission(permission: Application::PERMISSION_READ, typeParam: 'nodeCollection')] + #[ApiRoute( + verb: 'GET', + url: '/api/2/{nodeCollection}/{nodeId}/rows/count', + requirements: ['nodeCollection' => '(tables|views)', 'nodeId' => '(\\d+)'] + )] + public function countRows(string $nodeCollection, int $nodeId, ?string $filter = null, ?string $sort = null, ?string $search = null): DataResponse { + try { + $queryData = RowQuery::buildFromInput( + nodeType: $nodeCollection, + nodeId: $nodeId, + userId: $this->userId, + filter: $filter, + sort: $sort, + search: $search, + ); - /** - * @throws InvalidArgumentException - */ - protected function assertFilterValue(mixed $filter): void { - if (!is_array($filter) - || !isset($filter['columnId'], $filter['operator'], $filter['value']) - || count($filter) !== 3 - ) { - throw new InvalidArgumentException('Invalid filter supplied'); - } - // values higher than PHP_INT_MAX will be capped to PHP_INT_MAX on cast, - // checking it roughly is sufficient. - // the lower value boundary is the lowest meta column id in \OCA\Tables\Db\Column - $maxDigits = strlen((string)PHP_INT_MAX); - if (!is_numeric($filter['columnId']) - || (int)$filter['columnId'] < -5 - || !preg_match('/^-?\\d{0,' . $maxDigits . '}$/', (string)$filter['columnId']) - ) { - throw new InvalidArgumentException(sprintf('Invalid column id supplied: %s', (string)$filter['columnId'])); - } - if (!in_array($filter['operator'], [ - 'begins-with', - 'ends-with', - 'contains', - 'is-equal', - 'is-greater-than', - 'is-greater-than-or-equal', - 'is-lower-than', - 'is-lower-than-or-equal', - 'is-empty', - ], true)) { - throw new InvalidArgumentException('Invalid filter operator supplied'); + $count = $this->rowService->countByQuery($queryData); + return new DataResponse(['count' => $count]); + } catch (DoesNotExistException $e) { + return $this->handleNotFoundError(new NotFoundError($e->getMessage(), $e->getCode(), $e)); + } catch (MultipleObjectsReturnedException|InvalidArgumentException $e) { + return $this->handleBadRequestError(new BadRequestError($e->getMessage(), $e->getCode(), $e)); + } catch (InternalError|\Exception $e) { + return $this->handleError($e); } } - /** - * @throws InvalidArgumentException - */ - protected function assertSortValue(mixed $sort): void { - if (!is_array($sort) - || !isset($sort['columnId'], $sort['mode']) - || count($sort) !== 2 - ) { - throw new InvalidArgumentException('Invalid sort data supplied'); - } - // values higher than PHP_INT_MAX will be capped to PHP_INT_MAX on cast, - // checking it roughly is sufficient. - // the lower value boundary is the lowest meta column id in \OCA\Tables\Db\Column - $maxDigits = strlen((string)PHP_INT_MAX); - if (!is_numeric($sort['columnId']) - || (int)$sort['columnId'] < -5 - || !preg_match('/^-?\\d{0,' . $maxDigits . '}$/', (string)$sort['columnId']) - ) { - throw new InvalidArgumentException('Invalid column id supplied'); - } - if ($sort['mode'] !== 'DESC' && $sort['mode'] !== 'ASC') { - throw new InvalidArgumentException('Invalid sort mode supplied'); - } - } } diff --git a/lib/Db/Row2Mapper.php b/lib/Db/Row2Mapper.php index b8dffd2ec8..5420466c3c 100644 --- a/lib/Db/Row2Mapper.php +++ b/lib/Db/Row2Mapper.php @@ -132,7 +132,7 @@ public function setUserId(string $userId): void { * @return int[] * @throws InternalError */ - private function getWantedRowIds(string $userId, int $tableId, ?array $filter = null, ?array $sort = null, ?int $limit = null, ?int $offset = null): array { + private function getWantedRowIds(string $userId, int $tableId, ?array $filter = null, ?array $sort = null, ?int $limit = null, ?int $offset = null, ?array $showColumnIds = null, ?string $search = null): array { $qb = $this->db->getQueryBuilder(); $qb->select('sleeves.id') @@ -143,6 +143,10 @@ private function getWantedRowIds(string $userId, int $tableId, ?array $filter = $this->addFilterToQuery($qb, $filter, $userId); } + if ($search !== null && $search !== '' && $showColumnIds) { + $this->addSearchToQuery($qb, $search, $showColumnIds); + } + $this->addSortQueryForMultipleSleeveFinder($qb, 'sleeves', $sort); $qb->groupBy('sleeves.id'); @@ -175,11 +179,11 @@ private function getWantedRowIds(string $userId, int $tableId, ?array $filter = * @return Row2[] * @throws InternalError */ - public function findAll(array $showColumnIds, int $tableId, ?int $limit = null, ?int $offset = null, ?array $filter = null, ?array $sort = null, ?string $userId = null): array { + public function findAll(array $showColumnIds, int $tableId, ?int $limit = null, ?int $offset = null, ?array $filter = null, ?array $sort = null, ?string $search = null, ?string $userId = null): array { try { $this->columnMapper->preloadColumns($showColumnIds, $filter, $sort); - $wantedRowIdsArray = $this->getWantedRowIds($userId, $tableId, $filter, $sort, $limit, $offset); + $wantedRowIdsArray = $this->getWantedRowIds($userId ?? '', $tableId, $filter, $sort, $limit, $offset); // Get rows without SQL sorting $rows = $this->getRows($wantedRowIdsArray, $showColumnIds); @@ -192,6 +196,29 @@ public function findAll(array $showColumnIds, int $tableId, ?int $limit = null, } } + /** + * @param int[] $showColumnIds + * @param int $tableId + * @param array|null $filter + * @param array|null $sort + * @param string|null $search + * @param string|null $userId + * @return int + * @throws InternalError + */ + public function count(array $showColumnIds, int $tableId, ?array $filter = null, ?array $sort = null, ?string $search = null, ?string $userId = null): int { + try { + $this->columnMapper->preloadColumns($showColumnIds, $filter, $sort); + + $wantedRowIdsArray = $this->getWantedRowIds($userId ?? '', $tableId, $filter, $sort, null, null, $showColumnIds, $search); + + return count($wantedRowIdsArray); + } catch (DoesNotExistException $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); + } + } + /** * @param array $rowIds * @param array $columnIds @@ -486,6 +513,23 @@ private function getFilterExpression(IQueryBuilder $qb, Column $column, string $ } $filterExpression = $qb->expr()->like('value', $qb->createNamedParameter('%' . $this->db->escapeLikeParameter($value) . '%', $paramType)); break; + case 'contains-item': + $values = is_array($value) ? $value : [$value]; + if ($column->getType() === 'selection' && $column->getSubtype() === 'multi') { + $filterExpressions = []; + foreach ($values as $singleValue) { + $singleValue = (string)$singleValue; + $filterExpressions[] = $qb2->expr()->like('value', $qb->createNamedParameter('[' . $this->db->escapeLikeParameter($singleValue) . ']', IQueryBuilder::PARAM_STR)); + $filterExpressions[] = $qb2->expr()->like('value', $qb->createNamedParameter('[' . $this->db->escapeLikeParameter($singleValue) . ',%')); + $filterExpressions[] = $qb2->expr()->like('value', $qb->createNamedParameter('%,' . $this->db->escapeLikeParameter($singleValue) . ']%')); + $filterExpressions[] = $qb2->expr()->like('value', $qb->createNamedParameter('%,' . $this->db->escapeLikeParameter($singleValue) . ',%')); + } + $filterExpression = $qb2->expr()->orX(...$filterExpressions); + } else { + $filterExpression = $qb2->expr()->in('value', $qb->createNamedParameter($values, IQueryBuilder::PARAM_STR_ARRAY)); + } + $includeDefault = !empty(array_intersect((array)($defaultValue ?? []), $values)); + break; case 'does-not-contain': if (is_array($value) && $column->getType() === Column::TYPE_USERGROUP) { $filterExpressions = []; @@ -593,7 +637,7 @@ private function getFilterExpression(IQueryBuilder $qb, Column $column, string $ /** * @throws InternalError */ - private function getMetaFilterExpression(IQueryBuilder $qb, int $columnId, string $operator, string $value): IQueryBuilder { + private function getMetaFilterExpression(IQueryBuilder $qb, int $columnId, string $operator, string|array $value): IQueryBuilder { $qb2 = $this->db->getQueryBuilder(); $qb2->select('id'); $qb2->from('tables_row_sleeves'); @@ -1029,4 +1073,43 @@ private function sortRowsByIds(array $rows, array $wantedRowIds): array { return $sortedRows; } + + private function addSearchToQuery(IQueryBuilder $qb, string $search, array $showColumnIds): void { + $qb->andWhere( + $qb->expr()->in( + 'sleeves.id', + $qb->createFunction($this->getSearchSubquery($qb, $search, $showColumnIds)->getSQL()) + ) + ); + } + + private function getSearchSubquery(IQueryBuilder $qb, string $search, array $showColumnIds): IQueryBuilder { + $searchParam = $qb->createNamedParameter('%' . $this->db->escapeLikeParameter($search) . '%', IQueryBuilder::PARAM_STR, ':search'); + $columnIdsParam = $qb->createNamedParameter($showColumnIds, IQueryBuilder::PARAM_INT_ARRAY, ':searchColumnIds'); + + $qbSqlForColumnTypes = null; + foreach ($this->columnsHelper->columns as $columnType) { + $qbTmp = $this->db->getQueryBuilder(); + $qbTmp->select('row_id') + ->from('tables_row_cells_' . $columnType) + ->where($qb->expr()->in('column_id', $columnIdsParam)) + ->andWhere($qb->expr()->like( + $qb->expr()->castColumn('value', IQueryBuilder::PARAM_STR), + $searchParam + )); + + if ($qbSqlForColumnTypes) { + $qbSqlForColumnTypes .= ' UNION ALL ' . $qbTmp->getSQL() . ' '; + } else { + $qbSqlForColumnTypes = '(' . $qbTmp->getSQL(); + } + } + $qbSqlForColumnTypes .= ')'; + + $searchQb = $this->db->getQueryBuilder(); + $searchQb->select('row_id') + ->from($qb->createFunction($qbSqlForColumnTypes), 't_search'); + + return $searchQb; + } } diff --git a/lib/Db/RowCellSelectionMapper.php b/lib/Db/RowCellSelectionMapper.php index eba5332f1e..9c7e47045c 100644 --- a/lib/Db/RowCellSelectionMapper.php +++ b/lib/Db/RowCellSelectionMapper.php @@ -22,6 +22,9 @@ public function __construct(IDBConnection $db) { } public function filterValueToQueryParam(Column $column, mixed $value): mixed { + if (is_array($value)) { + return $value; + } return $this->valueToJsonDbValue($column, $value); } diff --git a/lib/Db/RowQuery.php b/lib/Db/RowQuery.php index c307071b93..c28aa9f4a5 100644 --- a/lib/Db/RowQuery.php +++ b/lib/Db/RowQuery.php @@ -9,12 +9,18 @@ namespace OCA\Tables\Db; +use InvalidArgumentException; +use OCA\Tables\Helper\ConversionHelper; +use OCA\Tables\Model\FilterSet; +use OCA\Tables\Model\SortRuleSet; + class RowQuery { protected ?string $userId = null; protected ?int $limit = null; protected ?int $offset = null; protected ?array $filter = null; protected ?array $sort = null; + protected ?string $search = null; public function __construct( protected int $nodeType, @@ -74,4 +80,64 @@ public function setSort(?array $sort): self { $this->sort = $sort; return $this; } + + public function getSearch(): ?string { + return $this->search; + } + + public function setSearch(?string $search): self { + $this->search = $search; + return $this; + } + + /** + * Build a RowQuery from request parameters. + * + * @return self + * @throws InvalidArgumentException + */ + public static function buildFromInput(string $nodeType, int $nodeId, string $userId, ?int $limit = null, ?int $offset = null, ?string $filter = null, ?string $sort = null, ?string $search = null): self { + $rowQuery = new self(ConversionHelper::stringNodeType2Const($nodeType), $nodeId); + $rowQuery->setLimit($limit) + ->setOffset($offset) + ->setFilter(self::parseFilter($filter)) + ->setSort(self::parseSort($sort)) + ->setSearch($search !== '' && $search !== null ? $search : null) + ->setUserId($userId); + return $rowQuery; + } + + /** + * Decode and validate the JSON encoded filter parameter. + * + * @return list}>>|null + * @throws InvalidArgumentException + */ + private static function parseFilter(?string $filter): ?array { + if ($filter === null || $filter === '') { + return null; + } + $decoded = json_decode($filter, true); + if (!is_array($decoded)) { + throw new InvalidArgumentException('Invalid filter supplied'); + } + return FilterSet::createFromInputArray($decoded)->jsonSerialize(); + } + + /** + * Decode and validate the JSON encoded sort parameter. + * + * @return list|null + * @throws InvalidArgumentException + */ + private static function parseSort(?string $sort): ?array { + if ($sort === null || $sort === '') { + return null; + } + $decoded = json_decode($sort, true); + if (!is_array($decoded)) { + throw new InvalidArgumentException('Invalid sort data supplied'); + } + return SortRuleSet::createFromInputArray($decoded)->jsonSerialize(); + } } diff --git a/lib/Service/RowService.php b/lib/Service/RowService.php index 60007fb425..8e2da31903 100644 --- a/lib/Service/RowService.php +++ b/lib/Service/RowService.php @@ -105,6 +105,7 @@ public function findAllByQuery(RowQuery $rowQuery): array { $userId = $rowQuery->getUserId() ?? ''; $filter = $rowQuery->getFilter(); $sort = $rowQuery->getSort(); + $search = $rowQuery->getSearch(); if ($rowQuery->getNodeType() === Application::NODE_TYPE_VIEW) { $view = $this->viewMapper->find($rowQuery->getNodeId()); @@ -130,6 +131,41 @@ public function findAllByQuery(RowQuery $rowQuery): array { $rowQuery->getOffset(), $filter, $sort, + $search, + $userId, + ); + } + + public function countByQuery(RowQuery $rowQuery): int { + $tableId = $rowQuery->getNodeId(); + $userId = $rowQuery->getUserId() ?? ''; + $filter = $rowQuery->getFilter(); + $sort = $rowQuery->getSort(); + $search = $rowQuery->getSearch(); + + if ($rowQuery->getNodeType() === Application::NODE_TYPE_VIEW) { + $view = $this->viewMapper->find($rowQuery->getNodeId()); + $tableId = $view->getTableId(); + $showColumnIds = $view->getColumnIds(); + + $filter = $this->mergeFilterWithViewFilter($filter, $view->getFilterArray()); + + if ($sort === null) { + $sort = $view->getSortArray(); + } + + $userId = $this->resolveFilterUserId($userId, $view); + } else { + $tableColumns = $this->columnMapper->findAllByTable($tableId); + $showColumnIds = array_map(static fn (Column $column) => $column->getId(), $tableColumns); + } + + return $this->row2Mapper->count( + $showColumnIds, + $tableId, + $filter, + $sort, + $search, $userId, ); } diff --git a/lib/Service/ValueObject/Filter.php b/lib/Service/ValueObject/Filter.php index 08c476241e..9194c90471 100644 --- a/lib/Service/ValueObject/Filter.php +++ b/lib/Service/ValueObject/Filter.php @@ -16,7 +16,7 @@ class Filter implements JsonSerializable { public function __construct( protected readonly int $columnId, protected readonly FilterOperator $operator, - protected readonly string $value, + protected readonly string|array $value, ) { } diff --git a/src/modules/main/partials/TableView.vue b/src/modules/main/partials/TableView.vue index 3b99b004e3..533cf00f61 100644 --- a/src/modules/main/partials/TableView.vue +++ b/src/modules/main/partials/TableView.vue @@ -6,6 +6,7 @@ [], }, + totalRows: { + type: Number, + default: null, + }, columns: { type: Array, default: () => [], diff --git a/src/modules/main/sections/DataTable.vue b/src/modules/main/sections/DataTable.vue index c72f8d7734..3fa20da62e 100644 --- a/src/modules/main/sections/DataTable.vue +++ b/src/modules/main/sections/DataTable.vue @@ -77,6 +77,7 @@ v-model:view-setting="localViewSetting" v-model:selected-rows="localSelectedRows" :rows="rows" + :total-rows="totalRows" :columns="columns" :element="table" :is-view="false" @@ -212,6 +213,10 @@ export default { type: Array, default: null, }, + totalRows: { + type: Number, + default: null, + }, viewSetting: { type: Object, default: null, diff --git a/src/modules/main/sections/MainWrapper.vue b/src/modules/main/sections/MainWrapper.vue index a0d60f6763..b55fb3e8ac 100644 --- a/src/modules/main/sections/MainWrapper.vue +++ b/src/modules/main/sections/MainWrapper.vue @@ -11,7 +11,9 @@ :view="element" :columns="columns" :rows="rows" + :total-rows="totalRows" :view-setting="viewSetting" + @update:viewSetting="viewSetting = $event" @create-column="createColumn" @import="openImportModal" @download-csv="downloadCSV" @@ -22,7 +24,9 @@ :table="element" :columns="columns" :rows="rows" + :total-rows="totalRows" :view-setting="viewSetting" + @update:viewSetting="viewSetting = $event" @create-column="createColumn" @import="openImportModal" @download-csv="downloadCSV" @@ -36,7 +40,7 @@ diff --git a/src/modules/main/sections/PublicMainWrapper.vue b/src/modules/main/sections/PublicMainWrapper.vue index 53bd3cc764..8a2f66d6db 100644 --- a/src/modules/main/sections/PublicMainWrapper.vue +++ b/src/modules/main/sections/PublicMainWrapper.vue @@ -7,7 +7,7 @@
- +
@@ -19,6 +19,7 @@ import exportTableMixin from '../../../shared/components/ncTable/mixins/exportTa import { useDataStore } from '../../../store/data.js' import { useTablesStore } from '../../../store/store.js' import { computed } from 'vue' +import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus' import { loadState } from '@nextcloud/initial-state' import { showError } from '@nextcloud/dialogs' import { translate as t } from '@nextcloud/l10n' @@ -44,18 +45,28 @@ export default { setup(props) { const store = useDataStore() - const { getColumns, getRows } = storeToRefs(store) + const { getColumns, getRows, getTotalRows } = storeToRefs(store) const stateKey = 'public-' + props.token const rows = computed(() => getRows.value(false, stateKey)) const columns = computed(() => getColumns.value(false, stateKey)) + const totalRows = computed(() => getTotalRows.value(false, stateKey)) - return { rows, columns } + return { rows, columns, totalRows, dataStore: store } }, data() { return { loading: false, + viewSetting: {}, + lastViewSettingFilter: null, + lastViewSettingSorting: null, + lastViewSettingSearchString: null, + rowsPerPage: 100, + pageNumber: 1, + paginationOffset: 0, + rowsLoading: false, + viewSettingInProgress: false, publicElement: { id: 'public', emoji: nodeData.emoji, @@ -75,24 +86,146 @@ export default { beforeMount() { this.setPublicToken(this.token) - this.loadData() + this.reload() + }, + + mounted() { + subscribe('tables:pagination-changed', this.onPaginationChanged) + }, + + beforeUnmount() { + unsubscribe('tables:pagination-changed', this.onPaginationChanged) + }, + + watch: { + viewSetting: { + handler() { + const newFilter = this.viewSetting?.filter ? JSON.stringify(this.viewSetting.filter) : null + const newSorting = this.viewSetting?.sorting ? JSON.stringify(this.viewSetting.sorting) : null + const newSearchString = this.viewSetting?.searchString || null + if (newFilter === this.lastViewSettingFilter && newSorting === this.lastViewSettingSorting && newSearchString === this.lastViewSettingSearchString) { + return + } + const oldFilter = this.lastViewSettingFilter + const oldSorting = this.lastViewSettingSorting + const oldSearchString = this.lastViewSettingSearchString + this.lastViewSettingFilter = newFilter + this.lastViewSettingSorting = newSorting + this.lastViewSettingSearchString = newSearchString + this.onViewSettingChanged(oldFilter, oldSorting, oldSearchString) + }, + deep: true, + }, }, methods: { - ...mapActions(useDataStore, ['loadPublicColumnsFromBE', 'loadPublicRowsFromBE', 'setPublicToken']), + ...mapActions(useDataStore, ['loadPublicColumnsFromBE', 'loadPublicRowsFromBE', 'loadPublicRowsCountFromBE', 'setPublicToken']), ...mapActions(useTablesStore, ['validatePublicExportAccess']), - async loadData() { + async reload() { + if (!this.token) { + return + } + this.loading = true + this.pageNumber = 1 + this.paginationOffset = 0 + + await this.loadPublicColumnsFromBE({ token: this.token }) + + this.rowsLoading = true + try { + await this.loadPublicRowsCountFromBE({ + token: this.token, + filter: this.viewSetting?.filter, + sort: this.viewSetting?.sorting, + search: this.viewSetting?.searchString, + }) + await this.loadPublicRowsFromBE({ + token: this.token, + filter: this.viewSetting?.filter, + sort: this.viewSetting?.sorting, + search: this.viewSetting?.searchString, + limit: this.rowsPerPage, + offset: this.paginationOffset, + }) + } finally { + this.rowsLoading = false + } + + this.loading = false + }, + + async onViewSettingChanged(oldFilter, oldSorting, oldSearchString) { + if (this.loading || this.rowsLoading) { + return + } + const filterChanged = this.lastViewSettingFilter !== oldFilter + const sortingChanged = this.lastViewSettingSorting !== oldSorting + const searchStringChanged = this.lastViewSettingSearchString !== oldSearchString + if (!filterChanged && !sortingChanged && !searchStringChanged) { + return + } + this.viewSettingInProgress = filterChanged || searchStringChanged + this.rowsLoading = true + try { + if (filterChanged || searchStringChanged) { + this.pageNumber = 1 + this.paginationOffset = 0 + emit('tables:pagination-changed', { pageNumber: 1, rowsPerPage: this.rowsPerPage }) + await this.loadPublicRowsCountFromBE({ + token: this.token, + filter: this.viewSetting?.filter, + sort: this.viewSetting?.sorting, + search: this.viewSetting?.searchString, + }) + await this.loadPublicRowsFromBE({ + token: this.token, + filter: this.viewSetting?.filter, + sort: this.viewSetting?.sorting, + search: this.viewSetting?.searchString, + limit: this.rowsPerPage, + offset: this.paginationOffset, + }) + } else if (sortingChanged) { + this.paginationOffset = (this.pageNumber - 1) * this.rowsPerPage + await this.loadPublicRowsFromBE({ + token: this.token, + filter: this.viewSetting?.filter, + sort: this.viewSetting?.sorting, + search: this.viewSetting?.searchString, + limit: this.rowsPerPage, + offset: this.paginationOffset, + }) + } + } finally { + this.rowsLoading = false + this.viewSettingInProgress = false + } + }, + + async onPaginationChanged({ pageNumber, rowsPerPage }) { + if (this.loading || this.viewSettingInProgress || this.rowsLoading) { + return + } + this.pageNumber = pageNumber + if (rowsPerPage) { + this.rowsPerPage = rowsPerPage + } + this.paginationOffset = (this.pageNumber - 1) * this.rowsPerPage + + this.rowsLoading = true try { - await Promise.all([ - this.loadPublicColumnsFromBE({ token: this.token }), - this.loadPublicRowsFromBE({ token: this.token }), - ]) - } catch (e) { - console.error('Error loading public data', e) + await this.loadPublicRowsFromBE({ + token: this.token, + filter: this.viewSetting?.filter, + sort: this.viewSetting?.sorting, + search: this.viewSetting?.searchString, + limit: this.rowsPerPage, + offset: this.paginationOffset, + }) } finally { - this.loading = false + this.rowsLoading = false } }, diff --git a/src/modules/main/sections/Table.vue b/src/modules/main/sections/Table.vue index 20da815562..e9778ccba0 100644 --- a/src/modules/main/sections/Table.vue +++ b/src/modules/main/sections/Table.vue @@ -13,7 +13,7 @@ @toggle-share="$emit('toggle-share')" @show-integration="$emit('show-integration')" @create-view="createView" /> - unselect all rows, e.g. after deleting selected rows