diff --git a/.github/workflows/app-upgrade-mysql.yml b/.github/workflows/app-upgrade-mysql.yml new file mode 100644 index 000000000..39001b40f --- /dev/null +++ b/.github/workflows/app-upgrade-mysql.yml @@ -0,0 +1,150 @@ +name: app upgrade mysql + +on: + pull_request: + branches: main + +permissions: + contents: read + +concurrency: + group: app-upgrade-mysql-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + changes: + runs-on: ubuntu-latest + + outputs: + src: ${{ steps.changes.outputs.src}} + + steps: + - uses: dorny/paths-filter@4512585405083f25c027a35db413c2b3b9006d50 # v2.11.1 + id: changes + continue-on-error: true + with: + filters: | + src: + - '.github/workflows/**' + - 'appinfo/**' + - 'lib/**' + - 'templates/**' + - 'tests/**' + - 'vendor/**' + - 'vendor-bin/**' + - '.php-cs-fixer.dist.php' + - 'composer.json' + - 'composer.lock' + + app-upgrade-mysql: + runs-on: ubuntu-latest + + needs: changes + if: needs.changes.outputs.src != 'false' + + strategy: + matrix: + php-versions: ['8.2'] + server-versions: ['master'] + + services: + mysql: + image: ghcr.io/nextcloud/continuous-integration-mysql-8.0:latest + ports: + - 4444:3306/tcp + env: + MYSQL_ROOT_PASSWORD: rootpassword + options: --health-cmd="mysqladmin ping" --health-interval 5s --health-timeout 2s --health-retries 5 + + steps: + - name: Set app env + run: | + # Split and keep last + echo "APP_NAME=${GITHUB_REPOSITORY##*/}" >> $GITHUB_ENV + + - name: Checkout server + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + with: + submodules: true + repository: nextcloud/server + ref: ${{ matrix.server-versions }} + + - name: Register text Git reference + run: | + text_app_ref="$(if [ "${{ matrix.server-versions }}" = "master" ]; then echo -n "main"; else echo -n "${{ matrix.server-versions }}"; fi)" + echo "text_app_ref=$text_app_ref" >> $GITHUB_ENV + + - name: Checkout text app + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + with: + repository: nextcloud/text + path: apps/text + ref: ${{ env.text_app_ref }} + + - name: Checkout viewer app + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + with: + repository: nextcloud/viewer + path: apps/viewer + ref: ${{ matrix.server-versions }} + + - name: Set up php ${{ matrix.php-versions }} + uses: shivammathur/setup-php@e6f75134d35752277f093989e72e140eaa222f35 # v2 + with: + php-version: ${{ matrix.php-versions }} + # https://docs.nextcloud.com/server/stable/admin_manual/installation/source_installation.html#prerequisites-for-manual-installation + extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, mysql, pdo_mysql, sqlite, pdo_sqlite + coverage: none + ini-file: development + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Enable ONLY_FULL_GROUP_BY MySQL option + run: | + echo "SET GLOBAL sql_mode=(SELECT CONCAT(@@sql_mode,',ONLY_FULL_GROUP_BY'));" | mysql -h 127.0.0.1 -P 4444 -u root -prootpassword + echo "SELECT @@sql_mode;" | mysql -h 127.0.0.1 -P 4444 -u root -prootpassword + + - name: Set up Nextcloud, enable tables from app store + env: + DB_PORT: 4444 + run: | + mkdir data + ./occ maintenance:install --verbose --database=mysql --database-name=nextcloud --database-host=127.0.0.1 --database-port=$DB_PORT --database-user=root --database-pass=rootpassword --admin-user admin --admin-pass admin + ./occ app:enable --force ${{ env.APP_NAME }} + + - name: Checkout app + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + with: + path: apps/${{ env.APP_NAME }} + + - name: Set up dependencies + working-directory: apps/${{ env.APP_NAME }} + run: composer i --no-dev + + - name: Upgrade Nextcloud and see whether the app still works + run: | + ./occ upgrade + ./occ app:list + + - name: Upload nextcloud logs + uses: actions/upload-artifact@v4 + if: always() + with: + name: nextcloud.log + path: data/nextcloud.log + retention-days: 5 + + + summary: + permissions: + contents: none + runs-on: ubuntu-latest + needs: [changes, app-upgrade-mysql] + + if: always() + + name: app-upgrade-mysql-summary + + steps: + - name: Summary status + run: if ${{ needs.changes.outputs.src != 'false' && needs.app-upgrade-mysql.result != 'success' }}; then exit 1; fi diff --git a/Makefile b/Makefile index c40dc60d9..0016427cb 100644 --- a/Makefile +++ b/Makefile @@ -103,6 +103,9 @@ lint: lint-php lint-js lint-css lint-xml lint-php: lint-php-lint lint-php-cs-fixer lint-php-psalm +lint-fast: + composer run psalm -- --show-info=false + lint-php-lint: # Check PHP syntax errors @! find $(php_dirs) -name "*.php" | xargs -I{} php -l '{}' | grep -v "No syntax errors detected" diff --git a/appinfo/info.xml b/appinfo/info.xml index abd009d7a..251444002 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -22,7 +22,7 @@ Share your tables and views with users and groups within your cloud. Have a good time and manage whatever you want. ]]> - 0.6.5 + 0.7.0-dev.1 agpl Florian Steffens Tables @@ -46,6 +46,11 @@ Have a good time and manage whatever you want. sqlite + + + OCA\Tables\Migration\NewDbStructureRepairStep + + OCA\Tables\Command\ListTables OCA\Tables\Command\AddTable @@ -53,6 +58,8 @@ Have a good time and manage whatever you want. OCA\Tables\Command\RenameTable OCA\Tables\Command\ChangeOwnershipTable OCA\Tables\Command\Clean + OCA\Tables\Command\CleanLegacy + OCA\Tables\Command\TransferLegacyRows diff --git a/cypress/e2e/view-filtering-selection.cy.js b/cypress/e2e/view-filtering-selection.cy.js new file mode 100644 index 000000000..4d5226ed6 --- /dev/null +++ b/cypress/e2e/view-filtering-selection.cy.js @@ -0,0 +1,371 @@ +let localUser + +describe('Filtering in a view by selection columns', () => { + + before(function() { + cy.createRandomUser().then(user => { + localUser = user + }) + }) + + beforeEach(function() { + cy.login(localUser) + cy.visit('apps/tables') + }) + + it('Setup table', () => { + cy.createTable('View filtering test table') + cy.createTextLineColumn('title', null, null, true) + cy.createSelectionColumn('selection', ['sel1', 'sel2', 'sel3', 'sel4'], null, false) + cy.createSelectionMultiColumn('multi selection', ['A', 'B', 'C', 'D'], null, false) + cy.createSelectionCheckColumn('check', null, false) + + // add row + cy.get('button').contains('Create row').click() + cy.fillInValueTextLine('title', 'first row') + cy.fillInValueSelection('selection', 'sel1') + cy.fillInValueSelectionMulti('multi selection', ['A', 'B']) + cy.fillInValueSelectionCheck('check') + cy.get('button').contains('Save').click() + + // add row + cy.get('button').contains('Create row').click() + cy.fillInValueTextLine('title', 'second row') + cy.fillInValueSelection('selection', 'sel2') + cy.fillInValueSelectionMulti('multi selection', ['B']) + cy.fillInValueSelectionCheck('check') + cy.get('button').contains('Save').click() + + // add row + cy.get('button').contains('Create row').click() + cy.fillInValueTextLine('title', 'third row') + cy.fillInValueSelection('selection', 'sel3') + cy.fillInValueSelectionMulti('multi selection', ['C', 'B', 'D']) + cy.get('button').contains('Save').click() + + // add row + cy.get('button').contains('Create row').click() + cy.fillInValueTextLine('title', 'fourth row') + cy.fillInValueSelectionMulti('multi selection', ['A']) + cy.fillInValueSelectionCheck('check') + cy.get('button').contains('Save').click() + + // add row + cy.get('button').contains('Create row').click() + cy.fillInValueTextLine('title', 'fifths row') + cy.fillInValueSelection('selection', 'sel4') + cy.fillInValueSelectionMulti('multi selection', ['D']) + cy.fillInValueSelectionCheck('check') + cy.get('button').contains('Save').click() + + // add row + cy.get('button').contains('Create row').click() + cy.fillInValueTextLine('title', 'sixths row') + cy.fillInValueSelection('selection', 'sel1') + cy.fillInValueSelectionMulti('multi selection', ['C', 'D']) + cy.fillInValueSelectionCheck('check') + cy.get('button').contains('Save').click() + + // add row + cy.get('button').contains('Create row').click() + cy.fillInValueTextLine('title', 'sevenths row') + cy.fillInValueSelection('selection', 'sel2') + cy.fillInValueSelectionMulti('multi selection', ['A', 'C', 'B', 'D']) + cy.get('button').contains('Save').click() + }) + + it('Filter view for single selection', () => { + cy.loadTable('View filtering test table') + + // # create view with filter + // ## create view and set title + const title = 'Filter for single selection' + cy.get('[data-cy="customTableAction"] button').click() + cy.get('.v-popper__popper li button span').contains('Create view').click({ force: true }) + cy.get('.modal-container #settings-section_title input').type(title) + + // ## add filter + cy.get('button').contains('Add new filter group').click() + cy.get('.modal-container .filter-group .v-select.select').eq(0).click() + cy.get('ul.vs__dropdown-menu li span[title="selection"]').click() + cy.get('.modal-container .filter-group .v-select.select').eq(1).click() + cy.get('ul.vs__dropdown-menu li span[title="Is equal"]').click() + cy.get('.modal-container .filter-group .v-select.select').eq(2).click() + cy.get('ul.vs__dropdown-menu li span[title="sel2"]').click() + + // ## save view + cy.intercept({ method: 'POST', url: '**/apps/tables/view' }).as('createView') + cy.intercept({ method: 'PUT', url: '**/apps/tables/view/*' }).as('updateView') + cy.contains('button', 'Create View').click() + cy.wait('@createView') + cy.wait('@updateView') + cy.contains('.app-navigation-entry-link span', title).should('exist') + + // # check for expected rows + // ## expected + let expected = ['sevenths row', 'second row'] + expected.forEach(item => { + cy.get('.custom-table table tr td div').contains(item).should('be.visible') + }) + + // ## not expected + let unexpected = ['first row', 'third row', 'fourth row', 'fifths row', 'sixths row'] + unexpected.forEach(item => { + cy.get('.custom-table table tr td div').contains(item).should('not.exist') + }) + + // # change filter value + // ## adjust filter + cy.get('[data-cy="customTableAction"] button').click() + cy.get('.v-popper__popper li button span').contains('Edit view').click({ force: true }) + cy.get('.modal-container .filter-group .v-select.select').eq(2).click() + cy.get('ul.vs__dropdown-menu li span[title="sel1"]').click() + + // ## update view + cy.intercept({ method: 'PUT', url: '**/apps/tables/view/*' }).as('updateView') + cy.contains('button', 'Save View').click() + cy.wait('@updateView') + + // # check for expected rows + // ## expected + expected = ['first row', 'sixths row'] + expected.forEach(item => { + cy.get('.custom-table table tr td div').contains(item).should('be.visible') + }) + + // ## not expected + unexpected = ['second row', 'third row', 'fourth row', 'fifths row', 'sevenths row'] + unexpected.forEach(item => { + cy.get('.custom-table table tr td div').contains(item).should('not.exist') + }) + }) + + it('Filter view for multi selection - equals', () => { + cy.loadTable('View filtering test table') + + // # create view with filter + // ## create view and set title + const title = 'Filter for multi selection 1' + cy.get('[data-cy="customTableAction"] button').click() + cy.get('.v-popper__popper li button span').contains('Create view').click({ force: true }) + cy.get('.modal-container #settings-section_title input').type(title) + + // ## add filter + cy.get('button').contains('Add new filter group').click() + cy.get('.modal-container .filter-group .v-select.select').eq(0).click() + cy.get('ul.vs__dropdown-menu li span[title="multi selection"]').click() + cy.get('.modal-container .filter-group .v-select.select').eq(1).click() + cy.get('ul.vs__dropdown-menu li span[title="Is equal"]').click() + cy.get('.modal-container .filter-group .v-select.select').eq(2).click() + cy.get('ul.vs__dropdown-menu li span[title="A"]').click() + + // ## save view + cy.intercept({ method: 'POST', url: '**/apps/tables/view' }).as('createView') + cy.intercept({ method: 'PUT', url: '**/apps/tables/view/*' }).as('updateView') + cy.contains('button', 'Create View').click() + cy.wait('@createView') + cy.wait('@updateView') + cy.contains('.app-navigation-entry-link span', title).should('exist') + + // # check for expected rows + // ## expected + const expected = ['fourth row'] + expected.forEach(item => { + cy.get('.custom-table table tr td div').contains(item).should('be.visible') + }) + + // ## not expected + const unexpected = ['first row', 'second row', 'third row', 'fifths row', 'sixths row', 'sevenths row'] + unexpected.forEach(item => { + cy.get('.custom-table table tr td div').contains(item).should('not.exist') + }) + }) + + it('Filter view for multi selection - contains', () => { + cy.loadTable('View filtering test table') + + // # create view with filter + // ## create view and set title + const title = 'Filter for multi selection 2' + cy.get('[data-cy="customTableAction"] button').click() + cy.get('.v-popper__popper li button span').contains('Create view').click({ force: true }) + cy.get('.modal-container #settings-section_title input').type(title) + + // ## add filter + cy.get('button').contains('Add new filter group').click() + cy.get('.modal-container .filter-group .v-select.select').eq(0).click() + cy.get('ul.vs__dropdown-menu li span[title="multi selection"]').click() + cy.get('.modal-container .filter-group .v-select.select').eq(1).click() + cy.get('ul.vs__dropdown-menu li span[title="Contains"]').click() + cy.get('.modal-container .filter-group .v-select.select').eq(2).click() + cy.get('ul.vs__dropdown-menu li span[title="A"]').click() + + // ## save view + cy.intercept({ method: 'POST', url: '**/apps/tables/view' }).as('createView') + cy.intercept({ method: 'PUT', url: '**/apps/tables/view/*' }).as('updateView') + cy.contains('button', 'Create View').click() + cy.wait('@createView') + cy.wait('@updateView') + cy.contains('.app-navigation-entry-link span', title).should('exist') + + // # check for expected rows + // ## expected + const expected = ['first row', 'fourth row', 'sevenths row'] + expected.forEach(item => { + cy.get('.custom-table table tr td div').contains(item).should('be.visible') + }) + + // ## not expected + const unexpected = ['second row', 'third row', 'fifths row', 'sixths row'] + unexpected.forEach(item => { + cy.get('.custom-table table tr td div').contains(item).should('not.exist') + }) + }) + + it('Filter view for multi selection - multiple contains', () => { + cy.loadTable('View filtering test table') + + // # create view with filter + // ## create view and set title + const title = 'Filter for multi selection 3' + cy.get('[data-cy="customTableAction"] button').click() + cy.get('.v-popper__popper li button span').contains('Create view').click({ force: true }) + cy.get('.modal-container #settings-section_title input').type(title) + + // ## add filter + cy.get('button').contains('Add new filter group').click() + cy.get('.modal-container .filter-group .v-select.select').eq(0).click() + cy.get('ul.vs__dropdown-menu li span[title="multi selection"]').click() + cy.get('.modal-container .filter-group .v-select.select').eq(1).click() + cy.get('ul.vs__dropdown-menu li span[title="Contains"]').click() + cy.get('.modal-container .filter-group .v-select.select').eq(2).click() + cy.get('ul.vs__dropdown-menu li span[title="A"]').click() + + cy.get('button').contains('Add new filter').click() + cy.get('.modal-container .filter-group .v-select.select').eq(3).click() + cy.get('ul.vs__dropdown-menu li span[title="multi selection"]').click() + cy.get('.modal-container .filter-group .v-select.select').eq(4).click() + cy.get('ul.vs__dropdown-menu li span[title="Contains"]').click() + cy.get('.modal-container .filter-group .v-select.select').eq(5).click() + cy.get('ul.vs__dropdown-menu li span[title="B"]').click() + + // ## save view + cy.intercept({ method: 'POST', url: '**/apps/tables/view' }).as('createView') + cy.intercept({ method: 'PUT', url: '**/apps/tables/view/*' }).as('updateView') + cy.contains('button', 'Create View').click() + cy.wait('@createView') + cy.wait('@updateView') + cy.contains('.app-navigation-entry-link span', title).should('exist') + + // # check for expected rows + // ## expected + const expected = ['first row', 'sevenths row'] + expected.forEach(item => { + cy.get('.custom-table table tr td div').contains(item).should('be.visible') + }) + + // ## not expected + const unexpected = ['second row', 'third row', 'fourth row', 'fifths row', 'sixths row'] + unexpected.forEach(item => { + cy.get('.custom-table table tr td div').contains(item).should('not.exist') + }) + }) + + it('Filter view for multi selection - multiple filter groups', () => { + cy.loadTable('View filtering test table') + + // # create view with filter + // ## create view and set title + const title = 'Filter for multi selection 3' + cy.get('[data-cy="customTableAction"] button').click() + cy.get('.v-popper__popper li button span').contains('Create view').click({ force: true }) + cy.get('.modal-container #settings-section_title input').type(title) + + // ## add filter + cy.get('button').contains('Add new filter group').click() + cy.get('.modal-container .filter-group .v-select.select').eq(0).click() + cy.get('ul.vs__dropdown-menu li span[title="multi selection"]').click() + cy.get('.modal-container .filter-group .v-select.select').eq(1).click() + cy.get('ul.vs__dropdown-menu li span[title="Contains"]').click() + cy.get('.modal-container .filter-group .v-select.select').eq(2).click() + cy.get('ul.vs__dropdown-menu li span[title="A"]').click() + + cy.get('button').contains('Add new filter').click() + cy.get('.modal-container .filter-group .v-select.select').eq(3).click() + cy.get('ul.vs__dropdown-menu li span[title="multi selection"]').click() + cy.get('.modal-container .filter-group .v-select.select').eq(4).click() + cy.get('ul.vs__dropdown-menu li span[title="Contains"]').click() + cy.get('.modal-container .filter-group .v-select.select').eq(5).click() + cy.get('ul.vs__dropdown-menu li span[title="B"]').click() + + cy.get('button').contains('Add new filter group').click() + cy.get('.modal-container .filter-group .v-select.select').eq(6).click() + cy.get('ul.vs__dropdown-menu li span[title="multi selection"]').click() + cy.get('.modal-container .filter-group .v-select.select').eq(7).click() + cy.get('ul.vs__dropdown-menu li span[title="Contains"]').click() + cy.get('.modal-container .filter-group .v-select.select').eq(8).click() + cy.get('ul.vs__dropdown-menu li span[title="D"]').click() + + // ## save view + cy.intercept({ method: 'POST', url: '**/apps/tables/view' }).as('createView') + cy.intercept({ method: 'PUT', url: '**/apps/tables/view/*' }).as('updateView') + cy.contains('button', 'Create View').click() + cy.wait('@createView') + cy.wait('@updateView') + cy.contains('.app-navigation-entry-link span', title).should('exist') + + // # check for expected rows + // ## expected + const expected = ['first row', 'third row', 'fifths row', 'sixths row', 'sevenths row'] + expected.forEach(item => { + cy.get('.custom-table table tr td div').contains(item).should('be.visible') + }) + + // ## not expected + const unexpected = ['second row', 'fourths row'] + unexpected.forEach(item => { + cy.get('.custom-table table tr td div').contains(item).should('not.exist') + }) + }) + + it('Filter view for selection check', () => { + cy.loadTable('View filtering test table') + + // # create view with filter + // ## create view and set title + const title = 'Filter for check selection' + cy.get('[data-cy="customTableAction"] button').click() + cy.get('.v-popper__popper li button span').contains('Create view').click({ force: true }) + cy.get('.modal-container #settings-section_title input').type(title) + + // ## add filter + cy.get('button').contains('Add new filter group').click() + cy.get('.modal-container .filter-group .v-select.select').eq(0).click() + cy.get('ul.vs__dropdown-menu li span[title="check"]').click() + cy.get('.modal-container .filter-group .v-select.select').eq(1).click() + cy.get('ul.vs__dropdown-menu li span[title="Is equal"]').click() + cy.get('.modal-container .filter-group .v-select.select').eq(2).click() + cy.get('ul.vs__dropdown-menu li span[title="Checked"]').click() + + // ## save view + cy.intercept({ method: 'POST', url: '**/apps/tables/view' }).as('createView') + cy.intercept({ method: 'PUT', url: '**/apps/tables/view/*' }).as('updateView') + cy.contains('button', 'Create View').click() + cy.wait('@createView') + cy.wait('@updateView') + cy.contains('.app-navigation-entry-link span', title).should('exist') + + // # check for expected rows + // ## expected + const expected = ['first row', 'second row', 'fourth row', 'fifths row', 'sixths row'] + expected.forEach(item => { + cy.get('.custom-table table tr td div').contains(item).should('be.visible') + }) + + // ## not expected + const unexpected = ['third row', 'sevenths row'] + unexpected.forEach(item => { + cy.get('.custom-table table tr td div').contains(item).should('not.exist') + }) + }) +}) diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 3d05189da..94b67ed02 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -422,3 +422,21 @@ Cypress.Commands.add('removeColumn', (title) => { cy.get('.v-popper__popper ul.nc-button-group-content').last().get('button').last().click() cy.get('.modal__content button').contains('Confirm').click() }) + +// fill in a value in the 'create row' or 'edit row' model +Cypress.Commands.add('fillInValueTextLine', (columnTitle, value) => { + cy.get('.modal__content [data-cy="' + columnTitle + '"] .slot input').type(value) +}) +Cypress.Commands.add('fillInValueSelection', (columnTitle, optionLabel) => { + cy.get('.modal__content [data-cy="' + columnTitle + '"] .slot input').click() + cy.get('ul.vs__dropdown-menu li span[title="' + optionLabel + '"]').click() +}) +Cypress.Commands.add('fillInValueSelectionMulti', (columnTitle, optionLabels) => { + optionLabels.forEach(item => { + cy.get('.modal__content [data-cy="' + columnTitle + '"] .slot input').click() + cy.get('ul.vs__dropdown-menu li span[title="' + item + '"]').click() + }) +}) +Cypress.Commands.add('fillInValueSelectionCheck', (columnTitle) => { + cy.get('.modal__content [data-cy="' + columnTitle + '"] .checkbox-radio-switch__label').click() +}) diff --git a/lib/Api/V1Api.php b/lib/Api/V1Api.php index 1b68f37c8..2c9a7b8c4 100644 --- a/lib/Api/V1Api.php +++ b/lib/Api/V1Api.php @@ -58,7 +58,7 @@ public function getData(int $nodeId, ?int $limit, ?int $offset, ?string $userId, // now add the rows foreach ($rows as $row) { - $rowData = $row->getDataArray(); + $rowData = $row->getData(); $line = []; foreach ($columns as $column) { $value = ''; diff --git a/lib/Command/Clean.php b/lib/Command/Clean.php index 901a2189f..40a286e53 100644 --- a/lib/Command/Clean.php +++ b/lib/Command/Clean.php @@ -23,17 +23,15 @@ namespace OCA\Tables\Command; -use OCA\Tables\Db\Row; -use OCA\Tables\Db\RowMapper; +use Exception; +use OCA\Tables\Db\Row2; +use OCA\Tables\Db\Row2Mapper; use OCA\Tables\Errors\InternalError; use OCA\Tables\Errors\NotFoundError; use OCA\Tables\Errors\PermissionError; use OCA\Tables\Service\ColumnService; use OCA\Tables\Service\RowService; use OCA\Tables\Service\TableService; -use OCP\AppFramework\Db\DoesNotExistException; -use OCP\AppFramework\Db\MultipleObjectsReturnedException; -use OCP\DB\Exception; use Psr\Log\LoggerInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -50,17 +48,17 @@ class Clean extends Command { protected RowService $rowService; protected TableService $tableService; protected LoggerInterface $logger; - protected RowMapper $rowMapper; + protected Row2Mapper $rowMapper; private bool $dry = false; private int $truncateLength = 20; - private ?Row $row = null; + private ?Row2 $row = null; private int $offset = -1; private OutputInterface $output; - public function __construct(LoggerInterface $logger, ColumnService $columnService, RowService $rowService, TableService $tableService, RowMapper $rowMapper) { + public function __construct(LoggerInterface $logger, ColumnService $columnService, RowService $rowService, TableService $tableService, Row2Mapper $rowMapper) { parent::__construct(); $this->logger = $logger; $this->columnService = $columnService; @@ -106,16 +104,21 @@ protected function execute(InputInterface $input, OutputInterface $output): int private function getNextRow():void { try { - $this->row = $this->rowMapper->findNext($this->offset); + $nextRowId = $this->rowMapper->findNextId($this->offset); + if ($nextRowId === null) { + $this->print(""); + $this->print("No more rows found.", self::PRINT_LEVEL_INFO); + $this->print(""); + $this->row = null; + return; + } + $tableId = $this->rowMapper->getTableIdForRow($nextRowId); + $columns = $this->columnService->findAllByTable($tableId, null, ''); + $this->row = $this->rowMapper->find($nextRowId, $columns); $this->offset = $this->row->getId(); - } catch (MultipleObjectsReturnedException|Exception $e) { + } catch (Exception $e) { $this->print('Error while fetching row', self::PRINT_LEVEL_ERROR); $this->logger->error('Following error occurred during executing occ command "'.self::class.'"', ['exception' => $e]); - } catch (DoesNotExistException $e) { - $this->print(""); - $this->print("No more rows found.", self::PRINT_LEVEL_INFO); - $this->print(""); - $this->row = null; } } @@ -141,14 +144,14 @@ private function checkIfColumnsForRowsExists(): void { } private function checkColumns(): void { - $data = json_decode($this->row->getData()); - foreach ($data as $date) { - $suffix = strlen($date->value) > $this->truncateLength ? "...": ""; + foreach ($this->row->getData() as $date) { + $valueAsString = is_string($date['value']) ? $date['value'] : json_encode($date['value']); + $suffix = strlen($valueAsString) > $this->truncateLength ? "...": ""; $this->print(""); - $this->print("columnId: " . $date->columnId . " -> " . substr($date->value, 0, $this->truncateLength) . $suffix, self::PRINT_LEVEL_INFO); + $this->print("columnId: " . $date['columnId'] . " -> " . substr($valueAsString, 0, $this->truncateLength) . $suffix, self::PRINT_LEVEL_INFO); try { - $this->columnService->find($date->columnId, ''); + $this->columnService->find($date['columnId'], ''); if($this->output->isVerbose()) { $this->print("column found", self::PRINT_LEVEL_SUCCESS); } @@ -159,10 +162,10 @@ private function checkColumns(): void { if($this->output->isVerbose()) { $this->print("corresponding column not found.", self::PRINT_LEVEL_ERROR); } else { - $this->print("columnId: " . $date->columnId . " not found, but needed by row ".$this->row->getId(), self::PRINT_LEVEL_WARNING); + $this->print("columnId: " . $date['columnId'] . " not found, but needed by row ".$this->row->getId(), self::PRINT_LEVEL_WARNING); } // if the corresponding column is not found, lets delete the data from the row. - $this->deleteDataFromRow($date->columnId); + $this->deleteDataFromRow($date['columnId']); } catch (PermissionError $e) { $this->print("😱ī¸ permission error while looking for column", self::PRINT_LEVEL_ERROR); $this->logger->error('Following error occurred during executing occ command "'.self::class.'"', ['exception' => $e]); @@ -182,17 +185,18 @@ private function deleteDataFromRow(int $columnId): void { } $this->print("DANGER, start deleting", self::PRINT_LEVEL_WARNING); - $data = json_decode($this->row->getData(), true); + $data = $this->row->getData(); - // $this->print("Data before: \t".json_encode(array_values($data), 0), self::PRINT_LEVEL_INFO); + $this->print("Data before: \t".json_encode(array_values($data)), self::PRINT_LEVEL_INFO); $key = array_search($columnId, array_column($data, 'columnId')); unset($data[$key]); - // $this->print("Data after: \t".json_encode(array_values($data), 0), self::PRINT_LEVEL_INFO); - $this->row->setDataArray(array_values($data)); + $this->print("Data after: \t".json_encode(array_values($data)), self::PRINT_LEVEL_INFO); + $this->row->setData(array_values($data)); + try { - $this->rowMapper->update($this->row); + $this->rowMapper->update($this->row, $this->columnService->findAllByTable($this->row->getTableId())); $this->print("Row successfully updated", self::PRINT_LEVEL_SUCCESS); - } catch (Exception $e) { + } catch (InternalError|PermissionError $e) { $this->print("Error while updating row to db.", self::PRINT_LEVEL_ERROR); $this->logger->error('Following error occurred during executing occ command "'.self::class.'"', ['exception' => $e]); } diff --git a/lib/Command/CleanLegacy.php b/lib/Command/CleanLegacy.php new file mode 100644 index 000000000..be2a7868a --- /dev/null +++ b/lib/Command/CleanLegacy.php @@ -0,0 +1,224 @@ + + * + * @author Florian Steffens + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\Tables\Command; + +use OCA\Tables\Db\LegacyRow; +use OCA\Tables\Db\LegacyRowMapper; +use OCA\Tables\Errors\InternalError; +use OCA\Tables\Errors\NotFoundError; +use OCA\Tables\Errors\PermissionError; +use OCA\Tables\Service\ColumnService; +use OCA\Tables\Service\RowService; +use OCA\Tables\Service\TableService; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\MultipleObjectsReturnedException; +use OCP\DB\Exception; +use Psr\Log\LoggerInterface; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +class CleanLegacy extends Command { + public const PRINT_LEVEL_SUCCESS = 1; + public const PRINT_LEVEL_INFO = 2; + public const PRINT_LEVEL_WARNING = 3; + public const PRINT_LEVEL_ERROR = 4; + + protected ColumnService $columnService; + protected RowService $rowService; + protected TableService $tableService; + protected LoggerInterface $logger; + protected LegacyRowMapper $rowMapper; + + private bool $dry = false; + private int $truncateLength = 20; + + private ?LegacyRow $row = null; + private int $offset = -1; + + private OutputInterface $output; + + public function __construct(LoggerInterface $logger, ColumnService $columnService, RowService $rowService, TableService $tableService, LegacyRowMapper $rowMapper) { + parent::__construct(); + $this->logger = $logger; + $this->columnService = $columnService; + $this->rowService = $rowService; + $this->tableService = $tableService; + $this->rowMapper = $rowMapper; + } + + protected function configure(): void { + $this + ->setName('tables:legacy:clean') + ->setDescription('Clean the tables legacy data.') + ->addOption( + 'dry', + 'd', + InputOption::VALUE_NONE, + 'Prints all wanted changes, but do not write anything to the database.' + ) + ; + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * @return int + */ + protected function execute(InputInterface $input, OutputInterface $output): int { + $this->output = $output; + $this->dry = !!$input->getOption('dry'); + + if ($this->dry) { + $this->print("Dry run activated."); + } + if ($output->isVerbose()) { + $this->print("Verbose mode activated."); + } + + // check action, starting point for magic + $this->checkIfColumnsForRowsExists(); + + return 0; + } + + private function getNextRow():void { + try { + $this->row = $this->rowMapper->findNext($this->offset); + $this->offset = $this->row->getId(); + } catch (MultipleObjectsReturnedException|Exception $e) { + $this->print('Error while fetching row', self::PRINT_LEVEL_ERROR); + $this->logger->error('Following error occurred during executing occ command "'.self::class.'"', ['exception' => $e]); + } catch (DoesNotExistException $e) { + $this->print(""); + $this->print("No more rows found.", self::PRINT_LEVEL_INFO); + $this->print(""); + $this->row = null; + } + } + + + /** + * Take each data set from all rows and check if the column (mapped by id) exists + * + * @return void + */ + private function checkIfColumnsForRowsExists(): void { + + $this->getNextRow(); + while ($this->row) { + $this->print(""); + $this->print(""); + $this->print("Lets check row with id = " . $this->row->getId()); + $this->print("=========================================="); + + $this->checkColumns(); + + $this->getNextRow(); + } + } + + private function checkColumns(): void { + $data = json_decode($this->row->getData()); + foreach ($data as $date) { + // this is a fix and possible because we don't really need the row data + if (is_array($date->value)) { + $date->value = json_encode($date->value); + } + $suffix = strlen($date->value) > $this->truncateLength ? "...": ""; + $this->print(""); + $this->print("columnId: " . $date->columnId . " -> " . substr($date->value, 0, $this->truncateLength) . $suffix, self::PRINT_LEVEL_INFO); + + try { + $this->columnService->find($date->columnId, ''); + if($this->output->isVerbose()) { + $this->print("column found", self::PRINT_LEVEL_SUCCESS); + } + } catch (InternalError $e) { + $this->print("😱ī¸ internal error while looking for column", self::PRINT_LEVEL_ERROR); + $this->logger->error('Following error occurred during executing occ command "'.self::class.'"', ['exception' => $e]); + } catch (NotFoundError $e) { + if($this->output->isVerbose()) { + $this->print("corresponding column not found.", self::PRINT_LEVEL_ERROR); + } else { + $this->print("columnId: " . $date->columnId . " not found, but needed by row ".$this->row->getId(), self::PRINT_LEVEL_WARNING); + } + // if the corresponding column is not found, lets delete the data from the row. + $this->deleteDataFromRow($date->columnId); + } catch (PermissionError $e) { + $this->print("😱ī¸ permission error while looking for column", self::PRINT_LEVEL_ERROR); + $this->logger->error('Following error occurred during executing occ command "'.self::class.'"', ['exception' => $e]); + } + } + } + + /** + * Deletes the data for a given columnID from the dataset within a row + * @param int $columnId + * @return void + */ + private function deleteDataFromRow(int $columnId): void { + if ($this->dry) { + $this->print("Is dry run, will not remove the column data from row dataset.", self::PRINT_LEVEL_INFO); + return; + } + + $this->print("DANGER, start deleting", self::PRINT_LEVEL_WARNING); + $data = json_decode($this->row->getData(), true); + + // $this->print("Data before: \t".json_encode(array_values($data), 0), self::PRINT_LEVEL_INFO); + $key = array_search($columnId, array_column($data, 'columnId')); + unset($data[$key]); + // $this->print("Data after: \t".json_encode(array_values($data), 0), self::PRINT_LEVEL_INFO); + $this->row->setDataArray(array_values($data)); + try { + $this->rowMapper->update($this->row); + $this->print("Row successfully updated", self::PRINT_LEVEL_SUCCESS); + } catch (Exception $e) { + $this->print("Error while updating row to db.", self::PRINT_LEVEL_ERROR); + $this->logger->error('Following error occurred during executing occ command "'.self::class.'"', ['exception' => $e]); + } + } + + private function print(string $message, int $level = null): void { + if($level === self::PRINT_LEVEL_SUCCESS) { + echo "✅ ".$message; + echo "\n"; + } elseif ($level === self::PRINT_LEVEL_INFO && $this->output->isVerbose()) { + echo "ℹī¸ ".$message; + echo "\n"; + } elseif ($level === self::PRINT_LEVEL_WARNING) { + echo "⚠ī¸ ".$message; + echo "\n"; + } elseif ($level === self::PRINT_LEVEL_ERROR) { + echo "❌ ".$message; + echo "\n"; + } elseif ($this->output->isVerbose()) { + echo $message; + echo "\n"; + } + } + +} diff --git a/lib/Command/TransferLegacyRows.php b/lib/Command/TransferLegacyRows.php new file mode 100644 index 000000000..6c76c77dd --- /dev/null +++ b/lib/Command/TransferLegacyRows.php @@ -0,0 +1,183 @@ + + * + * @author Florian Steffens + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\Tables\Command; + +use OCA\Tables\Db\LegacyRowMapper; +use OCA\Tables\Db\Row2Mapper; +use OCA\Tables\Db\Table; +use OCA\Tables\Errors\InternalError; +use OCA\Tables\Errors\NotFoundError; +use OCA\Tables\Errors\PermissionError; +use OCA\Tables\Service\ColumnService; +use OCA\Tables\Service\TableService; +use OCP\DB\Exception; +use Psr\Log\LoggerInterface; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +class TransferLegacyRows extends Command { + protected TableService $tableService; + protected LoggerInterface $logger; + protected LegacyRowMapper $legacyRowMapper; + protected Row2Mapper $rowMapper; + protected ColumnService $columnService; + + public function __construct(TableService $tableService, LoggerInterface $logger, LegacyRowMapper $legacyRowMapper, Row2Mapper $rowMapper, ColumnService $columnService) { + parent::__construct(); + $this->tableService = $tableService; + $this->logger = $logger; + $this->legacyRowMapper = $legacyRowMapper; + $this->rowMapper = $rowMapper; + $this->columnService = $columnService; + } + + protected function configure(): void { + $this + ->setName('tables:legacy:transfer:rows') + ->setDescription('Transfer table legacy rows to new schema.') + ->addArgument( + 'table-ids', + InputArgument::OPTIONAL, + 'IDs of tables for the which data is to be transferred. (Multiple comma seperated possible)' + ) + ->addOption( + 'all', + null, + InputOption::VALUE_NONE, + 'Transfer all table data.' + ) + ->addOption( + 'delete', + null, + InputOption::VALUE_OPTIONAL, + 'Set to delete data from new db structure if any before transferring data.' + ) + ; + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * @return int + */ + protected function execute(InputInterface $input, OutputInterface $output): int { + $tableIds = $input->getArgument('table-ids'); + $optionAll = !!$input->getOption('all'); + $optionDelete = $input->getOption('delete') ?: null; + + if ($optionAll) { + $output->writeln("Look for tables"); + try { + $tables = $this->tableService->findAll('', true, true, false); + $output->writeln("Found ". count($tables) . " table(s)"); + } catch (InternalError $e) { + $output->writeln("Error while fetching tables. Will aboard."); + return 1; + } + } elseif ($tableIds) { + $output->writeln("Look for given table(s)"); + $tableIds = explode(',', $tableIds); + $tables = []; + foreach ($tableIds as $tableId) { + try { + $tables[] = $this->tableService->find((int)ltrim($tableId), true, ''); + } catch (InternalError|NotFoundError|PermissionError $e) { + $output->writeln("Could not load table id " . $tableId . ". Will continue."); + } + } + } else { + $output->writeln("🤷đŸģ‍ Add at least one table id or add the option --all to transfer all tables."); + return 2; + } + if ($optionDelete) { + $this->deleteDataForTables($tables, $output); + } + $this->transferDataForTables($tables, $output); + + return 0; + } + + /** + * @param Table[] $tables + * @param OutputInterface $output + * @return void + */ + private function transferDataForTables(array $tables, OutputInterface $output): void { + $i = 1; + foreach ($tables as $table) { + $output->writeln(''); + $output->writeln("-- Start transfer for table " . $table->getId() . " (" . $table->getTitle() . ") [" . $i . "/" . count($tables) . "]"); + try { + $this->transferTable($table, $output); + } catch (InternalError|PermissionError|Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + if ($output->isVerbose()) { + $output->writeln("❌ Error: " . $e->getMessage()); + } + $output->writeln("⚠ī¸ Could not transfer data. Continue with next table. The logs will have more information about the error: " . $e->getMessage()); + } + $i++; + } + } + + /** + * @throws PermissionError + * @throws InternalError + * @throws Exception + */ + private function transferTable(Table $table, OutputInterface $output): void { + $columns = $this->columnService->findAllByTable($table->getId(), null, ''); + $output->writeln("---- Found " . count($columns) . " columns"); + + $legacyRows = $this->legacyRowMapper->findAllByTable($table->getId()); + $output->writeln("---- Found " . count($legacyRows) . " rows"); + + foreach ($legacyRows as $legacyRow) { + $this->legacyRowMapper->transferLegacyRow($legacyRow, $columns); + } + $output->writeln("---- ✅ All rows transferred."); + } + + /** + * @param Table[] $tables + * @param OutputInterface $output + * @return void + */ + private function deleteDataForTables(array $tables, OutputInterface $output): void { + $output->writeln("Start deleting data for tables that should be transferred."); + foreach ($tables as $table) { + try { + $columns = $this->columnService->findAllByTable($table->getId(), null, ''); + } catch (InternalError|PermissionError $e) { + $output->writeln("Could not delete data for table " . $table->getId()); + break; + } + $this->rowMapper->deleteAllForTable($table->getId(), $columns); + $output->writeln("🗑ī¸ Data for table " . $table->getId() . " (" . $table->getTitle() . ")" . " removed."); + } + } +} diff --git a/lib/Controller/Api1Controller.php b/lib/Controller/Api1Controller.php index 13d1f7bff..a9d9a8566 100644 --- a/lib/Controller/Api1Controller.php +++ b/lib/Controller/Api1Controller.php @@ -937,7 +937,7 @@ public function indexTableRowsSimple(int $tableId, ?int $limit, ?int $offset): D */ public function indexTableRows(int $tableId, ?int $limit, ?int $offset): DataResponse { try { - return new DataResponse($this->rowService->findAllByTable($tableId, $this->userId, $limit, $offset)); + return new DataResponse($this->rowService->formatRows($this->rowService->findAllByTable($tableId, $this->userId, $limit, $offset))); } catch (PermissionError $e) { $this->logger->warning('A permission error occurred: ' . $e->getMessage()); $message = ['message' => $e->getMessage()]; @@ -967,7 +967,7 @@ public function indexTableRows(int $tableId, ?int $limit, ?int $offset): DataRes */ public function indexViewRows(int $viewId, ?int $limit, ?int $offset): DataResponse { try { - return new DataResponse($this->rowService->findAllByView($viewId, $this->userId, $limit, $offset)); + return new DataResponse($this->rowService->formatRows($this->rowService->findAllByView($viewId, $this->userId, $limit, $offset))); } catch (PermissionError $e) { $this->logger->warning('A permission error occurred: ' . $e->getMessage()); $message = ['message' => $e->getMessage()]; diff --git a/lib/Db/ColumnMapper.php b/lib/Db/ColumnMapper.php index 05c9466fc..ea76c0d95 100644 --- a/lib/Db/ColumnMapper.php +++ b/lib/Db/ColumnMapper.php @@ -21,7 +21,7 @@ public function __construct(IDBConnection $db, LoggerInterface $logger) { } /** - * @param int $id + * @param int $id Column ID * * @return Column * @throws DoesNotExistException @@ -36,6 +36,20 @@ public function find(int $id): Column { return $this->findEntity($qb); } + /** + * @param array $id Column IDs + * + * @return Column[] + * @throws Exception + */ + public function findAll(array $id): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->table) + ->where($qb->expr()->in('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT_ARRAY))); + return $this->findEntities($qb); + } + /** * @param int[] $ids * @@ -51,15 +65,15 @@ public function findMultiple(array $ids): array { } /** - * @param integer $tableID + * @param integer $tableId * @return array * @throws Exception */ - public function findAllByTable(int $tableID): array { + public function findAllByTable(int $tableId): array { $qb = $this->db->getQueryBuilder(); $qb->select('*') ->from($this->table) - ->where($qb->expr()->eq('table_id', $qb->createNamedParameter($tableID))); + ->where($qb->expr()->eq('table_id', $qb->createNamedParameter($tableId))); return $this->findEntities($qb); } diff --git a/lib/Db/Row.php b/lib/Db/LegacyRow.php similarity index 96% rename from lib/Db/Row.php rename to lib/Db/LegacyRow.php index 0e85589f7..045921853 100644 --- a/lib/Db/Row.php +++ b/lib/Db/LegacyRow.php @@ -21,7 +21,7 @@ * @method getData(): string * @method setData(string $data) */ -class Row extends Entity implements JsonSerializable { +class LegacyRow extends Entity implements JsonSerializable { protected ?int $tableId = null; protected ?string $createdBy = null; protected ?string $createdAt = null; diff --git a/lib/Db/RowMapper.php b/lib/Db/LegacyRowMapper.php similarity index 83% rename from lib/Db/RowMapper.php rename to lib/Db/LegacyRowMapper.php index 51cb35ae1..c3a4a5db2 100644 --- a/lib/Db/RowMapper.php +++ b/lib/Db/LegacyRowMapper.php @@ -22,8 +22,8 @@ use Psr\Container\NotFoundExceptionInterface; use Psr\Log\LoggerInterface; -/** @template-extends QBMapper */ -class RowMapper extends QBMapper { +/** @template-extends QBMapper */ +class LegacyRowMapper extends QBMapper { protected string $table = 'tables_rows'; protected TextColumnQB $textColumnQB; protected SelectionColumnQB $selectionColumnQB; @@ -33,11 +33,22 @@ class RowMapper extends QBMapper { protected ColumnMapper $columnMapper; protected LoggerInterface $logger; protected UserHelper $userHelper; + protected Row2Mapper $rowMapper; protected int $platform; - public function __construct(IDBConnection $db, LoggerInterface $logger, TextColumnQB $textColumnQB, SelectionColumnQB $selectionColumnQB, NumberColumnQB $numberColumnQB, DatetimeColumnQB $datetimeColumnQB, SuperColumnQB $columnQB, ColumnMapper $columnMapper, UserHelper $userHelper) { - parent::__construct($db, $this->table, Row::class); + public function __construct( + IDBConnection $db, + LoggerInterface $logger, + TextColumnQB $textColumnQB, + SelectionColumnQB $selectionColumnQB, + NumberColumnQB $numberColumnQB, + DatetimeColumnQB $datetimeColumnQB, + SuperColumnQB $columnQB, + ColumnMapper $columnMapper, + UserHelper $userHelper, + Row2Mapper $rowMapper) { + parent::__construct($db, $this->table, LegacyRow::class); $this->logger = $logger; $this->textColumnQB = $textColumnQB; $this->numberColumnQB = $numberColumnQB; @@ -46,6 +57,7 @@ public function __construct(IDBConnection $db, LoggerInterface $logger, TextColu $this->genericColumnQB = $columnQB; $this->columnMapper = $columnMapper; $this->userHelper = $userHelper; + $this->rowMapper = $rowMapper; $this->setPlatform(); } @@ -67,12 +79,12 @@ private function setPlatform() { /** * @param int $id * - * @return Row + * @return LegacyRow * @throws DoesNotExistException * @throws Exception * @throws MultipleObjectsReturnedException */ - public function find(int $id): Row { + public function find(int $id): LegacyRow { $qb = $this->db->getQueryBuilder(); $qb->select('t1.*') ->from($this->table, 't1') @@ -234,8 +246,8 @@ private function addFilterToQuery(IQueryBuilder $qb, View $view, array $neededCo foreach ($filterGroup as &$filter) { $filter['columnType'] = $neededColumnTypes[$filter['columnId']]; // TODO move resolution for magic fields to service layer - if(str_starts_with($filter['value'], '@')) { - $filter['value'] = $this->resolveSearchValue($filter['value'], $userId); + if(str_starts_with((string) $filter['value'], '@')) { + $filter['value'] = $this->resolveSearchValue((string) $filter['value'], $userId); } } } @@ -251,7 +263,7 @@ private function addFilterToQuery(IQueryBuilder $qb, View $view, array $neededCo * @param int $tableId * @param int|null $limit * @param int|null $offset - * @return array + * @return LegacyRow[] * @throws Exception */ public function findAllByTable(int $tableId, ?int $limit = null, ?int $offset = null): array { @@ -345,7 +357,7 @@ private function getAllColumnIdsFromView(View $view, IQueryBuilder $qb): array { * @throws DoesNotExistException * @throws Exception */ - public function findNext(int $offsetId = -1): Row { + public function findNext(int $offsetId = -1): LegacyRow { $qb = $this->db->getQueryBuilder(); $qb->select('t1.*') ->from($this->table, 't1') @@ -406,12 +418,12 @@ public function countRows(int $tableId): int { /** * @param int $id * @param View $view - * @return Row + * @return LegacyRow * @throws DoesNotExistException * @throws Exception * @throws MultipleObjectsReturnedException */ - public function findByView(int $id, View $view): Row { + public function findByView(int $id, View $view): LegacyRow { $qb = $this->db->getQueryBuilder(); $qb->select('t1.*') ->from($this->table, 't1') @@ -424,4 +436,58 @@ public function findByView(int $id, View $view): Row { return $row; } + + /** + * @param Column[] $columns + * @param LegacyRow $legacyRow + * + * @throws Exception + * @throws InternalError + */ + public function transferLegacyRow(LegacyRow $legacyRow, array $columns) { + $this->rowMapper->insert($this->migrateLegacyRow($legacyRow, $columns), $columns); + } + + /** + * @param LegacyRow $legacyRow + * @param Column[] $columns + * @return Row2 + */ + public function migrateLegacyRow(LegacyRow $legacyRow, array $columns): Row2 { + $row = new Row2(); + $row->setId($legacyRow->getId()); + $row->setTableId($legacyRow->getTableId()); + $row->setCreatedBy($legacyRow->getCreatedBy()); + $row->setCreatedAt($legacyRow->getCreatedAt()); + $row->setLastEditBy($legacyRow->getLastEditBy()); + $row->setLastEditAt($legacyRow->getLastEditAt()); + + $legacyData = $legacyRow->getDataArray(); + $data = []; + foreach ($legacyData as $legacyDatum) { + $columnId = $legacyDatum['columnId']; + if ($this->getColumnFromColumnsArray($columnId, $columns)) { + $data[] = $legacyDatum; + } else { + $this->logger->warning("The row with id " . $row->getId() . " has a value for the column with id " . $columnId . ". But this column does not exist or is not part of the table " . $row->getTableId() . ". Will ignore this value abd continue."); + } + } + $row->setData($data); + + return $row; + } + + /** + * @param int $columnId + * @param Column[] $columns + * @return Column|null + */ + private function getColumnFromColumnsArray(int $columnId, array $columns): ?Column { + foreach ($columns as $column) { + if($column->getId() === $columnId) { + return $column; + } + } + return null; + } } diff --git a/lib/Db/Row2.php b/lib/Db/Row2.php new file mode 100644 index 000000000..6b1b6adb0 --- /dev/null +++ b/lib/Db/Row2.php @@ -0,0 +1,159 @@ +id; + } + public function setId(int $id): void { + $this->id = $id; + } + + public function getTableId(): ?int { + return $this->tableId; + } + public function setTableId(int $tableId): void { + $this->tableId = $tableId; + } + + public function getCreatedBy(): ?string { + return $this->createdBy; + } + public function setCreatedBy(string $userId): void { + $this->createdBy = $userId; + } + + public function getCreatedAt(): ?string { + return $this->createdAt; + } + public function setCreatedAt(string $time): void { + $this->createdAt = $time; + } + + public function getLastEditBy(): ?string { + return $this->lastEditBy; + } + public function setLastEditBy(string $userId): void { + $this->lastEditBy = $userId; + } + + public function getLastEditAt(): ?string { + return $this->lastEditAt; + } + public function setLastEditAt(string $time): void { + $this->lastEditAt = $time; + } + + public function getData(): ?array { + return $this->data; + } + + /** + * @param list $data + * @return void + */ + public function setData(array $data): void { + foreach ($data as $cell) { + $this->insertOrUpdateCell($cell); + } + } + + /** + * @param int $columnId + * @param int|float|string $value + * @return void + */ + public function addCell(int $columnId, $value) { + $this->data[] = ['columnId' => $columnId, 'value' => $value]; + $this->addChangedColumnId($columnId); + } + + /** + * @param array{columnId: int, value: mixed} $entry + * @return string + */ + public function insertOrUpdateCell(array $entry): string { + $columnId = $entry['columnId']; + $value = $entry['value']; + foreach ($this->data as &$cell) { + if($cell['columnId'] === $columnId) { + if ($cell['value'] != $value) { // yes, no type safety here + $cell['value'] = $value; + $this->addChangedColumnId($columnId); + } + return 'updated'; + } + } + $this->data[] = ['columnId' => $columnId, 'value' => $value]; + $this->addChangedColumnId($columnId); + return 'inserted'; + } + + /** + * @psalm-return TablesRow + */ + public function jsonSerialize(): array { + return [ + 'id' => $this->id, + 'tableId' => $this->tableId, + 'createdBy' => $this->createdBy, + 'createdAt' => $this->createdAt, + 'lastEditBy' => $this->lastEditBy, + 'lastEditAt' => $this->lastEditAt, + 'data' => $this->data, + ]; + } + + /** + * Can only be changed by private methods + * @param int $columnId + * @return void + */ + private function addChangedColumnId(int $columnId): void { + if ($this->loaded && !in_array($columnId, $this->changedColumnIds)) { + $this->changedColumnIds[] = $columnId; + } + } + + /** + * @return list + */ + public function getChangedCells(): array { + $out = []; + foreach ($this->data as $cell) { + if (in_array($cell['columnId'], $this->changedColumnIds)) { + $out[] = $cell; + } + } + return $out; + } + + /** + * Set loaded status to true + * starting now changes will be tracked + * + * @return void + */ + public function markAsLoaded(): void { + $this->loaded = true; + } + +} diff --git a/lib/Db/Row2Mapper.php b/lib/Db/Row2Mapper.php new file mode 100644 index 000000000..f8603ddee --- /dev/null +++ b/lib/Db/Row2Mapper.php @@ -0,0 +1,780 @@ +rowSleeveMapper = $rowSleeveMapper; + $this->userId = $userId; + $this->db = $db; + $this->logger = $logger; + $this->userHelper = $userHelper; + $this->columnsHelper = $columnsHelper; + } + + /** + * @param Row2 $row + * @return Row2 + * @throws Exception + */ + public function delete(Row2 $row): Row2 { + $this->db->beginTransaction(); + try { + foreach ($this->columnsHelper->columns as $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 Exception(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); + } + $cellMapper->deleteAllForRow($row->getId()); + } + $this->rowSleeveMapper->deleteById($row->getId()); + $this->db->commit(); + } catch (Throwable $e) { + $this->db->rollBack(); + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new Exception(get_class($this) . ' - ' . __FUNCTION__ . ': '.$e->getMessage()); + } + + return $row; + } + + /** + * @param int $id + * @param Column[] $columns + * @return Row2 + * @throws InternalError + * @throws NotFoundError + */ + public function find(int $id, array $columns): Row2 { + $this->setColumns($columns); + $columnIdsArray = array_map(fn (Column $column) => $column->getId(), $columns); + $rows = $this->getRows([$id], $columnIdsArray); + if (count($rows) === 1) { + return $rows[0]; + } elseif (count($rows) === 0) { + $e = new Exception('Wanted row not found.'); + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new NotFoundError(get_class($this) . ' - ' . __FUNCTION__ . ': '.$e->getMessage()); + } else { + $e = new Exception('Too many results for one wanted row.'); + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': '.$e->getMessage()); + } + } + + /** + * @throws InternalError + */ + public function findNextId(int $offsetId = -1): ?int { + try { + $rowSleeve = $this->rowSleeveMapper->findNext($offsetId); + } catch (MultipleObjectsReturnedException|Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': '.$e->getMessage()); + } catch (DoesNotExistException $e) { + return null; + } + return $rowSleeve->getId(); + } + + /** + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + * @throws Exception + */ + public function getTableIdForRow(int $rowId): ?int { + $rowSleeve = $this->rowSleeveMapper->find($rowId); + return $rowSleeve->getTableId(); + } + + /** + * @param string $userId + * @param int $tableId + * @param array|null $filter + * @param int|null $limit + * @param int|null $offset + * @return int[] + * @throws InternalError + */ + private function getWantedRowIds(string $userId, int $tableId, ?array $filter = null, ?int $limit = null, ?int $offset = null): array { + $qb = $this->db->getQueryBuilder(); + + $qb->select('id') + ->from('tables_row_sleeves', 'sleeves') + ->where($qb->expr()->eq('table_id', $qb->createNamedParameter($tableId, IQueryBuilder::PARAM_INT))); + if($filter) { + $this->addFilterToQuery($qb, $filter, $userId); + } + if ($limit !== null) { + $qb->setMaxResults($limit); + } + if ($offset !== null) { + $qb->setFirstResult($offset); + } + + try { + $result = $this->db->executeQuery($qb->getSQL(), $qb->getParameters(), $qb->getParameterTypes()); + } catch (Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage(), ); + } + + return array_map(fn (array $item) => $item['id'], $result->fetchAll()); + } + + /** + * @param Column[] $columns + * @param int $tableId + * @param int|null $limit + * @param int|null $offset + * @param array|null $filter + * @param array|null $sort + * @param string|null $userId + * @return Row2[] + * @throws InternalError + */ + public function findAll(array $columns, int $tableId, int $limit = null, int $offset = null, array $filter = null, array $sort = null, string $userId = null): array { + $this->setColumns($columns); + $columnIdsArray = array_map(fn (Column $column) => $column->getId(), $columns); + + $wantedRowIdsArray = $this->getWantedRowIds($userId, $tableId, $filter, $limit, $offset); + + // TODO add sorting + + return $this->getRows($wantedRowIdsArray, $columnIdsArray); + } + + /** + * @param array $rowIds + * @param array $columnIds + * @return Row2[] + * @throws InternalError + */ + private function getRows(array $rowIds, array $columnIds): array { + $qb = $this->db->getQueryBuilder(); + + $qbSqlForColumnTypes = null; + foreach ($this->columnsHelper->columns as $columnType) { + $qbTmp = $this->db->getQueryBuilder(); + $qbTmp->select('*') + ->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') + ->from($qb->createFunction($qbSqlForColumnTypes), 't1') + ->innerJoin('t1', 'tables_row_sleeves', 'rowSleeve', 'rowSleeve.id = t1.row_id'); + + try { + $result = $this->db->executeQuery($qb->getSQL(), $qb->getParameters(), $qb->getParameterTypes()); + } 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); + } + + /** + * @throws InternalError + */ + private function addFilterToQuery(IQueryBuilder &$qb, array $filters, string $userId): void { + // TODO move this into service + $this->replacePlaceholderValues($filters, $userId); + + if (count($filters) > 0) { + $qb->andWhere( + $qb->expr()->orX( + ...$this->getFilterGroups($qb, $filters) + ) + ); + } + } + + private function replacePlaceholderValues(array &$filters, string $userId): void { + foreach ($filters as &$filterGroup) { + foreach ($filterGroup as &$filter) { + if(substr($filter['value'], 0, 1) === '@') { + $filter['value'] = $this->resolveSearchValue($filter['value'], $userId); + } + } + } + } + + /** + * @throws InternalError + */ + private function getFilterGroups(IQueryBuilder &$qb, array $filters): array { + $filterGroups = []; + foreach ($filters as $filterGroup) { + $filterGroups[] = $qb->expr()->andX(...$this->getFilter($qb, $filterGroup)); + } + return $filterGroups; + } + + /** + * @throws InternalError + */ + private function getFilter(IQueryBuilder &$qb, array $filterGroup): array { + $filterExpressions = []; + foreach ($filterGroup as $filter) { + $columnId = $filter['columnId']; + + // if is normal column + if ($columnId >= 0) { + $sql = $qb->expr()->in( + 'id', + $qb->createFunction($this->getFilterExpression($qb, $this->columns[$filter['columnId']], $filter['operator'], $filter['value'])->getSQL()) + ); + + // if is meta data column + } elseif ($columnId < 0) { + $sql = $qb->expr()->in( + 'id', + $qb->createFunction($this->getMetaFilterExpression($qb, $columnId, $filter['operator'], $filter['value'])->getSQL()) + ); + + // if column id is unknown + } else { + $e = new Exception("Needed column (" . $filter['columnId'] . ") not found."); + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': '.$e->getMessage()); + } + $filterExpressions[] = $sql; + } + return $filterExpressions; + } + + /** + * @throws InternalError + */ + private function getFilterExpression(IQueryBuilder $qb, Column $column, string $operator, string $value): IQueryBuilder { + $paramType = $this->getColumnDbParamType($column); + $value = $this->formatValue($column, $value, 'in'); + + $qb2 = $this->db->getQueryBuilder(); + $qb2->select('row_id'); + $qb2->where($qb->expr()->eq('column_id', $qb->createNamedParameter($column->getId()), IQueryBuilder::PARAM_INT)); + $qb2->from('tables_row_cells_' . $column->getType()); + + switch ($operator) { + case 'begins-with': + return $qb2->andWhere($qb->expr()->like('value', $qb->createNamedParameter('%'.$this->db->escapeLikeParameter($value), $paramType))); + case 'ends-with': + return $qb2->andWhere($qb->expr()->like('value', $qb->createNamedParameter($this->db->escapeLikeParameter($value).'%', $paramType))); + case 'contains': + if ($column->getType() === 'selection' && $column->getSubtype() === 'multi') { + $value = str_replace(['"', '\''], '', $value); + return $qb2->andWhere($qb2->expr()->orX( + $qb->expr()->like('value', $qb->createNamedParameter('['.$this->db->escapeLikeParameter($value).']')), + $qb->expr()->like('value', $qb->createNamedParameter('['.$this->db->escapeLikeParameter($value).',%')), + $qb->expr()->like('value', $qb->createNamedParameter('%,'.$this->db->escapeLikeParameter($value).']%')), + $qb->expr()->like('value', $qb->createNamedParameter('%,'.$this->db->escapeLikeParameter($value).',%')) + )); + } + return $qb2->andWhere($qb->expr()->like('value', $qb->createNamedParameter('%'.$this->db->escapeLikeParameter($value).'%', $paramType))); + case 'is-equal': + if ($column->getType() === 'selection' && $column->getSubtype() === 'multi') { + $value = str_replace(['"', '\''], '', $value); + return $qb2->andWhere($qb->expr()->eq('value', $qb->createNamedParameter('['.$this->db->escapeLikeParameter($value).']', $paramType))); + } + return $qb2->andWhere($qb->expr()->eq('value', $qb->createNamedParameter($value, $paramType))); + case 'is-greater-than': + return $qb2->andWhere($qb->expr()->gt('value', $qb->createNamedParameter($value, $paramType))); + case 'is-greater-than-or-equal': + return $qb2->andWhere($qb->expr()->gte('value', $qb->createNamedParameter($value, $paramType))); + case 'is-lower-than': + return $qb2->andWhere($qb->expr()->lt('value', $qb->createNamedParameter($value, $paramType))); + case 'is-lower-than-or-equal': + return $qb2->andWhere($qb->expr()->lte('value', $qb->createNamedParameter($value, $paramType))); + case 'is-empty': + return $qb2->andWhere($qb->expr()->isNull('value')); + default: + throw new InternalError('Operator '.$operator.' is not supported.'); + } + } + + /** + * + * -1 => 'number', ID + * -2 => 'text-line', Created + * -3 => 'datetime', At + * -4 => 'text-line', LastEdit + * -5 => 'datetime', At + * @throws InternalError + */ + private function getMetaFilterExpression(IQueryBuilder $qb, int $columnId, string $operator, string $value): IQueryBuilder { + $qb2 = $this->db->getQueryBuilder(); + $qb2->select('id'); + $qb2->from('tables_row_sleeves'); + + switch ($columnId) { + case -1: // row ID + $qb2->where($this->getSqlOperator($operator, $qb, 'id', $value, IQueryBuilder::PARAM_INT)); + break; + case -2: // created by + $qb2->where($this->getSqlOperator($operator, $qb, 'created_by', $value, IQueryBuilder::PARAM_STR)); + break; + case -3: // created at + $qb2->where($this->getSqlOperator($operator, $qb, 'created_at', $value, IQueryBuilder::PARAM_DATE)); + break; + case -4: // last edit by + $qb2->where($this->getSqlOperator($operator, $qb, 'last_edit_by', $value, IQueryBuilder::PARAM_STR)); + break; + case -5: // last edit at + $qb2->where($this->getSqlOperator($operator, $qb, 'last_edit_at', $value, IQueryBuilder::PARAM_DATE)); + break; + } + return $qb2; + } + + /** + * @param string $operator + * @param IQueryBuilder $qb + * @param string $columnName + * @param mixed $value + * @param mixed $paramType + * @return string + * @throws InternalError + */ + private function getSqlOperator(string $operator, IQueryBuilder $qb, string $columnName, $value, $paramType): string { + switch ($operator) { + case 'begins-with': + return $qb->expr()->like($columnName, $qb->createNamedParameter('%'.$this->db->escapeLikeParameter($value), $paramType)); + case 'ends-with': + return $qb->expr()->like($columnName, $qb->createNamedParameter($this->db->escapeLikeParameter($value).'%', $paramType)); + case 'contains': + return $qb->expr()->like($columnName, $qb->createNamedParameter('%'.$this->db->escapeLikeParameter($value).'%', $paramType)); + case 'is-equal': + return $qb->expr()->eq($columnName, $qb->createNamedParameter($value, $paramType)); + case 'is-greater-than': + return $qb->expr()->gt($columnName, $qb->createNamedParameter($value, $paramType)); + case 'is-greater-than-or-equal': + return $qb->expr()->gte($columnName, $qb->createNamedParameter($value, $paramType)); + case 'is-lower-than': + return $qb->expr()->lt($columnName, $qb->createNamedParameter($value, $paramType)); + case 'is-lower-than-or-equal': + return $qb->expr()->lte($columnName, $qb->createNamedParameter($value, $paramType)); + case 'is-empty': + return $qb->expr()->isNull($columnName); + default: + throw new InternalError('Operator '.$operator.' is not supported.'); + } + } + + /** @noinspection DuplicatedCode */ + private function resolveSearchValue(string $placeholder, string $userId): string { + if (substr($placeholder, 0, 14) === '@selection-id-') { + return substr($placeholder, 14); + } + switch (ltrim($placeholder, '@')) { + case 'me': return $userId; + case 'my-name': return $this->userHelper->getUserDisplayName($userId); + case 'checked': return 'true'; + case 'unchecked': return 'false'; + case 'stars-0': return '0'; + case 'stars-1': return '1'; + case 'stars-2': return '2'; + case 'stars-3': return '3'; + case 'stars-4': return '4'; + case 'stars-5': return '5'; + case 'datetime-date-today': return date('Y-m-d') ? date('Y-m-d') : ''; + case 'datetime-date-start-of-year': return date('Y-01-01') ? date('Y-01-01') : ''; + case 'datetime-date-start-of-month': return date('Y-m-01') ? date('Y-m-01') : ''; + case 'datetime-date-start-of-week': + $day = date('w'); + $result = date('m-d-Y', strtotime('-'.$day.' days')); + return $result ?: ''; + case 'datetime-time-now': return date('H:i'); + case 'datetime-now': return date('Y-m-d H:i') ? date('Y-m-d H:i') : ''; + default: return $placeholder; + } + } + + /** + * @param IResult $result + * @param RowSleeve[] $sleeves + * @return Row2[] + * @throws InternalError + */ + private function parseEntities(IResult $result, array $sleeves): array { + $data = $result->fetchAll(); + + $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()); + } + + foreach ($data as $rowData) { + if (!isset($rowData['row_id']) || !isset($rows[$rowData['row_id']])) { + break; + } + + /* @var array $rowData */ + $rows[$rowData['row_id']]->addCell($rowData['column_id'], $this->formatValue($this->columns[$rowData['column_id']], $rowData['value'])); + } + + // format an array without keys + $return = []; + foreach ($rows as $row) { + $return[] = $row; + } + return $return; + } + + /** + * @throws InternalError + */ + public function isRowInViewPresent(int $rowId, View $view, string $userId): bool { + return in_array($rowId, $this->getWantedRowIds($userId, $view->getTableId(), $view->getFilterArray())); + } + + /** + * @param Row2 $row + * @param Column[] $columns + * @return Row2 + * @throws InternalError + * @throws Exception + */ + public function insert(Row2 $row, array $columns): Row2 { + $this->setColumns($columns); + + if($row->getId()) { + // if row has an id from migration or import etc. + $rowSleeve = $this->createRowSleeveFromExistingData($row->getId(), $row->getTableId(), $row->getCreatedAt(), $row->getCreatedBy(), $row->getLastEditBy(), $row->getLastEditAt()); + } else { + // create a new row sleeve to get a new rowId + $rowSleeve = $this->createNewRowSleeve($row->getTableId()); + $row->setId($rowSleeve->getId()); + } + + // if the table/view has columns + if (count($columns) > 0) { + // write all cells to its db-table + foreach ($row->getData() as $cell) { + $this->insertCell($rowSleeve->getId(), $cell['columnId'], $cell['value'], $rowSleeve->getLastEditAt(), $rowSleeve->getLastEditBy()); + } + } + + return $row; + } + + /** + * @throws InternalError + */ + public function update(Row2 $row, array $columns): Row2 { + if(!$columns) { + throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': columns are missing'); + } + $this->setColumns($columns); + + // if nothing has changed + if (count($row->getChangedCells()) === 0) { + return $row; + } + + // update meta data for sleeve + 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()); + } + + // write all changed cells to its db-table + foreach ($row->getChangedCells() as $cell) { + $this->insertOrUpdateCell($sleeve->getId(), $cell['columnId'], $cell['value']); + } + + return $row; + } + + /** + * @throws Exception + */ + private function createNewRowSleeve(int $tableId): RowSleeve { + $rowSleeve = new RowSleeve(); + $rowSleeve->setTableId($tableId); + $this->updateMetaData($rowSleeve, true); + return $this->rowSleeveMapper->insert($rowSleeve); + } + + /** + * @throws Exception + */ + private function createRowSleeveFromExistingData(int $id, int $tableId, string $createdAt, string $createdBy, string $lastEditBy, string $lastEditAt): RowSleeve { + $rowSleeve = new RowSleeve(); + $rowSleeve->setId($id); + $rowSleeve->setTableId($tableId); + $rowSleeve->setCreatedBy($createdBy); + $rowSleeve->setCreatedAt($createdAt); + $rowSleeve->setLastEditBy($lastEditBy); + $rowSleeve->setLastEditAt($lastEditAt); + return $this->rowSleeveMapper->insert($rowSleeve); + } + + /** + * Updates the last_edit_by and last_edit_at data + * optional adds the created_by and created_at data + * + * @param RowSleeve|RowCellSuper $entity + * @param bool $setCreate + * @param string|null $lastEditAt + * @param string|null $lastEditBy + * @return void + */ + private function updateMetaData($entity, bool $setCreate = false, ?string $lastEditAt = null, ?string $lastEditBy = null): void { + $time = new DateTime(); + if ($setCreate) { + $entity->setCreatedBy($this->userId); + $entity->setCreatedAt($time->format('Y-m-d H:i:s')); + } + $entity->setLastEditBy($lastEditBy ?: $this->userId); + $entity->setLastEditAt($lastEditAt ?: $time->format('Y-m-d H:i:s')); + } + + /** + * Insert a cell to its specific db-table + * + * @throws InternalError + */ + private function insertCell(int $rowId, int $columnId, $value, ?string $lastEditAt = null, ?string $lastEditBy = null): void { + if (!isset($this->columns[$columnId])) { + $e = new Exception("Can not insert cell, because the given column-id is not known"); + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': '.$e->getMessage()); + } + + $cellClassName = 'OCA\Tables\Db\RowCell'.ucfirst($this->columns[$columnId]->getType()); + /** @var RowCellSuper $cell */ + $cell = new $cellClassName(); + + $cell->setRowIdWrapper($rowId); + $cell->setColumnIdWrapper($columnId); + $this->updateMetaData($cell, false, $lastEditAt, $lastEditBy); + + // insert new cell + $cellMapperClassName = 'OCA\Tables\Db\RowCell'.ucfirst($this->columns[$columnId]->getType()).'Mapper'; + /** @var QBMapper $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()); + } + + $v = $this->formatValue($this->columns[$columnId], $value, 'in'); + $cell->setValueWrapper($v); + + try { + $cellMapper->insert($cell); + } catch (Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': '.$e->getMessage()); + } + } + + /** + * @param RowCellSuper $cell + * @param RowCellMapperSuper $mapper + * @param mixed $value the value should be parsed to the correct format within the row service + * @param Column $column + * @throws InternalError + */ + private function updateCell(RowCellSuper $cell, RowCellMapperSuper $mapper, $value, Column $column): void { + $v = $this->formatValue($column, $value, 'in'); + $cell->setValueWrapper($v); + $this->updateMetaData($cell); + $mapper->updateWrapper($cell); + } + + /** + * @throws InternalError + */ + private function insertOrUpdateCell(int $rowId, int $columnId, $value): void { + $cellMapperClassName = 'OCA\Tables\Db\RowCell'.ucfirst($this->columns[$columnId]->getType()).'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()); + } + try { + $cell = $cellMapper->findByRowAndColumn($rowId, $columnId); + $this->updateCell($cell, $cellMapper, $value, $this->columns[$columnId]); + } catch (DoesNotExistException $e) { + $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()); + } + } + + /** + * @param Column[] $columns + */ + private function setColumns(array $columns): void { + foreach ($columns as $column) { + $this->columns[$column->getId()] = $column; + } + } + + /** + * @param Column $column + * @param mixed $value + * @param 'out'|'in' $mode Parse the value for incoming requests that get send to the db or outgoing, from the db to the services + * @return mixed + * @throws InternalError + */ + private function formatValue(Column $column, $value, string $mode = 'out') { + $cellMapperClassName = 'OCA\Tables\Db\RowCell'.ucfirst($column->getType()).'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()); + } + if ($mode === 'out') { + return $cellMapper->parseValueOutgoing($column, $value); + } else { + return $cellMapper->parseValueIncoming($column, $value); + } + } + + /** + * @throws InternalError + */ + private function getColumnDbParamType(Column $column) { + $cellMapperClassName = 'OCA\Tables\Db\RowCell'.ucfirst($column->getType()).'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()); + } + return $cellMapper->getDbParamType(); + } + + /** + * @throws InternalError + */ + public function deleteDataForColumn(Column $column): void { + $cellMapperClassName = 'OCA\Tables\Db\RowCell' . ucfirst($column->getType()) . '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()); + } + try { + $cellMapper->deleteAllForColumn($column->getId()); + } catch (Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': '.$e->getMessage()); + } + } + + /** + * @param int $tableId + * @param Column[] $columns + * @return void + */ + public function deleteAllForTable(int $tableId, array $columns): void { + foreach ($columns as $column) { + try { + $this->deleteDataForColumn($column); + } catch (InternalError $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + } + } + try { + $this->rowSleeveMapper->deleteAllForTable($tableId); + } catch (Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + } + } + + public function countRowsForTable(int $tableId): int { + return $this->rowSleeveMapper->countRows($tableId); + } + + /** + * @param View $view + * @param string $userId + * @param Column[] $columns + * @return int + */ + public function countRowsForView(View $view, string $userId, array $columns): int { + $this->setColumns($columns); + + $filter = $view->getFilterArray(); + try { + $rowIds = $this->getWantedRowIds($userId, $view->getTableId(), $filter); + } catch (InternalError $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + $rowIds = []; + } + return count($rowIds); + } + +} diff --git a/lib/Db/RowCellDatetime.php b/lib/Db/RowCellDatetime.php new file mode 100644 index 000000000..5ee46201b --- /dev/null +++ b/lib/Db/RowCellDatetime.php @@ -0,0 +1,12 @@ + */ +class RowCellDatetime extends RowCellSuper { + protected ?string $value = null; + + public function jsonSerialize(): array { + return parent::jsonSerializePreparation($this->value); + } +} diff --git a/lib/Db/RowCellDatetimeMapper.php b/lib/Db/RowCellDatetimeMapper.php new file mode 100644 index 000000000..00d6ed62f --- /dev/null +++ b/lib/Db/RowCellDatetimeMapper.php @@ -0,0 +1,14 @@ + */ +class RowCellDatetimeMapper extends RowCellMapperSuper { + protected string $table = 'tables_row_cells_datetime'; + + public function __construct(IDBConnection $db) { + parent::__construct($db, $this->table, RowCellDatetime::class); + } +} diff --git a/lib/Db/RowCellMapperSuper.php b/lib/Db/RowCellMapperSuper.php new file mode 100644 index 000000000..86f76509e --- /dev/null +++ b/lib/Db/RowCellMapperSuper.php @@ -0,0 +1,111 @@ + + * @template T of RowCellSuper + * @template TIncoming + * @template TOutgoing + */ +class RowCellMapperSuper extends QBMapper { + + public function __construct(IDBConnection $db, string $table, string $class) { + parent::__construct($db, $table, $class); + } + + /** + * Parse value for db results (after send request) + * eg for filtering + * + * @param Column $column + * @param TOutgoing $value + * @return TOutgoing + */ + public function parseValueOutgoing(Column $column, $value) { + return $value; + } + + /** + * Parse value for db requests (before send request) + * + * @param Column $column + * @param TIncoming $value + * @return TIncoming + */ + public function parseValueIncoming(Column $column, $value) { + return $value; + } + + public function getDbParamType() { + return IQueryBuilder::PARAM_STR; + } + + /** + * @throws Exception + */ + public function deleteAllForRow(int $rowId): void { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->tableName) + ->where( + $qb->expr()->eq('row_id', $qb->createNamedParameter($rowId, IQueryBuilder::PARAM_INT)) + ); + $qb->executeStatement(); + } + + /** + * @throws Exception + */ + public function deleteAllForColumn(int $columnId): void { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->tableName) + ->where( + $qb->expr()->eq('column_id', $qb->createNamedParameter($columnId, IQueryBuilder::PARAM_INT)) + ); + $qb->executeStatement(); + } + + /** + * @throws MultipleObjectsReturnedException + * @throws DoesNotExistException + * @throws Exception + */ + public function findByRowAndColumn(int $rowId, int $columnId): RowCellSuper { + $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->findEntity($qb); + } + + /** + * @throws MultipleObjectsReturnedException + * @throws DoesNotExistException + * @throws Exception + */ + public function find(int $id): RowCellSuper { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->tableName) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))); + return $this->findEntity($qb); + } + + /** + * @psalm-param T $cell + * @psalm-return T + * @throws Exception + */ + public function updateWrapper(RowCellSuper $cell): RowCellSuper { + return $this->update($cell); + } + +} diff --git a/lib/Db/RowCellNumber.php b/lib/Db/RowCellNumber.php new file mode 100644 index 000000000..c1d33f7a6 --- /dev/null +++ b/lib/Db/RowCellNumber.php @@ -0,0 +1,12 @@ + */ +class RowCellNumber extends RowCellSuper { + protected ?float $value = null; + + public function jsonSerialize(): array { + return parent::jsonSerializePreparation($this->value); + } +} diff --git a/lib/Db/RowCellNumberMapper.php b/lib/Db/RowCellNumberMapper.php new file mode 100644 index 000000000..3a8867722 --- /dev/null +++ b/lib/Db/RowCellNumberMapper.php @@ -0,0 +1,45 @@ + */ +class RowCellNumberMapper extends RowCellMapperSuper { + protected string $table = 'tables_row_cells_number'; + + public function __construct(IDBConnection $db) { + parent::__construct($db, $this->table, RowCellNumber::class); + } + + /** + * @inheritDoc + */ + public function parseValueOutgoing(Column $column, $value) { + if($value === '') { + return null; + } + $decimals = $column->getNumberDecimals() ?? 0; + if ($decimals === 0) { + return (int) $value; + } else { + return round(floatval($value), $decimals); + } + } + + /** + * @inheritDoc + */ + public function parseValueIncoming(Column $column, $value): ?float { + if(!is_numeric($value)) { + return null; + } + return (float) $value; + } + + public function getDbParamType() { + // seems to be a string for float/double values + return IQueryBuilder::PARAM_STR; + } +} diff --git a/lib/Db/RowCellSelection.php b/lib/Db/RowCellSelection.php new file mode 100644 index 000000000..42cc14202 --- /dev/null +++ b/lib/Db/RowCellSelection.php @@ -0,0 +1,12 @@ + */ +class RowCellSelection extends RowCellSuper { + protected ?string $value = null; + + public function jsonSerialize(): array { + return parent::jsonSerializePreparation($this->value); + } +} diff --git a/lib/Db/RowCellSelectionMapper.php b/lib/Db/RowCellSelectionMapper.php new file mode 100644 index 000000000..66d71dff0 --- /dev/null +++ b/lib/Db/RowCellSelectionMapper.php @@ -0,0 +1,38 @@ + + */ +class RowCellSelectionMapper extends RowCellMapperSuper { + protected string $table = 'tables_row_cells_selection'; + + public function __construct(IDBConnection $db) { + parent::__construct($db, $this->table, RowCellSelection::class); + } + + /** + * @inheritDoc + */ + public function parseValueIncoming(Column $column, $value): string { + if ($column->getSubtype() === 'check') { + return json_encode(ltrim($value, '"')); + } + + if ($column->getSubtype() === '' || $column->getSubtype() === null) { + return $value ?? ''; + } + + return json_encode($value); + } + + /** + * @inheritDoc + */ + public function parseValueOutgoing(Column $column, $value) { + return json_decode($value); + } +} diff --git a/lib/Db/RowCellSuper.php b/lib/Db/RowCellSuper.php new file mode 100644 index 000000000..87a232b3e --- /dev/null +++ b/lib/Db/RowCellSuper.php @@ -0,0 +1,61 @@ +addType('id', 'integer'); + $this->addType('columnId', 'integer'); + $this->addType('rowId', 'integer'); + } + + public function jsonSerializePreparation($value): array { + return [ + 'id' => $this->id, + 'columnId' => $this->columnId, + 'rowId' => $this->rowId, + 'lastEditBy' => $this->lastEditBy, + 'lastEditAt' => $this->lastEditAt, + 'value' => $value + ]; + } + + public function setRowIdWrapper(int $rowId) { + $this->setRowId($rowId); + } + + public function setColumnIdWrapper(int $columnId) { + $this->setColumnId($columnId); + } + + public function setValueWrapper($value) { + $this->setValue($value); + } +} diff --git a/lib/Db/RowCellText.php b/lib/Db/RowCellText.php new file mode 100644 index 000000000..0650d7d8f --- /dev/null +++ b/lib/Db/RowCellText.php @@ -0,0 +1,12 @@ + */ +class RowCellText extends RowCellSuper { + protected ?string $value = null; + + public function jsonSerialize(): array { + return parent::jsonSerializePreparation($this->value); + } +} diff --git a/lib/Db/RowCellTextMapper.php b/lib/Db/RowCellTextMapper.php new file mode 100644 index 000000000..2e1b8785e --- /dev/null +++ b/lib/Db/RowCellTextMapper.php @@ -0,0 +1,14 @@ + */ +class RowCellTextMapper extends RowCellMapperSuper { + protected string $table = 'tables_row_cells_text'; + + public function __construct(IDBConnection $db) { + parent::__construct($db, $this->table, RowCellText::class); + } +} diff --git a/lib/Db/RowSleeve.php b/lib/Db/RowSleeve.php new file mode 100644 index 000000000..24bdc5c22 --- /dev/null +++ b/lib/Db/RowSleeve.php @@ -0,0 +1,44 @@ +addType('id', 'integer'); + $this->addType('tableId', 'integer'); + } + + public function jsonSerialize(): array { + return [ + 'id' => $this->id, + 'tableId' => $this->tableId, + 'createdBy' => $this->createdBy, + 'createdAt' => $this->createdAt, + 'lastEditBy' => $this->lastEditBy, + 'lastEditAt' => $this->lastEditAt, + ]; + } +} diff --git a/lib/Db/RowSleeveMapper.php b/lib/Db/RowSleeveMapper.php new file mode 100644 index 000000000..6932a5960 --- /dev/null +++ b/lib/Db/RowSleeveMapper.php @@ -0,0 +1,112 @@ + */ +class RowSleeveMapper extends QBMapper { + protected string $table = 'tables_row_sleeves'; + protected LoggerInterface $logger; + + public function __construct(IDBConnection $db, LoggerInterface $logger) { + parent::__construct($db, $this->table, RowSleeve::class); + $this->logger = $logger; + } + + /** + * @throws MultipleObjectsReturnedException + * @throws DoesNotExistException + * @throws Exception + */ + public function find(int $id): RowSleeve { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->table) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))); + return $this->findEntity($qb); + } + + /** + * @param int[] $ids + * @return RowSleeve[] + * @throws Exception + */ + public function findMultiple(array $ids): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->table) + ->where($qb->expr()->in('id', $qb->createNamedParameter($ids, IQueryBuilder::PARAM_INT_ARRAY))); + return $this->findEntities($qb); + } + + /** + * @throws MultipleObjectsReturnedException + * @throws DoesNotExistException + * @throws Exception + */ + public function findNext(int $offsetId = -1): RowSleeve { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->table) + ->where($qb->expr()->gt('id', $qb->createNamedParameter($offsetId))) + ->setMaxResults(1) + ->orderBy('id', 'ASC'); + + return $this->findEntity($qb); + } + + /** + * @param int $sleeveId + * @throws Exception + */ + public function deleteById(int $sleeveId) { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->tableName) + ->where( + $qb->expr()->eq('id', $qb->createNamedParameter($sleeveId, IQueryBuilder::PARAM_INT)) + ); + $qb->executeStatement(); + } + + /** + * @param int $tableId + * @return int Effected rows + * @throws Exception + */ + public function deleteAllForTable(int $tableId): int { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->tableName) + ->where( + $qb->expr()->eq('table_id', $qb->createNamedParameter($tableId, IQueryBuilder::PARAM_INT)) + ); + return $qb->executeStatement(); + } + + /** + * @param int $tableId + * @return int + */ + public function countRows(int $tableId): int { + $qb = $this->db->getQueryBuilder(); + $qb->select($qb->func()->count('*', 'counter')); + $qb->from($this->table, 't1'); + $qb->where( + $qb->expr()->eq('table_id', $qb->createNamedParameter($tableId)) + ); + + try { + $result = $this->findOneQuery($qb); + return (int)$result['counter']; + } catch (DoesNotExistException|MultipleObjectsReturnedException|Exception $e) { + $this->logger->warning('Exception occurred: '.$e->getMessage().' Will return 0.'); + return 0; + } + } +} diff --git a/lib/Db/TableMapper.php b/lib/Db/TableMapper.php index 614b1baf4..5b62a167f 100644 --- a/lib/Db/TableMapper.php +++ b/lib/Db/TableMapper.php @@ -51,14 +51,14 @@ public function findOwnership(int $id): string { /** * @param string|null $userId - * @return array + * @return Table[] * @throws Exception */ public function findAll(?string $userId = null): array { $qb = $this->db->getQueryBuilder(); $qb->select('*') ->from($this->table); - if ($userId != null) { + if ($userId !== null && $userId !== '') { $qb->where($qb->expr()->eq('ownership', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR))); } return $this->findEntities($qb); diff --git a/lib/Db/View.php b/lib/Db/View.php index ac617238f..3e72d6240 100644 --- a/lib/Db/View.php +++ b/lib/Db/View.php @@ -3,7 +3,6 @@ namespace OCA\Tables\Db; use JsonSerializable; - use OCA\Tables\ResponseDefinitions; use OCP\AppFramework\Db\Entity; @@ -12,6 +11,8 @@ * * @psalm-import-type TablesView from ResponseDefinitions * + * @method getId(): int + * @method setId(int $id) * @method getTitle(): string * @method setTitle(string $title) * @method getTableId(): int @@ -76,7 +77,7 @@ public function getColumnsArray(): array { /** * @psalm-suppress MismatchingDocblockReturnType - * @return array{array-key, array{columnId: int, mode: 'ASC'|'DESC'}}|null + * @return list */ public function getSortArray(): array { return $this->getArray($this->getSort()); @@ -84,7 +85,7 @@ public function getSortArray(): array { /** * @psalm-suppress MismatchingDocblockReturnType - * @return array|null + * @return list> */ public function getFilterArray():array { return $this->getArray($this->getFilter()); diff --git a/lib/Db/ViewMapper.php b/lib/Db/ViewMapper.php index 65a82adf7..ab198c825 100644 --- a/lib/Db/ViewMapper.php +++ b/lib/Db/ViewMapper.php @@ -47,7 +47,7 @@ public function find(int $id, bool $skipEnhancement = false): View { /** * @param int|null $tableId - * @return array + * @return View[] * @throws Exception * @throws InternalError */ diff --git a/lib/Helper/ColumnsHelper.php b/lib/Helper/ColumnsHelper.php new file mode 100644 index 000000000..338ad07cd --- /dev/null +++ b/lib/Helper/ColumnsHelper.php @@ -0,0 +1,13 @@ +logger = $logger; + $this->tableService = $tableService; + $this->columnService = $columnService; + $this->legacyRowMapper = $legacyRowMapper; + $this->rowMapper = $rowMapper; + $this->config = $config; + } + + /** + * Returns the step's name + */ + public function getName(): string { + return 'Copy the data into the new db structure'; + } + + /** + * @param IOutput $output + */ + public function run(IOutput $output) { + $legacyRowTransferRunComplete = $this->config->getAppValue('tables', 'legacyRowTransferRunComplete', "false"); + + if ($legacyRowTransferRunComplete === "true") { + return; + } + + $output->info("Look for tables"); + try { + $tables = $this->tableService->findAll('', true, true, false); + $output->info("Found ". count($tables) . " table(s)"); + } catch (InternalError $e) { + $output->warning("Error while fetching tables. Will aboard."); + return; + } + $this->transferDataForTables($tables, $output); + $this->config->setAppValue('tables', 'legacyRowTransferRunComplete', "true"); + } + + /** + * @param Table[] $tables + * @return void + */ + private function transferDataForTables(array $tables, IOutput $output) { + $i = 1; + foreach ($tables as $table) { + $output->info("-- Start transfer for table " . $table->getId() . " (" . $table->getTitle() . ") [" . $i . "/" . count($tables) . "]"); + try { + $this->transferTable($table, $output); + } catch (InternalError|PermissionError|Exception|Throwable $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + $output->warning("Could not transfer data. Continue with next table. The logs will have more information about the error: " . $e->getMessage()); + } + $i++; + } + } + + /** + * @throws PermissionError + * @throws InternalError + * @throws Exception + */ + private function transferTable(Table $table, IOutput $output) { + $columns = $this->columnService->findAllByTable($table->getId(), null, ''); + $output->info("---- Found " . count($columns) . " columns"); + + $legacyRows = $this->legacyRowMapper->findAllByTable($table->getId()); + $output->info("---- Found " . count($legacyRows) . " rows"); + + $output->startProgress(count($legacyRows)); + foreach ($legacyRows as $legacyRow) { + $this->legacyRowMapper->transferLegacyRow($legacyRow, $columns); + $output->advance(1); + } + $output->finishProgress(); + } +} diff --git a/lib/Migration/Version000700Date20230916000000.php b/lib/Migration/Version000700Date20230916000000.php new file mode 100644 index 000000000..b9e138c0f --- /dev/null +++ b/lib/Migration/Version000700Date20230916000000.php @@ -0,0 +1,99 @@ + 'text', + 'db_type' => Types::TEXT, + ], + [ + 'name' => 'number', + 'db_type' => Types::FLOAT, + ], + [ + 'name' => 'datetime', + 'db_type' => Types::TEXT, + ], + [ + 'name' => 'selection', + 'db_type' => Types::TEXT, + ], + ]; + + /** + * @param IOutput $output + * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + * @param array $options + * @return null|ISchemaWrapper + * @throws Exception + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + $this->createRowSleevesTable($schema); + + $rowTypeSchema = $this->columns; + + foreach ($rowTypeSchema as $colType) { + $this->createRowValueTable($schema, $colType['name'], $colType['db_type']); + } + + return $schema; + } + + private function createRowValueTable(ISchemaWrapper $schema, string $name, string $type) { + if (!$schema->hasTable('tables_row_cells_'.$name)) { + $table = $schema->createTable('tables_row_cells_'.$name); + $table->addColumn('id', Types::INTEGER, [ + 'autoincrement' => true, + 'notnull' => true, + ]); + $table->addColumn('column_id', Types::INTEGER, ['notnull' => true]); + $table->addColumn('row_id', Types::INTEGER, ['notnull' => true]); + $table->addColumn('value', $type, ['notnull' => false]); + // we will write this data to use it one day to extract versions of rows based on the timestamp + $table->addColumn('last_edit_at', Types::DATETIME, ['notnull' => true]); + $table->addColumn('last_edit_by', Types::STRING, ['notnull' => true, 'length' => 64]); + $table->addIndex(['column_id', 'row_id']); + $table->setPrimaryKey(['id']); + } + } + + private function createRowSleevesTable(ISchemaWrapper $schema) { + if (!$schema->hasTable('tables_row_sleeves')) { + $table = $schema->createTable('tables_row_sleeves'); + $table->addColumn('id', Types::INTEGER, [ + 'autoincrement' => true, + 'notnull' => true, + ]); + $table->addColumn('table_id', Types::INTEGER, ['notnull' => true]); + $table->addColumn('created_by', Types::STRING, ['notnull' => true, 'length' => 64]); + $table->addColumn('created_at', Types::DATETIME, ['notnull' => true]); + $table->addColumn('last_edit_by', Types::STRING, ['notnull' => true, 'length' => 64]); + $table->addColumn('last_edit_at', Types::DATETIME, ['notnull' => true]); + $table->addIndex(['id']); + $table->setPrimaryKey(['id']); + } + } +} diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 1f36de842..42668bce5 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -20,8 +20,8 @@ * lastEditAt: string, * description: string|null, * columns: int[], - * sort: ?array{int, array{columnId: int, mode: 'ASC'|'DESC'}}, - * filter: ?array{int, array{columnId: int, operator: 'begins-with'|'ends-with'|'contains'|'is-equal'|'is-greater-than'|'is-greater-than-or-equal'|'is-lower-than'|'is-lower-than-or-equal'|'is-empty', value: string|int|float}}, + * sort: list, + * filter: list>, * isShared: bool, * onSharePermissions: ?array{ * read: bool, diff --git a/lib/Service/ColumnService.php b/lib/Service/ColumnService.php index 22c3c23a4..be8c03e73 100644 --- a/lib/Service/ColumnService.php +++ b/lib/Service/ColumnService.php @@ -451,8 +451,8 @@ public function delete(int $id, bool $skipRowCleanup = false, ?string $userId = if (!$skipRowCleanup) { try { - $this->rowService->deleteColumnDataFromRows($id); - } catch (PermissionError|\OCP\DB\Exception $e) { + $this->rowService->deleteColumnDataFromRows($item); + } catch (InternalError $e) { $this->logger->error($e->getMessage(), ['exception' => $e]); throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': '.$e->getMessage()); } diff --git a/lib/Service/PermissionsService.php b/lib/Service/PermissionsService.php index 36c1fa054..8b3c851de 100644 --- a/lib/Service/PermissionsService.php +++ b/lib/Service/PermissionsService.php @@ -280,11 +280,14 @@ public function canDeleteRowsByViewId(int $viewId, ?string $userId = null): bool } /** - * @param int $tableId + * @param int|null $tableId * @param string|null $userId * @return bool */ - public function canDeleteRowsByTableId(int $tableId, ?string $userId = null): bool { + public function canDeleteRowsByTableId(int $tableId = null, ?string $userId = null): bool { + if ($tableId === null) { + return false; + } return $this->checkPermissionById($tableId, 'table', 'delete', $userId); } diff --git a/lib/Service/RowService.php b/lib/Service/RowService.php index c9ff289bd..a789cc075 100644 --- a/lib/Service/RowService.php +++ b/lib/Service/RowService.php @@ -2,17 +2,19 @@ namespace OCA\Tables\Service; -use DateTime; use OCA\Tables\Db\Column; use OCA\Tables\Db\ColumnMapper; -use OCA\Tables\Db\Row; -use OCA\Tables\Db\RowMapper; +use OCA\Tables\Db\LegacyRowMapper; +use OCA\Tables\Db\Row2; +use OCA\Tables\Db\Row2Mapper; +use OCA\Tables\Db\Table; use OCA\Tables\Db\TableMapper; use OCA\Tables\Db\View; use OCA\Tables\Db\ViewMapper; use OCA\Tables\Errors\InternalError; use OCA\Tables\Errors\NotFoundError; use OCA\Tables\Errors\PermissionError; +use OCA\Tables\ResponseDefinitions; use OCA\Tables\Service\ColumnTypes\IColumnTypeBusiness; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\MultipleObjectsReturnedException; @@ -22,19 +24,33 @@ use Psr\Container\NotFoundExceptionInterface; use Psr\Log\LoggerInterface; +/** + * @psalm-import-type TablesRow from ResponseDefinitions + */ class RowService extends SuperService { - private RowMapper $mapper; + private LegacyRowMapper $mapper; private ColumnMapper $columnMapper; private ViewMapper $viewMapper; private TableMapper $tableMapper; + private Row2Mapper $row2Mapper; + private array $tmpRows = []; // holds already loaded rows as a small cache public function __construct(PermissionsService $permissionsService, LoggerInterface $logger, ?string $userId, - RowMapper $mapper, ColumnMapper $columnMapper, ViewMapper $viewMapper, TableMapper $tableMapper) { + LegacyRowMapper $mapper, ColumnMapper $columnMapper, ViewMapper $viewMapper, TableMapper $tableMapper, Row2Mapper $row2Mapper) { parent::__construct($logger, $userId, $permissionsService); $this->mapper = $mapper; $this->columnMapper = $columnMapper; $this->viewMapper = $viewMapper; $this->tableMapper = $tableMapper; + $this->row2Mapper = $row2Mapper; + } + + /** + * @param Row2[] $rows + * @psalm-return TablesRow[] + */ + public function formatRows(array $rows): array { + return array_map(fn (Row2 $row) => $row->jsonSerialize(), $rows); } /** @@ -42,14 +58,14 @@ public function __construct(PermissionsService $permissionsService, LoggerInterf * @param string $userId * @param ?int $limit * @param ?int $offset - * @return array + * @return Row2[] * @throws InternalError * @throws PermissionError */ public function findAllByTable(int $tableId, string $userId, ?int $limit = null, ?int $offset = null): array { try { if ($this->permissionsService->canReadRowsByElementId($tableId, 'table', $userId)) { - return $this->mapper->findAllByTable($tableId, $limit, $offset); + return $this->row2Mapper->findAll($this->columnMapper->findAllByTable($tableId), $tableId, $limit, $offset, null, null, $userId); } else { throw new PermissionError('no read access to table id = '.$tableId); } @@ -64,7 +80,7 @@ public function findAllByTable(int $tableId, string $userId, ?int $limit = null, * @param string $userId * @param int|null $limit * @param int|null $offset - * @return array + * @return Row2[] * @throws DoesNotExistException * @throws InternalError * @throws MultipleObjectsReturnedException @@ -73,7 +89,10 @@ public function findAllByTable(int $tableId, string $userId, ?int $limit = null, public function findAllByView(int $viewId, string $userId, ?int $limit = null, ?int $offset = null): array { try { if ($this->permissionsService->canReadRowsByElementId($viewId, 'view', $userId)) { - return $this->mapper->findAllByView($this->viewMapper->find($viewId), $userId, $limit, $offset); + $view = $this->viewMapper->find($viewId); + $columnsArray = $view->getColumnsArray(); + $columns = $this->columnMapper->findAll($columnsArray); + return $this->row2Mapper->findAll($columns, $view->getTableId(), $limit, $offset, $view->getFilterArray(), $view->getSortArray(), $userId); } else { throw new PermissionError('no read access to view id = '.$viewId); } @@ -86,42 +105,49 @@ public function findAllByView(int $viewId, string $userId, ?int $limit = null, ? /** * @param int $id - * @return Row + * @return Row2 * @throws InternalError * @throws NotFoundError * @throws PermissionError */ - public function find(int $id): Row { + public function find(int $id): Row2 { try { - $row = $this->mapper->find($id); + $columns = $this->columnMapper->findAllByTable($id); + } catch (Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': '.$e->getMessage()); + } - // security - if (!$this->permissionsService->canReadRowsByElementId($row->getTableId(), 'table')) { - throw new PermissionError('PermissionError: can not read row with id '.$id); - } + try { + $row = $this->row2Mapper->find($id, $columns); + } catch (InternalError $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': '.$e->getMessage(), $e->getCode(), $e); + } catch (NotFoundError $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new NotFoundError(get_class($this) . ' - ' . __FUNCTION__ . ': '.$e->getMessage()); + } - return $row; - } catch (DoesNotExistException $e) { - $this->logger->warning($e->getMessage()); - throw new NotFoundError($e->getMessage()); - } catch (MultipleObjectsReturnedException|Exception $e) { - $this->logger->error($e->getMessage()); - throw new InternalError($e->getMessage()); + // security + if (!$this->permissionsService->canReadRowsByElementId($row->getTableId(), 'table')) { + throw new PermissionError('PermissionError: can not read row with id '.$id); } + + return $row; } /** * @param int|null $tableId * @param int|null $viewId * @param list $data - * @return Row + * @return Row2 * * @throws NotFoundError * @throws PermissionError * @throws Exception * @throws InternalError */ - public function create(?int $tableId, ?int $viewId, array $data):Row { + public function create(?int $tableId, ?int $viewId, array $data): Row2 { if ($this->userId === null || $this->userId === '') { $e = new \Exception('No user id in context, but needed.'); $this->logger->error($e->getMessage(), ['exception' => $e]); @@ -168,19 +194,16 @@ public function create(?int $tableId, ?int $viewId, array $data):Row { $data = $this->cleanupData($data, $columns, $tableId, $viewId); - $time = new DateTime(); - $item = new Row(); - $item->setDataArray($data); - if ($tableId) { - $item->setTableId($tableId); - } elseif (isset($view) && $view) { - $item->setTableId($view->getTableId()); + // perf + $row2 = new Row2(); + $row2->setTableId($tableId); + $row2->setData($data); + try { + return $this->row2Mapper->insert($row2, $this->columnMapper->findAllByTable($tableId)); + } catch (InternalError|Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': '.$e->getMessage()); } - $item->setCreatedBy($this->userId); - $item->setCreatedAt($time->format('Y-m-d H:i:s')); - $item->setLastEditBy($this->userId); - $item->setLastEditAt($time->format('Y-m-d H:i:s')); - return $this->mapper->insert($item); } /** @@ -254,6 +277,34 @@ private function getColumnFromColumnsArray(int $columnId, array $columns): ?Colu return null; } + /** + * @throws NotFoundError + * @throws InternalError + */ + private function getRowById(int $rowId): Row2 { + if (isset($this->tmpRows[$rowId])) { + return $this->tmpRows[$rowId]; + } + + try { + if ($this->row2Mapper->getTableIdForRow($rowId) === null) { + $e = new \Exception('No table id in row, but needed.'); + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': '.$e->getMessage()); + } + $row = $this->row2Mapper->find($rowId, $this->columnMapper->findAllByTable($this->row2Mapper->getTableIdForRow($rowId))); + $row->markAsLoaded(); + } catch (InternalError|DoesNotExistException|MultipleObjectsReturnedException|Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': '.$e->getMessage()); + } catch (NotFoundError $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new NotFoundError(get_class($this) . ' - ' . __FUNCTION__ . ': '.$e->getMessage()); + } + $this->tmpRows[$rowId] = $row; + return $row; + } + /** * Update multiple cells in a row * @@ -261,10 +312,9 @@ private function getColumnFromColumnsArray(int $columnId, array $columns): ?Colu * @param int|null $viewId * @param list $data * @param string $userId - * @return Row + * @return Row2 * * @throws InternalError - * @throws PermissionError * @throws NotFoundError * @noinspection DuplicatedCode */ @@ -273,13 +323,13 @@ public function updateSet( ?int $viewId, array $data, string $userId - ):Row { + ): Row2 { try { - $item = $this->mapper->find($id); - } catch (MultipleObjectsReturnedException|Exception $e) { + $item = $this->getRowById($id); + } catch (InternalError $e) { $this->logger->error($e->getMessage(), ['exception' => $e]); throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': '.$e->getMessage()); - } catch (DoesNotExistException $e) { + } catch (NotFoundError $e) { $this->logger->error($e->getMessage(), ['exception' => $e]); throw new NotFoundError(get_class($this) . ' - ' . __FUNCTION__ . ': '.$e->getMessage()); } @@ -287,7 +337,9 @@ public function updateSet( if ($viewId) { // security if (!$this->permissionsService->canUpdateRowsByViewId($viewId)) { - throw new PermissionError('update row id = '.$item->getId().' is not allowed.'); + $e = new \Exception('Update row is not allowed.'); + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': '.$e->getMessage()); } try { @@ -300,11 +352,14 @@ public function updateSet( throw new NotFoundError(get_class($this) . ' - ' . __FUNCTION__ . ': '.$e->getMessage()); } - $rowIds = $this->mapper->getRowIdsOfView($view, $userId); - if(!in_array($id, $rowIds)) { - throw new PermissionError('update row id = '.$item->getId().' is not allowed.'); + // is row in view? + if(!$this->row2Mapper->isRowInViewPresent($id, $view, $userId)) { + $e = new \Exception('Update row is not allowed.'); + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': '.$e->getMessage()); } + // fetch all needed columns try { $columns = $this->columnMapper->findMultiple($view->getColumnsArray()); } catch (Exception $e) { @@ -317,7 +372,9 @@ public function updateSet( // security if (!$this->permissionsService->canUpdateRowsByTableId($tableId)) { - throw new PermissionError('update row id = '.$tableId.' is not allowed.'); + $e = new \Exception('Update row is not allowed.'); + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': '.$e->getMessage()); } try { $columns = $this->columnMapper->findAllByTable($tableId); @@ -329,146 +386,107 @@ public function updateSet( $data = $this->cleanupData($data, $columns, $item->getTableId(), $viewId); - $time = new DateTime(); - $oldData = $item->getDataArray(); foreach ($data as $entry) { // Check whether the column of which the value should change is part of the table / view $column = $this->getColumnFromColumnsArray($entry['columnId'], $columns); if ($column) { - $oldData = $this->replaceOrAddData($oldData, $entry); + $item->insertOrUpdateCell($entry); } else { $this->logger->warning("Column to update row not found, will continue and ignore this."); } } - $item->setDataArray($oldData); - $item->setLastEditBy($userId); - $item->setLastEditAt($time->format('Y-m-d H:i:s')); - try { - $row = $this->mapper->update($item); - } catch (Exception $e) { - $this->logger->error($e->getMessage(), ['exception' => $e]); - throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': '.$e->getMessage()); - } - - if ($viewId) { - try { - $row = $this->mapper->findByView($row->getId(), $view); - } catch (DoesNotExistException|MultipleObjectsReturnedException|Exception $e) { - $this->logger->error($e->getMessage(), ['exception' => $e]); - throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': '.$e->getMessage()); - } - } - - return $row; - } - - private function replaceOrAddData(array $dataArray, array $newDataObject): array { - $columnId = (int) $newDataObject['columnId']; - $value = $newDataObject['value']; - - $columnFound = false; - foreach ($dataArray as $key => $c) { - if ($c['columnId'] == $columnId) { - $dataArray[$key]['value'] = $value; - $columnFound = true; - break; - } - } - // if the value was not set, add it - if (!$columnFound) { - $dataArray[] = [ - "columnId" => $columnId, - "value" => $value - ]; - } - return $dataArray; + return $this->row2Mapper->update($item, $columns); } /** * @param int $id * @param int|null $viewId * @param string $userId - * @return Row + * @return Row2 * * @throws InternalError * @throws NotFoundError * @throws PermissionError + * @noinspection DuplicatedCode */ - public function delete(int $id, ?int $viewId, string $userId): Row { + public function delete(int $id, ?int $viewId, string $userId): Row2 { try { - $item = $this->mapper->find($id); + $item = $this->getRowById($id); + } catch (InternalError $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': '.$e->getMessage()); + } catch (NotFoundError $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new NotFoundError(get_class($this) . ' - ' . __FUNCTION__ . ': '.$e->getMessage()); + } - if ($viewId) { - // security - if (!$this->permissionsService->canDeleteRowsByViewId($viewId)) { - throw new PermissionError('update row id = '.$item->getId().' is not allowed.'); - } + if ($viewId) { + // security + if (!$this->permissionsService->canDeleteRowsByViewId($viewId)) { + $e = new \Exception('Update row is not allowed.'); + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': '.$e->getMessage()); + } + try { $view = $this->viewMapper->find($viewId); - $rowIds = $this->mapper->getRowIdsOfView($view, $userId); - if(!in_array($id, $rowIds)) { - throw new PermissionError('update row id = '.$item->getId().' is not allowed.'); - } - } else { - // security - if (!$this->permissionsService->canDeleteRowsByTableId($item->getTableId())) { - throw new PermissionError('update row id = '.$item->getId().' is not allowed.'); - } + } catch (InternalError|DoesNotExistException|MultipleObjectsReturnedException|Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': '.$e->getMessage()); + } + $rowIds = $this->mapper->getRowIdsOfView($view, $userId); + if(!in_array($id, $rowIds)) { + $e = new \Exception('Update row is not allowed.'); + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': '.$e->getMessage()); } + } else { + // security + if (!$this->permissionsService->canDeleteRowsByTableId($item->getTableId())) { + $e = new \Exception('Update row is not allowed.'); + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new PermissionError(get_class($this) . ' - ' . __FUNCTION__ . ': '.$e->getMessage()); + } + } - $this->mapper->delete($item); - return $item; - } catch (DoesNotExistException $e) { - throw new NotFoundError($e->getMessage()); - } catch (MultipleObjectsReturnedException|Exception $e) { - throw new InternalError($e->getMessage()); + try { + return $this->row2Mapper->delete($item); + } catch (Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': '.$e->getMessage()); } } /** * @param int $tableId * @param null|string $userId - * @return int * * @throws PermissionError * @throws Exception */ - public function deleteAllByTable(int $tableId, ?string $userId = null): int { + public function deleteAllByTable(int $tableId, ?string $userId = null): void { // security if (!$this->permissionsService->canDeleteRowsByTableId($tableId, $userId)) { throw new PermissionError('delete all rows for table id = '.$tableId.' is not allowed.'); } - return $this->mapper->deleteAllByTable($tableId); + $columns = $this->columnMapper->findAllByTable($tableId); + + $this->row2Mapper->deleteAllForTable($tableId, $columns); } /** - * @param int $columnId + * This deletes all data for a column, eg if the columns gets removed * - * @throws PermissionError - * @throws Exception + * >>> SECURITY <<< + * We do not check if you are allowed to remove this data. That has to be done before! + * Why? Mostly this check will have be run before and we can pass this here due to performance reasons. + * + * @param Column $column + * @throws InternalError */ - public function deleteColumnDataFromRows(int $columnId):void { - $rows = $this->mapper->findAllWithColumn($columnId); - - // security - if (count($rows) > 0) { - if (!$this->permissionsService->canUpdateRowsByTableId($rows[0]->getTableId())) { - throw new PermissionError('update row id = '.$rows[0]->getId().' within '.__FUNCTION__.' is not allowed.'); - } - } - - foreach ($rows as $row) { - /* @var $row Row */ - $data = $row->getDataArray(); - foreach ($data as $key => $col) { - if ($col['columnId'] == $columnId) { - unset($data[$key]); - } - } - $row->setDataArray($data); - $this->mapper->update($row); - } + public function deleteColumnDataFromRows(Column $column):void { + $this->row2Mapper->deleteDataForColumn($column); } /** @@ -479,7 +497,7 @@ public function deleteColumnDataFromRows(int $columnId):void { */ public function getRowsCount(int $tableId): int { if ($this->permissionsService->canReadRowsByElementId($tableId, 'table')) { - return $this->mapper->countRows($tableId); + return $this->row2Mapper->countRowsForTable($tableId); } else { throw new PermissionError('no read access for counting to table id = '.$tableId); } @@ -490,12 +508,16 @@ public function getRowsCount(int $tableId): int { * @param string $userId * @return int * - * @throws InternalError * @throws PermissionError */ public function getViewRowsCount(View $view, string $userId): int { if ($this->permissionsService->canReadRowsByElementId($view->getId(), 'view', $userId)) { - return $this->mapper->countRowsForView($view, $userId); + try { + return $this->row2Mapper->countRowsForView($view, $userId, $this->columnMapper->findAllByTable($view->getTableId())); + } catch (Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + return 0; + } } else { throw new PermissionError('no read access for counting to view id = '.$view->getId()); } diff --git a/lib/Service/ViewService.php b/lib/Service/ViewService.php index 453be6b9d..5dd6297fd 100644 --- a/lib/Service/ViewService.php +++ b/lib/Service/ViewService.php @@ -432,11 +432,25 @@ public function deleteColumnDataFromViews(int $columnId, Table $table) { return $sort['columnId'] !== $columnId; }); $filteredSortingRules = array_values($filteredSortingRules); - $filteredFilters = array_filter(array_map(function (array $filterGroup) use ($columnId) { - return array_filter($filterGroup, function (array $filter) use ($columnId) { - return $filter['columnId'] !== $columnId; - }); - }, $view->getFilterArray()), fn ($filterGroup) => !empty($filterGroup)); + + $filteredFilters = array_filter( + + array_map( + function (array $filterGroup) use ($columnId) { + return array_filter( + $filterGroup, + function (array $filter) use ($columnId) { + return $filter['columnId'] !== $columnId; + } + ); + }, + $view->getFilterArray() + ), + + fn ($filterGroup) => !empty($filterGroup) + + ); + $data = [ 'columns' => json_encode(array_values(array_diff($view->getColumnsArray(), [$columnId]))), 'sort' => json_encode($filteredSortingRules), diff --git a/openapi.json b/openapi.json index 3fbaad0ea..cf542b1fb 100644 --- a/openapi.json +++ b/openapi.json @@ -7341,4 +7341,4 @@ } }, "tags": [] -} \ No newline at end of file +} diff --git a/src/modules/modals/CreateRow.vue b/src/modules/modals/CreateRow.vue index c753dcf3c..b934756e4 100644 --- a/src/modules/modals/CreateRow.vue +++ b/src/modules/modals/CreateRow.vue @@ -8,7 +8,7 @@ -
+
diff --git a/src/shared/components/ncTable/mixins/filter.js b/src/shared/components/ncTable/mixins/filter.js index e65dc3206..34f796482 100644 --- a/src/shared/components/ncTable/mixins/filter.js +++ b/src/shared/components/ncTable/mixins/filter.js @@ -49,7 +49,7 @@ export const Filters = { Contains: new Filter({ id: FilterIds.Contains, label: t('tables', 'Contains'), - goodFor: [ColumnTypes.TextLine, ColumnTypes.TextLong, ColumnTypes.TextLink, ColumnTypes.TextRich], + goodFor: [ColumnTypes.TextLine, ColumnTypes.TextLong, ColumnTypes.TextLink, ColumnTypes.TextRich, ColumnTypes.SelectionMulti], incompatibleWith: [FilterIds.IsEmpty, FilterIds.IsEqual], }), BeginsWith: new Filter({ @@ -68,7 +68,7 @@ export const Filters = { id: FilterIds.IsEqual, label: t('tables', 'Is equal'), shortLabel: '=', - goodFor: [ColumnTypes.TextLine, ColumnTypes.Number, ColumnTypes.SelectionCheck, ColumnTypes.TextLink, ColumnTypes.NumberStars, ColumnTypes.NumberProgress, ColumnTypes.DatetimeDate, ColumnTypes.DatetimeTime, ColumnTypes.Datetime, ColumnTypes.Selection], + goodFor: [ColumnTypes.TextLine, ColumnTypes.Number, ColumnTypes.SelectionCheck, ColumnTypes.TextLink, ColumnTypes.NumberStars, ColumnTypes.NumberProgress, ColumnTypes.DatetimeDate, ColumnTypes.DatetimeTime, ColumnTypes.Datetime, ColumnTypes.Selection, ColumnTypes.SelectionMulti], incompatibleWith: [FilterIds.IsEmpty, FilterIds.IsEqual, FilterIds.BeginsWith, FilterIds.EndsWith, FilterIds.Contains, FilterIds.IsGreaterThan, FilterIds.IsGreaterThanOrEqual, FilterIds.IsLowerThan, FilterIds.IsLowerThanOrEqual], }), IsGreaterThan: new Filter({ diff --git a/tests/unit/Service/LegacyRowMapperTest.php b/tests/unit/Service/LegacyRowMapperTest.php new file mode 100644 index 000000000..e39d538dc --- /dev/null +++ b/tests/unit/Service/LegacyRowMapperTest.php @@ -0,0 +1,137 @@ + 1, 'value' => 'one']; + $col = new Column(); + $col->setId(1); + $col->setType('text'); + $col->setSubtype('line'); + $columns[] = $col; + + $data[] = ['columnId' => 2, 'value' => 22.2]; + $col = new Column(); + $col->setId(2); + $col->setType('number'); + $columns[] = $col; + + $data[] = ['columnId' => 3, 'value' => 1]; + $col = new Column(); + $col->setId(3); + $col->setType('selection'); + $columns[] = $col; + + $data[] = ['columnId' => 12, 'value' => '2']; + $col = new Column(); + $col->setId(12); + $col->setType('selection'); + $columns[] = $col; + + $data[] = ['columnId' => 4, 'value' => '"true"']; + $col = new Column(); + $col->setId(4); + $col->setType('selection'); + $col->setSubtype('check'); + $columns[] = $col; + + $data[] = ['columnId' => 5, 'value' => '"false"']; + $col = new Column(); + $col->setId(5); + $col->setType('selection'); + $col->setSubtype('check'); + $columns[] = $col; + + $data[] = ['columnId' => 6, 'value' => '[1]']; + $col = new Column(); + $col->setId(6); + $col->setType('selection'); + $col->setSubtype('multi'); + $columns[] = $col; + + $data[] = ['columnId' => 7, 'value' => '[2,3]']; + $col = new Column(); + $col->setId(7); + $col->setType('selection'); + $col->setSubtype('multi'); + $columns[] = $col; + + $data[] = ['columnId' => 8, 'value' => 'null']; + $col = new Column(); + $col->setId(8); + $col->setType('selection'); + $col->setSubtype('multi'); + $columns[] = $col; + + $data[] = ['columnId' => 9, 'value' => '2023-12-24 10:00']; + $col = new Column(); + $col->setId(9); + $col->setType('datetime'); + $columns[] = $col; + + $data[] = ['columnId' => 10, 'value' => '2023-12-25']; + $col = new Column(); + $col->setId(10); + $col->setType('datetime'); + $col->setSubtype('date'); + $columns[] = $col; + + $data[] = ['columnId' => 11, 'value' => '11:11']; + $col = new Column(); + $col->setId(11); + $col->setType('datetime'); + $col->setSubtype('time'); + $columns[] = $col; + + $dbConnection = Server::get(IDBConnection::class); + $textColumnQb = $this->createMock(TextColumnQB::class); + $selectionColumnQb = $this->createMock(SelectionColumnQB::class); + $numberColumnQb = $this->createMock(NumberColumnQB::class); + $datetimeColumnQb = $this->createMock(DatetimeColumnQB::class); + $superColumnQb = $this->createMock(SuperColumnQB::class); + $columnMapper = $this->createMock(ColumnMapper::class); + $row2Mapper = $this->createMock(Row2Mapper::class); + $logger = $this->createMock(LoggerInterface::class); + $userHelper = $this->createMock(UserHelper::class); + $legacyRowMapper = new LegacyRowMapper($dbConnection, $logger, $textColumnQb, $selectionColumnQb, $numberColumnQb, $datetimeColumnQb, $superColumnQb, $columnMapper, $userHelper, $row2Mapper); + + $legacyRow = new LegacyRow(); + $legacyRow->setId(5); + $legacyRow->setTableId(10); + $legacyRow->setCreatedBy('user1'); + $legacyRow->setCreatedAt('2023-12-24 09:00:00'); + $legacyRow->setLastEditAt('2023-12-24 09:30:00'); + $legacyRow->setLastEditBy('user1'); + $legacyRow->setDataArray($data); + + $row2 = $legacyRowMapper->migrateLegacyRow($legacyRow, $columns); + $data2 = $row2->getData(); + + self::assertTrue($data === $data2); + self::assertTrue($legacyRow->jsonSerialize() === $row2->jsonSerialize()); + } +}