From 2ad5d178654a38f89ade2c9b63657130f7245372 Mon Sep 17 00:00:00 2001 From: Kostiantyn Miakshyn Date: Sun, 28 Dec 2025 16:49:54 +0100 Subject: [PATCH 1/2] Fix: Large table causes sql error Signed-off-by: Kostiantyn Miakshyn --- lib/Db/Row2Mapper.php | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/lib/Db/Row2Mapper.php b/lib/Db/Row2Mapper.php index 67a84740d..2cb10bb3b 100644 --- a/lib/Db/Row2Mapper.php +++ b/lib/Db/Row2Mapper.php @@ -28,6 +28,8 @@ use Throwable; class Row2Mapper { + private const MAX_DB_PARAMETERS = 65535; + use TTransactional; private RowSleeveMapper $rowSleeveMapper; @@ -193,6 +195,37 @@ public function findAll(array $showColumnIds, int $tableId, ?int $limit = null, * @throws InternalError */ private function getRows(array $rowIds, array $columnIds): array { + if (empty($rowIds)) { + return []; + } + + $columnTypesCount = count($this->columnsHelper->columns); + if ($columnTypesCount === 0) { + return []; + } + + $columnsCount = count($columnIds); + $maxParamsPerType = floor(self::MAX_DB_PARAMETERS / $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(...$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; From deddba82fc4696872716459c81ea98664ff2b429 Mon Sep 17 00:00:00 2001 From: Kostiantyn Miakshyn Date: Mon, 29 Dec 2025 10:59:08 +0100 Subject: [PATCH 2/2] 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 | 226 +++++------------- lib/Db/RowCellMapperSuper.php | 4 + lib/Db/RowCellUsergroupMapper.php | 7 + lib/Db/RowLoader/CachedRowLoader.php | 89 +++++++ lib/Db/RowLoader/NormalizedRowLoader.php | 150 ++++++++++++ lib/Db/RowLoader/RowLoader.php | 22 ++ lib/Db/RowSleeve.php | 16 ++ lib/Db/RowSleeveMapper.php | 1 + lib/Helper/ColumnsHelper.php | 18 ++ lib/Migration/CacheSleeveCells.php | 107 +++++++++ .../Version001010Date20251229000000.php | 40 ++++ 13 files changed, 514 insertions(+), 173 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 0fc31a0b0..541d03201 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.0.0-alpha.1 + 2.0.0-alpha.2 agpl Florian Steffens Tables @@ -57,6 +57,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 ab38369bb..da036fec1 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 2cb10bb3b..a1ada8acd 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 MAX_DB_PARAMETERS = 65535; - 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(); @@ -178,7 +185,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 +198,16 @@ 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)) { return []; } - $columnTypesCount = count($this->columnsHelper->columns); - if ($columnTypesCount === 0) { - return []; - } - - $columnsCount = count($columnIds); - $maxParamsPerType = floor(self::MAX_DB_PARAMETERS / $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(...$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()); - } - - return $this->parseEntities($result, $sleeves); + return $this->rowLoaders[$loader]->getRows($rowIds, $columnIds); } /** @@ -652,61 +583,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 */ @@ -731,9 +607,12 @@ public function insert(Row2 $row): 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; } @@ -750,9 +629,9 @@ public function update(Row2 $row): Row2 { // update meta data for sleeve try { - $sleeve = $this->rowSleeveMapper->find($row->getId()); - $this->updateMetaData($sleeve); - $this->rowSleeveMapper->update($sleeve); + $rowSleeve = $this->rowSleeveMapper->find($row->getId()); + $this->updateMetaData($rowSleeve); + $this->rowSleeveMapper->update($rowSleeve); } catch (DoesNotExistException|MultipleObjectsReturnedException|Exception $e) { $this->logger->error($e->getMessage(), ['exception' => $e]); throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); @@ -761,9 +640,12 @@ public function update(Row2 $row): Row2 { $this->columnMapper->preloadColumns(array_column($changedCells, 'columnId')); // write all changed cells to its db-table + $cachedCells = $rowSleeve->getCachedCellsArray(); foreach ($changedCells as $cell) { - $this->insertOrUpdateCell($sleeve->getId(), $cell['columnId'], $cell['value']); + $cachedCells[$cell['columnId']] = $this->insertOrUpdateCell($rowSleeve->getId(), $cell['columnId'], $cell['value']); } + $rowSleeve->setCachedCellsArray($cachedCells); + $this->rowSleeveMapper->update($rowSleeve); return $row; } @@ -815,9 +697,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) { @@ -827,7 +711,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()) { @@ -839,6 +723,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 */ @@ -848,11 +733,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; } /** @@ -860,53 +748,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 a3389b310..cbe7b0a84 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; } diff --git a/lib/Db/RowCellUsergroupMapper.php b/lib/Db/RowCellUsergroupMapper.php index 41ed770c8..4f1934f83 100644 --- a/lib/Db/RowCellUsergroupMapper.php +++ b/lib/Db/RowCellUsergroupMapper.php @@ -58,6 +58,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 000000000..d93f1f23e --- /dev/null +++ b/lib/Db/RowLoader/CachedRowLoader.php @@ -0,0 +1,89 @@ +getRowsChunk($chunkedRowIds, $columnIds); + } + + return array_merge(...$chunks); + } + + /** + * @param array $rowIds + * @param array $columnIds + * @return Row2[] + * @throws InternalError + */ + private function getRowsChunk(array $rowIds, array $columnIds): 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->parseEntities($sleeves, $columnIds); + } + + /** + * @param RowSleeve[] $sleeves + * @param int[] $columnIds + * @return Row2[] + * @throws InternalError + */ + private function parseEntities(array $sleeves, array $columnIds): array { + $rows = []; + foreach ($sleeves as $sleeve) { + $row = new Row2(); + $row->setId($sleeve->getId()); + $row->setCreatedBy($sleeve->getCreatedBy()); + $row->setCreatedAt($sleeve->getCreatedAt()); + $row->setLastEditBy($sleeve->getLastEditBy()); + $row->setLastEditAt($sleeve->getLastEditAt()); + $row->setTableId($sleeve->getTableId()); + + $cachedCells = $sleeve->getCachedCellsArray(); + foreach ($columnIds as $columnId) { + if (empty($cachedCells[$columnId])) { + continue; + } + + $column = $this->columnMapper->find($columnId); + $cellMapper = $this->columnsHelper->getCellMapperFromType($column->getType()); + foreach ($cachedCells[$columnId] as $rowData) { + $value = $cellMapper->formatRowData($column, $rowData); + $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 000000000..2bdadf266 --- /dev/null +++ b/lib/Db/RowLoader/NormalizedRowLoader.php @@ -0,0 +1,150 @@ +columnsHelper->columns)); + $chunkSize = max(1, $maxParametersPerType - count($columnIds)); + + $chunks = []; + foreach (array_chunk($rowIds, $chunkSize) as $chunkedRowIds) { + $chunks[] = $this->getRowsChunk($chunkedRowIds, $columnIds); + } + + 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(); + + $subqueries = []; + 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'))); + + $subqueries[] = $qbTmp->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->parseEntities($result, $sleeves); + } + + /** + * @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 = []; + + while ($rowData = $result->fetch()) { + if (!isset($rowData['row_id'], $rows[$rowData['row_id']])) { + break; + } + + $column = $this->columnMapper->find($rowData['column_id']); + $cellMapper = $this->columnsHelper->getCellMapperFromType($column->getType()); + $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 000000000..fcb98658e --- /dev/null +++ b/lib/Db/RowLoader/RowLoader.php @@ -0,0 +1,22 @@ +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 d19b4a232..47b8e4447 100644 --- a/lib/Db/RowSleeveMapper.php +++ b/lib/Db/RowSleeveMapper.php @@ -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', diff --git a/lib/Helper/ColumnsHelper.php b/lib/Helper/ColumnsHelper.php index 035dfcc1b..e06ece6bf 100644 --- a/lib/Helper/ColumnsHelper.php +++ b/lib/Helper/ColumnsHelper.php @@ -9,6 +9,12 @@ use OCA\Tables\Constants\UsergroupType; use OCA\Tables\Db\Column; +use OCA\Tables\Db\RowCellMapperSuper; +use OCA\Tables\Errors\InternalError; +use OCP\Server; +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\NotFoundExceptionInterface; +use Psr\Log\LoggerInterface; class ColumnsHelper { @@ -23,6 +29,7 @@ class ColumnsHelper { public function __construct( private UserHelper $userHelper, private CircleHelper $circleHelper, + private LoggerInterface $logger, ) { } @@ -79,4 +86,15 @@ public function resolveSearchValue(string $placeholder, string $userId, ?Column default: return $placeholder; } } + + public 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(), $e->getCode(), $e); + } + } } diff --git a/lib/Migration/CacheSleeveCells.php b/lib/Migration/CacheSleeveCells.php new file mode 100644 index 000000000..3501f0650 --- /dev/null +++ b/lib/Migration/CacheSleeveCells.php @@ -0,0 +1,107 @@ +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()); + $cell = $cellMapper->findByRowAndColumn($rowId, $column->getId()); + 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 000000000..8276d59e3 --- /dev/null +++ b/lib/Migration/Version001010Date20251229000000.php @@ -0,0 +1,40 @@ +getTable('tables_row_sleeves'); + if (!$table->hasColumn('tables_row_sleeves')) { + $table->addColumn('cached_cells', Types::JSON, [ + 'notnull' => false, + ]); + } + + return $schema; + } +}