From 8be6fb947b2fbc3a31fdeb6a697899b71d41e8bc Mon Sep 17 00:00:00 2001 From: Krystian Szymukowicz Date: Sun, 4 Oct 2026 22:25:52 +0200 Subject: [PATCH] [BUGFIX] Return creation and modification date stored by TYPO3 Extbase sets TCA `ctrl.crdate` and `ctrl.tstamp` only in the database row, so after POST, PUT and PATCH the persisted object still held old values (e.g. `null` for a new record). Models with non-nullable getters (e.g. `News::getCrdate(): DateTime` in EXT:news) failed during response serialization with HTTP 500, although the record was already saved. After persisting, stored creation and modification dates are copied from the database to the object. Values are mapped the same way as Extbase maps them when loading the object (including typed properties which are not initialized, empty timestamps and the `extbase.consistentDateTimeHandling` feature), so the response of write operations is the same as the response of subsequent GET. --- .../GeorgRinger.News/Domain.Model.News.yml | 4 +- .../AbstractOperationHandler.php | 12 ++ .../CollectionPostOperationHandler.php | 1 + .../ItemPatchOperationHandler.php | 1 + .../ItemPutOperationHandler.php | 1 + Classes/Service/CommonDateFieldsService.php | 146 ++++++++++++++++++ .../Miscellaneous/Changelog/Index.rst | 1 + .../CommonDateFieldsDispatcherTest.php | 91 +++++++++++ .../Classes/Domain/Model/Article.php | 71 +++++++++ ...tx_functionaltest_domain_model_article.php | 33 ++++ .../Serializer/Domain.Model.Article.yml | 10 ++ .../t3api_functional_test/ext_tables.sql | 4 + Tests/Functional/Fixtures/articles.csv | 3 + Tests/Postman/t3apinews.crud.json | 65 +++++++- phpstan.neon | 10 ++ 15 files changed, 446 insertions(+), 7 deletions(-) create mode 100644 Classes/Service/CommonDateFieldsService.php create mode 100644 Tests/Functional/Dispatcher/CommonDateFieldsDispatcherTest.php create mode 100644 Tests/Functional/Fixtures/Extensions/t3api_functional_test/Classes/Domain/Model/Article.php create mode 100644 Tests/Functional/Fixtures/Extensions/t3api_functional_test/Configuration/TCA/tx_functionaltest_domain_model_article.php create mode 100644 Tests/Functional/Fixtures/Extensions/t3api_functional_test/Resources/Private/Serializer/Domain.Model.Article.yml create mode 100644 Tests/Functional/Fixtures/articles.csv diff --git a/.ddev/test/files/src/t3apinews/Resources/Private/Serializer/GeorgRinger.News/Domain.Model.News.yml b/.ddev/test/files/src/t3apinews/Resources/Private/Serializer/GeorgRinger.News/Domain.Model.News.yml index 6a8f4b63..96a4da0f 100644 --- a/.ddev/test/files/src/t3apinews/Resources/Private/Serializer/GeorgRinger.News/Domain.Model.News.yml +++ b/.ddev/test/files/src/t3apinews/Resources/Private/Serializer/GeorgRinger.News/Domain.Model.News.yml @@ -11,7 +11,9 @@ GeorgRinger\News\Domain\Model\News: datetime: groups: [api_get_collection_t3apinews_news, api_get_item_t3apinews_news, api_post_item_t3apinews_news, api_patch_item_t3apinews_news, api_put_item_t3apinews_news] crdate: - groups: [api_post_item_t3apinews_news, api_patch_item_t3apinews_news, api_put_item_t3apinews_news] + groups: [api_get_item_t3apinews_news, api_post_item_t3apinews_news, api_patch_item_t3apinews_news, api_put_item_t3apinews_news] + tstamp: + groups: [api_get_item_t3apinews_news, api_post_item_t3apinews_news, api_patch_item_t3apinews_news, api_put_item_t3apinews_news] author: groups: [api_get_collection_t3apinews_news, api_get_item_t3apinews_news, api_post_item_t3apinews_news, api_patch_item_t3apinews_news, api_put_item_t3apinews_news] authorEmail: diff --git a/Classes/OperationHandler/AbstractOperationHandler.php b/Classes/OperationHandler/AbstractOperationHandler.php index 54a42420..eb8171c3 100644 --- a/Classes/OperationHandler/AbstractOperationHandler.php +++ b/Classes/OperationHandler/AbstractOperationHandler.php @@ -11,6 +11,7 @@ use SourceBroker\T3api\Exception\OperationNotAllowedException; use SourceBroker\T3api\Security\OperationAccessChecker; use SourceBroker\T3api\Serializer\ContextBuilder\DeserializationContextBuilder; +use SourceBroker\T3api\Service\CommonDateFieldsService; use SourceBroker\T3api\Service\SerializerService; use SourceBroker\T3api\Service\SlugService; use SourceBroker\T3api\Service\ValidationService; @@ -21,6 +22,8 @@ abstract class AbstractOperationHandler implements OperationHandlerInterface { protected SlugService $slugService; + protected CommonDateFieldsService $commonDateFieldsService; + public function __construct( protected readonly SerializerService $serializerService, protected readonly ValidationService $validationService, @@ -38,6 +41,15 @@ public function injectSlugService(SlugService $slugService): void $this->slugService = $slugService; } + /** + * Injected by method (not constructor) so that adding new dependency does not break handlers which extend this + * class and override the constructor. + */ + public function injectCommonDateFieldsService(CommonDateFieldsService $commonDateFieldsService): void + { + $this->commonDateFieldsService = $commonDateFieldsService; + } + protected function getRepositoryForOperation(OperationInterface $operation): CommonRepository { return CommonRepository::getInstanceForOperation($operation); diff --git a/Classes/OperationHandler/CollectionPostOperationHandler.php b/Classes/OperationHandler/CollectionPostOperationHandler.php index 5b83082b..62fcebb0 100644 --- a/Classes/OperationHandler/CollectionPostOperationHandler.php +++ b/Classes/OperationHandler/CollectionPostOperationHandler.php @@ -37,6 +37,7 @@ public function handle(OperationInterface $operation, Request $request, array $r $repository->add($object); GeneralUtility::makeInstance(PersistenceManager::class)->persistAll(); $this->slugService->processSlugs($object); + $this->commonDateFieldsService->applyToObject($object); $response = $response ? $response->withStatus(201) : $response; diff --git a/Classes/OperationHandler/ItemPatchOperationHandler.php b/Classes/OperationHandler/ItemPatchOperationHandler.php index 5e7457d0..1680367d 100644 --- a/Classes/OperationHandler/ItemPatchOperationHandler.php +++ b/Classes/OperationHandler/ItemPatchOperationHandler.php @@ -44,6 +44,7 @@ public function handle( $repository->update($object); GeneralUtility::makeInstance(PersistenceManager::class)->persistAll(); $this->slugService->processSlugs($object); + $this->commonDateFieldsService->applyToObject($object); return $object; } diff --git a/Classes/OperationHandler/ItemPutOperationHandler.php b/Classes/OperationHandler/ItemPutOperationHandler.php index 7bc150c8..c1cd78a8 100644 --- a/Classes/OperationHandler/ItemPutOperationHandler.php +++ b/Classes/OperationHandler/ItemPutOperationHandler.php @@ -54,6 +54,7 @@ public function handle( $repository->add($object); GeneralUtility::makeInstance(PersistenceManager::class)->persistAll(); $this->slugService->processSlugs($object); + $this->commonDateFieldsService->applyToObject($object); return $object; } diff --git a/Classes/Service/CommonDateFieldsService.php b/Classes/Service/CommonDateFieldsService.php new file mode 100644 index 00000000..cf1ca031 --- /dev/null +++ b/Classes/Service/CommonDateFieldsService.php @@ -0,0 +1,146 @@ +dataMapFactory->buildDataMap(get_class($object)); + $columnNames = array_filter([ + $dataMap->getCreationDateColumnName(), + $dataMap->getModificationDateColumnName(), + ]); + + if ($columnNames === []) { + return; + } + + // Iterate over class schema, not object values - uninitialized typed properties are not returned by + // `_getProperties()`. + $properties = []; + foreach ($this->reflectionService->getClassSchema($object)->getDomainObjectProperties() as $property) { + $columnMap = $dataMap->getColumnMap($property->getName()); + if ($columnMap !== null && in_array($columnMap->getColumnName(), $columnNames, true)) { + $properties[] = [$property, $columnMap]; + } + } + + if ($properties === []) { + return; + } + + $row = $this->getRow( + $dataMap->getTableName(), + (int)($object->_getProperty(AbstractDomainObject::PROPERTY_LOCALIZED_UID) ?? $object->getUid()), + array_map(static fn(array $item): string => $item[1]->getColumnName(), $properties) + ); + + if ($row === null) { + return; + } + + foreach ($properties as [$property, $columnMap]) { + $value = $row[$columnMap->getColumnName()] ?? null; + if ($value === null) { + continue; + } + + $type = $property->getPrimaryType(); + $className = $type?->getClassName(); + if (in_array($type?->getBuiltinType(), ['int', 'integer'], true)) { + $value = (int)$value; + } elseif ($className !== null && is_subclass_of($className, \DateTimeInterface::class)) { + $value = $this->mapDateTime($value, $columnMap, $className); + } else { + continue; + } + + if ($value !== null || $property->isNullable()) { + $object->_setProperty($property->getName(), $value); + $object->_memorizeCleanState($property->getName()); + } + } + } + + /** + * @param class-string<\DateTimeInterface> $targetType + * @see \TYPO3\CMS\Extbase\Persistence\Generic\Mapper\DataMapper::mapDateTime() + */ + protected function mapDateTime(int|string $value, ColumnMap $columnMap, string $targetType): ?\DateTimeInterface + { + // Feature exists since TYPO3 13 (enabled by default since TYPO3 14) + if ($this->features->isFeatureEnabled('extbase.consistentDateTimeHandling')) { + $dateTime = DateTimeFactory::createFromDatabaseValueAndTCAConfig( + $value, + [ + 'type' => 'datetime', + 'format' => $columnMap->getDateTimeFormat(), + 'dbType' => $columnMap->getDateTimeStorageFormat(), + 'nullable' => $columnMap->isNullable(), + ] + ); + + return $dateTime === null ? null : match ($targetType) { + \DateTimeImmutable::class => $dateTime, + \DateTime::class => \DateTime::createFromImmutable($dateTime), + default => GeneralUtility::makeInstance($targetType, $dateTime->format('Y-m-d H:i:s.v e')), + }; + } + + if (empty($value) || $value === '0000-00-00' || $value === '0000-00-00 00:00:00' || $value === '00:00:00') { + return null; + } + if (!in_array($columnMap->getDateTimeStorageFormat(), QueryHelper::getDateTimeTypes(), true)) { + $value = date('c', (int)$value); + } + + return GeneralUtility::makeInstance($targetType, $value); + } + + /** + * @param string[] $columnNames + */ + protected function getRow(string $tableName, int $uid, array $columnNames): ?array + { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable($tableName); + $queryBuilder->getRestrictions()->removeAll(); + $row = $queryBuilder + ->select(...$columnNames) + ->from($tableName) + ->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($uid, Connection::PARAM_INT))) + ->executeQuery() + ->fetchAssociative(); + + return $row === false ? null : $row; + } +} diff --git a/Documentation/Miscellaneous/Changelog/Index.rst b/Documentation/Miscellaneous/Changelog/Index.rst index 3afb5d5f..96fb2a1f 100644 --- a/Documentation/Miscellaneous/Changelog/Index.rst +++ b/Documentation/Miscellaneous/Changelog/Index.rst @@ -14,6 +14,7 @@ Changelog - Validate pagination parameters like API Platform: invalid ``page`` / ``itemsPerPage`` returns ``400 Bad Request``, ``itemsPerPage=0`` returns only ``hydra:totalItems``, empty collection has one page in ``hydra:view``. See :ref:`pagination`. [`commit `__] - Add ``@id`` to related objects when their API resource class is set as ``type`` in YAML metadata. See :ref:`integration`. [`commit `__] - Add ``version`` and ``providesPackages`` to ``composer.json`` (TYPO3 14.3 classic mode metadata). +- Return creation and modification date (TCA ``ctrl.crdate`` and ``ctrl.tstamp``) stored by TYPO3 in response of POST, PUT and PATCH operations. Fixes HTTP 500 after creating record without creation date when getter does not allow ``null``. - Add TYPO3 14 support. [`commit `__] - Add PHP 8.5 support. [`commit `__] - Add tag-based :ref:`response cache ` for GET endpoints. [`commit `__] diff --git a/Tests/Functional/Dispatcher/CommonDateFieldsDispatcherTest.php b/Tests/Functional/Dispatcher/CommonDateFieldsDispatcherTest.php new file mode 100644 index 00000000..920c99f3 --- /dev/null +++ b/Tests/Functional/Dispatcher/CommonDateFieldsDispatcherTest.php @@ -0,0 +1,91 @@ +importCSVDataSet(__DIR__ . '/../Fixtures/articles.csv'); + } + + #[Test] + public function postReturnsDatesStoredInDatabase(): void + { + $article = $this->dispatch('POST', 'https://example.com/_api/articles', ['title' => 'New article']); + + self::assertNotNull($article['crdate']); + self::assertNotNull($article['tstamp']); + $this->assertDatesAreSameAsInGetResponse($article); + } + + #[Test] + public function patchReturnsUpdatedModificationDate(): void + { + $tstampBeforeUpdate = $this->dispatch('GET', 'https://example.com/_api/articles/1')['tstamp']; + $article = $this->dispatch('PATCH', 'https://example.com/_api/articles/1', ['title' => 'Changed title']); + + self::assertNotSame($tstampBeforeUpdate, $article['tstamp']); + $this->assertDatesAreSameAsInGetResponse($article); + } + + #[Test] + public function patchReturnsZeroCreationDateMappedLikeExtbase(): void + { + $article = $this->dispatch('PATCH', 'https://example.com/_api/articles/1', ['title' => 'Changed title']); + + self::assertNull($article['crdate'] ?? null); + $this->assertDatesAreSameAsInGetResponse($article); + } + + private function assertDatesAreSameAsInGetResponse(array $article): void + { + // Drop objects held by persistence session, so GET maps the record from the database + $this->get(PersistenceManagerInterface::class)->clearState(); + $storedArticle = $this->dispatch('GET', 'https://example.com/_api/articles/' . $article['uid']); + + self::assertSame($storedArticle['crdate'] ?? null, $article['crdate'] ?? null); + self::assertSame($storedArticle['tstamp'] ?? null, $article['tstamp'] ?? null); + } + + private function dispatch(string $method, string $url, ?array $body = null): array + { + $symfonyRequest = SymfonyRequest::create( + $url, + $method, + [], + [], + [], + ['CONTENT_TYPE' => 'application/json'], + $body === null ? null : json_encode($body, JSON_THROW_ON_ERROR) + ); + $response = new Response(); + + return json_decode( + $this->get(Bootstrap::class)->processOperationByRequest( + (new RequestContext())->fromRequest($symfonyRequest), + $symfonyRequest, + $response + ), + true, + 512, + JSON_THROW_ON_ERROR + ); + } +} diff --git a/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Classes/Domain/Model/Article.php b/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Classes/Domain/Model/Article.php new file mode 100644 index 00000000..585a44e2 --- /dev/null +++ b/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Classes/Domain/Model/Article.php @@ -0,0 +1,71 @@ +title; + } + + public function setTitle(string $title): void + { + $this->title = $title; + } + + public function getCrdate(): ?\DateTime + { + return $this->crdate; + } + + public function getTstamp(): \DateTime + { + return $this->tstamp; + } +} diff --git a/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Configuration/TCA/tx_functionaltest_domain_model_article.php b/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Configuration/TCA/tx_functionaltest_domain_model_article.php new file mode 100644 index 00000000..7b955126 --- /dev/null +++ b/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Configuration/TCA/tx_functionaltest_domain_model_article.php @@ -0,0 +1,33 @@ + [ + 'label' => 'Title', + 'config' => [ + 'type' => 'input', + ], + ], + // Extbase maps only fields configured in TCA columns + 'crdate' => [ + 'label' => 'Creation date', + 'config' => [ + 'type' => 'datetime', + ], + ], + 'tstamp' => [ + 'label' => 'Modification date', + 'config' => [ + 'type' => 'datetime', + ], + ], + ], + 'title' +); +$tca['ctrl']['crdate'] = 'crdate'; +$tca['ctrl']['tstamp'] = 'tstamp'; + +return $tca; diff --git a/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Resources/Private/Serializer/Domain.Model.Article.yml b/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Resources/Private/Serializer/Domain.Model.Article.yml new file mode 100644 index 00000000..edcc6434 --- /dev/null +++ b/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Resources/Private/Serializer/Domain.Model.Article.yml @@ -0,0 +1,10 @@ +T3apiTests\FunctionalTest\Domain\Model\Article: + properties: + uid: + groups: [api_article] + title: + groups: [api_article] + crdate: + groups: [api_article] + tstamp: + groups: [api_article] diff --git a/Tests/Functional/Fixtures/Extensions/t3api_functional_test/ext_tables.sql b/Tests/Functional/Fixtures/Extensions/t3api_functional_test/ext_tables.sql index 119d6520..6817b949 100644 --- a/Tests/Functional/Fixtures/Extensions/t3api_functional_test/ext_tables.sql +++ b/Tests/Functional/Fixtures/Extensions/t3api_functional_test/ext_tables.sql @@ -30,3 +30,7 @@ CREATE TABLE tx_functionaltest_product_tag_mm ( KEY uid_local (uid_local), KEY uid_foreign (uid_foreign) ); + +CREATE TABLE tx_functionaltest_domain_model_article ( + title varchar(255) DEFAULT '' NOT NULL +); diff --git a/Tests/Functional/Fixtures/articles.csv b/Tests/Functional/Fixtures/articles.csv new file mode 100644 index 00000000..2a75c11a --- /dev/null +++ b/Tests/Functional/Fixtures/articles.csv @@ -0,0 +1,3 @@ +"tx_functionaltest_domain_model_article",,,,, +,"uid","pid","title","crdate","tstamp" +,1,0,"Article without creation date",0,1577836800 diff --git a/Tests/Postman/t3apinews.crud.json b/Tests/Postman/t3apinews.crud.json index 5df8bd93..50e0c872 100644 --- a/Tests/Postman/t3apinews.crud.json +++ b/Tests/Postman/t3apinews.crud.json @@ -171,6 +171,8 @@ " }", ");", "", + "pm.environment.set('newsCrdate', response.crdate);", + "pm.environment.set('newsTstamp', response.tstamp);", "pm.environment.set('newsUid', response.uid);", "pm.environment.set('singleNewsEndpoint', response['@id']);", "", @@ -180,7 +182,14 @@ " pm.expect(response.pathSegment).to.eql('this-is-an-example-news');", " }", ");", - "" + "", + "pm.test(", + " 'Creation and modification date are returned',", + " function() {", + " pm.expect(response.crdate).to.be.a('string');", + " pm.expect(response.tstamp).to.be.a('string');", + " }", + ");" ], "type": "text/javascript" } @@ -198,7 +207,7 @@ ], "body": { "mode": "raw", - "raw": "{\n\t\"title\": \"This is an example news\",\n\t\"alternativeTitle\": \"Alt title\",\n\t\"crdate\": \"2019-12-27T16:58:23.063Z\",\n\t\"datetime\": \"2019-12-27T16:58:23.063Z\",\n\t\"categories\": [{{categoryUid}}],\n\t\"bodytext\": \"Here is the body\"\n}", + "raw": "{\n\t\"title\": \"This is an example news\",\n\t\"alternativeTitle\": \"Alt title\",\n\t\"datetime\": \"2019-12-27T16:58:23.063Z\",\n\t\"categories\": [{{categoryUid}}],\n\t\"bodytext\": \"Here is the body\"\n}", "options": { "raw": { "language": "json" @@ -257,6 +266,20 @@ " function() {", " pm.expect(response.categories[0]['@id']).to.eql(pm.environment.get('singleCategoryEndpoint'));", " }", + ");", + "", + "pm.test(", + " 'Creation date is same as in response of write operation',", + " function() {", + " pm.expect(response.crdate).to.eql(pm.environment.get('newsCrdate'));", + " }", + ");", + "", + "pm.test(", + " 'Modification date is same as in response of write operation',", + " function() {", + " pm.expect(response.tstamp).to.eql(pm.environment.get('newsTstamp'));", + " }", ");" ], "type": "text/javascript" @@ -440,7 +463,8 @@ " pm.response.to.have.status(200);", " }", ");", - "" + "", + "pm.environment.set('newsTstamp', pm.response.json().tstamp);" ], "type": "text/javascript" } @@ -512,7 +536,20 @@ " pm.expect(response.pathSegment).to.eql('this-is-an-example-news');", " }", ");", - "" + "", + "pm.test(", + " 'Creation date is same as in response of write operation',", + " function() {", + " pm.expect(response.crdate).to.eql(pm.environment.get('newsCrdate'));", + " }", + ");", + "", + "pm.test(", + " 'Modification date is same as in response of write operation',", + " function() {", + " pm.expect(response.tstamp).to.eql(pm.environment.get('newsTstamp'));", + " }", + ");" ], "type": "text/javascript" } @@ -551,7 +588,10 @@ " pm.response.to.have.status(200);", " }", ");", - "" + "", + "const response = pm.response.json();", + "pm.environment.set('newsCrdate', response.crdate);", + "pm.environment.set('newsTstamp', response.tstamp);" ], "type": "text/javascript" } @@ -623,7 +663,20 @@ " pm.expect(response.pathSegment).to.eql('this-is-even-new-title');", " }", ");", - "" + "", + "pm.test(", + " 'Creation date is same as in response of write operation',", + " function() {", + " pm.expect(response.crdate).to.eql(pm.environment.get('newsCrdate'));", + " }", + ");", + "", + "pm.test(", + " 'Modification date is same as in response of write operation',", + " function() {", + " pm.expect(response.tstamp).to.eql(pm.environment.get('newsTstamp'));", + " }", + ");" ], "type": "text/javascript" } diff --git a/phpstan.neon b/phpstan.neon index d19ef13a..2e8f1b3d 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -32,3 +32,13 @@ parameters: - message: "#Call to method getRequest\\(\\) on an unknown class TYPO3\\\\CMS\\\\Frontend\\\\Event\\\\BeforePageCacheIdentifierIsHashedEvent#" reportUnmatched: false + + # @todo This ignoreError can be removed when support for TYPO3 v12 is dropped + - + message: "#Call to an undefined method TYPO3\\\\CMS\\\\Extbase\\\\Persistence\\\\Generic\\\\Mapper\\\\ColumnMap::(getDateTimeFormat|isNullable)\\(\\)#" + reportUnmatched: false + + # @todo This ignoreError can be removed when support for TYPO3 v12 is dropped + - + message: "#on an unknown class TYPO3\\\\CMS\\\\Core\\\\Domain\\\\DateTimeFactory#" + reportUnmatched: false