diff --git a/.ddev/config.yaml b/.ddev/config.yaml index 38fd594..ebd7f72 100644 --- a/.ddev/config.yaml +++ b/.ddev/config.yaml @@ -1,7 +1,7 @@ name: xlsexport type: typo3 -docroot: public -php_version: "8.1" +docroot: .Build/Web +php_version: "8.2" webserver_type: nginx-fpm router_http_port: "80" router_https_port: "443" @@ -11,8 +11,7 @@ additional_fqdns: [] database: type: mariadb version: "10.3" -nfs_mount_enabled: false -mutagen_enabled: false +performance_mode: none use_dns_when_possible: true composer_version: "2" web_environment: diff --git a/.github/workflows/testcore12.yml b/.github/workflows/testcore12.yml new file mode 100644 index 0000000..e600593 --- /dev/null +++ b/.github/workflows/testcore12.yml @@ -0,0 +1,181 @@ +name: tests core 12 + +on: + pull_request: + workflow_dispatch: + +jobs: + code-quality: + name: "code quality with core v12" + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + php-version: [ '8.1'] + permissions: + # actions: read|write|none + actions: none + # checks: read|write|none + checks: none + # contents: read|write|none + contents: read + # deployments: read|write|none + deployments: none + # id-token: read|write|none + id-token: none + # issues: read|write|none + issues: none + # discussions: read|write|none + discussions: none + # packages: read|write|none + packages: read + # pages: read|write|none + pages: none + # pull-requests: read|write|none + pull-requests: none + # repository-projects: read|write|none + repository-projects: read + # security-events: read|write|none + security-events: none + # statuses: read|write|none + statuses: none + steps: + - name: "Checkout" + uses: actions/checkout@v4 + + - name: "Prepare dependencies for TYPO3 v12" + run: "Build/Scripts/runTests.sh -t 12 -p ${{ matrix.php-version }} -s composer require typo3/cms-core:^12.4 -W" + +# Disabled, as latest installable version of TypoScript linter does not support the TYPO3 backend layout +# override syntax in PageTSConfig files. +# @see https://docs.typo3.org/c/typo3/cms-core/main/en-us/Changelog/12.0/Feature-96812-OverrideBackendTemplatesWithTSconfig.html +# - name: "Run TypoScript lint" +# run: "Build/Scripts/runTests.sh -t 12 -p ${{ matrix.php-version }} -s lintTypoScript" + + - name: "Run PHP lint" + run: "Build/Scripts/runTests.sh -t 12 -p ${{ matrix.php-version }} -s lintPhp" + + - name: "Validate CGL" + run: "Build/Scripts/runTests.sh -t 12 -p ${{ matrix.php-version }} -s cgl" + + - name: "Ensure tests methods do not start with \"test\"" + run: "Build/Scripts/runTests.sh -t 12 -p ${{ matrix.php-version }} -s checkTestMethodsPrefix" + + - name: "Ensure UTF-8 files do not contain BOM" + run: "Build/Scripts/runTests.sh -t 12 -p ${{ matrix.php-version }} -s checkBom" + +# Disabled until documentation is added +# - name: "Test .rst files for integrity" +# run: "Build/Scripts/runTests.sh -t 12 -p ${{ matrix.php-version }} -s checkRst" + + - name: "Find duplicate exception codes" + run: "Build/Scripts/runTests.sh -t 12 -p ${{ matrix.php-version }} -s checkExceptionCodes" + + - name: "Run PHPStan" + run: "Build/Scripts/runTests.sh -t 12 -p ${{ matrix.php-version }} -s phpstan" + + typoscript: + name: "Linting TypoScript and TSConfig files" + runs-on: ubuntu-24.04 + permissions: + # actions: read|write|none + actions: none + # checks: read|write|none + checks: none + # contents: read|write|none + contents: read + # deployments: read|write|none + deployments: none + # id-token: read|write|none + id-token: none + # issues: read|write|none + issues: none + # discussions: read|write|none + discussions: none + # packages: read|write|none + packages: read + # pages: read|write|none + pages: none + # pull-requests: read|write|none + pull-requests: none + # repository-projects: read|write|none + repository-projects: read + # security-events: read|write|none + security-events: none + # statuses: read|write|none + statuses: none + steps: + - name: "Checkout" + uses: actions/checkout@v4 + + - name: "Prepare dependencies for TYPO3 v12" + run: "Build/Scripts/runTests.sh -t 12 -p 8.1 -s composerUpdate" + + + testsuite: + name: all tests with core v12 + runs-on: ubuntu-24.04 + needs: + - code-quality + - typoscript + strategy: + fail-fast: false + matrix: + php-version: [ '8.1', '8.2', '8.3', '8.4' ] + permissions: + # actions: read|write|none + actions: none + # checks: read|write|none + checks: none + # contents: read|write|none + contents: read + # deployments: read|write|none + deployments: none + # id-token: read|write|none + id-token: none + # issues: read|write|none + issues: none + # discussions: read|write|none + discussions: none + # packages: read|write|none + packages: read + # pages: read|write|none + pages: none + # pull-requests: read|write|none + pull-requests: none + # repository-projects: read|write|none + repository-projects: read + # security-events: read|write|none + security-events: none + # statuses: read|write|none + statuses: none + steps: + - name: "Checkout" + uses: actions/checkout@v4 + + - name: "Prepare dependencies for TYPO3 v12" + run: "Build/Scripts/runTests.sh -t 12 -p ${{ matrix.php-version }} -s composer require typo3/cms-core:^12.4 -W" + + - name: "Unit" + run: "Build/Scripts/runTests.sh -t 12 -p ${{ matrix.php-version }} -s unit" + + - name: "Functional SQLite" + run: "Build/Scripts/runTests.sh -t 12 -p ${{ matrix.php-version }} -s functional -d sqlite" + + - name: "Functional MariaDB 10.5 mysqli" + run: "Build/Scripts/runTests.sh -t 12 -p ${{ matrix.php-version }} -s functional -d mariadb -a mysqli" + + - name: "Functional MariaDB 10.5 pdo_mysql" + run: "Build/Scripts/runTests.sh -t 12 -p ${{ matrix.php-version }} -s functional -d mariadb -a pdo_mysql" + + - name: "Functional MySQL 8.0 mysqli" + run: "Build/Scripts/runTests.sh -t 12 -p ${{ matrix.php-version }} -s functional -d mariadb -a mysqli" + + - name: "Functional MySQL 8.0 pdo_mysql" + run: "Build/Scripts/runTests.sh -t 12 -p ${{ matrix.php-version }} -s functional -d mariadb -a pdo_mysql" + + - name: "Functional PostgresSQL 10" + # v12 postgres functional disabled with PHP 8.2 since https://github.com/doctrine/dbal/commit/73eec6d882b99e1e2d2d937accca89c1bd91b2d7 + # is not fixed in doctrine core v12 doctrine 2.13.9 + if: ${{ matrix.php <= '8.1' }} + run: "Build/Scripts/runTests.sh -t 12 -p ${{ matrix.php-version }} -s functional -d postgres" diff --git a/Build/Scripts/runTests.sh b/Build/Scripts/runTests.sh index 12a03f0..6d3a6b7 100755 --- a/Build/Scripts/runTests.sh +++ b/Build/Scripts/runTests.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # -# lavitto/typo3-form-to-database test runner based on docker/podman. +# calien-typo3-xlseport test runner based on docker/podman. # if [ "${CI}" != "true" ]; then trap 'echo "runTests.sh SIGINT signal emitted";cleanUp;exit 2' SIGINT @@ -444,7 +444,7 @@ echo "Architecture" ${ARCH} "requires" ${IMAGE_SELENIUM} "to run acceptance test shift $((OPTIND - 1)) SUFFIX=$(echo $RANDOM) -NETWORK="lavitto-form-to-database-${SUFFIX}" +NETWORK="calien-typo3-xlseport-${SUFFIX}" ${CONTAINER_BIN} network create ${NETWORK} >/dev/null if [ "${CONTAINER_BIN}" == "docker" ]; then diff --git a/Build/Scripts/testMethodPrefixChecker.php b/Build/Scripts/testMethodPrefixChecker.php index e1f1886..b733752 100755 --- a/Build/Scripts/testMethodPrefixChecker.php +++ b/Build/Scripts/testMethodPrefixChecker.php @@ -27,7 +27,7 @@ public function enterNode(Node $node): void } } -$parser = (new ParserFactory())->createForVersion(\PhpParser\PhpVersion::getHostVersion()); +$parser = (new ParserFactory())->createForNewestSupportedVersion(); $finder = new Symfony\Component\Finder\Finder(); $finder->files() diff --git a/Build/phpstan/phpstan-baseline.neon b/Build/phpstan/phpstan-baseline.neon index 6ac1669..09602d8 100644 --- a/Build/phpstan/phpstan-baseline.neon +++ b/Build/phpstan/phpstan-baseline.neon @@ -1,16 +1,66 @@ parameters: ignoreErrors: - - message: "#^Parameter \\#1 \\$formSettings of method Lavitto\\\\FormToDatabase\\\\Controller\\\\FormResultsController\\:\\:getAvailableFormDefinitions\\(\\) expects array\\{persistenceManager\\: array\\{allowedFileMounts\\: array\\\\}\\}, array given\\.$#" + message: "#^Expression on left side of \\?\\? is not nullable\\.$#" + count: 1 + path: ../../Classes/Controller/XlsExportController.php + + - + message: "#^Property Calien\\\\Xlsexport\\\\Controller\\\\XlsExportController\\:\\:\\$modTSconfig \\(array\\\\) does not accept array\\\\.$#" + count: 1 + path: ../../Classes/Controller/XlsExportController.php + + - + message: "#^Instanceof between string and Doctrine\\\\DBAL\\\\ParameterType will always evaluate to false\\.$#" + count: 1 + path: ../../Classes/Service/DatabaseQueryTypoScriptParser.php + + - + message: "#^Parameter \\#1 \\$objectOrClass of class ReflectionClass constructor expects class\\-string\\\\|T of object, string given\\.$#" + count: 1 + path: ../../Classes/Service/DatabaseQueryTypoScriptParser.php + + - + message: "#^Parameter \\#2 \\$configuration of method Calien\\\\Xlsexport\\\\Service\\\\DatabaseQueryTypoScriptParser\\:\\:generateValue\\(\\) expects array\\{fieldName\\: string, parameter\\: array\\\\|float\\|int\\|string, type\\: string, expressionType\\: string, isColumn\\?\\: bool\\}, array\\{fieldName\\: string, parameter\\: array\\\\|float\\|int\\|string, type\\: 0\\|1\\|2\\|3\\|4\\|5\\|101\\|102\\|117, expressionType\\: string, isColumn\\?\\: bool\\} given\\.$#" + count: 6 + path: ../../Classes/Service/DatabaseQueryTypoScriptParser.php + + - + message: "#^Strict comparison using \\=\\=\\= between '0'\\|bool and '1' will always evaluate to false\\.$#" + count: 1 + path: ../../Classes/Service/DatabaseQueryTypoScriptParser.php + + - + message: "#^Call to an undefined method TYPO3\\\\CMS\\\\Core\\\\Database\\\\Query\\\\QueryBuilder\\:\\:getFrom\\(\\)\\.$#" count: 2 - path: ../../Classes/Controller/FormResultsController.php + path: ../../Tests/Functional/Service/DatabaseQueryTypoScriptParserTest.php + + - + message: "#^Call to an undefined method TYPO3\\\\CMS\\\\Core\\\\Database\\\\Query\\\\QueryBuilder\\:\\:getSelect\\(\\)\\.$#" + count: 4 + path: ../../Tests/Functional/Service/DatabaseQueryTypoScriptParserTest.php + + - + message: "#^Call to an undefined method TYPO3\\\\CMS\\\\Core\\\\Database\\\\Query\\\\QueryBuilder\\:\\:getWhere\\(\\)\\.$#" + count: 1 + path: ../../Tests/Functional/Service/DatabaseQueryTypoScriptParserTest.php + + - + message: "#^Parameter \\#1 \\$configuration of method Calien\\\\Xlsexport\\\\Service\\\\DatabaseQueryTypoScriptParser\\:\\:buildQueryBuilderFromArray\\(\\) expects array\\{table\\: non\\-empty\\-string, alias\\?\\: non\\-empty\\-string, select\\: array\\, count\\?\\: non\\-empty\\-string, selectLiteral\\?\\: array\\, where\\: array\\\\|float\\|int\\|string, type\\: 0\\|1\\|2\\|3\\|4\\|5\\|101\\|102\\|117, expressionType\\: string, isColumn\\?\\: bool\\}\\>, join\\?\\: array\\\\|float\\|int\\|string, type\\: 0\\|1\\|2\\|3\\|4\\|5\\|101\\|102\\|117, expressionType\\: string, isColumn\\?\\: bool\\}\\>\\}\\>, leftJoin\\?\\: array\\\\|float\\|int\\|string, type\\: 0\\|1\\|2\\|3\\|4\\|5\\|101\\|102\\|117, expressionType\\: string, isColumn\\?\\: bool\\}\\>\\}\\>, \\.\\.\\.\\}, array\\{table\\: 'pages', select\\: array\\{'uid', 'pid', 'title'\\}, where\\: array\\{array\\{fieldName\\: 'pid', parameter\\: 1, type\\: 'Connection\\:\\:PARAM…', expressionType\\: 'eq'\\}\\}, join\\: array\\{array\\{from\\: 'pages', to\\: 'tt_content', where\\: array\\{array\\{fieldName\\: 'pages\\.uid', parameter\\: 'tt_content\\.pid', type\\: 'Connection\\:\\:PARAM…', expressionType\\: 'eq', isColumn\\: true\\}\\}\\}\\}\\} given\\.$#" + count: 1 + path: ../../Tests/Functional/Service/DatabaseQueryTypoScriptParserTest.php + + - + message: "#^Parameter \\#1 \\$configuration of method Calien\\\\Xlsexport\\\\Service\\\\DatabaseQueryTypoScriptParser\\:\\:buildQueryBuilderFromArray\\(\\) expects array\\{table\\: non\\-empty\\-string, alias\\?\\: non\\-empty\\-string, select\\: array\\, count\\?\\: non\\-empty\\-string, selectLiteral\\?\\: array\\, where\\: array\\\\|float\\|int\\|string, type\\: 0\\|1\\|2\\|3\\|4\\|5\\|101\\|102\\|117, expressionType\\: string, isColumn\\?\\: bool\\}\\>, join\\?\\: array\\\\|float\\|int\\|string, type\\: 0\\|1\\|2\\|3\\|4\\|5\\|101\\|102\\|117, expressionType\\: string, isColumn\\?\\: bool\\}\\>\\}\\>, leftJoin\\?\\: array\\\\|float\\|int\\|string, type\\: 0\\|1\\|2\\|3\\|4\\|5\\|101\\|102\\|117, expressionType\\: string, isColumn\\?\\: bool\\}\\>\\}\\>, \\.\\.\\.\\}, array\\{table\\: 'pages', select\\: array\\{'uid', 'pid', 'title'\\}, where\\: array\\{array\\{fieldName\\: 'pid', parameter\\: 1, type\\: 'Connection\\:\\:PARAM…', expressionType\\: 'eq'\\}\\}\\} given\\.$#" + count: 1 + path: ../../Tests/Functional/Service/DatabaseQueryTypoScriptParserTest.php - - message: "#^Parameter \\#1 \\$targetFolder of method TYPO3\\\\CMS\\\\Core\\\\Resource\\\\AbstractFile\\:\\:moveTo\\(\\) expects TYPO3\\\\CMS\\\\Core\\\\Resource\\\\Folder, TYPO3\\\\CMS\\\\Core\\\\Resource\\\\FolderInterface given\\.$#" + message: "#^Parameter \\#1 \\$configuration of method Calien\\\\Xlsexport\\\\Service\\\\DatabaseQueryTypoScriptParser\\:\\:buildQueryBuilderFromArray\\(\\) expects array\\{table\\: non\\-empty\\-string, alias\\?\\: non\\-empty\\-string, select\\: array\\, count\\?\\: non\\-empty\\-string, selectLiteral\\?\\: array\\, where\\: array\\\\|float\\|int\\|string, type\\: 0\\|1\\|2\\|3\\|4\\|5\\|101\\|102\\|117, expressionType\\: string, isColumn\\?\\: bool\\}\\>, join\\?\\: array\\\\|float\\|int\\|string, type\\: 0\\|1\\|2\\|3\\|4\\|5\\|101\\|102\\|117, expressionType\\: string, isColumn\\?\\: bool\\}\\>\\}\\>, leftJoin\\?\\: array\\\\|float\\|int\\|string, type\\: 0\\|1\\|2\\|3\\|4\\|5\\|101\\|102\\|117, expressionType\\: string, isColumn\\?\\: bool\\}\\>\\}\\>, \\.\\.\\.\\}, array\\{table\\: 'pages', select\\: array\\{'uid', 'pid', 'title'\\}, where\\: array\\{array\\{fieldName\\: 'pid', parameter\\: 1, type\\: 'Connection\\:\\:PARAM…', expressionType\\: 'not\\-allowed…'\\}\\}\\} given\\.$#" count: 1 - path: ../../Classes/Controller/FormResultsController.php + path: ../../Tests/Functional/Service/DatabaseQueryTypoScriptParserTest.php - - message: "#^Parameter \\#1 \\$targetFolder of method TYPO3\\\\CMS\\\\Core\\\\Resource\\\\AbstractFile\\:\\:copyTo\\(\\) expects TYPO3\\\\CMS\\\\Core\\\\Resource\\\\Folder, TYPO3\\\\CMS\\\\Core\\\\Resource\\\\FolderInterface given\\.$#" + message: "#^Parameter \\#1 \\$configuration of method Calien\\\\Xlsexport\\\\Service\\\\DatabaseQueryTypoScriptParser\\:\\:buildQueryBuilderFromArray\\(\\) expects array\\{table\\: non\\-empty\\-string, alias\\?\\: non\\-empty\\-string, select\\: array\\, count\\?\\: non\\-empty\\-string, selectLiteral\\?\\: array\\, where\\: array\\\\|float\\|int\\|string, type\\: 0\\|1\\|2\\|3\\|4\\|5\\|101\\|102\\|117, expressionType\\: string, isColumn\\?\\: bool\\}\\>, join\\?\\: array\\\\|float\\|int\\|string, type\\: 0\\|1\\|2\\|3\\|4\\|5\\|101\\|102\\|117, expressionType\\: string, isColumn\\?\\: bool\\}\\>\\}\\>, leftJoin\\?\\: array\\\\|float\\|int\\|string, type\\: 0\\|1\\|2\\|3\\|4\\|5\\|101\\|102\\|117, expressionType\\: string, isColumn\\?\\: bool\\}\\>\\}\\>, \\.\\.\\.\\}, array\\{table\\: 'pages', select\\: array\\{'uid', 'pid', 'title'\\}, where\\: array\\{array\\{fieldName\\: 'pid', parameter\\: array\\{1\\}, type\\: 'Connection\\:\\:PARAM…', expressionType\\: 'in'\\}\\}\\} given\\.$#" count: 1 - path: ../../Classes/Hooks/FormHooks.php + path: ../../Tests/Functional/Service/DatabaseQueryTypoScriptParserTest.php diff --git a/Build/phpstan/phpstan.neon b/Build/phpstan/phpstan.neon index de1cd19..38667e0 100644 --- a/Build/phpstan/phpstan.neon +++ b/Build/phpstan/phpstan.neon @@ -5,12 +5,13 @@ includes: parameters: # Use local .cache dir instead of /tmp tmpDir: ../../.cache/phpstan - ignoreErrors: - - '#Variable \$_EXTKEY might not be defined\.#' +# ignoreErrors: +# - '#Variable \$_EXTKEY might not be defined\.#' level: 8 paths: - - ../../. + - ../../Classes + - ../../Tests excludePaths: - ../../.Build/* diff --git a/Classes/Controller/XlsExportController.php b/Classes/Controller/XlsExportController.php index 63b6ccb..6242703 100644 --- a/Classes/Controller/XlsExportController.php +++ b/Classes/Controller/XlsExportController.php @@ -1,201 +1,109 @@ */ - protected array $hooks = []; + protected array $modTSconfig = []; + + private readonly string $moduleName; + protected ModuleTemplate $moduleTemplate; public function __construct( - ConnectionPool $connectionPool, - ModuleTemplateFactory $moduleTemplateFactory + private readonly ModuleTemplateFactory $moduleTemplateFactory, + private readonly TypoScriptService $typoScriptService, + private readonly DatabaseQueryTypoScriptParser $databaseQueryTypoScriptParser, + private readonly SpreadsheetWriteService $spreadsheetWriteService ) { - $this->dbConnection = $connectionPool; - $this->moduleTemplateFactory = $moduleTemplateFactory; - $this->loadHooks(); + $this->moduleName = 'web_xlsexport'; } - /** - * action index - * renders the export view - * - * @throws Exception - * @throws \Doctrine\DBAL\Exception - */ - public function indexAction(): ResponseInterface + public function index(ServerRequestInterface $request): ResponseInterface { - $this->pageId = (int)($this->request->getParsedBody()['id'] ?? $this->request->getQueryParams()['id'] ?? null) ?? 0; - $this->view->assign('id', $this->pageId); - if ($this->pageId > 0) { - $this->loadTSconfig($this->pageId); - - if ( - array_key_exists('exports.', $this->selfSettings) - && is_array($this->selfSettings['exports.']) - ) { - $this->buildDataArrayForListView(); - $this->view->assign('settings', $this->selfSettings); - $this->addAdditionalData(); - } else { - $this->view->assign('noconfig', 1); - } + $this->moduleTemplate = $this->moduleTemplateFactory->create($request); + $pageId = (int)($request->getQueryParams()['id'] ?? null) ?? 0; + $this->loadTSconfig($pageId); + $assignedValues = [ + 'noConfig' => $this->modTSconfig === [], + 'datasets' => [], + 'pageId' => $pageId, + ]; + foreach ($this->modTSconfig as $configName => $configuration) { + $countQuery = $this->databaseQueryTypoScriptParser->buildCountQueryFromArray($configuration); + $this->databaseQueryTypoScriptParser->replacePlaceholderWithCurrentId($countQuery, $pageId); + $assignedValues['datasets'][$configName] = [ + 'label' => $configuration['label'] ?? $configuration['table'], + 'count' => $countQuery->executeQuery()->fetchOne(), + ]; } - return $this->htmlResponse(); + + $this->moduleTemplate->assignMultiple($assignedValues); + + return $this->moduleTemplate->renderResponse('XlsExport/Index'); } /** - * action export - * - * @throws Exception - * @throws \Doctrine\DBAL\Exception - * @throws \PhpOffice\PhpSpreadsheet\Exception - * @throws \PhpOffice\PhpSpreadsheet\Writer\Exception - * @throws DBALException + * @throws ExportWithoutConfigurationException + * @throws ConfigurationNotFoundException */ - public function exportAction(int $id, string $config): ResponseInterface + public function export(ServerRequestInterface $request): ResponseInterface { - $this->pageId = $id; - - $this->loadTSconfig($this->pageId); - - $settings = $this->selfSettings['exports.'][$config . '.']; - - $event = $this->eventDispatcher->dispatch(new AlternateExportQueryEvent($settings, $config)); - - $settings = $event->getManipulatedSettings(); - - $file = $this->doExport($settings, $this->pageId); - - //ins Archiv verschieben - if ($settings['archive']) { - $archive = $settings['archive']; - - $dbQuery = $this->dbConnection->getQueryBuilderForTable($settings['table']); - $dbQuery->update($settings['table']) - ->where( - $dbQuery->expr()->eq('pid', $dbQuery->createNamedParameter($this->pageId, \PDO::PARAM_INT)) - )->set('pid', $dbQuery->createNamedParameter($archive, \PDO::PARAM_INT))->executeStatement(); + $pageId = $request->getQueryParams()['id'] ?? null; + $configuration = $request->getQueryParams()['configuration'] ?? null; + if ($pageId === null || $configuration === null) { + throw new ExportWithoutConfigurationException( + 'For an export you need a valid configuration key', + 1731105142347 + ); + } + $pageId = (int)$pageId; + + $this->loadTSconfig($pageId); + if (!array_key_exists($configuration, $this->modTSconfig)) { + throw new ConfigurationNotFoundException( + 'Configuration not found for export on current page', + 1731105227250 + ); } - return (new Response()) - ->withHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') - ->withHeader( - 'Content-Disposition', - sprintf( - 'attachment;filename="%s_%s_%d.xlsx"', - date('Y-m-d-His'), - $settings['table'], - $this->pageId - ) - ) - ->withHeader('Cache-Control', 'max-age=0') - ->withBody($file); - } + $exportDataQuery = $this->databaseQueryTypoScriptParser->buildQueryBuilderFromArray($this->modTSconfig[$configuration]); + $this->databaseQueryTypoScriptParser->replacePlaceholderWithCurrentId($exportDataQuery, $pageId); - protected function buildDataArrayForListView(): void - { - $datasets = []; - $event = $this->eventDispatcher->dispatch(new AlternateCheckQueryEvent($this->selfSettings['exports.'])); - $this->selfSettings['exports.'] = $event->getManipulatedSettings(); - foreach ($this->selfSettings['exports.'] as $key => $config) { - $keyWithoutDot = str_replace('.', '', $key); - if (strlen($config['check']) > 20) { - $table = $config['table']; - $checkQuery = $config['check']; - - /** @deprecated use PSR-14 event instead, will be removed in future versions */ - if (array_key_exists($table, $this->hooks) && is_array($this->hooks[$keyWithoutDot])) { - foreach ($this->hooks[$keyWithoutDot] as $classObj) { - $hookObj = GeneralUtility::makeInstance($classObj); - if (method_exists($hookObj, 'alternateCheckQuery')) { - trigger_error( - 'Usage of hooks inside XLS export is deprecated and will be removed in future versions. Use PSR-14 Event dispatching instead.', - E_USER_DEPRECATED - ); - $checkQuery = $hookObj->alternateCheckQuery($checkQuery, $this); - } - } - } - - $statement = sprintf($checkQuery, $this->pageId); - $dbQuery = $this->dbConnection->getQueryBuilderForTable($table)->getConnection(); - $result = $dbQuery->executeQuery($statement)->fetchAllAssociative(); - - // if all datasets from this page should be exported - if (count($result) == 1) { - $count = $result[0]; - $datasets[$keyWithoutDot]['count'] = $count['count(uid)'] ?? $count['count(*)']; - } else { - foreach ($result as $row) { - $datasets[$keyWithoutDot]['options'][end($row)]['count'] = $row['count(*)']; - } - } - - $datasets[$keyWithoutDot]['label'] = $config['label'] ?: $table; - $datasets[$keyWithoutDot]['config'] = $keyWithoutDot; - } - } - $this->view->assign('datasets', $datasets); - } + $result = $exportDataQuery->executeQuery(); - protected function addAdditionalData(): void - { - $additionalData = []; - if (array_key_exists('additionalData', $this->hooks)) { - foreach ($this->hooks['additionalData'] as $classObj) { - $hookObj = GeneralUtility::makeInstance($classObj); - if (method_exists($hookObj, 'addAdditionalData')) { - $hookObj->addAdditionalData($additionalData, $this); - } - } - } - if (count($additionalData) > 0) { - $this->view->assign('additionalData', $additionalData); - } + $spreadsheet = $this->spreadsheetWriteService->generateSpreadsheet($result, $this->modTSconfig[$configuration], $configuration); + + return (new ResponseFactory()) + ->createResponse() + ->withBody($spreadsheet) + ->withHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); } - /** - * @deprecated Will be removed in future version - */ - private function loadHooks(): void + private function loadTSconfig(int $currentId): void { - /** @deprecated Use PSR-14 Events instead */ - if ( - array_key_exists('xlsexport', $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']) - && array_key_exists('alternateQueries', $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['xlsexport']) - && is_array($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['xlsexport']['alternateQueries']) - ) { - $this->hooks = $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['xlsexport']['alternateQueries']; + $TSconfig = BackendUtility::getPagesTSconfig($currentId); + $moduleConfigArrayName = sprintf('%s.', $this->moduleName); + if (array_key_exists($moduleConfigArrayName, $TSconfig['mod.'])) { + $this->modTSconfig = $this->typoScriptService->convertTypoScriptArrayToPlainArray($TSconfig['mod.'][$moduleConfigArrayName]); } } } diff --git a/Classes/Event/AlternateFirstColumnInSheetEvent.php b/Classes/Event/AlternateFirstColumnInSheetEvent.php new file mode 100644 index 0000000..0c8cbc8 --- /dev/null +++ b/Classes/Event/AlternateFirstColumnInSheetEvent.php @@ -0,0 +1,21 @@ +firstColumn; + } + + public function setFirstColumn(string $firstColumn): void + { + $this->firstColumn = $firstColumn; + } + +} diff --git a/Classes/Event/AlternateHeaderLineEvent.php b/Classes/Event/AlternateHeaderLineEvent.php new file mode 100644 index 0000000..f535656 --- /dev/null +++ b/Classes/Event/AlternateHeaderLineEvent.php @@ -0,0 +1,43 @@ +headerFieldLabels; + } + + /** + * @param string[] $headerFieldLabels + */ + public function setHeaderFieldLabels(array $headerFieldLabels): void + { + $this->headerFieldLabels = $headerFieldLabels; + } + + public function getConfiguration(): string + { + return $this->configuration; + } +} diff --git a/Classes/Event/ManipulateRowEntryEvent.php b/Classes/Event/ManipulateRowEntryEvent.php new file mode 100644 index 0000000..4ce1a04 --- /dev/null +++ b/Classes/Event/ManipulateRowEntryEvent.php @@ -0,0 +1,47 @@ + $row + * @param string[] $fieldLabels + */ + public function __construct( + private array $row, + private readonly array $fieldLabels, + private readonly string $configurationKey + ) {} + + /** + * @return array + */ + public function getRow(): array + { + return $this->row; + } + + /** + * @param array $row + */ + public function setRow(array $row): void + { + $this->row = $row; + } + + /** + * @return string[] + */ + public function getFieldLabels(): array + { + return $this->fieldLabels; + } + + public function getConfigurationKey(): string + { + return $this->configurationKey; + } +} diff --git a/Classes/Exception/ConfigurationNotFoundException.php b/Classes/Exception/ConfigurationNotFoundException.php new file mode 100644 index 0000000..a4661ad --- /dev/null +++ b/Classes/Exception/ConfigurationNotFoundException.php @@ -0,0 +1,7 @@ +sheet = $sheet; - $this->colIndexer = $colIndexer; - $this->currentRow = $currentRow; - } - - /** - * getSheet - * returns the current worksheet to add new columns - * - * @return Worksheet - */ - public function getSheet(): Worksheet - { - return $this->sheet; - } - - /** - * returns the current colIndexer for the next column to be written - * can be manipulated - * to get next Columns name, call ExportTrait::$cols[$colIndexer] - * - * @return int - */ - public function getColIndexer(): int - { - return $this->colIndexer; - } - - /** - * returns the current row of the sheet, should not be manipulated to avoid overriding in next line - * @return int - */ - public function getCurrentRow(): int - { - return $this->currentRow; - } -} diff --git a/Classes/Export/Event/AlternateCheckQueryEvent.php b/Classes/Export/Event/AlternateCheckQueryEvent.php deleted file mode 100644 index fe74a43..0000000 --- a/Classes/Export/Event/AlternateCheckQueryEvent.php +++ /dev/null @@ -1,75 +0,0 @@ - $exportConfig) { - $keyWithoutDot = str_replace('.', '', $exportConfigKey); - $this->exportKeys[] = $keyWithoutDot; - $this->exportConfiguration[$exportConfigKey] = $exportConfig; - } - } - - /** - * checkExportConfigExists - * - * Event listener should call this method to check if access is needed - * - * @param string $exportKey - * @return bool - */ - public function checkExportConfigExists(string $exportKey): bool - { - return in_array($exportKey, $this->exportKeys); - } - - public function alternateCheckQuery(string $exportKey, string $check): void - { - $exportConfig = sprintf('%s.', $exportKey); - if ($this->exportConfiguration[$exportConfig]['check'] && !$this->exportConfiguration[$exportConfig]['manipulated']) { - $this->exportConfiguration[$exportConfig]['check'] = $check; - $this->exportConfiguration[$exportConfig]['manipulated'] = true; - } - } - - public function isPropagationStopped(): bool - { - $allManipulated = true; - foreach ($this->exportConfiguration as $config) { - if (!array_key_exists('manipulated', $config) || !$config['manipulated']) { - $allManipulated = false; - } - } - return $allManipulated; - } - - public function getManipulatedSettings(): array - { - return $this->exportConfiguration; - } -} diff --git a/Classes/Export/Event/AlternateExportQueryEvent.php b/Classes/Export/Event/AlternateExportQueryEvent.php deleted file mode 100644 index b3c37e9..0000000 --- a/Classes/Export/Event/AlternateExportQueryEvent.php +++ /dev/null @@ -1,62 +0,0 @@ - - */ - protected array $exportConfiguration = []; - - /** - * @param array $settings - * @param string $config - */ - public function __construct(array $settings, string $config) - { - $this->exportKey = $config; - $this->exportConfiguration = $settings; - } - - /** - * checkExportConfigExists - * - * Event listener should call this method to check if access is needed - * - * @param string $exportKey - * @return bool - */ - public function checkExportConfigExists(string $exportKey): bool - { - return $exportKey === $this->exportKey; - } - - public function alternateExportQuery(string $export): void - { - if ($this->exportConfiguration['export'] && !$this->exportConfiguration['manipulated']) { - $this->exportConfiguration['export'] = $export; - $this->exportConfiguration['manipulated'] = true; - } - } - - public function isPropagationStopped(): bool - { - $allManipulated = true; - if (!array_key_exists('manipulated', $this->exportConfiguration) || !$this->exportConfiguration['manipulated']) { - $allManipulated = false; - } - return $allManipulated; - } - - public function getManipulatedSettings(): array - { - return $this->exportConfiguration; - } -} diff --git a/Classes/Export/Event/AlternateHeaderLineEvent.php b/Classes/Export/Event/AlternateHeaderLineEvent.php deleted file mode 100644 index 538a573..0000000 --- a/Classes/Export/Event/AlternateHeaderLineEvent.php +++ /dev/null @@ -1,13 +0,0 @@ - $currentRow - */ - private array $currentRow; - - private mixed $value; - - public function __construct( - string $columnName, - array $currentRow, - mixed $value - ) { - $this->columnName = $columnName; - $this->currentRow = $currentRow; - $this->value = $value; - } - - public function getColumnName(): string - { - return $this->columnName; - } - - /** - * @return array - */ - public function getCurrentRow(): array - { - return $this->currentRow; - } - - public function getValue(): mixed - { - return $this->value; - } - - public function setValue(mixed $value): void - { - $this->value = $value; - } -} diff --git a/Classes/Service/DatabaseQueryTypoScriptParser.php b/Classes/Service/DatabaseQueryTypoScriptParser.php new file mode 100644 index 0000000..d229e96 --- /dev/null +++ b/Classes/Service/DatabaseQueryTypoScriptParser.php @@ -0,0 +1,509 @@ +, + * join?: array + * }>, + * leftJoin?: array + * }>, + * rightJoin?: array + * }>, + * } $configuration + * @throws ExpressionTypeNotValidException + * @throws TypeIsNotAllowedAsQuoteException + */ + public function buildQueryBuilderFromArray(array $configuration): QueryBuilder + { + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) + ->getQueryBuilderForTable($configuration['table']); + $statement = $queryBuilder + ->select(...array_values($configuration['select'])) + ->from($configuration['table'], $configuration['alias'] ?? null); + + if (($configuration['selectLiteral'] ?? []) !== []) { + $statement->selectLiteral(...array_values($configuration['selectLiteral'])); + } + if ($configuration['where'] !== []) { + $where = []; + foreach ($configuration['where'] as $whereConfiguration) { + $where[] = $this->buildForExpressionType($queryBuilder, $whereConfiguration); + } + + $statement->where(...array_values($where)); + } + + if (($configuration['join'] ?? []) !== []) { + foreach ($configuration['join'] as $equiJoin) { + $this->buildEquiJoin($statement, $queryBuilder, $equiJoin); + } + } + + if (($configuration['leftJoin'] ?? []) !== []) { + foreach ($configuration['leftJoin'] as $leftJoin) { + $this->buildLeftJoin($statement, $queryBuilder, $leftJoin); + } + } + + if (($configuration['rightJoin'] ?? []) !== []) { + foreach ($configuration['rightJoin'] as $rightJoin) { + $this->buildRightJoin($statement, $queryBuilder, $rightJoin); + } + } + + return $statement; + } + + /** + * @param array{ + * table: non-empty-string, + * alias?: non-empty-string, + * select: non-empty-string[], + * count?: non-empty-string, + * selectLiteral?: non-empty-string[], + * where: array, + * join?: array + * }>, + * leftJoin?: array + * }>, + * rightJoin?: array + * }>, + * } $configuration + * @throws ExpressionTypeNotValidException + * @throws TypeIsNotAllowedAsQuoteException + */ + public function buildCountQueryFromArray(array $configuration): QueryBuilder + { + $statement = $this->buildQueryBuilderFromArray($configuration); + $statement->getConcreteQueryBuilder()->resetOrderBy(); + $statement->count($configuration['count'] ?? '*'); + + return $statement; + } + + public function replacePlaceholderWithCurrentId(QueryBuilder $statement, int $currentId): void + { + foreach ($statement->getParameters() as $key => $param) { + if ($param === '###CURRENT_ID###') { + $statement->setParameter($key, $currentId, Connection::PARAM_INT); + } + } + } + + /** + * @param array{ + * fieldName: string, + * parameter: float|int|string|float[]|int[]|string[], + * type: Connection::PARAM_*, + * expressionType: string, + * isColumn?: bool + * } $configuration + * @throws ExpressionTypeNotValidException + * @throws TypeIsNotAllowedAsQuoteException + */ + private function buildForExpressionType( + QueryBuilder $queryBuilder, + array $configuration + ): string { + return match ($configuration['expressionType']) { + 'eq' => $this->buildEquals($queryBuilder, $configuration), + 'neq' => $this->buildNotEquals($queryBuilder, $configuration), + 'gt' => $this->buildGreaterThan($queryBuilder, $configuration), + 'gte' => $this->buildGreaterThanOrEquals($queryBuilder, $configuration), + 'lt' => $this->buildLessThan($queryBuilder, $configuration), + 'lte' => $this->buildLessThanOrEquals($queryBuilder, $configuration), + 'isNull' => $this->buildIsNull($queryBuilder, $configuration['fieldName']), + 'isNotNull' => $this->buildIsNotNull($queryBuilder, $configuration['fieldName']), + 'in' => $this->buildIn($queryBuilder, $configuration['fieldName'], $configuration['parameter'], $configuration['type']), + 'inSet' => $this->buildInSet($queryBuilder, $configuration['fieldName'], $configuration['parameter']), + default => throw new ExpressionTypeNotValidException( + sprintf('The given expression type "%s" is not valid', $configuration['expressionType']), + 1731081406988 + ) + }; + } + + /** + * @param array{ + * fieldName: string, + * parameter: float|int|string|float[]|int[]|string[], + * type: Connection::PARAM_*, + * expressionType: string, + * isColumn?: bool + * } $configuration + * @throws ParameterHasWrongTypeException + * @throws \ReflectionException + */ + private function buildEquals(QueryBuilder $queryBuilder, array $configuration): string + { + return $queryBuilder->expr()->eq( + $configuration['fieldName'], + $this->generateValue($queryBuilder, $configuration) + ); + } + + /** + * @param array{ + * fieldName: string, + * parameter: float|int|string|float[]|int[]|string[], + * type: Connection::PARAM_*, + * expressionType: string, + * isColumn?: bool + * } $configuration + * @throws ParameterHasWrongTypeException + * @throws \ReflectionException + */ + private function buildNotEquals(QueryBuilder $queryBuilder, array $configuration): string + { + return $queryBuilder->expr()->neq( + $configuration['fieldName'], + $this->generateValue($queryBuilder, $configuration) + ); + } + + /** + * @param array{ + * fieldName: string, + * parameter: float|int|string|float[]|int[]|string[], + * type: Connection::PARAM_*, + * expressionType: string, + * isColumn?: bool + * } $configuration + * @throws ParameterHasWrongTypeException + * @throws \ReflectionException + */ + private function buildGreaterThan(QueryBuilder $queryBuilder, array $configuration): string + { + return $queryBuilder->expr()->gt( + $configuration['fieldName'], + $this->generateValue($queryBuilder, $configuration) + ); + } + + /** + * @param array{ + * fieldName: string, + * parameter: float|int|string|float[]|int[]|string[], + * type: Connection::PARAM_*, + * expressionType: string, + * isColumn?: bool + * } $configuration + * @throws ParameterHasWrongTypeException + * @throws \ReflectionException + */ + private function buildGreaterThanOrEquals(QueryBuilder $queryBuilder, array $configuration): string + { + return $queryBuilder->expr()->gte( + $configuration['fieldName'], + $this->generateValue($queryBuilder, $configuration) + ); + } + + /** + * @param array{ + * fieldName: string, + * parameter: float|int|string|float[]|int[]|string[], + * type: Connection::PARAM_*, + * expressionType: string, + * isColumn?: bool + * } $configuration + * @throws ParameterHasWrongTypeException + * @throws \ReflectionException + */ + private function buildLessThan(QueryBuilder $queryBuilder, array $configuration): string + { + return $queryBuilder->expr()->lt( + $configuration['fieldName'], + $this->generateValue($queryBuilder, $configuration) + ); + } + + /** + * @param array{ + * fieldName: string, + * parameter: float|int|string|float[]|int[]|string[], + * type: Connection::PARAM_*, + * expressionType: string, + * isColumn?: bool + * } $configuration + * @throws ParameterHasWrongTypeException + * @throws \ReflectionException + */ + private function buildLessThanOrEquals(QueryBuilder $queryBuilder, array $configuration): string + { + return $queryBuilder->expr()->lte( + $configuration['fieldName'], + $this->generateValue($queryBuilder, $configuration) + ); + } + + private function buildIsNull(QueryBuilder $queryBuilder, string $fieldName): string + { + return $queryBuilder->expr()->isNull($fieldName); + } + + private function buildIsNotNull(QueryBuilder $queryBuilder, string $fieldName): string + { + return $queryBuilder->expr()->isNotNull($fieldName); + } + + /** + * @param array|float|int|string $parameter + */ + private function buildInSet(QueryBuilder $queryBuilder, string $fieldName, mixed $parameter): string + { + return $queryBuilder->expr()->inSet($fieldName, $queryBuilder->createNamedParameter($parameter, Connection::PARAM_STR)); + } + + /** + * @param array|float|int|string $parameter + * @param string|int|ParameterType $type Connection::PARAM_* + * @throws TypeIsNotAllowedAsQuoteException + * @throws ParameterHasWrongTypeException + * @see Connection::PARAM_* + */ + private function buildIn(QueryBuilder $queryBuilder, string $fieldName, mixed $parameter, string|int|ParameterType $type): string + { + if (!is_array($parameter)) { + throw new ParameterHasWrongTypeException( + sprintf('Parameter has to be array for building "in" statement, "%s" given', gettype($parameter)), + 1731094230854 + ); + } + $quotedParameter = match ($type) { + Connection::PARAM_STR => $queryBuilder->quoteArrayBasedValueListToStringList($parameter), + Connection::PARAM_INT => $queryBuilder->quoteArrayBasedValueListToIntegerList($parameter), + default => throw new TypeIsNotAllowedAsQuoteException( + sprintf('The type "%s" can not be quoted for usage as `in`', $type?->name ?? $type), + 1731082482677 + ) + }; + return $queryBuilder->expr()->in($fieldName, $quotedParameter); + } + + /** + * @param array{ + * from: non-empty-string, + * to: non-empty-string, + * toAlias?: non-empty-string, + * where: array + * } $joinConfiguration + * @throws ExpressionTypeNotValidException + * @throws TypeIsNotAllowedAsQuoteException + */ + private function buildEquiJoin(QueryBuilder $statement, QueryBuilder $queryBuilder, array $joinConfiguration): void + { + $where = []; + foreach ($joinConfiguration['where'] as $joinWhere) { + $where[] = $this->buildForExpressionType($queryBuilder, $joinWhere); + } + $statement->join( + $joinConfiguration['from'], + $joinConfiguration['to'], + $joinConfiguration['toAlias'] ?? $joinConfiguration['to'], + ($where !== []) ? (string)$queryBuilder->expr()->and(...array_values($where)) : null + ); + } + + /** + * @param array{ + * from: non-empty-string, + * to: non-empty-string, + * toAlias?: non-empty-string, + * where: array + * } $joinConfiguration + * @throws ExpressionTypeNotValidException + * @throws TypeIsNotAllowedAsQuoteException + */ + private function buildLeftJoin(QueryBuilder $statement, QueryBuilder $queryBuilder, array $joinConfiguration): void + { + $where = []; + foreach ($joinConfiguration['where'] as $joinWhere) { + $where[] = $this->buildForExpressionType($queryBuilder, $joinWhere); + } + $statement->leftJoin( + $joinConfiguration['from'], + $joinConfiguration['to'], + $joinConfiguration['toAlias'] ?? $joinConfiguration['to'], + ($where !== []) ? (string)$queryBuilder->expr()->and(...array_values($where)) : null + ); + } + + /** + * @param array{ + * from: non-empty-string, + * to: non-empty-string, + * toAlias?: non-empty-string, + * where: array + * } $joinConfiguration + * @throws ExpressionTypeNotValidException + * @throws TypeIsNotAllowedAsQuoteException + */ + private function buildRightJoin(QueryBuilder $statement, QueryBuilder $queryBuilder, array $joinConfiguration): void + { + $where = []; + foreach ($joinConfiguration['where'] as $joinWhere) { + $where[] = $this->buildForExpressionType($queryBuilder, $joinWhere); + } + $statement->rightJoin( + $joinConfiguration['from'], + $joinConfiguration['to'], + $joinConfiguration['toAlias'] ?? $joinConfiguration['to'], + ($where !== []) ? (string)$queryBuilder->expr()->and(...array_values($where)) : null + ); + } + + /** + * @param array{ + * fieldName: string, + * parameter: float|int|string|float[]|int[]|string[], + * type: class-string|string, + * expressionType: string, + * isColumn?: bool + * } $configuration + * @throws ParameterHasWrongTypeException + * @throws \ReflectionException + */ + private function generateValue(QueryBuilder $queryBuilder, array $configuration): string + { + // true not possible because of TypoScript load + if (($configuration['isColumn'] ?? '0') === '1') { + if (!is_string($configuration['parameter'])) { + throw new ParameterHasWrongTypeException( + sprintf('Parameter has to be string, if "isColumn" is set to true, "%s" given', gettype($configuration['parameter'])), + 1731093539911 + ); + } + $value = $queryBuilder->quoteIdentifier($configuration['parameter']); + } else { + if ($configuration['type'] instanceof ParameterType) { + $constant = $configuration['type']; + } else { + $partsOfType = GeneralUtility::trimExplode('::', $configuration['type']); + $class = $partsOfType[0]; + if ($partsOfType[0] === 'Connection') { + $class = Connection::class; + } + $reflection = new \ReflectionClass($class); + $constant = $reflection->getConstant($partsOfType[1]); + } + + $value = $queryBuilder->createNamedParameter($configuration['parameter'], $constant); + } + + return $value; + } +} diff --git a/Classes/Service/SpreadsheetWriteService.php b/Classes/Service/SpreadsheetWriteService.php new file mode 100644 index 0000000..4703c10 --- /dev/null +++ b/Classes/Service/SpreadsheetWriteService.php @@ -0,0 +1,91 @@ +setActiveSheetIndex(0); + + /** @var AlternateFirstColumnInSheetEvent $alternateFirstColumnEvent */ + $alternateFirstColumnEvent = $this->eventDispatcher->dispatch(new AlternateFirstColumnInSheetEvent()); + $firstColumn = $alternateFirstColumnEvent->getFirstColumn(); + + /** @var AlternateHeaderLineEvent $alternateHeaderLineEvent */ + $alternateHeaderLineEvent = $this->eventDispatcher->dispatch(new AlternateHeaderLineEvent($configuration['fieldLabels'], $configurationKey)); + $headerFieldLabels = $alternateHeaderLineEvent->getHeaderFieldLabels(); + $sheet->fromArray($headerFieldLabels, null, $firstColumn . '1'); + while ($dataRow = $result->fetchAssociative()) { + $row = $sheet->getHighestRow() + 1; + + /** @var ManipulateRowEntryEvent $manipulateRowEvent */ + $manipulateRowEvent = $this->eventDispatcher->dispatch(new ManipulateRowEntryEvent($dataRow, $headerFieldLabels, $configurationKey)); + $sheet->fromArray($manipulateRowEvent->getRow(), null, $firstColumn . $row); + } + + $iWriter = IOFactory::createWriter( + $spreadsheet, + $this->resolveFormatToWriterConstant($configuration['format'] ?? 'xlsx') + ); + + $resource = fopen('php://memory', 'w'); + if (!is_resource($resource)) { + throw new \RuntimeException( + 'Can not create resource for spreadsheet writer', + 1731108793376 + ); + } + $iWriter->save($resource); + + return new Stream($resource); + } + + /** + * @throws ExportFormatNotDetectedException + */ + private function resolveFormatToWriterConstant(string $format): string + { + $detectConstant = sprintf('WRITER_%s', mb_strtoupper($format)); + $reflection = new \ReflectionClass(IOFactory::class); + $writerType = $reflection->getConstant($detectConstant); + if (!is_string($writerType)) { + throw new ExportFormatNotDetectedException( + sprintf('The export format for file format "%s" was not found.', $format), + 1731106070328 + ); + } + + return $writerType; + } +} diff --git a/Classes/Traits/ExportTrait.php b/Classes/Traits/ExportTrait.php deleted file mode 100644 index 2ebdfac..0000000 --- a/Classes/Traits/ExportTrait.php +++ /dev/null @@ -1,164 +0,0 @@ -getProperties()->setCreator('TYPO3 Export') - ->setLastModifiedBy('TYPO3 Export') - ->setTitle('Export ' . ' Dokument') - ->setSubject('Export ' . ' Dokument') - ->setCreated(time()) - ->setDescription('Export ' . ' Dokument Quelle '); - - $sheet = self::$spreadSheet->setActiveSheetIndex(0); - - self::$rowCount = 1; - - return $sheet; - } - - /** - * writeHeader - * @param Worksheet $sheet - * @param array $headerFields - */ - protected static function writeHeader(Worksheet $sheet, array $headerFields) - { - foreach ($headerFields as $field => $value) { - $sheet->setCellValue(self::$cols[$field] . self::$rowCount, $value); - } - self::$rowCount++; - } - - /** - * writeExcel - * @param Worksheet $sheet - * @param array $dataset - * @param array $exportFields - * @param string $table - * @param bool $autoFilter - * @param array $hookArray @deprecated - */ - protected function writeExcel( - Worksheet $sheet, - array $dataset, - array $exportFields, - string $table = '', - bool $autoFilter = false, - array $hookArray = [] - ) { - $data = []; - foreach ($dataset as $item) { - $data[] = $item; - } - - foreach ($data as $currentData) { - $colIndexer = 0; - foreach ($exportFields as $colIndexer => $value) { - $manipulateCellData = new ManipulateCellDataEvent($value, $currentData, $currentData[$value]); - if (!empty($this->eventDispatcher)) { - $this->eventDispatcher->dispatch($manipulateCellData); - } - $sheet->setCellValue(self::$cols[$colIndexer] . self::$rowCount, $manipulateCellData->getValue()); - } - $colIndexer++; - if (!empty($this->eventDispatcher)) { - $this->eventDispatcher->dispatch(new AddColumnsToSheetEvent($sheet, $colIndexer, self::$rowCount)); - } - if (array_key_exists($table, $hookArray) && is_array($hookArray[$table])) { - $colIndexer--; - foreach ($hookArray[$table] as $classObj) { - $hookObj = GeneralUtility::makeInstance($classObj); - if (method_exists($hookObj, 'addColumns')) { - trigger_error( - 'Usage of hooks inside XLS export is deprecated and will be removed in future versions. Use PSR-14 Event dispatching instead.', - E_USER_DEPRECATED - ); - $hookObj->addColumns($sheet, self::class, $colIndexer, self::$rowCount); - } - } - } - self::$rowCount++; - } - - if ($autoFilter) { - $sheet->setAutoFilter($sheet->calculateWorksheetDimension()); - } - - for ($i = 0; $i < count($exportFields); $i++) { - $sheet->getColumnDimension(self::$cols[$i])->setAutoSize(true); - } - - foreach ($sheet->getRowIterator() as $rowDimension) { - self::_autofitRowHeight($rowDimension); - } - } - - /** - * _autofitRowHeight - * @param Row $row - * @param int $rowPadding - * @return Worksheet - */ - private static function _autofitRowHeight(Row $row, int $rowPadding = 5): Worksheet - { - $ws = $row->getWorksheet(); - $cellIterator = $row->getCellIterator(); - $maxCellLines = 0; // Init - - // Find out max cell line count - foreach ($cellIterator as $cell) { - $lines = explode("\n", (string)$cell->getValue()); - $lineCount = 0; - // Ignore empty lines - foreach ($lines as &$ignored) { - $lineCount++; - } - $maxCellLines = max($maxCellLines, $lineCount); - } - - // Force minimum line height to 1 - $maxCellLines = max($maxCellLines, 1); - - // Adjust row height - $rowDimension = $ws->getRowDimension($row->getRowIndex()); - $rowHeight = (15 * $maxCellLines) + $rowPadding; // XLSX_LINE_HEIGHT = 13 - $rowDimension->setRowHeight($rowHeight); - return $ws; - } -} diff --git a/Classes/Traits/ExportWithTsSettingsTrait.php b/Classes/Traits/ExportWithTsSettingsTrait.php deleted file mode 100644 index 414ddc6..0000000 --- a/Classes/Traits/ExportWithTsSettingsTrait.php +++ /dev/null @@ -1,133 +0,0 @@ -moduleName); - if (array_key_exists($moduleConfigArrayName, $TSconfig['mod.'])) { - $this->modTSconfig = $TSconfig['mod.'][$moduleConfigArrayName]; - $this->selfSettings = array_merge_recursive($this->selfSettings, $this->modTSconfig['settings.']); - } - } - - /** - * doExport - * @param array $settings - * @param int $currentId - * @return StreamInterface - * @throws Exception - * @throws \Doctrine\DBAL\Exception - * @throws \PhpOffice\PhpSpreadsheet\Exception - * @throws \PhpOffice\PhpSpreadsheet\Writer\Exception - */ - protected function doExport(array $settings, int $currentId): StreamInterface - { - $this->normalizeSettings($settings); - $hookArray = []; - if ( - array_key_exists('xlsexport', $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']) - && array_key_exists('alternateQueries', $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['xlsexport']) - && is_array($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['xlsexport']['alternateQueries']) - ) { - $hookArray = $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['xlsexport']['alternateQueries']; - } - - $exportfieldnames = []; - $exportfields = []; - - foreach ($settings['exportfields.'] as $value) { - $exportfields[] = $value; - } - foreach ($settings['exportfieldnames.'] as $value) { - $exportfieldnames[] = $value; - } - $exportQuery = $settings['export']; - if (array_key_exists($settings['table'], $hookArray) && is_array($hookArray[$settings['table']])) { - foreach ($hookArray[$settings['table']] as $classObj) { - $hookObj = GeneralUtility::makeInstance($classObj); - if (method_exists($hookObj, 'alternateExportQuery')) { - trigger_error( - 'Usage of hooks inside XLS export is deprecated and will be removed in future versions. Use PSR-14 Event dispatching instead.', - E_USER_DEPRECATED - ); - $exportQuery = $hookObj->alternateExportQuery($exportQuery, $this, ''); - } - } - } - - $statement = sprintf($exportQuery, $currentId); - $dbQuery = $this->dbConnection->getQueryBuilderForTable($settings['table'])->getConnection(); - $result = $dbQuery->executeQuery($statement)->fetchAllAssociative(); - - $sheet = $this->loadSheet(); - - $this->rowCount = 1; - - $headerManipulated = false; - if (array_key_exists($settings['table'], $hookArray) && is_array($hookArray[$settings['table']])) { - foreach ($hookArray[$settings['table']] as $classObj) { - $hookObj = GeneralUtility::makeInstance($classObj); - if (method_exists($hookObj, 'alternateHeaderLine')) { - trigger_error( - 'Usage of hooks inside XLS export is deprecated and will be removed in future versions. Use PSR-14 Event dispatching instead.', - E_USER_DEPRECATED - ); - $hookObj->alternateHeaderLine($sheet, $this, $exportfieldnames, $this->rowCount); - $headerManipulated = true; - } - } - } - - if (!$headerManipulated) { - // Zeile mit den Spaltenbezeichungen - $this->writeHeader($sheet, $exportfieldnames); - } - - // Die Datensätze eintragen - - $this->writeExcel($sheet, $result, $exportfields, $settings['table'], (bool)$settings['autofilter'], $hookArray); - - $tempFile = GeneralUtility::tempnam('xlsexport_', '.xlsx'); - $objWriter = IOFactory::createWriter(self::$spreadSheet, 'Xlsx'); - $objWriter->save($tempFile); - return new Stream($tempFile); - } - - private function normalizeSettings(array &$settings): void - { - if (!array_key_exists('autofilter', $settings)) { - $settings['autofilter'] = false; - } - } -} diff --git a/Configuration/Backend/Modules.php b/Configuration/Backend/Modules.php new file mode 100644 index 0000000..08d4b4c --- /dev/null +++ b/Configuration/Backend/Modules.php @@ -0,0 +1,26 @@ + [ + 'routes' => [ + '_default' => [ + 'target' => XlsExportController::class . '::index', + ], + 'export' => [ + 'path' => '/export', + 'target' => XlsExportController::class . '::export', + ], + ], + 'parent' => 'web', + 'access' => 'user', + 'position' => [ + 'after' => 'web_list', + ], + 'iconIdentifier' => 'mimetypes-excel', + 'labels' => 'LLL:EXT:xlsexport/Resources/Private/Language/locallang_db.xlf', + ], +]; diff --git a/Configuration/Services.yaml b/Configuration/Services.yaml index ea4170d..deb5de0 100644 --- a/Configuration/Services.yaml +++ b/Configuration/Services.yaml @@ -6,6 +6,3 @@ services: Calien\Xlsexport\: resource: '../Classes/*' - - Calien\Xlsexport\Controller\XlsExportController: - tags: ['backend.controller'] diff --git a/Configuration/TCA/Overrides/pages.php b/Configuration/TCA/Overrides/pages.php deleted file mode 100755 index 06a6134..0000000 --- a/Configuration/TCA/Overrides/pages.php +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - diff --git a/Resources/Private/Partials/.gitkeep b/Resources/Private/Partials/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/Resources/Private/Partials/List.html b/Resources/Private/Partials/List.html index 1fff9c4..9c7fa9e 100644 --- a/Resources/Private/Partials/List.html +++ b/Resources/Private/Partials/List.html @@ -31,9 +31,11 @@ - + - {dataset.label} + + + {dataset.count} {f:translate(key: 'index.records', extensionName: 'xlsexport')} @@ -43,14 +45,14 @@ - {f:translate(key: 'index.export', extensionName: 'xlsexport')} - + diff --git a/Resources/Private/Templates/XlsExport/Export.html b/Resources/Private/Templates/XlsExport/Export.html deleted file mode 100755 index 7c83d16..0000000 --- a/Resources/Private/Templates/XlsExport/Export.html +++ /dev/null @@ -1,16 +0,0 @@ - - - -

