From 44a99b41169c48ae1800fd692062418ef395d10d Mon Sep 17 00:00:00 2001 From: Mathias Bolt Lesniak Date: Fri, 23 Sep 2022 13:02:43 +0200 Subject: [PATCH] [!!!][FEATURE] Use DTO for record representation and identity (#77) This internal change does not affect any external APIs, but improves the way record data and identities are handled. This fixes and issue that made multilingual operations impossible, as well as making it easier to work with record data and identities. * `\Pixelant\Interest\Domain\Model\Dto\RecordRepresentation` represents a record state, and includes data as well as a `RecordInstanceIdentifier` object. * `\Pixelant\Interest\Domain\Model\Dto\RecordInstanceIdentifier` represents a record's identity: table, UID, language, workspace, and remote ID. It can output both the remote ID as it is seen from the outside (which is only unique when combined with language and workspace) and the internal, so-called "aspected", remote ID, which is unique for each language and workspace combination. Also fixes the condition for group relations. Was only accepting `internal_type` as "db", but `allowed` can also indicate a database relation. Co-authored-by: Mats Svensson --- .ddev/config.yaml | 1 + .github/workflows/ci.yml | 2 +- .github/workflows/codecoverage.yml | 2 +- Classes/Command/CreateCommandController.php | 30 +- Classes/Command/DeleteCommandController.php | 19 +- Classes/Command/UpdateCommandController.php | 30 +- .../Operation/AbstractRecordOperation.php | 114 ++--- .../Operation/CreateRecordOperation.php | 12 +- .../Operation/DeleteRecordOperation.php | 12 +- .../Operation/UpdateRecordOperation.php | 12 +- .../Model/Dto/Exception/AbstractException.php | 12 + .../Exception/InvalidArgumentException.php | 9 + .../Model/Dto/RecordInstanceIdentifier.php | 214 ++++++++++ .../Domain/Model/Dto/RecordRepresentation.php | 49 +++ .../AbstractRecordRequestHandler.php | 25 +- .../CreateOrUpdateRequestHandler.php | 19 +- .../RequestHandler/CreateRequestHandler.php | 13 +- .../RequestHandler/DeleteRequestHandler.php | 11 +- .../RequestHandler/UpdateRequestHandler.php | 13 +- Classes/Router/HttpRequestRouter.php | 2 +- ...tractRecordOperationFunctionalTestCase.php | 18 +- .../Operation/CreateRecordOperationTest.php | 133 +++++- .../Operation/DeleteRecordOperationTest.php | 69 ++- .../Operation/Fixtures/BackendUser.csv | 3 + .../Fixtures/BackendUserForVersion9.csv | 3 + .../Operation/Fixtures/Records.csv | 38 ++ .../Operation/Fixtures/Sites/main/config.yaml | 59 +++ .../Fixtures/Sites/secondary/config.yaml | 59 +++ .../Operation/UpdateRecordOperationTest.php | 123 +++++- .../Operation/CreateRecordOperationTest.php | 79 ++++ .../AbstractRecordRequestHandlerTest.php | 402 +++++++++++------- 31 files changed, 1239 insertions(+), 348 deletions(-) create mode 100644 Classes/Domain/Model/Dto/Exception/AbstractException.php create mode 100644 Classes/Domain/Model/Dto/Exception/InvalidArgumentException.php create mode 100644 Classes/Domain/Model/Dto/RecordInstanceIdentifier.php create mode 100644 Classes/Domain/Model/Dto/RecordRepresentation.php create mode 100644 Tests/Functional/DataHandling/Operation/Fixtures/BackendUser.csv create mode 100644 Tests/Functional/DataHandling/Operation/Fixtures/BackendUserForVersion9.csv create mode 100644 Tests/Functional/DataHandling/Operation/Fixtures/Records.csv create mode 100644 Tests/Functional/DataHandling/Operation/Fixtures/Sites/main/config.yaml create mode 100644 Tests/Functional/DataHandling/Operation/Fixtures/Sites/secondary/config.yaml create mode 100644 Tests/Unit/DataHandling/Operation/CreateRecordOperationTest.php diff --git a/.ddev/config.yaml b/.ddev/config.yaml index 8a453020..e1a3e4b3 100644 --- a/.ddev/config.yaml +++ b/.ddev/config.yaml @@ -15,6 +15,7 @@ mutagen_enabled: false hooks: post-start: - exec: composer install --no-progress + - exec: export PHP_IDE_CONFIG="serverName=interest.ddev.site" omit_containers: [dba, ddev-ssh-agent] webimage_extra_packages: [parallel] use_dns_when_possible: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 05691f5c..e2e96441 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -152,7 +152,7 @@ jobs: composer-dependencies: lowest functional-tests: name: "Functional tests" - runs-on: ubuntu-18.04 + runs-on: ubuntu-20.04 needs: php-lint steps: - name: "Checkout" diff --git a/.github/workflows/codecoverage.yml b/.github/workflows/codecoverage.yml index ae988d52..b7c01d00 100644 --- a/.github/workflows/codecoverage.yml +++ b/.github/workflows/codecoverage.yml @@ -8,7 +8,7 @@ on: jobs: code-coverage: name: "Calculate code coverage" - runs-on: ubuntu-18.04 + runs-on: ubuntu-20.04 steps: - name: "Checkout" uses: actions/checkout@v3 diff --git a/Classes/Command/CreateCommandController.php b/Classes/Command/CreateCommandController.php index 23024b22..978f0fb8 100644 --- a/Classes/Command/CreateCommandController.php +++ b/Classes/Command/CreateCommandController.php @@ -8,6 +8,8 @@ use Pixelant\Interest\DataHandling\Operation\Event\Exception\StopRecordOperationException; use Pixelant\Interest\DataHandling\Operation\Exception\IdentityConflictException; use Pixelant\Interest\DataHandling\Operation\UpdateRecordOperation; +use Pixelant\Interest\Domain\Model\Dto\RecordInstanceIdentifier; +use Pixelant\Interest\Domain\Model\Dto\RecordRepresentation; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; @@ -48,11 +50,15 @@ protected function execute(InputInterface $input, OutputInterface $output) foreach ($input->getOption('data') as $remoteId => $data) { try { (new CreateRecordOperation( - $data, - $input->getArgument('endpoint'), - $remoteId, - $input->getArgument('language'), - $input->getArgument('workspace'), + new RecordRepresentation( + $data, + new RecordInstanceIdentifier( + $input->getArgument('endpoint'), + $remoteId, + (string)$input->getArgument('language'), + (string)$input->getArgument('workspace'), + ) + ), $input->getOption('metaData') ))(); } catch (StopRecordOperationException $exception) { @@ -66,11 +72,15 @@ protected function execute(InputInterface $input, OutputInterface $output) try { (new UpdateRecordOperation( - $data, - $input->getArgument('endpoint'), - $remoteId, - $input->getArgument('language'), - $input->getArgument('workspace'), + new RecordRepresentation( + $data, + new RecordInstanceIdentifier( + $input->getArgument('endpoint'), + $remoteId, + (string)$input->getArgument('language'), + (string)$input->getArgument('workspace'), + ) + ), $input->getOption('metaData') ))(); } catch (StopRecordOperationException $exception) { diff --git a/Classes/Command/DeleteCommandController.php b/Classes/Command/DeleteCommandController.php index fd2f0e05..42fa0e32 100644 --- a/Classes/Command/DeleteCommandController.php +++ b/Classes/Command/DeleteCommandController.php @@ -8,6 +8,9 @@ use Pixelant\Interest\Database\RelationHandlerWithoutReferenceIndex; use Pixelant\Interest\DataHandling\Operation\DeleteRecordOperation; use Pixelant\Interest\DataHandling\Operation\Event\Exception\StopRecordOperationException; +use Pixelant\Interest\Domain\Model\Dto\RecordInstanceIdentifier; +use Pixelant\Interest\Domain\Model\Dto\RecordRepresentation; +use Pixelant\Interest\Domain\Repository\RemoteIdMappingRepository; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; @@ -75,12 +78,22 @@ protected function execute(InputInterface $input, OutputInterface $output) { $exceptions = []; + $mappingRepository = GeneralUtility::makeInstance(RemoteIdMappingRepository::class); + foreach (GeneralUtility::trimExplode(',', $input->getArgument('remoteId'), true) as $remoteId) { + $table = $mappingRepository->table($remoteId); + try { (new DeleteRecordOperation( - $remoteId, - $input->getArgument('language'), - $input->getArgument('workspace') + new RecordRepresentation( + [], + new RecordInstanceIdentifier( + $table, + $remoteId, + (string)$input->getArgument('language'), + (string)$input->getArgument('workspace'), + ) + ) ))(); } catch (StopRecordOperationException $exception) { $output->writeln($exception->getMessage(), OutputInterface::VERBOSITY_VERY_VERBOSE); diff --git a/Classes/Command/UpdateCommandController.php b/Classes/Command/UpdateCommandController.php index 2879507e..4a94616c 100644 --- a/Classes/Command/UpdateCommandController.php +++ b/Classes/Command/UpdateCommandController.php @@ -8,6 +8,8 @@ use Pixelant\Interest\DataHandling\Operation\Event\Exception\StopRecordOperationException; use Pixelant\Interest\DataHandling\Operation\Exception\NotFoundException; use Pixelant\Interest\DataHandling\Operation\UpdateRecordOperation; +use Pixelant\Interest\Domain\Model\Dto\RecordInstanceIdentifier; +use Pixelant\Interest\Domain\Model\Dto\RecordRepresentation; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; @@ -48,11 +50,15 @@ protected function execute(InputInterface $input, OutputInterface $output) foreach ($input->getOption('data') as $remoteId => $data) { try { (new UpdateRecordOperation( - $data, - $input->getArgument('endpoint'), - $remoteId, - $input->getArgument('language'), - $input->getArgument('workspace'), + new RecordRepresentation( + $data, + new RecordInstanceIdentifier( + $input->getArgument('endpoint'), + $remoteId, + (string)$input->getArgument('language'), + (string)$input->getArgument('workspace'), + ) + ), $input->getOption('metaData') ))(); } catch (StopRecordOperationException $exception) { @@ -66,11 +72,15 @@ protected function execute(InputInterface $input, OutputInterface $output) try { (new CreateRecordOperation( - $data, - $input->getArgument('endpoint'), - $remoteId, - $input->getArgument('language'), - $input->getArgument('workspace'), + new RecordRepresentation( + $data, + new RecordInstanceIdentifier( + $input->getArgument('endpoint'), + $remoteId, + (string)$input->getArgument('language'), + (string)$input->getArgument('workspace'), + ) + ), $input->getOption('metaData') ))(); } catch (StopRecordOperationException $exception) { diff --git a/Classes/DataHandling/Operation/AbstractRecordOperation.php b/Classes/DataHandling/Operation/AbstractRecordOperation.php index 7e633ea1..e9f3c625 100644 --- a/Classes/DataHandling/Operation/AbstractRecordOperation.php +++ b/Classes/DataHandling/Operation/AbstractRecordOperation.php @@ -13,6 +13,7 @@ use Pixelant\Interest\DataHandling\Operation\Exception\DataHandlerErrorException; use Pixelant\Interest\DataHandling\Operation\Exception\InvalidArgumentException; use Pixelant\Interest\DataHandling\Operation\Exception\NotFoundException; +use Pixelant\Interest\Domain\Model\Dto\RecordRepresentation; use Pixelant\Interest\Domain\Repository\PendingRelationsRepository; use Pixelant\Interest\Domain\Repository\RemoteIdMappingRepository; use Pixelant\Interest\Utility\CompatibilityUtility; @@ -21,7 +22,6 @@ use Pixelant\Interest\Utility\TcaUtility; use TYPO3\CMS\Backend\Utility\BackendUtility; use TYPO3\CMS\Core\Site\Entity\SiteLanguage; -use TYPO3\CMS\Core\Site\SiteFinder; use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Core\Utility\MathUtility; use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer; @@ -126,28 +126,26 @@ abstract class AbstractRecordOperation protected string $hash; /** - * @param array $data - * @param string $table - * @param string $remoteId - * @param string|null $language as RFC 1766/3066 string, e.g. nb or sv-SE. - * @param string|null $workspace workspace represented with a remote ID. + * @var RecordRepresentation + */ + protected RecordRepresentation $recordRepresentation; + + /** + * @param RecordRepresentation $recordRepresentation to perform the operation on. * @param array|null $metaData any additional data items not to be persisted but used in processing. * * @throws StopRecordOperationException is re-thrown from BeforeRecordOperationEvent handlers */ public function __construct( - array $data, - string $table, - string $remoteId, - ?string $language = null, - ?string $workspace = null, + RecordRepresentation $recordRepresentation, ?array $metaData = [] ) { - $this->table = strtolower($table); - $this->remoteId = $remoteId; - $this->data = $data; + $this->recordRepresentation = $recordRepresentation; + $this->table = strtolower($this->recordRepresentation->getRecordInstanceIdentifier()->getTable()); + $this->remoteId = $this->recordRepresentation->getRecordInstanceIdentifier()->getRemoteIdWithAspects(); + $this->data = $this->recordRepresentation->getData(); $this->metaData = $metaData ?? []; - $this->workspace = $workspace; + $this->workspace = $this->recordRepresentation->getRecordInstanceIdentifier()->getWorkspace(); $this->configurationProvider = GeneralUtility::makeInstance(ConfigurationProvider::class); @@ -155,7 +153,7 @@ public function __construct( $this->pendingRelationsRepository = GeneralUtility::makeInstance(PendingRelationsRepository::class); - $this->language = $this->resolveLanguage((string)$language); + $this->language = $this->recordRepresentation->getRecordInstanceIdentifier()->getLanguage(); $this->createTranslationFields(); @@ -189,7 +187,7 @@ public function __construct( $this->dataHandler = GeneralUtility::makeInstance(DataHandler::class); $this->dataHandler->start([], []); - if (!isset($this->getData()['pid']) && $this instanceof ContentObjectRenderer) { + if (!isset($this->getData()['pid']) && $this instanceof CreateRecordOperation) { $this->data['pid'] = $this->storagePid; } } @@ -272,7 +270,7 @@ public function getArguments(): array */ private function validateFieldNames(): void { - $fieldsNotInTca = array_diff_key($this->getData(), $GLOBALS['TCA'][$this->getTable()]['columns']) ?? []; + $fieldsNotInTca = array_diff_key($this->getData(), $GLOBALS['TCA'][$this->getTable()]['columns'] ?? []); if (count(array_diff(array_keys($fieldsNotInTca), ['pid'])) > 0) { throw new ConflictException( @@ -313,6 +311,10 @@ private function resolveStoragePid(): int $settings['persistence.']['storagePid.'] ?? [] ); + if (empty($pid)) { + $pid = 0; + } + if (!MathUtility::canBeInterpretedAsInteger($pid)) { throw new InvalidArgumentException( 'The PID "' . $pid . '" is invalid and must be an integer.', @@ -323,63 +325,6 @@ private function resolveStoragePid(): int return (int)$pid; } - /** - * Resolves a site language. If no language is defined, the sites's default language will be returned. If the - * storagePid has no site, null will be returned. - * - * @param string|null $language - * @return SiteLanguage|null - * @throws InvalidArgumentException - */ - protected function resolveLanguage(?string $language): ?SiteLanguage - { - if (!TcaUtility::isLocalizable($this->getTable()) || empty($language)) { - return null; - } - - /** @var SiteFinder $siteFinder */ - $siteFinder = GeneralUtility::makeInstance(SiteFinder::class); - - $sites = $siteFinder->getAllSites(); - - $siteLanguages = []; - - foreach ($sites as $site) { - $siteLanguages = array_merge($siteLanguages, $site->getAllLanguages()); - } - - // This is the equivalent of running array_unique, but supports objects. - $siteLanguages = array_reduce($siteLanguages, function (array $uniqueSiteLanguages, SiteLanguage $item) { - /** @var SiteLanguage $siteLanguage */ - foreach ($uniqueSiteLanguages as $siteLanguage) { - if ($siteLanguage->getLanguageId() === $item->getLanguageId()) { - return $uniqueSiteLanguages; - } - } - - $uniqueSiteLanguages[] = $item; - - return $uniqueSiteLanguages; - }, []); - - foreach ($siteLanguages as $siteLanguage) { - $hreflang = $siteLanguage->getHreflang(); - - // In case this is the short form, e.g. "nb" or "sv", not "nb-NO" or "sv-SE". - if (strlen($language) === 2) { - $hreflang = substr($hreflang, 0, 2); - } - - if (strtolower($hreflang) === strtolower($language)) { - return $siteLanguage; - } - } - - throw new InvalidArgumentException( - 'The language "' . $language . '" is not defined in this TYPO3 instance.' - ); - } - /** * Resolves the UID for the remote ID. * @@ -564,7 +509,10 @@ protected function isRelationalField(string $field): bool return ( $tca['type'] === 'group' - && $tca['internal_type'] === 'db' + && ( + ($tca['internal_type'] ?? null) === 'db' + || isset($tca['allowed']) + ) ) || ( in_array($tca['type'], ['inline', 'select'], true) @@ -598,7 +546,7 @@ protected function isSingleRelationField(string $field): bool $tca = $this->getTcaFieldConfigurationAndRespectColumnsOverrides($field); - return $tca['maxitems'] === 1 && empty($tca['foreign_table']); + return ($tca['maxitems'] ?? 0) === 1 && empty($tca['foreign_table']); } /** @@ -847,6 +795,18 @@ protected function reduceFieldSingleValueArrayToScalar(string $fieldName): void { foreach ($this->data as $fieldName => $fieldValue) { if (is_array($fieldValue) && count($fieldValue) <= 1) { + if ( + $fieldValue === [] + && ( + $fieldName === TcaUtility::getTranslationSourceField($this->getTable()) + || $fieldName === TcaUtility::getTransOrigPointerField($this->getTable()) + ) + ) { + $this->data[$fieldName] = 0; + + continue; + } + $this->data[$fieldName] = $fieldValue[array_key_first($fieldValue)]; // Unset empty single-relation fields (1:n) in new records. diff --git a/Classes/DataHandling/Operation/CreateRecordOperation.php b/Classes/DataHandling/Operation/CreateRecordOperation.php index b8605e89..72b0c2d6 100644 --- a/Classes/DataHandling/Operation/CreateRecordOperation.php +++ b/Classes/DataHandling/Operation/CreateRecordOperation.php @@ -5,6 +5,7 @@ namespace Pixelant\Interest\DataHandling\Operation; use Pixelant\Interest\DataHandling\Operation\Exception\IdentityConflictException; +use Pixelant\Interest\Domain\Model\Dto\RecordRepresentation; use Pixelant\Interest\Domain\Repository\RemoteIdMappingRepository; use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Core\Utility\StringUtility; @@ -15,13 +16,11 @@ class CreateRecordOperation extends AbstractRecordOperation { public function __construct( - array $data, - string $table, - string $remoteId, - ?string $language = null, - ?string $workspace = null, + RecordRepresentation $recordRepresentation, ?array $metaData = [] ) { + $remoteId = $recordRepresentation->getRecordInstanceIdentifier()->getRemoteIdWithAspects(); + if (GeneralUtility::makeInstance(RemoteIdMappingRepository::class)->exists($remoteId)) { throw new IdentityConflictException( 'The remote ID "' . $remoteId . '" already exists.', @@ -29,13 +28,14 @@ public function __construct( ); } - parent::__construct($data, $table, $remoteId, $language, $workspace, $metaData); + parent::__construct($recordRepresentation, $metaData); if (!isset($this->getData()['pid'])) { $this->setData(array_merge($this->getData(), ['pid' => $this->getStoragePid()])); } $uid = $this->getUid() ?: StringUtility::getUniqueId('NEW'); + $table = $recordRepresentation->getRecordInstanceIdentifier()->getTable(); $this->dataHandler->datamap[$table][$uid] = $this->getData(); diff --git a/Classes/DataHandling/Operation/DeleteRecordOperation.php b/Classes/DataHandling/Operation/DeleteRecordOperation.php index d609c247..7bc56cc2 100644 --- a/Classes/DataHandling/Operation/DeleteRecordOperation.php +++ b/Classes/DataHandling/Operation/DeleteRecordOperation.php @@ -12,6 +12,7 @@ use Pixelant\Interest\DataHandling\Operation\Event\BeforeRecordOperationEvent; use Pixelant\Interest\DataHandling\Operation\Event\Exception\StopRecordOperationException; use Pixelant\Interest\DataHandling\Operation\Exception\NotFoundException; +use Pixelant\Interest\Domain\Model\Dto\RecordRepresentation; use Pixelant\Interest\Domain\Repository\PendingRelationsRepository; use Pixelant\Interest\Domain\Repository\RemoteIdMappingRepository; use Pixelant\Interest\Utility\CompatibilityUtility; @@ -23,14 +24,13 @@ class DeleteRecordOperation extends AbstractRecordOperation { public function __construct( - string $remoteId, - ?string $language = null, - ?string $workspace = null + RecordRepresentation $recordRepresentation ) { - $this->workspace = $workspace; + $this->workspace = $recordRepresentation->getRecordInstanceIdentifier()->getWorkspace(); $this->mappingRepository = GeneralUtility::makeInstance(RemoteIdMappingRepository::class); + $remoteId = $recordRepresentation->getRecordInstanceIdentifier()->getRemoteIdWithAspects(); if (!$this->mappingRepository->exists($remoteId)) { throw new NotFoundException( 'The remote ID "' . $remoteId . '" doesn\'t exist.', @@ -41,11 +41,11 @@ public function __construct( $this->remoteId = $remoteId; $this->metaData = []; $this->data = []; - $this->table = $this->mappingRepository->table($remoteId); + $this->table = $recordRepresentation->getRecordInstanceIdentifier()->getTable(); $this->pendingRelationsRepository = GeneralUtility::makeInstance(PendingRelationsRepository::class); - $this->language = $this->resolveLanguage((string)$language); + $this->language = $recordRepresentation->getRecordInstanceIdentifier()->getLanguage(); $this->uid = $this->resolveUid(); $this->hash = md5(static::class . serialize($this->getArguments())); diff --git a/Classes/DataHandling/Operation/UpdateRecordOperation.php b/Classes/DataHandling/Operation/UpdateRecordOperation.php index 7ce22e2b..b400d830 100644 --- a/Classes/DataHandling/Operation/UpdateRecordOperation.php +++ b/Classes/DataHandling/Operation/UpdateRecordOperation.php @@ -5,6 +5,7 @@ namespace Pixelant\Interest\DataHandling\Operation; use Pixelant\Interest\DataHandling\Operation\Exception\NotFoundException; +use Pixelant\Interest\Domain\Model\Dto\RecordRepresentation; use Pixelant\Interest\Domain\Repository\RemoteIdMappingRepository; use TYPO3\CMS\Core\Utility\GeneralUtility; @@ -14,13 +15,10 @@ class UpdateRecordOperation extends AbstractRecordOperation { public function __construct( - array $data, - string $table, - string $remoteId, - ?string $language = null, - ?string $workspace = null, + RecordRepresentation $recordRepresentation, ?array $metaData = [] ) { + $remoteId = $recordRepresentation->getRecordInstanceIdentifier()->getRemoteIdWithAspects(); if (!GeneralUtility::makeInstance(RemoteIdMappingRepository::class)->exists($remoteId)) { throw new NotFoundException( 'The remote ID "' . $remoteId . '" doesn\'t exist.', @@ -28,7 +26,9 @@ public function __construct( ); } - parent::__construct($data, $table, $remoteId, $language, $workspace, $metaData); + parent::__construct($recordRepresentation, $metaData); + + $table = $recordRepresentation->getRecordInstanceIdentifier()->getTable(); $this->dataHandler->datamap[$table][$this->getUid()] = $this->getData(); } diff --git a/Classes/Domain/Model/Dto/Exception/AbstractException.php b/Classes/Domain/Model/Dto/Exception/AbstractException.php new file mode 100644 index 00000000..11a3e1f6 --- /dev/null +++ b/Classes/Domain/Model/Dto/Exception/AbstractException.php @@ -0,0 +1,12 @@ +table = strtolower($table); + $this->remoteId = $remoteId; + $this->language = $this->resolveLanguage($language); + $this->workspace = $workspace; + } + + /** + * @return string + */ + public function getTable(): string + { + return $this->table; + } + + /** + * @return SiteLanguage|null + */ + public function getLanguage(): ?SiteLanguage + { + return $this->language; + } + + /** + * Returns true if a SiteLanguage is set. + * + * @return bool + */ + public function hasLanguage(): bool + { + return $this->language !== null; + } + + /** + * Returns original unmodified remote id set in construct. + * + * @return string + */ + public function getRemoteId(): string + { + return $this->remoteId; + } + + /** + * Returns workspace. + * + * @return string|null + */ + public function getWorkspace(): ?string + { + return $this->workspace; + } + + /** + * Returns true if workspace is set. + * + * @return bool + */ + public function hasWorkspace(): bool + { + return $this->workspace !== null; + } + + /** + * Returns remote id with aspects, such as language and workspace ID. + * If language is null or language ID zero, the $remoteId is removed unchanged. + * + * @return string + */ + public function getRemoteIdWithAspects(): string + { + if ( + !TcaUtility::isLocalizable($this->getTable()) + || $this->getLanguage() === null + || $this->getLanguage()->getLanguageId() === 0 + ) { + return $this->remoteId; + } + + $languageAspect = self::LANGUAGE_ASPECT_PREFIX . $this->getLanguage()->getLanguageId(); + + if (strpos($this->remoteId, $languageAspect) !== false) { + return $this->remoteId; + } + + $remoteId = $this->remoteId; + + return $remoteId . $languageAspect; + } + + /** + * @param string $remoteId + * @return string + */ + public function removeAspectsFromRemoteId(string $remoteId): string + { + if (strpos($remoteId, self::LANGUAGE_ASPECT_PREFIX) === false) { + return $remoteId; + } + + return substr($remoteId, 0, strpos($remoteId, self::LANGUAGE_ASPECT_PREFIX)); + } + + /** + * Resolves a site language. If no language is defined, the sites's default language will be returned. If the + * storagePid has no site, null will be returned. + * + * @param string|null $language + * @return SiteLanguage|null + * @throws InvalidArgumentException + */ + protected function resolveLanguage(?string $language): ?SiteLanguage + { + if (!TcaUtility::isLocalizable($this->getTable()) || empty($language)) { + return null; + } + + /** @var SiteFinder $siteFinder */ + $siteFinder = GeneralUtility::makeInstance(SiteFinder::class); + + $sites = $siteFinder->getAllSites(); + + $siteLanguages = []; + + foreach ($sites as $site) { + $siteLanguages = array_merge($siteLanguages, $site->getAllLanguages()); + } + + // This is the equivalent of running array_unique, but supports objects. + $siteLanguages = array_reduce($siteLanguages, function (array $uniqueSiteLanguages, SiteLanguage $item) { + /** @var SiteLanguage $siteLanguage */ + foreach ($uniqueSiteLanguages as $siteLanguage) { + if ($siteLanguage->getLanguageId() === $item->getLanguageId()) { + return $uniqueSiteLanguages; + } + } + + $uniqueSiteLanguages[] = $item; + + return $uniqueSiteLanguages; + }, []); + + foreach ($siteLanguages as $siteLanguage) { + $hreflang = $siteLanguage->getHreflang(); + + // In case this is the short form, e.g. "nb" or "sv", not "nb-NO" or "sv-SE". + if (strlen($language) === 2) { + $hreflang = substr($hreflang, 0, 2); + } + + if (strtolower($hreflang) === strtolower($language)) { + return $siteLanguage; + } + } + + throw new InvalidArgumentException( + 'The language "' . $language . '" is not defined in this TYPO3 instance.' + ); + } +} diff --git a/Classes/Domain/Model/Dto/RecordRepresentation.php b/Classes/Domain/Model/Dto/RecordRepresentation.php new file mode 100644 index 00000000..f0ca4064 --- /dev/null +++ b/Classes/Domain/Model/Dto/RecordRepresentation.php @@ -0,0 +1,49 @@ +data = $data; + $this->recordInstanceIdentifier = $recordInstanceIdentifier; + } + + /** + * @return array + */ + public function getData(): array + { + return $this->data; + } + + /** + * @return RecordInstanceIdentifier + */ + public function getRecordInstanceIdentifier(): RecordInstanceIdentifier + { + return $this->recordInstanceIdentifier; + } +} diff --git a/Classes/RequestHandler/AbstractRecordRequestHandler.php b/Classes/RequestHandler/AbstractRecordRequestHandler.php index f375181d..99b7ac24 100644 --- a/Classes/RequestHandler/AbstractRecordRequestHandler.php +++ b/Classes/RequestHandler/AbstractRecordRequestHandler.php @@ -8,6 +8,8 @@ use Pixelant\Interest\Database\RelationHandlerWithoutReferenceIndex; use Pixelant\Interest\DataHandling\Operation\Event\Exception\StopRecordOperationException; use Pixelant\Interest\DataHandling\Operation\Exception\AbstractException; +use Pixelant\Interest\Domain\Model\Dto\RecordInstanceIdentifier; +use Pixelant\Interest\Domain\Model\Dto\RecordRepresentation; use Pixelant\Interest\RequestHandler\ExceptionConverter\OperationToRequestHandlerExceptionConverter; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; @@ -145,17 +147,10 @@ public function handle(): ResponseInterface /** * Handle a single record operation. * - * @param string $table - * @param string $remoteId - * @param string $language - * @param string $workspace + * @param RecordRepresentation $recordRepresentation */ abstract protected function handleSingleOperation( - string $table, - string $remoteId, - string $language, - string $workspace, - array $data + RecordRepresentation $recordRepresentation ): void; /** @@ -335,7 +330,17 @@ protected function handleOperations(&$operationCount = 0): array $operationCount++; try { - $this->handleSingleOperation($table, $remoteId, $language, $workspace, $data); + $this->handleSingleOperation( + new RecordRepresentation( + $data, + new RecordInstanceIdentifier( + $table, + $remoteId, + (string)$language, + (string)$workspace + ) + ) + ); } catch (StopRecordOperationException $exception) { continue; } catch (AbstractException $exception) { diff --git a/Classes/RequestHandler/CreateOrUpdateRequestHandler.php b/Classes/RequestHandler/CreateOrUpdateRequestHandler.php index 9122da5f..13654bc9 100644 --- a/Classes/RequestHandler/CreateOrUpdateRequestHandler.php +++ b/Classes/RequestHandler/CreateOrUpdateRequestHandler.php @@ -7,6 +7,7 @@ use Pixelant\Interest\DataHandling\Operation\CreateRecordOperation; use Pixelant\Interest\DataHandling\Operation\Exception\NotFoundException; use Pixelant\Interest\DataHandling\Operation\UpdateRecordOperation; +use Pixelant\Interest\Domain\Model\Dto\RecordRepresentation; class CreateOrUpdateRequestHandler extends AbstractRecordRequestHandler { @@ -14,28 +15,16 @@ class CreateOrUpdateRequestHandler extends AbstractRecordRequestHandler * @inheritDoc */ protected function handleSingleOperation( - string $table, - string $remoteId, - string $language, - string $workspace, - array $data + RecordRepresentation $recordRepresentation ): void { try { (new UpdateRecordOperation( - $data, - $table, - $remoteId, - $language !== '' ? $language : null, - $workspace !== '' ? $workspace : null, + $recordRepresentation, $this->metaData ))(); } catch (NotFoundException $exception) { (new CreateRecordOperation( - $data, - $table, - $remoteId, - $language !== '' ? $language : null, - $workspace !== '' ? $workspace : null, + $recordRepresentation, $this->metaData ))(); } diff --git a/Classes/RequestHandler/CreateRequestHandler.php b/Classes/RequestHandler/CreateRequestHandler.php index f0d78ec7..45635338 100644 --- a/Classes/RequestHandler/CreateRequestHandler.php +++ b/Classes/RequestHandler/CreateRequestHandler.php @@ -5,6 +5,7 @@ namespace Pixelant\Interest\RequestHandler; use Pixelant\Interest\DataHandling\Operation\CreateRecordOperation; +use Pixelant\Interest\Domain\Model\Dto\RecordRepresentation; class CreateRequestHandler extends AbstractRecordRequestHandler { @@ -12,18 +13,10 @@ class CreateRequestHandler extends AbstractRecordRequestHandler * @inheritDoc */ protected function handleSingleOperation( - string $table, - string $remoteId, - string $language, - string $workspace, - array $data + RecordRepresentation $recordRepresentation ): void { (new CreateRecordOperation( - $data, - $table, - $remoteId, - $language !== '' ? $language : null, - $workspace !== '' ? $workspace : null, + $recordRepresentation, $this->metaData ))(); } diff --git a/Classes/RequestHandler/DeleteRequestHandler.php b/Classes/RequestHandler/DeleteRequestHandler.php index 644851a0..f1f344b6 100644 --- a/Classes/RequestHandler/DeleteRequestHandler.php +++ b/Classes/RequestHandler/DeleteRequestHandler.php @@ -5,6 +5,7 @@ namespace Pixelant\Interest\RequestHandler; use Pixelant\Interest\DataHandling\Operation\DeleteRecordOperation; +use Pixelant\Interest\Domain\Model\Dto\RecordRepresentation; class DeleteRequestHandler extends AbstractRecordRequestHandler { @@ -12,16 +13,10 @@ class DeleteRequestHandler extends AbstractRecordRequestHandler * @inheritDoc */ protected function handleSingleOperation( - string $table, - string $remoteId, - string $language, - string $workspace, - array $data + RecordRepresentation $recordRepresentation ): void { (new DeleteRecordOperation( - $remoteId, - $language !== '' ? $language : null, - $workspace !== '' ? $workspace : null + $recordRepresentation ))(); } } diff --git a/Classes/RequestHandler/UpdateRequestHandler.php b/Classes/RequestHandler/UpdateRequestHandler.php index ee45f006..c50d8b4f 100644 --- a/Classes/RequestHandler/UpdateRequestHandler.php +++ b/Classes/RequestHandler/UpdateRequestHandler.php @@ -5,6 +5,7 @@ namespace Pixelant\Interest\RequestHandler; use Pixelant\Interest\DataHandling\Operation\UpdateRecordOperation; +use Pixelant\Interest\Domain\Model\Dto\RecordRepresentation; class UpdateRequestHandler extends AbstractRecordRequestHandler { @@ -12,18 +13,10 @@ class UpdateRequestHandler extends AbstractRecordRequestHandler * @inheritDoc */ protected function handleSingleOperation( - string $table, - string $remoteId, - string $language, - string $workspace, - array $data + RecordRepresentation $recordRepresentation ): void { (new UpdateRecordOperation( - $data, - $table, - $remoteId, - $language !== '' ? $language : null, - $workspace !== '' ? $workspace : null, + $recordRepresentation, $this->metaData ))(); } diff --git a/Classes/Router/HttpRequestRouter.php b/Classes/Router/HttpRequestRouter.php index 6d0d6e12..36a0a382 100644 --- a/Classes/Router/HttpRequestRouter.php +++ b/Classes/Router/HttpRequestRouter.php @@ -60,7 +60,7 @@ public static function route(ServerRequestInterface $request): ResponseInterface } try { - if ($entryPointParts[0] ?? null === 'authenticate') { + if (($entryPointParts[0] ?? null) === 'authenticate') { return GeneralUtility::makeInstance( AuthenticateRequestHandler::class, $entryPointParts, diff --git a/Tests/Functional/DataHandling/Operation/AbstractRecordOperationFunctionalTestCase.php b/Tests/Functional/DataHandling/Operation/AbstractRecordOperationFunctionalTestCase.php index 77f3d067..a2e04ef2 100644 --- a/Tests/Functional/DataHandling/Operation/AbstractRecordOperationFunctionalTestCase.php +++ b/Tests/Functional/DataHandling/Operation/AbstractRecordOperationFunctionalTestCase.php @@ -4,6 +4,8 @@ namespace Pixelant\Interest\Tests\Functional\DataHandling\Operation; +use Pixelant\Interest\Utility\CompatibilityUtility; +use TYPO3\CMS\Core\Configuration\SiteConfiguration; use TYPO3\CMS\Core\Localization\LanguageService; use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase; @@ -19,14 +21,26 @@ protected function setUp(): void { parent::setUp(); - $this->setUpBackendUserFromFixture(1); + if (CompatibilityUtility::typo3VersionIsLessThan('10')) { + $this->importCSVDataSet(__DIR__ . '/Fixtures/BackendUserForVersion9.csv'); + } else { + $this->importCSVDataSet(__DIR__ . '/Fixtures/BackendUser.csv'); + } - $this->importDataSet('PACKAGE:typo3/testing-framework/Resources/Core/Functional/Fixtures/pages.xml'); + $this->importCSVDataSet(__DIR__ . '/Fixtures/Records.csv'); + + $this->setUpBackendUser(1); $this->setUpFrontendRootPage(1); GeneralUtility::setIndpEnv('TYPO3_REQUEST_URL', 'http://www.example.com/'); + $siteConfiguration = new SiteConfiguration( + GeneralUtility::getFileAbsFileName('EXT:interest/Tests/Functional/DataHandling/Operation/Fixtures/Sites') + ); + + GeneralUtility::setSingletonInstance(SiteConfiguration::class, $siteConfiguration); + $languageServiceMock = $this->createMock(LanguageService::class); $languageServiceMock diff --git a/Tests/Functional/DataHandling/Operation/CreateRecordOperationTest.php b/Tests/Functional/DataHandling/Operation/CreateRecordOperationTest.php index d2d54429..c996c091 100644 --- a/Tests/Functional/DataHandling/Operation/CreateRecordOperationTest.php +++ b/Tests/Functional/DataHandling/Operation/CreateRecordOperationTest.php @@ -10,6 +10,8 @@ namespace Pixelant\Interest\Tests\Functional\DataHandling\Operation; use Pixelant\Interest\DataHandling\Operation\CreateRecordOperation; +use Pixelant\Interest\Domain\Model\Dto\RecordInstanceIdentifier; +use Pixelant\Interest\Domain\Model\Dto\RecordRepresentation; use Pixelant\Interest\Domain\Repository\RemoteIdMappingRepository; class CreateRecordOperationTest extends AbstractRecordOperationFunctionalTestCase @@ -20,18 +22,20 @@ class CreateRecordOperationTest extends AbstractRecordOperationFunctionalTestCas public function creatingPageResultsInPageRecord(): void { $data = [ - 'pid' => 'ParentPage', + 'pid' => 'RootPage', 'title' => 'INTEREST', ]; $mappingRepository = new RemoteIdMappingRepository(); - $mappingRepository->add('ParentPage', 'pages', 1); - (new CreateRecordOperation( - $data, - 'pages', - 'Page-1' + new RecordRepresentation( + $data, + new RecordInstanceIdentifier( + 'pages', + 'Page-1' + ) + ) ))(); $createdPageUid = $mappingRepository->get('Page-1'); @@ -48,4 +52,121 @@ public function creatingPageResultsInPageRecord(): void self::assertSame($data['title'], $databaseRow['title']); } + + /** + * @test + */ + public function createOperationResultsInCorrectRecord() + { + $data = $this->recordRepresentationAndCorrespondingRowDataProvider(); + + $originalName = $this->getName(); + + foreach ($data as $key => $value) { + $this->setName($originalName . ' (' . $key . ')'); + + $this->createOperationResultsInCorrectRecordDataIteration(...$value); + } + } + + protected function createOperationResultsInCorrectRecordDataIteration( + RecordRepresentation $recordRepresentation, + array $expectedRow + ) { + $mappingRepository = new RemoteIdMappingRepository(); + + (new CreateRecordOperation($recordRepresentation))(); + + $queryFields = implode(',', array_keys($expectedRow)); + $table = $recordRepresentation->getRecordInstanceIdentifier()->getTable(); + $uid = $mappingRepository->get($recordRepresentation->getRecordInstanceIdentifier()->getRemoteIdWithAspects()); + + $createdRecord = $this + ->getConnectionPool() + ->getConnectionForTable($table) + ->executeQuery( + 'SELECT ' . $queryFields . ' FROM ' . $table . ' WHERE uid = ' . $uid + ) + ->fetchAssociative(); + + self::assertEquals($createdRecord, $expectedRow, 'Comparing created record with expected data.'); + } + + public function recordRepresentationAndCorrespondingRowDataProvider() + { + return [ + 'Base language record' => [ + new RecordRepresentation( + [ + 'pid' => 'RootPage', + 'header' => 'TEST', + ], + new RecordInstanceIdentifier( + 'tt_content', + 'ContentA', + '' + ) + ), + [ + 'pid' => 1, + 'header' => 'TEST', + ], + ], + 'Record with language' => [ + new RecordRepresentation( + [ + 'pid' => 'RootPage', + 'header' => 'TEST', + ], + new RecordInstanceIdentifier( + 'tt_content', + 'ContentB', + 'de' + ) + ), + [ + 'pid' => 1, + 'header' => 'TEST', + 'sys_language_uid' => 1, + ], + ], + 'Translation of base language record' => [ + new RecordRepresentation( + [ + 'pid' => 'RootPage', + 'header' => 'Translated TEST', + ], + new RecordInstanceIdentifier( + 'tt_content', + 'ContentElement', + 'de' + ) + ), + [ + 'pid' => 1, + 'header' => 'Translated TEST', + 'sys_language_uid' => 1, + 'l18n_parent' => 297, + ], + ], + 'Relation to multiple records' => [ + new RecordRepresentation( + [ + 'pid' => 'RootPage', + 'CType' => 'shortcut', + 'records' => 'ContentElement,TranslatedContentElement', + ], + new RecordInstanceIdentifier( + 'tt_content', + 'ReferenceContentElement', + '' + ) + ), + [ + 'CType' => 'shortcut', + 'records' => '297,298', + ], + ], + ]; + } } diff --git a/Tests/Functional/DataHandling/Operation/DeleteRecordOperationTest.php b/Tests/Functional/DataHandling/Operation/DeleteRecordOperationTest.php index 97b2ab03..14c034a7 100644 --- a/Tests/Functional/DataHandling/Operation/DeleteRecordOperationTest.php +++ b/Tests/Functional/DataHandling/Operation/DeleteRecordOperationTest.php @@ -10,6 +10,8 @@ namespace Pixelant\Interest\Tests\Functional\DataHandling\Operation; use Pixelant\Interest\DataHandling\Operation\DeleteRecordOperation; +use Pixelant\Interest\Domain\Model\Dto\RecordInstanceIdentifier; +use Pixelant\Interest\Domain\Model\Dto\RecordRepresentation; use Pixelant\Interest\Domain\Repository\RemoteIdMappingRepository; class DeleteRecordOperationTest extends AbstractRecordOperationFunctionalTestCase @@ -23,7 +25,15 @@ public function deletingPageSetsDeletedField() $mappingRepository->add('Dummy1234', 'pages', 4); - (new DeleteRecordOperation('Dummy1234'))(); + (new DeleteRecordOperation( + new RecordRepresentation( + [], + new RecordInstanceIdentifier( + 'pages', + 'Dummy1234' + ) + ) + ))(); $databaseRow = $this ->getConnectionPool() @@ -35,4 +45,61 @@ public function deletingPageSetsDeletedField() self::assertSame(1, $databaseRow['deleted']); } + + /** + * @test + */ + public function deletingContentSetsDeletedField() + { + $mappingRepository = new RemoteIdMappingRepository(); + + (new DeleteRecordOperation( + new RecordRepresentation( + [], + new RecordInstanceIdentifier( + 'tt_content', + 'TranslatedContentElement' + ) + ) + ))(); + + $databaseRow = $this + ->getConnectionPool() + ->getConnectionForTable('tt_content') + ->executeQuery('SELECT * FROM tt_content WHERE uid = 298') + ->fetchAssociative(); + + self::assertIsArray($databaseRow); + + self::assertSame(1, $databaseRow['deleted']); + } + + /** + * @test + */ + public function deletingTranslationOfContentSetsDeletedField() + { + $mappingRepository = new RemoteIdMappingRepository(); + + (new DeleteRecordOperation( + new RecordRepresentation( + [], + new RecordInstanceIdentifier( + 'tt_content', + 'TranslatedContentElement', + 'de' + ) + ) + ))(); + + $databaseRow = $this + ->getConnectionPool() + ->getConnectionForTable('tt_content') + ->executeQuery('SELECT * FROM tt_content WHERE uid = 299') + ->fetchAssociative(); + + self::assertIsArray($databaseRow); + + self::assertSame(1, $databaseRow['deleted']); + } } diff --git a/Tests/Functional/DataHandling/Operation/Fixtures/BackendUser.csv b/Tests/Functional/DataHandling/Operation/Fixtures/BackendUser.csv new file mode 100644 index 00000000..553e8a73 --- /dev/null +++ b/Tests/Functional/DataHandling/Operation/Fixtures/BackendUser.csv @@ -0,0 +1,3 @@ +be_users,,,,,,,,,,,,,,,,, +,uid,pid,tstamp,username,password,admin,disable,starttime,endtime,options,crdate,cruser_id,workspace_perms,deleted,TSconfig,lastlogin,workspace_id +,1,0,1366642540,admin,$1$tCrlLajZ$C0sikFQQ3SWaFAZ1Me0Z/1,1,0,0,0,0,1366642540,0,1,0,,1371033743,0 diff --git a/Tests/Functional/DataHandling/Operation/Fixtures/BackendUserForVersion9.csv b/Tests/Functional/DataHandling/Operation/Fixtures/BackendUserForVersion9.csv new file mode 100644 index 00000000..7f3ac3ca --- /dev/null +++ b/Tests/Functional/DataHandling/Operation/Fixtures/BackendUserForVersion9.csv @@ -0,0 +1,3 @@ +be_users,,,,,,,,,,,,,,,,, +,uid,pid,tstamp,username,password,admin,disable,starttime,endtime,options,crdate,cruser_id,workspace_perms,deleted,TSconfig,lastlogin,workspace_id,disableIPlock +,1,0,1366642540,admin,$1$tCrlLajZ$C0sikFQQ3SWaFAZ1Me0Z/1,1,0,0,0,0,1366642540,0,1,0,,1371033743,0,1 diff --git a/Tests/Functional/DataHandling/Operation/Fixtures/Records.csv b/Tests/Functional/DataHandling/Operation/Fixtures/Records.csv new file mode 100644 index 00000000..28d80a56 --- /dev/null +++ b/Tests/Functional/DataHandling/Operation/Fixtures/Records.csv @@ -0,0 +1,38 @@ +pages,,,,,,,,,,,,,,,,, +,uid,pid,title,sorting,deleted,perms_everybody,,,,,,,,,,, +,1,0,Root,128,0,15,,,,,,,,,,, +,2,1,Dummy 1-2,128,0,15,,,,,,,,,,, +,3,2,Dummy 1-2-3,128,0,15,,,,,,,,,,, +,4,3,Dummy 1-2-3-4,128,0,15,,,,,,,,,,, +,5,1,Dummy 1-5,256,0,15,,,,,,,,,,, +,6,5,Dummy 1-5-6,128,0,15,,,,,,,,,,, +,7,0,Root 2,256,0,15,,,,,,,,,,, +sys_language,,,,,,,,,,,,,,,,, +,uid,pid,tstamp,title,flag,language_isocode,,,,,,,,,,, +,1,0,1622463076,german,de,de,,,,,,,,,,, +,2,0,1623662734,spanish,es,es,,,,,,,,,,, +,3,0,1623662780,french,fr,fr,,,,,,,,,,, +tt_content,,,,,,,,,,,,,,,,, +,uid,pid,sorting,deleted,sys_language_uid,l18n_parent,l10n_source,t3_origuid,header,CType,records,,,,,, +,296,1,256,0,0,0,0,0,Regular Element #2,text,,,,,,, +,297,1,256,0,0,0,0,0,Regular Element #1,text,,,,,,, +,298,1,256,0,0,0,0,0,Translated Base Language,text,,,,,,, +,299,1,256,0,1,298,298,0,Translated German Language,text,,,,,,, +,300,1,256,0,0,0,0,0,Multiple references,text,"297,298",,,,,, +tx_interest_remote_id_mapping,,,,,,,,,,,,,,,,, +,uid,pid,remote_id,table,uid_local,manual,,,,,,,,,,, +,1,0,RootPage,pages,1,1,,,,,,,,,,, +,2,0,Dummy12Page,pages,2,1,,,,,,,,,,, +,3,0,Dummy123Page,pages,3,1,,,,,,,,,,, +,4,0,Dummy1234Page,pages,4,1,,,,,,,,,,, +,5,0,Dummy15,pages,5,1,,,,,,,,,,, +,6,0,Dummy156,pages,6,1,,,,,,,,,,, +,7,0,Root2Page,pages,7,1,,,,,,,,,,, +,8,0,german,sys_language,1,1,,,,,,,,,,, +,9,0,spanish,sys_language,2,1,,,,,,,,,,, +,10,0,french,sys_language,3,1,,,,,,,,,,, +,11,0,ContentElement2,tt_content,296,1,,,,,,,,,,, +,12,0,ContentElement,tt_content,297,1,,,,,,,,,,, +,13,0,TranslatedContentElement,tt_content,298,1,,,,,,,,,,, +,14,0,TranslatedContentElement|||L1,tt_content,299,1,,,,,,,,,,, +,15,0,MultipleReferences,tt_content,300,1,,,,,,,,,,, diff --git a/Tests/Functional/DataHandling/Operation/Fixtures/Sites/main/config.yaml b/Tests/Functional/DataHandling/Operation/Fixtures/Sites/main/config.yaml new file mode 100644 index 00000000..88ed6c69 --- /dev/null +++ b/Tests/Functional/DataHandling/Operation/Fixtures/Sites/main/config.yaml @@ -0,0 +1,59 @@ +base: '/' +languages: + - + title: English + enabled: true + languageId: 0 + base: / + typo3Language: default + locale: en_US.UTF-8 + iso-639-1: en + navigationTitle: English + hreflang: en-us + direction: ltr + flag: us + websiteTitle: '' + - + title: German + enabled: true + base: /da/ + typo3Language: de + locale: de_DE.UTF-8 + iso-639-1: de + navigationTitle: German + hreflang: de + direction: ltr + fallbackType: strict + fallbacks: '' + flag: de + languageId: 1 + - + title: Spanish + enabled: true + base: /es/ + typo3Language: es + locale: es_ES.UTF-8 + iso-639-1: es + navigationTitle: Spenish + hreflang: es + direction: ltr + fallbackType: strict + fallbacks: '' + flag: es + languageId: 2 + - + title: French + enabled: true + base: /fr/ + typo3Language: fr + locale: fr_FR.UTF-8 + iso-639-1: fr + navigationTitle: French + hreflang: fr + direction: ltr + fallbackType: strict + fallbacks: '' + flag: fr + languageId: 3 +rootPageId: 1 +websiteTitle: RootPage diff --git a/Tests/Functional/DataHandling/Operation/Fixtures/Sites/secondary/config.yaml b/Tests/Functional/DataHandling/Operation/Fixtures/Sites/secondary/config.yaml new file mode 100644 index 00000000..007a3711 --- /dev/null +++ b/Tests/Functional/DataHandling/Operation/Fixtures/Sites/secondary/config.yaml @@ -0,0 +1,59 @@ +base: '/' +languages: + - + title: English + enabled: true + languageId: 0 + base: / + typo3Language: default + locale: en_US.UTF-8 + iso-639-1: en + navigationTitle: English + hreflang: en-us + direction: ltr + flag: us + websiteTitle: '' + - + title: German + enabled: true + base: /da/ + typo3Language: de + locale: de_DE.UTF-8 + iso-639-1: de + navigationTitle: German + hreflang: de + direction: ltr + fallbackType: strict + fallbacks: '' + flag: de + languageId: 1 + - + title: Spanish + enabled: true + base: /es/ + typo3Language: es + locale: es_ES.UTF-8 + iso-639-1: es + navigationTitle: Spenish + hreflang: es + direction: ltr + fallbackType: strict + fallbacks: '' + flag: es + languageId: 2 + - + title: French + enabled: true + base: /fr/ + typo3Language: fr + locale: fr_FR.UTF-8 + iso-639-1: fr + navigationTitle: French + hreflang: fr + direction: ltr + fallbackType: strict + fallbacks: '' + flag: fr + languageId: 3 +rootPageId: 7 +websiteTitle: RootPage diff --git a/Tests/Functional/DataHandling/Operation/UpdateRecordOperationTest.php b/Tests/Functional/DataHandling/Operation/UpdateRecordOperationTest.php index 9294a093..b790e96b 100644 --- a/Tests/Functional/DataHandling/Operation/UpdateRecordOperationTest.php +++ b/Tests/Functional/DataHandling/Operation/UpdateRecordOperationTest.php @@ -10,6 +10,8 @@ namespace Pixelant\Interest\Tests\Functional\DataHandling\Operation; use Pixelant\Interest\DataHandling\Operation\UpdateRecordOperation; +use Pixelant\Interest\Domain\Model\Dto\RecordInstanceIdentifier; +use Pixelant\Interest\Domain\Model\Dto\RecordRepresentation; use Pixelant\Interest\Domain\Repository\RemoteIdMappingRepository; class UpdateRecordOperationTest extends AbstractRecordOperationFunctionalTestCase @@ -25,12 +27,14 @@ public function updatingPageChangesFields() $mappingRepository = new RemoteIdMappingRepository(); - $mappingRepository->add('RootPage', 'pages', 1); - (new UpdateRecordOperation( - $data, - 'pages', - 'RootPage' + new RecordRepresentation( + $data, + new RecordInstanceIdentifier( + 'pages', + 'RootPage' + ) + ) ))(); $databaseRow = $this @@ -43,4 +47,113 @@ public function updatingPageChangesFields() self::assertSame($data['title'], $databaseRow['title']); } + + /** + * @test + */ + public function updateOperationResultsInCorrectRecord() + { + $data = $this->recordRepresentationAndCorrespondingRowDataProvider(); + + $originalName = $this->getName(); + + foreach ($data as $key => $value) { + $this->setName($originalName . ' (' . $key . ')'); + + $this->updateOperationResultsInCorrectRecordDataIteration(...$value); + } + } + + protected function updateOperationResultsInCorrectRecordDataIteration( + RecordRepresentation $recordRepresentation, + array $expectedRow + ) { + $mappingRepository = new RemoteIdMappingRepository(); + + (new UpdateRecordOperation($recordRepresentation))(); + + $queryFields = implode(',', array_keys($expectedRow)); + $table = $recordRepresentation->getRecordInstanceIdentifier()->getTable(); + $uid = $mappingRepository->get($recordRepresentation->getRecordInstanceIdentifier()->getRemoteIdWithAspects()); + + $createdRecord = $this + ->getConnectionPool() + ->getConnectionForTable($table) + ->executeQuery( + 'SELECT ' . $queryFields . ' FROM ' . $table . ' WHERE uid = ' . $uid + ) + ->fetchAssociative(); + + self::assertEquals($createdRecord, $expectedRow, 'Comparing created record with expected data.'); + } + + public function recordRepresentationAndCorrespondingRowDataProvider() + { + return [ + 'Base language record' => [ + new RecordRepresentation( + [ + 'bodytext' => 'base language text', + ], + new RecordInstanceIdentifier( + 'tt_content', + 'TranslatedContentElement', + '' + ) + ), + [ + 'pid' => 1, + 'uid' => 298, + 'bodytext' => 'base language text', + ], + ], + 'Translated record' => [ + new RecordRepresentation( + [ + 'bodytext' => 'translated text', + ], + new RecordInstanceIdentifier( + 'tt_content', + 'TranslatedContentElement', + 'de' + ) + ), + [ + 'pid' => 1, + 'uid' => 299, + 'bodytext' => 'translated text', + ], + ], + 'Remove one of multiple relations' => [ + new RecordRepresentation( + [ + 'records' => 'TranslatedContentElement', + ], + new RecordInstanceIdentifier( + 'tt_content', + 'MultipleReferences', + '' + ) + ), + [ + 'records' => '298', + ], + ], + 'Add one of multiple relations first' => [ + new RecordRepresentation( + [ + 'records' => 'ContentElement2,TranslatedContentElement', + ], + new RecordInstanceIdentifier( + 'tt_content', + 'MultipleReferences', + '' + ) + ), + [ + 'records' => '296,298', + ], + ], + ]; + } } diff --git a/Tests/Unit/DataHandling/Operation/CreateRecordOperationTest.php b/Tests/Unit/DataHandling/Operation/CreateRecordOperationTest.php new file mode 100644 index 00000000..13593603 --- /dev/null +++ b/Tests/Unit/DataHandling/Operation/CreateRecordOperationTest.php @@ -0,0 +1,79 @@ +resetSingletonInstances = true; + + GeneralUtility::setSingletonInstance( + ConfigurationProvider::class, + $this->createMock(ConfigurationProvider::class) + ); + + GeneralUtility::setSingletonInstance( + RemoteIdMappingRepository::class, + $this->createMock(RemoteIdMappingRepository::class) + ); + + GeneralUtility::setSingletonInstance( + PendingRelationsRepository::class, + $this->createMock(PendingRelationsRepository::class) + ); + + $eventDispatcherMock = $this->createMock(EventDispatcher::class); + + $eventDispatcherMock + ->method('dispatch') + ->willReturnArgument(0); + + GeneralUtility::setSingletonInstance( + EventDispatcher::class, + $eventDispatcherMock + ); + + GeneralUtility::addInstance( + DataHandler::class, + $this->createMock(DataHandler::class) + ); + } + + /** + * @test + */ + public function resolveStoragePidReturnsZeroIfRootLevelIsOne() + { + $GLOBALS['TCA']['testtable'] = [ + 'ctrl' => [ + 'rootLevel' => 1, + ], + 'columns' => [], + ]; + + $subject = new CreateRecordOperation( + new RecordRepresentation( + [], + new RecordInstanceIdentifier('testtable', 'remoteId') + ) + ); + + self::assertEquals(0, $subject->getData()['pid']); + } +} diff --git a/Tests/Unit/RequestHandler/AbstractRecordRequestHandlerTest.php b/Tests/Unit/RequestHandler/AbstractRecordRequestHandlerTest.php index 94fef03a..df7eda38 100644 --- a/Tests/Unit/RequestHandler/AbstractRecordRequestHandlerTest.php +++ b/Tests/Unit/RequestHandler/AbstractRecordRequestHandlerTest.php @@ -4,6 +4,8 @@ namespace Pixelant\Interest\Tests\Unit\RequestHandler; +use Pixelant\Interest\Domain\Model\Dto\RecordInstanceIdentifier; +use Pixelant\Interest\Domain\Model\Dto\RecordRepresentation; use Pixelant\Interest\RequestHandler\AbstractRecordRequestHandler; use TYPO3\CMS\Core\Http\ServerRequest; use TYPO3\TestingFramework\Core\Unit\UnitTestCase; @@ -64,13 +66,17 @@ public function correctlyCompliesDataProvider() ], [ [ - 'table', - 'remoteId', - '', - '', - [ - 'title' => 'TEST', - ], + new RecordRepresentation( + [ + 'title' => 'TEST', + ], + new RecordInstanceIdentifier( + 'table', + 'remoteId', + '', + '' + ) + ), ], ], ], @@ -87,13 +93,17 @@ public function correctlyCompliesDataProvider() ], [ [ - 'table', - 'remoteId', - 'language', - '', - [ - 'title' => 'TEST', - ], + new RecordRepresentation( + [ + 'title' => 'TEST', + ], + new RecordInstanceIdentifier( + 'table', + 'remoteId', + 'language', + '' + ) + ), ], ], ], @@ -111,13 +121,17 @@ public function correctlyCompliesDataProvider() ], [ [ - 'table', - 'remoteId', - 'language', - '', - [ - 'title' => 'TEST', - ], + new RecordRepresentation( + [ + 'title' => 'TEST', + ], + new RecordInstanceIdentifier( + 'table', + 'remoteId', + 'language', + '' + ) + ), ], ], ], @@ -134,13 +148,17 @@ public function correctlyCompliesDataProvider() ], [ [ - 'table', - 'remoteId', - '', - '', - [ - 'title' => 'TEST', - ], + new RecordRepresentation( + [ + 'title' => 'TEST', + ], + new RecordInstanceIdentifier( + 'table', + 'remoteId', + '', + '' + ) + ), ], ], ], @@ -159,13 +177,17 @@ public function correctlyCompliesDataProvider() ], [ [ - 'table', - 'remoteId', - 'language', - '', - [ - 'title' => 'TEST', - ], + new RecordRepresentation( + [ + 'title' => 'TEST', + ], + new RecordInstanceIdentifier( + 'table', + 'remoteId', + 'language', + '' + ) + ), ], ], ], @@ -182,13 +204,17 @@ public function correctlyCompliesDataProvider() ], [ [ - 'table', - 'remoteId', - '', - '', - [ - 'title' => 'TEST', - ], + new RecordRepresentation( + [ + 'title' => 'TEST', + ], + new RecordInstanceIdentifier( + 'table', + 'remoteId', + '', + '' + ) + ), ], ], ], @@ -207,13 +233,17 @@ public function correctlyCompliesDataProvider() ], [ [ - 'table', - 'remoteId', - 'language', - '', - [ - 'title' => 'TEST', - ], + new RecordRepresentation( + [ + 'title' => 'TEST', + ], + new RecordInstanceIdentifier( + 'table', + 'remoteId', + 'language', + '' + ) + ), ], ], ], @@ -233,22 +263,30 @@ public function correctlyCompliesDataProvider() ], [ [ - 'table', - 'remoteId1', - '', - '', - [ - 'title' => 'TEST1', - ], + new RecordRepresentation( + [ + 'title' => 'TEST1', + ], + new RecordInstanceIdentifier( + 'table', + 'remoteId1', + '', + '' + ) + ), ], [ - 'table', - 'remoteId2', - '', - '', - [ - 'title' => 'TEST2', - ], + new RecordRepresentation( + [ + 'title' => 'TEST2', + ], + new RecordInstanceIdentifier( + 'table', + 'remoteId2', + '', + '' + ) + ), ], ], ], @@ -269,22 +307,30 @@ public function correctlyCompliesDataProvider() ], [ [ - 'table', - 'remoteId', - 'language1', - '', - [ - 'title' => 'TEST1', - ], + new RecordRepresentation( + [ + 'title' => 'TEST1', + ], + new RecordInstanceIdentifier( + 'table', + 'remoteId', + 'language1', + '' + ) + ), ], [ - 'table', - 'remoteId', - 'language2', - '', - [ - 'title' => 'TEST2', - ], + new RecordRepresentation( + [ + 'title' => 'TEST2', + ], + new RecordInstanceIdentifier( + 'table', + 'remoteId', + 'language2', + '' + ) + ), ], ], ], @@ -306,88 +352,112 @@ public function correctlyCompliesDataProvider() ], [ [ - 'table', - 'remoteId', - 'language1', - '', - [ - 'title' => 'TEST1', - ], + new RecordRepresentation( + [ + 'title' => 'TEST1', + ], + new RecordInstanceIdentifier( + 'table', + 'remoteId', + 'language1', + '' + ) + ), ], [ - 'table', - 'remoteId', - 'language2', - '', - [ - 'title' => 'TEST2', - ], + new RecordRepresentation( + [ + 'title' => 'TEST2', + ], + new RecordInstanceIdentifier( + 'table', + 'remoteId', + 'language2', + '' + ) + ), ], ], 'Single record with multiple remote IDs and multiple languages in data' => [ - [ - 'table', - ], - [ - 'data' => [ - 'remoteId1' => [ - 'language1' => [ - 'title' => 'TEST1', - ], - 'language2' => [ - 'title' => 'TEST2', - ], - ], - 'remoteId2' => [ - 'language3' => [ - 'title' => 'TEST1', + [ + 'table', + ], + [ + 'data' => [ + 'remoteId1' => [ + 'language1' => [ + 'title' => 'TEST1', + ], + 'language2' => [ + 'title' => 'TEST2', + ], ], - 'language4' => [ - 'title' => 'TEST2', + 'remoteId2' => [ + 'language3' => [ + 'title' => 'TEST1', + ], + 'language4' => [ + 'title' => 'TEST2', + ], ], - ], + ], ], - ], - [ [ - 'table', - 'remoteId1', - 'language1', - '', [ - 'title' => 'TEST1', + new RecordRepresentation( + [ + 'title' => 'TEST1', + ], + new RecordInstanceIdentifier( + 'table', + 'remoteId1', + 'language1', + '' + ) + ), ], - ], - [ - 'table', - 'remoteId1', - 'language2', - '', [ - 'title' => 'TEST2', + new RecordRepresentation( + [ + 'title' => 'TEST2', + ], + new RecordInstanceIdentifier( + 'table', + 'remoteId1', + 'language2', + '' + ) + ), ], - ], - [ - 'table', - 'remoteId2', - 'language3', - '', [ - 'title' => 'TEST1', + new RecordRepresentation( + [ + 'title' => 'TEST1', + ], + new RecordInstanceIdentifier( + 'table', + 'remoteId2', + 'language3', + '' + ) + ), ], - ], - [ - 'table', - 'remoteId2', - 'language4', - '', [ - 'title' => 'TEST2', + new RecordRepresentation( + [ + 'title' => 'TEST2', + ], + new RecordInstanceIdentifier( + 'table', + 'remoteId2', + 'language4', + '' + ) + ), ], ], ], - ], 'Single record with table and remote ID in data' => [ [], [ @@ -401,13 +471,17 @@ public function correctlyCompliesDataProvider() ], [ [ - 'table', - 'remoteId', - '', - '', - [ - 'title' => 'TEST', - ], + new RecordRepresentation( + [ + 'title' => 'TEST', + ], + new RecordInstanceIdentifier( + 'table', + 'remoteId', + '', + '' + ) + ), ], ], ], @@ -433,22 +507,30 @@ public function correctlyCompliesDataProvider() ], [ [ - 'table1', - 'remoteId1', - 'language1', - '', - [ - 'title' => 'TEST1', - ], + new RecordRepresentation( + [ + 'title' => 'TEST1', + ], + new RecordInstanceIdentifier( + 'table1', + 'remoteId1', + 'language1', + '' + ) + ), ], [ - 'table2', - 'remoteId2', - 'language2', - '', - [ - 'title' => 'TEST2', - ], + new RecordRepresentation( + [ + 'title' => 'TEST2', + ], + new RecordInstanceIdentifier( + 'table2', + 'remoteId2', + 'language2', + '' + ) + ), ], ], ],