From 80656a60dad8657c1893cd81b74d051f7cde7f3c Mon Sep 17 00:00:00 2001 From: Kostiantyn Miakshyn Date: Sun, 28 Dec 2025 16:49:54 +0100 Subject: [PATCH 1/4] Fix: Large table causes sql error Signed-off-by: Kostiantyn Miakshyn --- lib/Db/Row2Mapper.php | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/lib/Db/Row2Mapper.php b/lib/Db/Row2Mapper.php index 7ec48500f9..a60a0e01d8 100644 --- a/lib/Db/Row2Mapper.php +++ b/lib/Db/Row2Mapper.php @@ -28,10 +28,10 @@ use Throwable; class Row2Mapper { - use TTransactional; - private const DB_CHUNK_SIZE = 1_000; + use TTransactional; + private RowSleeveMapper $rowSleeveMapper; private ?string $userId; private IDBConnection $db; @@ -124,10 +124,6 @@ public function getTableIdForRow(int $rowId): ?int { return $rowSleeve->getTableId(); } - public function setUserId(string $userId): void { - $this->userId = $userId; - } - /** * @return int[] * @throws InternalError @@ -203,14 +199,27 @@ private function getRows(array $rowIds, array $columnIds): array { return []; } - $allRows = []; - foreach (array_chunk($rowIds, self::DB_CHUNK_SIZE) as $rowIdChunk) { - $allRows[] = $this->getRowsChunk($rowIdChunk, $columnIds); + $columnTypesCount = count($this->columnsHelper->columns); + if ($columnTypesCount === 0) { + return []; + } + + $columnsCount = count($columnIds); + $maxParamsPerType = floor(self::DB_CHUNK_SIZE / $columnTypesCount) ; + $calculatedChunkSize = (int)($maxParamsPerType - $columnsCount); + $chunkSize = max(1, $calculatedChunkSize); + + $rowIdChunks = array_chunk($rowIds, $chunkSize); + $chunks = []; + foreach ($rowIdChunks as $chunkedRowIds) { + $chunks[] = $this->getRowsChunk($chunkedRowIds, $columnIds); } - return array_merge(...$allRows); + + return array_merge(...$chunks); } /** + * Builds and executes the UNION ALL query for a specific chunk of rows. * @param array $rowIds * @param array $columnIds * @return Row2[] From 0d16156e6b79570100edfc5a010695daa9cb803f Mon Sep 17 00:00:00 2001 From: Kostiantyn Miakshyn Date: Mon, 29 Dec 2025 10:59:08 +0100 Subject: [PATCH 2/4] Fix: Large table causes sql error (cache cells) Signed-off-by: Kostiantyn Miakshyn --- appinfo/info.xml | 3 +- lib/Db/ColumnMapper.php | 4 +- lib/Db/Row2Mapper.php | 228 +++++------------- lib/Db/RowCellMapperSuper.php | 19 ++ lib/Db/RowCellUsergroupMapper.php | 7 + lib/Db/RowLoader/CachedRowLoader.php | 92 +++++++ lib/Db/RowLoader/NormalizedRowLoader.php | 153 ++++++++++++ lib/Db/RowLoader/RowLoader.php | 29 +++ lib/Db/RowSleeve.php | 16 ++ lib/Db/RowSleeveMapper.php | 6 +- lib/Helper/ColumnsHelper.php | 33 ++- lib/Migration/CacheSleeveCells.php | 105 ++++++++ .../Version001010Date20251229000000.php | 41 ++++ tests/unit/Database/DatabaseTestCase.php | 26 ++ tests/unit/Db/Row2MapperTestDependencies.php | 54 ++++- 15 files changed, 642 insertions(+), 174 deletions(-) create mode 100644 lib/Db/RowLoader/CachedRowLoader.php create mode 100644 lib/Db/RowLoader/NormalizedRowLoader.php create mode 100644 lib/Db/RowLoader/RowLoader.php create mode 100644 lib/Migration/CacheSleeveCells.php create mode 100644 lib/Migration/Version001010Date20251229000000.php diff --git a/appinfo/info.xml b/appinfo/info.xml index 4f05ced258..3bed189c8f 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -26,7 +26,7 @@ Share your tables and views with users and groups within your cloud. Have a good time and manage whatever you want. ]]> - 2.2.0 + 2.2.1 AGPL-3.0-or-later Nextcloud GmbH and Nextcloud contributors Tables @@ -58,6 +58,7 @@ Have a good time and manage whatever you want. OCA\Tables\Migration\NewDbStructureRepairStep OCA\Tables\Migration\DbRowSleeveSequence + OCA\Tables\Migration\CacheSleeveCells diff --git a/lib/Db/ColumnMapper.php b/lib/Db/ColumnMapper.php index 527b728d97..e0266c5ede 100644 --- a/lib/Db/ColumnMapper.php +++ b/lib/Db/ColumnMapper.php @@ -91,7 +91,7 @@ public function findAll(array $id): array { /** * @param integer $tableId - * @return array + * @return Column[] * @throws Exception */ public function findAllByTable(int $tableId): array { @@ -116,7 +116,7 @@ public function findAllByTable(int $tableId): array { /** * @param integer $tableID - * @return array + * @return int[] * @throws Exception */ public function findAllIdsByTable(int $tableID): array { diff --git a/lib/Db/Row2Mapper.php b/lib/Db/Row2Mapper.php index a60a0e01d8..feb41177ce 100644 --- a/lib/Db/Row2Mapper.php +++ b/lib/Db/Row2Mapper.php @@ -10,6 +10,8 @@ use DateTime; use DateTimeImmutable; use OCA\Tables\Constants\UsergroupType; +use OCA\Tables\Db\RowLoader\CachedRowLoader; +use OCA\Tables\Db\RowLoader\NormalizedRowLoader; use OCA\Tables\Errors\InternalError; use OCA\Tables\Errors\NotFoundError; use OCA\Tables\Helper\ColumnsHelper; @@ -18,18 +20,12 @@ use OCP\AppFramework\Db\MultipleObjectsReturnedException; use OCP\AppFramework\Db\TTransactional; use OCP\DB\Exception; -use OCP\DB\IResult; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; -use OCP\Server; -use Psr\Container\ContainerExceptionInterface; -use Psr\Container\NotFoundExceptionInterface; use Psr\Log\LoggerInterface; use Throwable; class Row2Mapper { - private const DB_CHUNK_SIZE = 1_000; - use TTransactional; private RowSleeveMapper $rowSleeveMapper; @@ -40,8 +36,15 @@ class Row2Mapper { protected ColumnMapper $columnMapper; private ColumnsHelper $columnsHelper; + /** + * @var array + */ + private array $rowLoaders; - public function __construct(?string $userId, IDBConnection $db, LoggerInterface $logger, UserHelper $userHelper, RowSleeveMapper $rowSleeveMapper, ColumnsHelper $columnsHelper, ColumnMapper $columnMapper) { + public function __construct(?string $userId, IDBConnection $db, LoggerInterface $logger, UserHelper $userHelper, RowSleeveMapper $rowSleeveMapper, ColumnsHelper $columnsHelper, ColumnMapper $columnMapper, + NormalizedRowLoader $normalizedRowLoader, + CachedRowLoader $cachedRowLoader, + ) { $this->rowSleeveMapper = $rowSleeveMapper; $this->userId = $userId; $this->db = $db; @@ -49,6 +52,10 @@ public function __construct(?string $userId, IDBConnection $db, LoggerInterface $this->userHelper = $userHelper; $this->columnsHelper = $columnsHelper; $this->columnMapper = $columnMapper; + $this->rowLoaders = [ + RowLoader\RowLoader::LOADER_NORMALIZED => $normalizedRowLoader, + RowLoader\RowLoader::LOADER_CACHED => $cachedRowLoader, + ]; } /** @@ -60,7 +67,7 @@ public function delete(Row2 $row): Row2 { $this->db->beginTransaction(); try { foreach ($this->columnsHelper->columns as $columnType) { - $this->getCellMapperFromType($columnType)->deleteAllForRow($row->getId()); + $this->columnsHelper->getCellMapperFromType($columnType)->deleteAllForRow($row->getId()); } $this->rowSleeveMapper->deleteById($row->getId()); $this->db->commit(); @@ -124,6 +131,10 @@ public function getTableIdForRow(int $rowId): ?int { return $rowSleeve->getTableId(); } + public function setUserId(string $userId): void { + $this->userId = $userId; + } + /** * @return int[] * @throws InternalError @@ -178,7 +189,7 @@ public function findAll(array $showColumnIds, int $tableId, ?int $limit = null, $wantedRowIdsArray = $this->getWantedRowIds($userId, $tableId, $filter, $sort, $limit, $offset); // Get rows without SQL sorting - $rows = $this->getRows($wantedRowIdsArray, $showColumnIds); + $rows = $this->getRows($wantedRowIdsArray, $showColumnIds, RowLoader\RowLoader::LOADER_CACHED); // Sort rows in PHP to preserve the order from getWantedRowIds return $this->sortRowsByIds($rows, $wantedRowIdsArray); @@ -191,92 +202,26 @@ public function findAll(array $showColumnIds, int $tableId, ?int $limit = null, /** * @param array $rowIds * @param array $columnIds + * @param RowLoader\RowLoader::LOADER_* $loader * @return Row2[] * @throws InternalError */ - private function getRows(array $rowIds, array $columnIds): array { + private function getRows(array $rowIds, array $columnIds, string $loader = RowLoader\RowLoader::LOADER_NORMALIZED): array { if (empty($rowIds) || empty($columnIds)) { return []; } - $columnTypesCount = count($this->columnsHelper->columns); - if ($columnTypesCount === 0) { - return []; - } - - $columnsCount = count($columnIds); - $maxParamsPerType = floor(self::DB_CHUNK_SIZE / $columnTypesCount) ; - $calculatedChunkSize = (int)($maxParamsPerType - $columnsCount); - $chunkSize = max(1, $calculatedChunkSize); - - $rowIdChunks = array_chunk($rowIds, $chunkSize); - $chunks = []; - foreach ($rowIdChunks as $chunkedRowIds) { - $chunks[] = $this->getRowsChunk($chunkedRowIds, $columnIds); + $columns = $mappers = []; + foreach ($columnIds as $columnId) { + $columns[$columnId] = $this->columnMapper->find($columnId); + $mappers[$columnId] = $this->getCellMapper($columns[$columnId]); } - return array_merge(...$chunks); - } - - /** - * Builds and executes the UNION ALL query for a specific chunk of rows. - * @param array $rowIds - * @param array $columnIds - * @return Row2[] - * @throws InternalError - */ - private function getRowsChunk(array $rowIds, array $columnIds): array { - $qb = $this->db->getQueryBuilder(); - - $qbSqlForColumnTypes = null; - foreach ($this->columnsHelper->columns as $columnType) { - $qbTmp = $this->db->getQueryBuilder(); - $qbTmp->select('row_id', 'column_id', 'last_edit_at', 'last_edit_by') - ->selectAlias($qb->expr()->castColumn('value', IQueryBuilder::PARAM_STR), 'value'); - - // This is not ideal but I cannot think of a good way to abstract this away into the mapper right now - // Ideally we dynamically construct this query depending on what additional selects the column type requires - // however the union requires us to match the exact number of selects for each column type - if ($columnType === Column::TYPE_USERGROUP) { - $qbTmp->selectAlias($qb->expr()->castColumn('value_type', IQueryBuilder::PARAM_STR), 'value_type'); - } else { - $qbTmp->selectAlias($qbTmp->createFunction('NULL'), 'value_type'); - } - - $qbTmp - ->from('tables_row_cells_' . $columnType) - ->where($qb->expr()->in('column_id', $qb->createNamedParameter($columnIds, IQueryBuilder::PARAM_INT_ARRAY, ':columnIds'))) - ->andWhere($qb->expr()->in('row_id', $qb->createNamedParameter($rowIds, IQueryBuilder::PARAM_INT_ARRAY, ':rowsIds'))); - - if ($qbSqlForColumnTypes) { - $qbSqlForColumnTypes .= ' UNION ALL ' . $qbTmp->getSQL() . ' '; - } else { - $qbSqlForColumnTypes = '(' . $qbTmp->getSQL(); - } - } - $qbSqlForColumnTypes .= ')'; - - $qb->select('row_id', 'column_id', 'created_by', 'created_at', 't1.last_edit_by', 't1.last_edit_at', 'value', 'table_id') - // Also should be more generic (see above) - ->addSelect('value_type') - ->from($qb->createFunction($qbSqlForColumnTypes), 't1') - ->innerJoin('t1', 'tables_row_sleeves', 'rs', 'rs.id = t1.row_id'); - - try { - $result = $qb->executeQuery(); - } catch (Exception $e) { - $this->logger->error($e->getMessage(), ['exception' => $e]); - throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage(), ); - } - - try { - $sleeves = $this->rowSleeveMapper->findMultiple($rowIds); - } catch (Exception $e) { - $this->logger->error($e->getMessage(), ['exception' => $e]); - throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); + $rows = []; + foreach ($this->rowLoaders[$loader]->getRows($rowIds, $columns, $mappers) as $row) { + $rows[] = $row; } - - return $this->parseEntities($result, $sleeves); + return $rows; } /** @@ -665,61 +610,6 @@ private function getSqlOperator(string $operator, IQueryBuilder $qb, string $col } } - /** - * @param IResult $result - * @param RowSleeve[] $sleeves - * @return Row2[] - * @throws InternalError - */ - private function parseEntities(IResult $result, array $sleeves): array { - $rows = []; - foreach ($sleeves as $sleeve) { - $rows[$sleeve->getId()] = new Row2(); - $rows[$sleeve->getId()]->setId($sleeve->getId()); - $rows[$sleeve->getId()]->setCreatedBy($sleeve->getCreatedBy()); - $rows[$sleeve->getId()]->setCreatedAt($sleeve->getCreatedAt()); - $rows[$sleeve->getId()]->setLastEditBy($sleeve->getLastEditBy()); - $rows[$sleeve->getId()]->setLastEditAt($sleeve->getLastEditAt()); - $rows[$sleeve->getId()]->setTableId($sleeve->getTableId()); - } - - $rowValues = []; - $keyToColumnId = []; - $keyToRowId = []; - $cellMapperCache = []; - - while ($rowData = $result->fetch()) { - if (!isset($rowData['row_id'], $rows[$rowData['row_id']])) { - break; - } - - $column = $this->columnMapper->find($rowData['column_id']); - $columnType = $column->getType(); - if (!isset($cellMapperCache[$columnType])) { - $cellMapperCache[$columnType] = $this->getCellMapperFromType($columnType); - } - $value = $cellMapperCache[$columnType]->formatRowData($column, $rowData); - $compositeKey = (string)$rowData['row_id'] . ',' . (string)$rowData['column_id']; - if ($cellMapperCache[$columnType]->hasMultipleValues()) { - if (array_key_exists($compositeKey, $rowValues)) { - $rowValues[$compositeKey][] = $value; - } else { - $rowValues[$compositeKey] = [$value]; - } - } else { - $rowValues[$compositeKey] = $value; - } - $keyToColumnId[$compositeKey] = $rowData['column_id']; - $keyToRowId[$compositeKey] = $rowData['row_id']; - } - - foreach ($rowValues as $compositeKey => $value) { - $rows[$keyToRowId[$compositeKey]]->addCell($keyToColumnId[$compositeKey], $value); - } - - return array_values($rows); - } - /** * @throws InternalError */ @@ -748,9 +638,12 @@ public function insert(Row2 $row, ?string $userId = null): Row2 { } // write all cells to its db-table + $cachedCells = []; foreach ($row->getData() as $cell) { - $this->insertCell($rowSleeve->getId(), $cell['columnId'], $cell['value'], $rowSleeve->getLastEditAt(), $rowSleeve->getLastEditBy()); + $cachedCells[$cell['columnId']] = $this->insertCell($rowSleeve->getId(), $cell['columnId'], $cell['value'], $rowSleeve->getLastEditAt(), $rowSleeve->getLastEditBy()); } + $rowSleeve->setCachedCellsArray($cachedCells); + $this->rowSleeveMapper->update($rowSleeve); return $row; } @@ -784,9 +677,12 @@ public function update(Row2 $row, ?string $userId = null): Row2 { $this->columnMapper->preloadColumns(array_column($changedCells, 'columnId')); // write all changed cells to its db-table + $cachedCells = $sleeve->getCachedCellsArray(); foreach ($changedCells as $cell) { - $this->insertOrUpdateCell($sleeve->getId(), $cell['columnId'], $cell['value']); + $cachedCells[$cell['columnId']] = $this->insertOrUpdateCell($sleeve->getId(), $cell['columnId'], $cell['value']); } + $sleeve->setCachedCellsArray($cachedCells); + $this->rowSleeveMapper->update($sleeve); return $row; } @@ -838,9 +734,11 @@ private function updateMetaData($entity, bool $setCreate = false, ?string $lastE /** * Insert a cell to its specific db-table * + * @return array normalized cell data + * * @throws InternalError */ - private function insertCell(int $rowId, int $columnId, $value, ?string $lastEditAt = null, ?string $lastEditBy = null): void { + private function insertCell(int $rowId, int $columnId, $value, ?string $lastEditAt = null, ?string $lastEditBy = null): array { try { $column = $this->columnMapper->find($columnId); } catch (DoesNotExistException $e) { @@ -850,7 +748,7 @@ private function insertCell(int $rowId, int $columnId, $value, ?string $lastEdit // insert new cell $cellMapper = $this->getCellMapper($column); - + $cachedCell = []; try { $cellClassName = 'OCA\Tables\Db\RowCell' . ucfirst($column->getType()); if ($cellMapper->hasMultipleValues()) { @@ -862,6 +760,7 @@ private function insertCell(int $rowId, int $columnId, $value, ?string $lastEdit $this->updateMetaData($cell, false, $lastEditAt, $lastEditBy); $cellMapper->applyDataToEntity($column, $cell, $val); $cellMapper->insert($cell); + $cachedCell[] = $cellMapper->toArray($cell); } } else { /** @var RowCellSuper $cell */ @@ -871,11 +770,14 @@ private function insertCell(int $rowId, int $columnId, $value, ?string $lastEdit $this->updateMetaData($cell, false, $lastEditAt, $lastEditBy); $cellMapper->applyDataToEntity($column, $cell, $value); $cellMapper->insert($cell); + $cachedCell = $cellMapper->toArray($cell); } } catch (Exception $e) { $this->logger->error($e->getMessage(), ['exception' => $e]); throw new InternalError('Failed to insert column: ' . $e->getMessage(), 0, $e); } + + return $cachedCell; } /** @@ -883,53 +785,51 @@ private function insertCell(int $rowId, int $columnId, $value, ?string $lastEdit * @param RowCellMapperSuper $mapper * @param mixed $value the value should be parsed to the correct format within the row service * @param Column $column + * + * @return array normalized cell data * @throws InternalError */ - private function updateCell(RowCellSuper $cell, RowCellMapperSuper $mapper, $value, Column $column): void { - $this->getCellMapper($column)->applyDataToEntity($column, $cell, $value); + private function updateCell(RowCellSuper $cell, RowCellMapperSuper $mapper, $value, Column $column): array { + $cellMapper = $this->getCellMapper($column); + $cellMapper->applyDataToEntity($column, $cell, $value); $this->updateMetaData($cell); $mapper->updateWrapper($cell); + + return $cellMapper->toArray($cell); } /** + * @return array normalized cell data * @throws InternalError */ - private function insertOrUpdateCell(int $rowId, int $columnId, $value): void { + private function insertOrUpdateCell(int $rowId, int $columnId, $value): array { $column = $this->columnMapper->find($columnId); $cellMapper = $this->getCellMapper($column); + $cachedCell = []; try { if ($cellMapper->hasMultipleValues()) { - $this->atomic(function () use ($cellMapper, $rowId, $columnId, $value) { + $this->atomic(function () use ($cellMapper, $rowId, $columnId, $value, &$cachedCell) { // For a usergroup field with mutiple values, each is inserted as a new cell // we need to delete all previous cells for this row and column, otherwise we get duplicates $cellMapper->deleteAllForColumnAndRow($columnId, $rowId); - $this->insertCell($rowId, $columnId, $value); + $cachedCell = $this->insertCell($rowId, $columnId, $value); }, $this->db); } else { $cell = $cellMapper->findByRowAndColumn($rowId, $columnId); - $this->updateCell($cell, $cellMapper, $value, $column); + $cachedCell = $this->updateCell($cell, $cellMapper, $value, $column); } } catch (DoesNotExistException) { - $this->insertCell($rowId, $columnId, $value); + $cachedCell = $this->insertCell($rowId, $columnId, $value); } catch (MultipleObjectsReturnedException|Exception $e) { $this->logger->error($e->getMessage(), ['exception' => $e]); - throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); + throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage(), $e->getCode(), $e); } - } - private function getCellMapper(Column $column): RowCellMapperSuper { - return $this->getCellMapperFromType($column->getType()); + return $cachedCell; } - private function getCellMapperFromType(string $columnType): RowCellMapperSuper { - $cellMapperClassName = 'OCA\Tables\Db\RowCell' . ucfirst($columnType) . 'Mapper'; - /** @var RowCellMapperSuper $cellMapper */ - try { - return Server::get($cellMapperClassName); - } catch (NotFoundExceptionInterface|ContainerExceptionInterface $e) { - $this->logger->error($e->getMessage(), ['exception' => $e]); - throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); - } + private function getCellMapper(Column $column): RowCellMapperSuper { + return $this->columnsHelper->getCellMapperFromType($column->getType()); } /** diff --git a/lib/Db/RowCellMapperSuper.php b/lib/Db/RowCellMapperSuper.php index be377c5d87..3c1d1c50e9 100644 --- a/lib/Db/RowCellMapperSuper.php +++ b/lib/Db/RowCellMapperSuper.php @@ -49,6 +49,10 @@ public function applyDataToEntity(Column $column, RowCellSuper $cell, $data): vo $cell->setValue($data); } + public function toArray(RowCellSuper $cell): array { + return ['value' => $cell->getValue()]; + } + public function getDbParamType() { return IQueryBuilder::PARAM_STR; } @@ -113,6 +117,21 @@ public function findByRowAndColumn(int $rowId, int $columnId): RowCellSuper { return $this->findEntity($qb); } + /** + * @throws MultipleObjectsReturnedException + * @throws DoesNotExistException + * @throws Exception + * @return RowCellSuper[] + */ + public function findManyByRowAndColumn(int $rowId, int $columnId): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->tableName) + ->where($qb->expr()->eq('row_id', $qb->createNamedParameter($rowId, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('column_id', $qb->createNamedParameter($columnId, IQueryBuilder::PARAM_INT))); + return $this->findEntities($qb); + } + /** * @throws MultipleObjectsReturnedException * @throws DoesNotExistException diff --git a/lib/Db/RowCellUsergroupMapper.php b/lib/Db/RowCellUsergroupMapper.php index af975a8c37..2ea15230d0 100644 --- a/lib/Db/RowCellUsergroupMapper.php +++ b/lib/Db/RowCellUsergroupMapper.php @@ -60,6 +60,13 @@ public function formatRowData(Column $column, array $row) { ]; } + public function toArray(RowCellSuper $cell): array { + return [ + 'value' => $cell->getValue(), + 'value_type' => $cell->getValueType(), + ]; + } + public function hasMultipleValues(): bool { return true; } diff --git a/lib/Db/RowLoader/CachedRowLoader.php b/lib/Db/RowLoader/CachedRowLoader.php new file mode 100644 index 0000000000..b5c4e17243 --- /dev/null +++ b/lib/Db/RowLoader/CachedRowLoader.php @@ -0,0 +1,92 @@ +getRowsChunk($chunkedRowIds, $columns, $mappers); + } + } + + /** + * @param array $rowIds + * @param array $columns Column per columnId + * @param array $mappers Mapper per columnId + * @return Row2[] + * @throws InternalError + */ + private function getRowsChunk(array $rowIds, array $columns, array $mappers): array { + try { + $sleeves = $this->rowSleeveMapper->findMultiple($rowIds); + } catch (Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage(), $e->getCode(), $e); + } + + return $this->parseResult($sleeves, $columns, $mappers); + } + + /** + * @param list> $sleeves + * @param array $columns Column per columnId + * @param array $mappers Mapper per columnId + * @return Row2[] + */ + private function parseResult(array $sleeves, array $columns, $mappers): array { + $rows = []; + foreach ($sleeves as $sleeve) { + $id = (int)$sleeve['id']; + $row = new Row2(); + $row->setId($id); + $row->setCreatedBy($sleeve['created_by']); + $row->setCreatedAt($sleeve['created_at']); + $row->setLastEditBy($sleeve['last_edit_by']); + $row->setLastEditAt($sleeve['last_edit_at']); + $row->setTableId((int)$sleeve['table_id']); + + $cachedCells = json_decode($sleeve['cached_cells'] ?? '{}', true); + foreach ($columns as $columnId => $column) { + if (empty($cachedCells[$columnId])) { + continue; + } + + $cellMapper = $mappers[$columnId]; + if ($cellMapper->hasMultipleValues()) { + $value = array_map(static fn ($rowData) => $cellMapper->formatRowData($column, $rowData), + $cachedCells[$columnId]); + } else { + $value = $cellMapper->formatRowData($column, $cachedCells[$columnId]); + } + + $row->addCell($columnId, $value); + } + + $rows[] = $row; + } + + return $rows; + } +} diff --git a/lib/Db/RowLoader/NormalizedRowLoader.php b/lib/Db/RowLoader/NormalizedRowLoader.php new file mode 100644 index 0000000000..5a5e541109 --- /dev/null +++ b/lib/Db/RowLoader/NormalizedRowLoader.php @@ -0,0 +1,153 @@ +columnsHelper->columns)); + $chunkSize = max(1, $maxParametersPerType - count($columns)); + + foreach (array_chunk($rowIds, $chunkSize) as $chunkedRowIds) { + yield from $this->getRowsChunk($chunkedRowIds, $columns, $mappers); + } + } + + /** + * Builds and executes the UNION ALL query for a specific chunk of rows. + * @param array $rowIds + * @param array $columns Column per columnId + * @param array $mappers Mapper per columnId + * @return Row2[] + * @throws InternalError + */ + private function getRowsChunk(array $rowIds, array $columns, array $mappers): array { + $qb = $this->db->getQueryBuilder(); + + $subqueries = []; + foreach ($this->columnsHelper->columns as $columnType) { + $qbValues = $this->db->getQueryBuilder(); + $qbValues->select('row_id', 'column_id', 'last_edit_at', 'last_edit_by') + ->selectAlias($qb->expr()->castColumn('value', IQueryBuilder::PARAM_STR), 'value'); + + // This is not ideal but I cannot think of a good way to abstract this away into the mapper right now + // Ideally we dynamically construct this query depending on what additional selects the column type requires + // however the union requires us to match the exact number of selects for each column type + if ($columnType === Column::TYPE_USERGROUP) { + $qbValues->selectAlias($qb->expr()->castColumn('value_type', IQueryBuilder::PARAM_STR), 'value_type'); + } else { + $qbValues->selectAlias($qbValues->createFunction('NULL'), 'value_type'); + } + + $qbValues + ->from('tables_row_cells_' . $columnType) + ->where($qb->expr()->in('column_id', $qb->createNamedParameter(array_keys($columns), IQueryBuilder::PARAM_INT_ARRAY, ':columnIds'))) + ->andWhere($qb->expr()->in('row_id', $qb->createNamedParameter($rowIds, IQueryBuilder::PARAM_INT_ARRAY, ':rowsIds'))); + + $subqueries[] = $qbValues->getSQL(); + } + + $qb->select('row_id', 'column_id', 'created_by', 'created_at', 't1.last_edit_by', 't1.last_edit_at', 'value', 'table_id') + // Also should be more generic (see above) + ->addSelect('value_type') + ->from($qb->createFunction('(' . implode(' UNION ALL ', $subqueries) . ')'), 't1') + ->innerJoin('t1', 'tables_row_sleeves', 'rs', 'rs.id = t1.row_id'); + + try { + $result = $qb->executeQuery(); + } catch (Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage(), $e->getCode(), $e); + } + + try { + $sleeves = $this->rowSleeveMapper->findMultiple($rowIds); + } catch (Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage(), $e->getCode(), $e); + } + + return $this->parseResult($result, $sleeves, $columns, $mappers); + } + + /** + * @param IResult $result + * @param list> $sleeves + * @param array $columns Column per columnId + * @param array $mappers Mapper per columnId + * @return Row2[] + * @throws InternalError + */ + private function parseResult(IResult $result, array $sleeves, array $columns, array $mappers): array { + $rows = []; + foreach ($sleeves as $sleeve) { + $id = (int)$sleeve['id']; + $rows[$id] = new Row2(); + $rows[$id]->setId($id); + $rows[$id]->setCreatedBy($sleeve['created_by']); + $rows[$id]->setCreatedAt($sleeve['created_at']); + $rows[$id]->setLastEditBy($sleeve['last_edit_by']); + $rows[$id]->setLastEditAt($sleeve['last_edit_at']); + $rows[$id]->setTableId((int)$sleeve['table_id']); + } + + $rowValues = []; + $keyToColumnId = []; + $keyToRowId = []; + + while ($rowData = $result->fetch()) { + if (!isset($rowData['row_id'], $rows[$rowData['row_id']])) { + break; + } + + $column = $columns[$rowData['column_id']]; + $cellMapper = $mappers[$rowData['column_id']]; + $value = $cellMapper->formatRowData($column, $rowData); + $compositeKey = (string)$rowData['row_id'] . ',' . (string)$rowData['column_id']; + if ($cellMapper->hasMultipleValues()) { + if (array_key_exists($compositeKey, $rowValues)) { + $rowValues[$compositeKey][] = $value; + } else { + $rowValues[$compositeKey] = [$value]; + } + } else { + $rowValues[$compositeKey] = $value; + } + $keyToColumnId[$compositeKey] = $rowData['column_id']; + $keyToRowId[$compositeKey] = $rowData['row_id']; + } + + foreach ($rowValues as $compositeKey => $value) { + $rows[$keyToRowId[$compositeKey]]->addCell($keyToColumnId[$compositeKey], $value); + } + + return array_values($rows); + } +} diff --git a/lib/Db/RowLoader/RowLoader.php b/lib/Db/RowLoader/RowLoader.php new file mode 100644 index 0000000000..223a2f1eb4 --- /dev/null +++ b/lib/Db/RowLoader/RowLoader.php @@ -0,0 +1,29 @@ + $columns Column per columnId + * @param array $mappers Mapper per columnId + * @return iterable + * @throws InternalError + */ + public function getRows(array $rowIds, array $columns, array $mappers): iterable; +} diff --git a/lib/Db/RowSleeve.php b/lib/Db/RowSleeve.php index b909a3f44f..07ca20de94 100644 --- a/lib/Db/RowSleeve.php +++ b/lib/Db/RowSleeve.php @@ -14,6 +14,8 @@ * @psalm-suppress PropertyNotSetInConstructor * @method getTableId(): ?int * @method setTableId(int $columnId) + * @method getCachedCells(): string + * @method setCachedCells(string $cachedCells) * @method getCreatedBy(): string * @method setCreatedBy(string $createdBy) * @method getCreatedAt(): string @@ -25,6 +27,7 @@ */ class RowSleeve extends Entity implements JsonSerializable { protected ?int $tableId = null; + protected ?string $cachedCells = null; protected ?string $createdBy = null; protected ?string $createdAt = null; protected ?string $lastEditBy = null; @@ -33,12 +36,25 @@ class RowSleeve extends Entity implements JsonSerializable { public function __construct() { $this->addType('id', 'integer'); $this->addType('tableId', 'integer'); + $this->addType('cachedCells', 'string'); + } + + /** + * @return array Indexed by column ID + */ + public function getCachedCellsArray(): array { + return json_decode($this->cachedCells, true) ?: []; + } + + public function setCachedCellsArray(array $array):void { + $this->setCachedCells(json_encode($array)); } public function jsonSerialize(): array { return [ 'id' => $this->id, 'tableId' => $this->tableId, + 'cachedCells' => $this->cachedCells, 'createdBy' => $this->createdBy, 'createdAt' => $this->createdAt, 'lastEditBy' => $this->lastEditBy, diff --git a/lib/Db/RowSleeveMapper.php b/lib/Db/RowSleeveMapper.php index f5a21fe5c1..c70571a49f 100644 --- a/lib/Db/RowSleeveMapper.php +++ b/lib/Db/RowSleeveMapper.php @@ -40,7 +40,7 @@ public function find(int $id): RowSleeve { /** * @param int[] $ids - * @return RowSleeve[] + * @return list> Raw data from DB for the best performance * @throws Exception */ public function findMultiple(array $ids): array { @@ -49,6 +49,7 @@ public function findMultiple(array $ids): array { $qb->select( $sleeveAlias . '.id', $sleeveAlias . '.table_id', + $sleeveAlias . '.cached_cells', $sleeveAlias . '.created_by', $sleeveAlias . '.created_at', $sleeveAlias . '.last_edit_by', @@ -56,7 +57,8 @@ public function findMultiple(array $ids): array { ) ->from($this->table, $sleeveAlias) ->where($qb->expr()->in($sleeveAlias . '.id', $qb->createNamedParameter($ids, IQueryBuilder::PARAM_INT_ARRAY))); - return $this->findEntities($qb); + + return $qb->executeQuery()->fetchAll(); } /** diff --git a/lib/Helper/ColumnsHelper.php b/lib/Helper/ColumnsHelper.php index f926d2ba3c..3a4b92d5af 100644 --- a/lib/Helper/ColumnsHelper.php +++ b/lib/Helper/ColumnsHelper.php @@ -9,11 +9,15 @@ use OCA\Tables\Constants\UsergroupType; use OCA\Tables\Db\Column; +use OCA\Tables\Db\RowCellMapperSuper; +use OCA\Tables\Errors\InternalError; use OCA\Tables\Service\ColumnTypes\IColumnTypeBusiness; use OCP\Server; +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\NotFoundExceptionInterface; +use Psr\Log\LoggerInterface; class ColumnsHelper { - public array $columns = [ Column::TYPE_TEXT, Column::TYPE_NUMBER, @@ -28,9 +32,15 @@ class ColumnsHelper { */ private array $columnBusinesses = []; + /** + * @var array + */ + private array $cellMappers = []; + public function __construct( - private UserHelper $userHelper, - private CircleHelper $circleHelper, + private readonly UserHelper $userHelper, + private readonly CircleHelper $circleHelper, + private readonly LoggerInterface $logger, ) { } @@ -104,4 +114,21 @@ public function getColumnBusinessObject(Column $column): IColumnTypeBusiness { return $this->columnBusinesses[$cacheKey] = $columnBusiness; } + + public function getCellMapperFromType(string $columnType): RowCellMapperSuper { + if (isset($this->cellMappers[$columnType])) { + return $this->cellMappers[$columnType]; + } + + $cellMapperClassName = 'OCA\Tables\Db\RowCell' . ucfirst($columnType) . 'Mapper'; + /** @var RowCellMapperSuper $cellMapper */ + try { + $cellMapper = Server::get($cellMapperClassName); + } catch (NotFoundExceptionInterface|ContainerExceptionInterface $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage(), previous: $e); + } + + return $this->cellMappers[$columnType] = $cellMapper; + } } diff --git a/lib/Migration/CacheSleeveCells.php b/lib/Migration/CacheSleeveCells.php new file mode 100644 index 0000000000..bedb141380 --- /dev/null +++ b/lib/Migration/CacheSleeveCells.php @@ -0,0 +1,105 @@ +config->getAppValue('tables', 'cachingSleeveCellsComplete', 'false') === 'true'; + if ($cachingSleeveCellsComplete) { + return; + } + + foreach ($this->getTableIds() as $tableId) { + $columns = $this->columnMapper->findAllByTable($tableId); + + while ($rowIds = $this->getPendingRowIds($tableId)) { + foreach ($rowIds as $rowId) { + $cachedCells = []; + foreach ($columns as $column) { + $cellMapper = $this->columnsHelper->getCellMapperFromType($column->getType()); + $cells = $cellMapper->findManyByRowAndColumn($rowId, $column->getId()); + foreach ($cells as $cell) { + if ($cellMapper->hasMultipleValues()) { + $cachedCells[$column->getId()][] = $cellMapper->toArray($cell); + } else { + $cachedCells[$column->getId()] = $cellMapper->toArray($cell); + } + } + } + + $sleeve = $this->rowSleeveMapper->find($rowId); + $sleeve->setCachedCellsArray($cachedCells); + $this->rowSleeveMapper->update($sleeve); + } + } + + $this->logger->info('Finished caching cells for table ' . $tableId); + } + + $this->config->setAppValue('tables', 'cachingSleeveCellsComplete', 'true'); + } + + /** + * @return int[] + */ + public function getTableIds(): array { + return $this->db->getQueryBuilder() + ->select('id') + ->from('tables_tables') + ->orderBy('id') + ->setMaxResults(PHP_INT_MAX) + ->executeQuery() + ->fetchAll(\PDO::FETCH_COLUMN); + + } + + /** + * @return int[] + */ + private function getPendingRowIds(int $tableId): array { + $qb = $this->db->getQueryBuilder(); + + return $qb->select('id') + ->from('tables_row_sleeves') + ->where($qb->expr()->isNull('cached_cells')) + ->andWhere($qb->expr()->eq('table_id', $qb->createNamedParameter($tableId, \PDO::PARAM_INT))) + ->orderBy('id') + ->setMaxResults(1000) + ->executeQuery() + ->fetchAll(\PDO::FETCH_COLUMN); + } +} diff --git a/lib/Migration/Version001010Date20251229000000.php b/lib/Migration/Version001010Date20251229000000.php new file mode 100644 index 0000000000..a0c1c51224 --- /dev/null +++ b/lib/Migration/Version001010Date20251229000000.php @@ -0,0 +1,41 @@ +getTable('tables_row_sleeves'); + if (!$table->hasColumn('tables_row_sleeves')) { + $table->addColumn('cached_cells', Types::JSON, [ + 'notnull' => false, + ]); + } + + return $schema; + } +} diff --git a/tests/unit/Database/DatabaseTestCase.php b/tests/unit/Database/DatabaseTestCase.php index a4412307a3..10507bf75d 100644 --- a/tests/unit/Database/DatabaseTestCase.php +++ b/tests/unit/Database/DatabaseTestCase.php @@ -292,6 +292,7 @@ protected function createTestRowWithData(int $tableId, array $rowData = [], arra if (!empty($cellsData)) { $this->addCellsToRow($rowId, $cellsData, $columnMapping); + $this->updateCachedCells($rowId, $cellsData, $columnMapping); } return $result; @@ -350,6 +351,31 @@ protected function insertCellIntoTypeTable(int $rowId, int $columnId, $value, st $qb->executeStatement(); } + /** + * Updates the cached_cells column for a row + */ + protected function updateCachedCells(int $rowId, array $cellsData, array $columnMapping = []): void { + $cachedCells = []; + foreach ($cellsData as $columnIdentifier => $value) { + // Convert test_ident to actual column ID if mapping is provided + if (is_string($columnIdentifier) && isset($columnMapping[$columnIdentifier])) { + $columnId = $columnMapping[$columnIdentifier]; + } else { + $columnId = $columnIdentifier; + } + + // Format the value as expected by CachedRowLoader (matches cell mapper toArray format) + $cachedCells[$columnId] = ['value' => $value]; + } + + $qb = $this->connection->getQueryBuilder(); + $qb->update('tables_row_sleeves') + ->set('cached_cells', $qb->createNamedParameter(json_encode($cachedCells))) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($rowId))); + + $qb->executeStatement(); + } + /** * Creates a complete test table with columns and rows with data */ diff --git a/tests/unit/Db/Row2MapperTestDependencies.php b/tests/unit/Db/Row2MapperTestDependencies.php index 297eaf1674..3e2bf0cf79 100644 --- a/tests/unit/Db/Row2MapperTestDependencies.php +++ b/tests/unit/Db/Row2MapperTestDependencies.php @@ -12,11 +12,21 @@ use OCA\Tables\Db\Column; use OCA\Tables\Db\ColumnMapper; use OCA\Tables\Db\Row2Mapper; +use OCA\Tables\Db\RowCellDatetimeMapper; +use OCA\Tables\Db\RowCellNumberMapper; +use OCA\Tables\Db\RowCellSelectionMapper; +use OCA\Tables\Db\RowCellTextMapper; +use OCA\Tables\Db\RowCellUsergroupMapper; +use OCA\Tables\Db\RowLoader\CachedRowLoader; +use OCA\Tables\Db\RowLoader\NormalizedRowLoader; use OCA\Tables\Db\RowSleeveMapper; use OCA\Tables\Helper\CircleHelper; use OCA\Tables\Helper\ColumnsHelper; +use OCA\Tables\Helper\GroupHelper; use OCA\Tables\Helper\UserHelper; use OCP\AppFramework\Db\DoesNotExistException; +use OCP\IUserManager; +use OCP\IUserSession; use PHPUnit\Framework\MockObject\MockObject; use Psr\Log\LoggerInterface; @@ -35,6 +45,8 @@ trait Row2MapperTestDependencies { protected ColumnsHelper|MockObject $columnsHelper; protected LoggerInterface|MockObject $logger; protected CircleHelper|MockObject $circleHelper; + protected NormalizedRowLoader $normalizedRowLoader; + protected CachedRowLoader $cachedRowLoader; protected static bool $testDataInitialized = false; protected static int $testTableId; @@ -56,7 +68,22 @@ protected function setupDependencies(): void { $this->logger = $this->createMock(LoggerInterface::class); $this->rowSleeveMapper = new RowSleeveMapper($this->connectionAdapter, $this->logger); - $this->columnsHelper = new ColumnsHelper($this->userHelper, $this->circleHelper); + $this->columnsHelper = $this->createMock(ColumnsHelper::class); + + // Mock getCellMapperFromType to return real cell mappers + $this->setupCellMappers(); + + // Use real loader instances since we have a real database + $this->normalizedRowLoader = new NormalizedRowLoader( + $this->connectionAdapter, + $this->rowSleeveMapper, + $this->columnsHelper, + $this->logger + ); + $this->cachedRowLoader = new CachedRowLoader( + $this->rowSleeveMapper, + $this->logger + ); $this->mapper = new Row2Mapper( 'test_user', @@ -65,7 +92,9 @@ protected function setupDependencies(): void { $this->userHelper, $this->rowSleeveMapper, $this->columnsHelper, - $this->columnMapper + $this->columnMapper, + $this->normalizedRowLoader, + $this->cachedRowLoader ); if (!self::$testDataInitialized) { @@ -74,6 +103,27 @@ protected function setupDependencies(): void { } } + /** + * Sets up real cell mappers by mocking getCellMapperFromType + * This is needed because ColumnsHelper uses Server::get() which doesn't work in unit tests + */ + private function setupCellMappers(): void { + $userManager = $this->createMock(IUserManager::class); + $userSession = $this->createMock(IUserSession::class); + + $this->columnsHelper->method('getCellMapperFromType') + ->willReturnCallback(function ($columnType) use ($userManager, $userSession) { + return match ($columnType) { + Column::TYPE_TEXT => new RowCellTextMapper($this->connectionAdapter), + Column::TYPE_NUMBER => new RowCellNumberMapper($this->connectionAdapter), + Column::TYPE_DATETIME => new RowCellDatetimeMapper($this->connectionAdapter), + Column::TYPE_SELECTION => new RowCellSelectionMapper($this->connectionAdapter), + Column::TYPE_USERGROUP => new RowCellUsergroupMapper($this->connectionAdapter, $userManager, $this->circleHelper, $this->createMock(GroupHelper::class), $userSession), + default => throw new \InvalidArgumentException("Unknown column type: $columnType"), + }; + }); + } + /** * Initializes comprehensive test data for sorting and querying tests * From e804e3c2c4331f0b02ce6ea2816c718e910e7a36 Mon Sep 17 00:00:00 2001 From: Kostiantyn Miakshyn Date: Sat, 20 Jun 2026 14:30:49 +0200 Subject: [PATCH 3/4] Fix: Large table causes sql error (code review fixes WIP) Signed-off-by: Kostiantyn Miakshyn --- appinfo/info.xml | 2 +- lib/Migration/CacheSleeveCells.php | 3 +-- ...20251229000000.php => Version002030Date20260620000000.php} | 4 ++-- 3 files changed, 4 insertions(+), 5 deletions(-) rename lib/Migration/{Version001010Date20251229000000.php => Version002030Date20260620000000.php} (88%) diff --git a/appinfo/info.xml b/appinfo/info.xml index 3bed189c8f..82ef71244a 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -26,7 +26,7 @@ Share your tables and views with users and groups within your cloud. Have a good time and manage whatever you want. ]]> - 2.2.1 + 2.3.0 AGPL-3.0-or-later Nextcloud GmbH and Nextcloud contributors Tables diff --git a/lib/Migration/CacheSleeveCells.php b/lib/Migration/CacheSleeveCells.php index bedb141380..a9c18e1832 100644 --- a/lib/Migration/CacheSleeveCells.php +++ b/lib/Migration/CacheSleeveCells.php @@ -1,7 +1,7 @@ select('id') ->from('tables_tables') ->orderBy('id') - ->setMaxResults(PHP_INT_MAX) ->executeQuery() ->fetchAll(\PDO::FETCH_COLUMN); diff --git a/lib/Migration/Version001010Date20251229000000.php b/lib/Migration/Version002030Date20260620000000.php similarity index 88% rename from lib/Migration/Version001010Date20251229000000.php rename to lib/Migration/Version002030Date20260620000000.php index a0c1c51224..01dcecb25b 100644 --- a/lib/Migration/Version001010Date20251229000000.php +++ b/lib/Migration/Version002030Date20260620000000.php @@ -17,7 +17,7 @@ use OCP\Migration\IOutput; use OCP\Migration\SimpleMigrationStep; -class Version001010Date20251229000000 extends SimpleMigrationStep { +class Version002030Date20260620000000 extends SimpleMigrationStep { /** * @param IOutput $output * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` @@ -30,7 +30,7 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt $schema = $schemaClosure(); $table = $schema->getTable('tables_row_sleeves'); - if (!$table->hasColumn('tables_row_sleeves')) { + if (!$table->hasColumn('cached_cells')) { $table->addColumn('cached_cells', Types::JSON, [ 'notnull' => false, ]); From a15068411851df93cc76debbfd1a841e8e2a2861 Mon Sep 17 00:00:00 2001 From: Kostiantyn Miakshyn Date: Sat, 20 Jun 2026 14:49:42 +0200 Subject: [PATCH 4/4] Fix: Large table causes sql error (code review fixes WIP) Signed-off-by: Kostiantyn Miakshyn --- lib/Db/Row2Mapper.php | 1 - lib/Db/RowCellMapperSuper.php | 12 +++++++++ lib/Service/RowService.php | 1 + lib/UserMigration/TablesMigrator.php | 38 ++++++++++++++++++++++++++++ tests/unit/TablesMigratorTest.php | 6 ++++- 5 files changed, 56 insertions(+), 2 deletions(-) diff --git a/lib/Db/Row2Mapper.php b/lib/Db/Row2Mapper.php index feb41177ce..50623eff89 100644 --- a/lib/Db/Row2Mapper.php +++ b/lib/Db/Row2Mapper.php @@ -668,7 +668,6 @@ public function update(Row2 $row, ?string $userId = null): Row2 { try { $sleeve = $this->rowSleeveMapper->find($row->getId()); $this->updateMetaData($sleeve); - $this->rowSleeveMapper->update($sleeve); } catch (DoesNotExistException|MultipleObjectsReturnedException|Exception $e) { $this->logger->error($e->getMessage(), ['exception' => $e]); throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); diff --git a/lib/Db/RowCellMapperSuper.php b/lib/Db/RowCellMapperSuper.php index 3c1d1c50e9..acfcbe25b3 100644 --- a/lib/Db/RowCellMapperSuper.php +++ b/lib/Db/RowCellMapperSuper.php @@ -132,6 +132,18 @@ public function findManyByRowAndColumn(int $rowId, int $columnId): array { return $this->findEntities($qb); } + /** + * @throws Exception + * @return RowCellSuper[] + */ + public function findAllForRow(int $rowId): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->tableName) + ->where($qb->expr()->eq('row_id', $qb->createNamedParameter($rowId, IQueryBuilder::PARAM_INT))); + return $this->findEntities($qb); + } + /** * @throws MultipleObjectsReturnedException * @throws DoesNotExistException diff --git a/lib/Service/RowService.php b/lib/Service/RowService.php index 2c37ca335a..afd9d54671 100644 --- a/lib/Service/RowService.php +++ b/lib/Service/RowService.php @@ -901,6 +901,7 @@ public function importRow(Table $table, array $row): int { $qb->insert('tables_row_sleeves') ->values([ 'table_id' => $qb->createNamedParameter($table->getId(), IQueryBuilder::PARAM_INT), + 'cached_cells' => $qb->createNamedParameter(json_encode([])), 'created_by' => $qb->createNamedParameter($table->getOwnership()), 'created_at' => $qb->createNamedParameter($row['createdAt']), 'last_edit_by' => $qb->createNamedParameter($table->getOwnership()), diff --git a/lib/UserMigration/TablesMigrator.php b/lib/UserMigration/TablesMigrator.php index 7a039ad38a..40373040ee 100644 --- a/lib/UserMigration/TablesMigrator.php +++ b/lib/UserMigration/TablesMigrator.php @@ -14,6 +14,7 @@ use OCA\Tables\Db\ContextNodeRelationMapper; use OCA\Tables\Db\RowCellDatetime; use OCA\Tables\Db\RowCellDatetimeMapper; +use OCA\Tables\Db\RowCellMapperSuper; use OCA\Tables\Db\RowCellNumber; use OCA\Tables\Db\RowCellNumberMapper; use OCA\Tables\Db\RowCellSelection; @@ -41,6 +42,7 @@ use OCP\UserMigration\IMigrator; use OCP\UserMigration\ISizeEstimationMigrator; use OCP\UserMigration\TMigratorBasicVersionHandling; +use Psr\Log\LoggerInterface; use Symfony\Component\Console\Output\OutputInterface; class TablesMigrator implements IMigrator, ISizeEstimationMigrator { @@ -83,6 +85,7 @@ public function __construct( protected RowService $rowService, private ContextService $contextService, private ShareService $shareService, + protected LoggerInterface $logger, ) { } @@ -279,6 +282,8 @@ public function import( $userId, ); + $this->rebuildCachedCells($rowIdMap); + $connection->commit(); } catch (\Throwable $e) { $connection->rollBack(); @@ -335,6 +340,39 @@ private function importRowCells(array $data, array $rowIdMap, array $columnIdMap } } + private function rebuildCachedCells(array $rowIdMap): void { + foreach ($rowIdMap as $newRowId) { + $sleeve = $this->rowSleeveMapper->find($newRowId); + $cachedCells = []; + + /** @var RowCellMapperSuper[] $cellMappers */ + $cellMappers = [ + $this->rowCellTextMapper, + $this->rowCellNumberMapper, + $this->rowCellDatetimeMapper, + $this->rowCellSelectionMapper, + $this->rowCellUsergroupMapper, + ]; + + foreach ($cellMappers as $mapper) { + try { + $cells = $mapper->findAllForRow($newRowId); + foreach ($cells as $cell) { + $cachedCells[$cell->getColumnId()] = $mapper->toArray($cell); + } + } catch (\Exception $e) { + $this->logger->error('Failed to load cells for row during cached_cells rebuild', [ + 'rowId' => $newRowId, + 'exception' => $e->getMessage(), + ]); + } + } + + $sleeve->setCachedCellsArray($cachedCells); + $this->rowSleeveMapper->update($sleeve); + } + } + /** * @param IImportSource $importSource * @param array $tableIdMap diff --git a/tests/unit/TablesMigratorTest.php b/tests/unit/TablesMigratorTest.php index 856d91adc3..fde2f9b8a6 100644 --- a/tests/unit/TablesMigratorTest.php +++ b/tests/unit/TablesMigratorTest.php @@ -36,6 +36,7 @@ use OCP\UserMigration\IExportDestination; use OCP\UserMigration\IImportSource; use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; use Symfony\Component\Console\Output\NullOutput; class TablesMigratorTest extends TestCase { @@ -60,6 +61,7 @@ class TablesMigratorTest extends TestCase { private $rowService; private $contextService; private $shareService; + private $logger; protected function setUp(): void { $this->l10n = $this->createMock(IL10N::class); @@ -82,6 +84,7 @@ protected function setUp(): void { $this->rowService = $this->createMock(RowService::class); $this->contextService = $this->createMock(ContextService::class); $this->shareService = $this->createMock(ShareService::class); + $this->logger = $this->createMock(LoggerInterface::class); $this->migrator = new TablesMigrator( $this->l10n, @@ -103,7 +106,8 @@ protected function setUp(): void { $this->columnService, $this->rowService, $this->contextService, - $this->shareService + $this->shareService, + $this->logger ); }