{f:translate(key: 'export.message.done')}

-

{f:translate(key: 'export.message.file')} {fileurl}

- -

{f:translate(key: 'export.message.noarchive')}

-
-
- diff --git a/Resources/Private/Templates/XlsExport/Index.html b/Resources/Private/Templates/XlsExport/Index.html index 13efd27..d6817d2 100755 --- a/Resources/Private/Templates/XlsExport/Index.html +++ b/Resources/Private/Templates/XlsExport/Index.html @@ -7,29 +7,20 @@ f:schemaLocation="https://fluidtypo3.org/schemas/fluid-master.xsd" data-namespace-typo3-fluid="true" > - +

XLS Exporter

- + - - - - {f:translate(key: 'noconfig.body') -> f:format.raw()} - - - - - - + + {f:translate(key: 'noconfig.body', extensionName: 'xlsexport') -> f:format.raw()} + - - {f:translate(key: 'index.selectPage', extensionName: 'xlsexport')} - + diff --git a/Tests/Functional/Service/DatabaseQueryTypoScriptParserTest.php b/Tests/Functional/Service/DatabaseQueryTypoScriptParserTest.php new file mode 100644 index 0000000..60d7771 --- /dev/null +++ b/Tests/Functional/Service/DatabaseQueryTypoScriptParserTest.php @@ -0,0 +1,174 @@ + 'pages', + 'select' => [ + 'uid', + 'pid', + 'title', + ], + 'where' => [ + [ + 'fieldName' => 'pid', + 'parameter' => 1, + 'type' => 'Connection::PARAM_INT', + 'expressionType' => 'eq', + ], + ], + ]; + + $subject = new DatabaseQueryTypoScriptParser(); + + $statement = $subject->buildQueryBuilderFromArray($tsConfig); + + self::assertInstanceOf(QueryBuilder::class, $statement); + + $connectionParams = $statement->getConnection()->getParams(); + $escapeCharacter = match ($connectionParams['driver']) { + 'pdo_pgsql', 'pdo_sqlite' => '"', + default => '`' + }; + + if ((new Typo3Version())->getMajorVersion() <= 12) { + $parts = $statement->getQueryParts(); + + self::assertIsArray($parts['select']); + self::assertContains(sprintf('%1$suid%1$s', $escapeCharacter), $parts['select']); + self::assertContains(sprintf('%1$spid%1$s', $escapeCharacter), $parts['select']); + self::assertContains(sprintf('%1$stitle%1$s', $escapeCharacter), $parts['select']); + self::assertInstanceOf(CompositeExpression::class, $parts['where']); + self::assertEquals('AND', $parts['where']->getType()); + self::assertEquals(1, $parts['where']->count()); + self::assertEquals(sprintf('%1$spid%1$s = :dcValue1', $escapeCharacter), (string)$parts['where']); + self::assertIsArray($parts['from']); + $from = array_pop($parts['from']); + self::assertIsArray($from); + self::assertEquals(['alias' => null, 'table' => sprintf('%1$spages%1$s', $escapeCharacter)], $from); + } else { + self::assertIsArray($statement->getSelect()); + self::assertContains('"uid"', $statement->getSelect()); + self::assertContains('"pid"', $statement->getSelect()); + self::assertContains('"title"', $statement->getSelect()); + self::assertEquals('"pid" = :dcValue1', (string)$statement->getWhere()); + self::assertIsArray($statement->getFrom()); + $fromArray = $statement->getFrom(); + $from = array_pop($fromArray); + self::assertEquals('"pages"', $from->table); + self::assertNull($from->alias); + } + } + + #[Test] + public function invalidExpressionTypeThrowsException(): void + { + $tsConfig = [ + 'table' => 'pages', + 'select' => [ + 'uid', + 'pid', + 'title', + ], + 'where' => [ + [ + 'fieldName' => 'pid', + 'parameter' => 1, + 'type' => 'Connection::PARAM_INT', + 'expressionType' => 'not-allowed-expression-type', + ], + ], + ]; + + $subject = new DatabaseQueryTypoScriptParser(); + + self::expectException(ExpressionTypeNotValidException::class); + $subject->buildQueryBuilderFromArray($tsConfig); + } + + #[Test] + public function invalidFieldTypeThrowsException(): void + { + $tsConfig = [ + 'table' => 'pages', + 'select' => [ + 'uid', + 'pid', + 'title', + ], + 'where' => [ + [ + 'fieldName' => 'pid', + 'parameter' => [1], + 'type' => 'Connection::PARAM_BOOL', + 'expressionType' => 'in', + ], + ], + ]; + + $subject = new DatabaseQueryTypoScriptParser(); + + self::expectException(TypeIsNotAllowedAsQuoteException::class); + $subject->buildQueryBuilderFromArray($tsConfig); + } + + #[Test] + public function equiJoinBuildWorksCorrect(): void + { + $tsConfig = [ + 'table' => 'pages', + 'select' => [ + 'uid', + 'pid', + 'title', + ], + 'where' => [ + [ + 'fieldName' => 'pid', + 'parameter' => 1, + 'type' => 'Connection::PARAM_INT', + 'expressionType' => 'eq', + ], + ], + 'join' => [ + [ + 'from' => 'pages', + 'to' => 'tt_content', + 'where' => [ + [ + 'fieldName' => 'pages.uid', + 'parameter' => 'tt_content.pid', + 'type' => 'Connection::PARAM_STR', + 'expressionType' => 'eq', + 'isColumn' => true, + ], + ], + ], + ], + ]; + + $subject = new DatabaseQueryTypoScriptParser(); + + $statement = $subject->buildQueryBuilderFromArray($tsConfig); + + self::assertInstanceOf(QueryBuilder::class, $statement); + + //$parts = $statement->getQueryParts(); + } +} diff --git a/Tests/Unit/DummyTest.php b/Tests/Unit/DummyTest.php new file mode 100644 index 0000000..7226103 --- /dev/null +++ b/Tests/Unit/DummyTest.php @@ -0,0 +1,17 @@ +withPaths([ - __DIR__ . '/Build', - __DIR__ . '/Classes', - __DIR__ . '/Configuration', - __DIR__ . '/ext_emconf.php', - __DIR__ . '/ext_localconf.php', - __DIR__ . '/ext_tables.php', - ]) - // uncomment to reach your current PHP version - // ->withPhpSets() - ->withPhpVersion(PhpVersion::PHP_81) - ->withSets([ - Typo3SetList::CODE_QUALITY, - Typo3SetList::GENERAL, - Typo3LevelSetList::UP_TO_TYPO3_12, - ]) - # To have a better analysis from PHPStan, we teach it here some more things - ->withPHPStanConfigs([ - Typo3Option::PHPSTAN_FOR_RECTOR_PATH - ]) - ->withRules([ - AddVoidReturnTypeWhereNoReturnRector::class, - ConvertImplicitVariablesToExplicitGlobalsRector::class, - ]) - ->withConfiguredRule(ExtEmConfRector::class, [ - ExtEmConfRector::PHP_VERSION_CONSTRAINT => '8.1.0-8.4.99', - ExtEmConfRector::TYPO3_VERSION_CONSTRAINT => '12.4.0-13.4.99', - ExtEmConfRector::ADDITIONAL_VALUES_TO_BE_REMOVED => [] - ]) - # If you use withImportNames(), you should consider excluding some TYPO3 files. - ->withSkip([ - // @see https://github.com/sabbelasichon/typo3-rector/issues/2536 - __DIR__ . '/**/Configuration/ExtensionBuilder/*', - NameImportingPostRector::class => [ - 'ext_localconf.php', // This line can be removed since TYPO3 11.4, see https://docs.typo3.org/c/typo3/cms-core/main/en-us/Changelog/11.4/Important-94280-MoveContentsOfExtPhpIntoLocalScopes.html - 'ext_tables.php', // This line can be removed since TYPO3 11.4, see https://docs.typo3.org/c/typo3/cms-core/main/en-us/Changelog/11.4/Important-94280-MoveContentsOfExtPhpIntoLocalScopes.html - 'ClassAliasMap.php', - ] - ]) -;