Skip to content

Commit 623a4e8

Browse files
committed
Enhancement: Update rows via import
Signed-off-by: Kostiantyn Miakshyn <molodchick@gmail.com>
1 parent 61afecd commit 623a4e8

15 files changed

Lines changed: 192 additions & 46 deletions

File tree

cypress/e2e/tables-import.cy.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,40 @@ describe('Import csv', () => {
6161
cy.get('[data-cy="importResultRowErrors"]').should('contain.text', '0')
6262
})
6363

64+
it('Import csv from device with updating of existent files', () => {
65+
cy.intercept({ method: 'GET', url: '**/apps/tables/row/table/*' }).as('rowsReq')
66+
67+
cy.loadTable('Welcome to Nextcloud Tables!')
68+
69+
cy.wait('@rowsReq').then(({ response }) => {
70+
const firstRow = response.body[0]
71+
const csv = [
72+
['id', 'What', 'How to do'],
73+
[firstRow.id, 'What (Updated)', 'How to do (Updated)'],
74+
]
75+
76+
cy.writeFile('cypress/fixtures/test-import-update.csv', csv.map(row => row.join(',')).join('\n'))
77+
})
78+
79+
cy.clickOnTableThreeDotMenu('Import')
80+
cy.get('.modal__content button').contains('Upload from device').click()
81+
cy.get('input[type="file"]').selectFile('cypress/fixtures/test-import-update.csv', { force: true })
82+
83+
cy.get('.modal__content button').contains('Preview').click()
84+
cy.get('.file_import__preview tbody tr', { timeout: 20000 }).should('have.length', 3)
85+
86+
cy.intercept({ method: 'POST', url: '**/apps/tables/importupload/table/*'}).as('importUploadReq')
87+
cy.get('.modal__content button').contains('Import').click()
88+
cy.wait('@importUploadReq')
89+
cy.get('[data-cy="importResultColumnsFound"]', { timeout: 20000 }).should('contain.text', '2')
90+
cy.get('[data-cy="importResultColumnsMatch"]').should('contain.text', '3')
91+
cy.get('[data-cy="importResultColumnsCreated"]').should('contain.text', '0')
92+
cy.get('[data-cy="importResultRowsInserted"]').should('contain.text', '0')
93+
cy.get('[data-cy="importResultRowsUpdated"]').should('contain.text', '1')
94+
cy.get('[data-cy="importResultParsingErrors"]').should('contain.text', '0')
95+
cy.get('[data-cy="importResultRowErrors"]').should('contain.text', '0')
96+
})
97+
6498
})
6599

66100
describe('Import csv from Files file action', () => {

lib/Controller/Api1Controller.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1348,7 +1348,7 @@ public function updateRow(int $rowId, ?int $viewId, $data): DataResponse {
13481348
}
13491349

13501350
try {
1351-
return new DataResponse($this->rowService->updateSet($rowId, $viewId, $dataNew, $this->userId)->jsonSerialize());
1351+
return new DataResponse($this->rowService->updateSet($rowId, $viewId, $dataNew, $this->userId, null)->jsonSerialize());
13521352
} catch (BadRequestError $e) {
13531353
$this->logger->warning('An bad request was encountered: ' . $e->getMessage(), ['exception' => $e]);
13541354
return new DataResponse(['message' => $e->translatedMessage ?: $e->getMessage()], Http::STATUS_BAD_REQUEST);

lib/Controller/RowController.php

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ public function update(
6464
$columnId,
6565
$data
6666
) {
67-
return $this->service->updateSet($id, $viewId, ['columnId' => $columnId, 'value' => $data], $this->userId);
67+
return $this->service->updateSet($id, $viewId, ['columnId' => $columnId, 'value' => $data], $this->userId, null);
6868
});
6969
}
7070

@@ -73,18 +73,13 @@ public function updateSet(
7373
int $id,
7474
?int $viewId,
7575
array $data,
76-
7776
): DataResponse {
7877
return $this->handleError(function () use (
7978
$id,
8079
$viewId,
8180
$data
8281
) {
83-
return $this->service->updateSet(
84-
$id,
85-
$viewId,
86-
$data,
87-
$this->userId);
82+
return $this->service->updateSet($id, $viewId, $data, $this->userId, null);
8883
});
8984
}
9085

lib/Db/Column.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,8 @@ class Column extends EntitySuper implements JsonSerializable {
108108

109109
public const SUBTYPE_TEXT_LINE = 'line';
110110

111+
public const META_ID_TITLE = 'id';
112+
111113
protected ?string $title = null;
112114
protected ?int $tableId = null;
113115
protected ?string $createdBy = null;

lib/ResponseDefinitions.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@
147147
* matching_columns_count: int,
148148
* created_columns_count: int,
149149
* inserted_rows_count: int,
150+
* updated_rows_count: int,
150151
* errors_parsing_count: int,
151152
* errors_count: int,
152153
* }

lib/Service/ColumnService.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -473,7 +473,7 @@ public function findOrCreateColumnsByTitleForTableAsArray(?int $tableId, ?int $v
473473
$result[$i] = '';
474474
}
475475
// if column was not found
476-
if ($result[$i] === '' && $createUnknownColumns) {
476+
if ($result[$i] === '' && $createUnknownColumns && $dataTypes[$i]['type'] !== Column::TYPE_META_ID) {
477477
$description = $this->l->t('This column was automatically created by the import service.');
478478
$result[$i] = $this->create(
479479
$userId,

lib/Service/ImportService.php

Lines changed: 65 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@
3434
use Psr\Container\ContainerExceptionInterface;
3535
use Psr\Container\NotFoundExceptionInterface;
3636
use Psr\Log\LoggerInterface;
37-
use Throwable;
3837
use TypeError;
3938
use function file_exists;
4039
use function is_string;
@@ -55,9 +54,11 @@ class ImportService extends SuperService {
5554
private ?int $viewId = null;
5655
private array $columns = [];
5756
private bool $createUnknownColumns = true;
57+
private ?int $idColumnIndex = null;
5858
private int $countMatchingColumns = 0;
5959
private int $countCreatedColumns = 0;
6060
private int $countInsertedRows = 0;
61+
private int $countUpdatedRows = 0;
6162
private int $countErrors = 0;
6263
private int $countParsingErrors = 0;
6364

@@ -152,14 +153,19 @@ private function getPreviewData(Worksheet $worksheet): array {
152153
$column = $this->columns[$colIndex];
153154
$columns[] = $column;
154155
} else {
155-
$columns[] = [
156+
$column = [
156157
'title' => $title,
157158
'type' => $this->rawColumnDataTypes[$colIndex]['type'],
158159
'subtype' => $this->rawColumnDataTypes[$colIndex]['subtype'] ?? null,
159160
'numberDecimals' => $this->rawColumnDataTypes[$colIndex]['number_decimals'] ?? 0,
160161
'numberPrefix' => $this->rawColumnDataTypes[$colIndex]['number_prefix'] ?? '',
161162
'numberSuffix' => $this->rawColumnDataTypes[$colIndex]['number_suffix'] ?? '',
162163
];
164+
if (mb_strtolower($title) === Column::META_ID_TITLE) {
165+
$column['id'] = Column::TYPE_META_ID;
166+
}
167+
168+
$columns[] = $column;
163169
}
164170
}
165171

@@ -179,7 +185,7 @@ private function getPreviewData(Worksheet $worksheet): array {
179185
$colIndex = $cellIterator->getCurrentColumnIndex() - 1;
180186
$column = $this->columns[$colIndex];
181187

182-
if (!array_key_exists($colIndex, $this->columns)) {
188+
if (!array_key_exists($colIndex, $columns)) {
183189
continue;
184190
}
185191

@@ -324,6 +330,7 @@ public function import(?int $tableId, ?int $viewId, string $path, bool $createMi
324330
'matching_columns_count' => $this->countMatchingColumns,
325331
'created_columns_count' => $this->countCreatedColumns,
326332
'inserted_rows_count' => $this->countInsertedRows,
333+
'updated_rows_count' => $this->countUpdatedRows,
327334
'errors_parsing_count' => $this->countParsingErrors,
328335
'errors_count' => $this->countErrors,
329336
];
@@ -354,7 +361,7 @@ private function loop(Worksheet $worksheet): void {
354361

355362
foreach ($worksheet->getRowIterator(2) as $row) {
356363
// parse row data
357-
$this->createRow($row);
364+
$this->upsertRow($row);
358365
}
359366
}
360367

@@ -387,25 +394,46 @@ private function parseValueByColumnType(string $value, Column $column): string {
387394
* @throws MultipleObjectsReturnedException
388395
* @throws NotFoundError
389396
*/
390-
private function createRow(Row $row): void {
397+
private function upsertRow(Row $row): void {
391398
$cellIterator = $row->getCellIterator();
392399
$cellIterator->setIterateOnlyExistingCells(false);
393400

394401
try {
395402
$i = -1;
396403
$data = [];
397404
$hasData = false;
405+
$id = null;
398406
foreach ($cellIterator as $cell) {
399407
$i++;
400408

409+
if ($this->idColumnIndex !== null && $i === $this->idColumnIndex) {
410+
// if this is the ID column, we need to get the ID from the cell
411+
if ($cell && $cell->getValue() !== null) {
412+
$id = $cell->getValue();
413+
}
414+
if ($id !== null && !is_numeric($id)) {
415+
$this->logger->warning('ID column value is not numeric: ' . $id);
416+
$this->countErrors++;
417+
return;
418+
}
419+
$id = (int)$id;
420+
continue;
421+
}
422+
423+
$columnKey = $i;
424+
if ($this->columnsConfig && $this->idColumnIndex !== null && $i > $this->idColumnIndex) {
425+
// if we have an ID column, we need to adjust the index
426+
$columnKey = $i - 1;
427+
}
428+
401429
// only add the dataset if column is known
402-
if (!isset($this->columns[$i]) || $this->columns[$i] === '') {
430+
if (!isset($this->columns[$columnKey]) || $this->columns[$columnKey] === '') {
403431
$this->logger->debug('Column unknown while fetching rows data for importing.');
404432
continue;
405433
}
406434

407435
/** @var Column $column */
408-
$column = $this->columns[$i];
436+
$column = $this->columns[$columnKey];
409437

410438
// if cell is empty
411439
if (!$cell || $cell->getValue() === null) {
@@ -446,26 +474,31 @@ private function createRow(Row $row): void {
446474
];
447475
}
448476

449-
if ($hasData) {
477+
if (!$hasData) {
478+
$this->logger->debug('Skipped empty row ' . $row->getRowIndex() . ' during import');
479+
return;
480+
}
481+
482+
if ($id) {
483+
$this->rowService->updateSet($id, $this->viewId, $data, $this->userId, $this->tableId);
484+
$this->countUpdatedRows++;
485+
} else {
450486
$this->rowService->create($this->tableId, $this->viewId, $data);
451487
$this->countInsertedRows++;
452-
} else {
453-
$this->logger->debug('Skipped empty row ' . $row->getRowIndex() . ' during import');
454488
}
455489
} catch (PermissionError $e) {
456-
$this->logger->error('Could not create row while importing, no permission.', ['exception' => $e]);
490+
$this->logger->error('Could not create/update row while importing, no permission.', ['exception' => $e]);
457491
$this->countErrors++;
458492
} catch (BadRequestError|InternalError $e) {
459-
$this->logger->error('Error while creating new row for import.', ['exception' => $e]);
493+
$this->logger->error('Error while creating/updating new row for import.', ['exception' => $e]);
460494
$this->countErrors++;
461495
} catch (NotFoundError $e) {
462496
$this->logger->error($e->getMessage(), ['exception' => $e]);
463-
throw new NotFoundError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage());
464-
} catch (Throwable $e) {
497+
throw new NotFoundError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage(), 0, $e);
498+
} catch (\Throwable $e) {
465499
$this->countErrors++;
466-
$this->logger->error('Error while creating new row for import.', ['exception' => $e]);
500+
$this->logger->error('Error while creating/updating new row for import.', ['exception' => $e]);
467501
}
468-
469502
}
470503

471504
private function valueToDateTimeImmutable(mixed $value): ?DateTimeImmutable {
@@ -522,9 +555,25 @@ private function getColumns(Row $firstRow, Row $secondRow): void {
522555
if ($cell && $cell->getValue() !== null && $cell->getValue() !== '') {
523556
$title = $cell->getValue();
524557

558+
if (!$this->columnsConfig && mb_strtolower($title) === Column::META_ID_TITLE) {
559+
$this->idColumnIndex = $index;
560+
$titles[] = $title;
561+
$dataTypes[] = $this->parseColumnDataType($secondRowCellIterator->current());
562+
$secondRowCellIterator->next();
563+
$index++;
564+
continue;
565+
}
525566
if (isset($this->columnsConfig[$index]) && $this->columnsConfig[$index]['action'] === 'exist' && $this->columnsConfig[$index]['existColumn']) {
526567
$title = $this->columnsConfig[$index]['existColumn']['label'];
527568
$countMatchingColumnsFromConfig++;
569+
570+
// no need to create the ID (Meta) column as it used for update
571+
if ($this->columnsConfig[$index]['existColumn']['id'] === Column::TYPE_META_ID) {
572+
$this->idColumnIndex = $index;
573+
$secondRowCellIterator->next();
574+
$index++;
575+
continue;
576+
}
528577
}
529578
if (isset($this->columnsConfig[$index]) && $this->columnsConfig[$index]['action'] === 'new' && $this->createUnknownColumns) {
530579
$column = $this->columnService->create(

lib/Service/RowService.php

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,7 @@ public function updateSet(
410410
?int $viewId,
411411
array $data,
412412
string $userId,
413+
?int $tableId,
413414
): Row2 {
414415
try {
415416
$item = $this->getRowById($id);
@@ -459,8 +460,14 @@ public function updateSet(
459460
throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage());
460461
}
461462
} else {
462-
// if no view id is set, we assume a table and take the tableId from the row
463-
$tableId = $item->getTableId();
463+
if ($tableId === null) {
464+
$tableId = $item->getTableId();
465+
}
466+
if ($tableId !== $item->getTableId()) {
467+
$e = new \Exception('Row does not belong to table with id ' . $tableId);
468+
$this->logger->error($e->getMessage(), ['exception' => $e]);
469+
throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage());
470+
}
464471

465472
// security
466473
if (!$this->permissionsService->canReadRowsByElementId($item->getTableId(), 'table', $userId)) {

openapi.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,7 @@
310310
"matching_columns_count",
311311
"created_columns_count",
312312
"inserted_rows_count",
313+
"updated_rows_count",
313314
"errors_parsing_count",
314315
"errors_count"
315316
],
@@ -330,6 +331,10 @@
330331
"type": "integer",
331332
"format": "int64"
332333
},
334+
"updated_rows_count": {
335+
"type": "integer",
336+
"format": "int64"
337+
},
333338
"errors_parsing_count": {
334339
"type": "integer",
335340
"format": "int64"

src/modules/modals/ImportPreview.vue

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ import { ColumnTypes } from '../../shared/components/ncTable/mixins/columnHandle
7070
import { emit } from '@nextcloud/event-bus'
7171
import { useTablesStore } from '../../store/store.js'
7272
import { useDataStore } from '../../store/data.js'
73+
import { TYPE_META_ID } from '../../shared/constants.js'
7374
7475
export default {
7576
name: 'ImportPreview',
@@ -116,10 +117,17 @@ export default {
116117
return []
117118
}
118119
119-
return this.existingColumns.map(column => ({
120+
const columns = this.existingColumns.map(column => ({
120121
id: column.id,
121122
label: column.title,
122123
}))
124+
125+
columns.unshift({
126+
id: TYPE_META_ID,
127+
label: t('tables', 'ID (Meta)'),
128+
})
129+
130+
return columns
123131
},
124132
},
125133

0 commit comments

Comments
 (0)