Commit da4f3421 authored by Oliver Hader's avatar Oliver Hader Committed by Oliver Hader
Browse files

[TASK] Introduce CorrelationId model

Resolves: #89298
Releases: master
Change-Id: Icb2d406d8ba3759c8f999966fc68b8e31b046c01
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/61855


Tested-by: default avatarTYPO3com <noreply@typo3.com>
Tested-by: Frank Nägler's avatarFrank Nägler <frank.naegler@typo3.org>
Tested-by: Oliver Hader's avatarOliver Hader <oliver.hader@typo3.org>
Reviewed-by: Frank Nägler's avatarFrank Nägler <frank.naegler@typo3.org>
Reviewed-by: Oliver Hader's avatarOliver Hader <oliver.hader@typo3.org>
parent 72a9cca0
......@@ -17,6 +17,7 @@ namespace TYPO3\CMS\Backend\Tests\Functional\History;
*/
use TYPO3\CMS\Core\DataHandling\History\RecordHistoryStore;
use TYPO3\CMS\Core\DataHandling\Model\CorrelationId;
use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase;
class RecordHistoryStoreTest extends FunctionalTestCase
......@@ -35,13 +36,13 @@ class RecordHistoryStoreTest extends FunctionalTestCase
$this->subject = new RecordHistoryStore();
}
protected function getRecordCountByCorrelationId(string $correlationId): int
protected function getRecordCountByCorrelationId(CorrelationId $correlationId): int
{
$queryBuilder = $this->getConnectionPool()->getQueryBuilderForTable('sys_history');
return (int)$queryBuilder
->count('uid')
->from('sys_history')
->where($queryBuilder->expr()->eq('correlation_id', $queryBuilder->createNamedParameter($correlationId)))
->where($queryBuilder->expr()->eq('correlation_id', $queryBuilder->createNamedParameter((string)$correlationId)))
->execute()
->fetchColumn(0);
}
......@@ -51,7 +52,7 @@ class RecordHistoryStoreTest extends FunctionalTestCase
*/
public function addRecordAddsARecordToTheDatabase(): void
{
$correlationId = '092a640c-bd8c-490d-b993-ed4bcef1a1f2';
$correlationId = CorrelationId::forSubject('092a640c-bd8c-490d-b993-ed4bcef1a1f2');
$this->assertSame(0, $this->getRecordCountByCorrelationId($correlationId));
$this->subject->addRecord('foo', 1, [], $correlationId);
$this->assertSame(1, $this->getRecordCountByCorrelationId($correlationId));
......@@ -62,7 +63,7 @@ class RecordHistoryStoreTest extends FunctionalTestCase
*/
public function modifyRecordAddsARecordToTheDatabase(): void
{
$correlationId = '058f117c-5e21-4222-b308-085fc1113604';
$correlationId = CorrelationId::forSubject('058f117c-5e21-4222-b308-085fc1113604');
$this->assertSame(0, $this->getRecordCountByCorrelationId($correlationId));
$this->subject->modifyRecord('foo', 1, [], $correlationId);
$this->assertSame(1, $this->getRecordCountByCorrelationId($correlationId));
......@@ -73,7 +74,7 @@ class RecordHistoryStoreTest extends FunctionalTestCase
*/
public function deleteRecordAddsARecordToTheDatabase(): void
{
$correlationId = 'e1a2ea91-fe2f-4a01-b50b-5c2924a27568';
$correlationId = CorrelationId::forSubject('e1a2ea91-fe2f-4a01-b50b-5c2924a27568');
$this->assertSame(0, $this->getRecordCountByCorrelationId($correlationId));
$this->subject->deleteRecord('foo', 1, $correlationId);
$this->assertSame(1, $this->getRecordCountByCorrelationId($correlationId));
......@@ -84,7 +85,7 @@ class RecordHistoryStoreTest extends FunctionalTestCase
*/
public function undeleteRecordAddsARecordToTheDatabase(): void
{
$correlationId = 'ab902256-56f2-43bd-b857-f7a0b974e9db';
$correlationId = CorrelationId::forSubject('ab902256-56f2-43bd-b857-f7a0b974e9db');
$this->assertSame(0, $this->getRecordCountByCorrelationId($correlationId));
$this->subject->undeleteRecord('foo', 1, $correlationId);
$this->assertSame(1, $this->getRecordCountByCorrelationId($correlationId));
......@@ -95,7 +96,7 @@ class RecordHistoryStoreTest extends FunctionalTestCase
*/
public function moveRecordAddsARecordToTheDatabase(): void
{
$correlationId = '9d806d3a-1d7a-4e62-816f-9fa1a1b3fe5b';
$correlationId = CorrelationId::forSubject('9d806d3a-1d7a-4e62-816f-9fa1a1b3fe5b');
$this->assertSame(0, $this->getRecordCountByCorrelationId($correlationId));
$this->subject->moveRecord('foo', 1, [], $correlationId);
$this->assertSame(1, $this->getRecordCountByCorrelationId($correlationId));
......
......@@ -45,6 +45,7 @@ use TYPO3\CMS\Core\Database\ReferenceIndex;
use TYPO3\CMS\Core\Database\RelationHandler;
use TYPO3\CMS\Core\DataHandling\History\RecordHistoryStore;
use TYPO3\CMS\Core\DataHandling\Localization\DataMapProcessor;
use TYPO3\CMS\Core\DataHandling\Model\CorrelationId;
use TYPO3\CMS\Core\DataHandling\Model\RecordStateFactory;
use TYPO3\CMS\Core\Html\RteHtmlParser;
use TYPO3\CMS\Core\Localization\LanguageService;
......@@ -248,7 +249,8 @@ class DataHandler implements LoggerAwareInterface
/**
* A string which can be used as correlationId for RecordHistory entries.
* The string can later be used to rollback multiple changes at once.
* @var string
*
* @var CorrelationId|null
*/
protected $correlationId;
......@@ -655,6 +657,11 @@ class DataHandler implements LoggerAwareInterface
$this->deleteTree = 1;
}
// set correlation id for each new set of data or commands
$this->correlationId = CorrelationId::forScope(
md5(StringUtility::getUniqueId(self::class))
);
// Get default values from user TSconfig
$tcaDefaultOverride = $this->BE_USER->getTSConfig()['TCAdefaults.'] ?? null;
if (is_array($tcaDefaultOverride)) {
......@@ -8522,11 +8529,22 @@ class DataHandler implements LoggerAwareInterface
}
}
public function setCorrelationId(string $correlationId): void
/**
* @param CorrelationId $correlationId
*/
public function setCorrelationId(CorrelationId $correlationId): void
{
$this->correlationId = $correlationId;
}
/**
* @return CorrelationId|null
*/
public function getCorrelationId(): ?CorrelationId
{
return $this->correlationId;
}
/**
* Entry point to post process a database insert. Currently bails early unless a UID has been forced
* and the database platform is not MySQL.
......
......@@ -17,6 +17,7 @@ namespace TYPO3\CMS\Core\DataHandling\History;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\DataHandling\Model\CorrelationId;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
......@@ -84,7 +85,7 @@ class RecordHistoryStore
* @param string|null $correlationId
* @return string
*/
public function addRecord(string $table, int $uid, array $payload, string $correlationId = null): string
public function addRecord(string $table, int $uid, array $payload, CorrelationId $correlationId = null): string
{
$data = [
'actiontype' => self::ACTION_ADD,
......@@ -96,10 +97,8 @@ class RecordHistoryStore
'tstamp' => $this->tstamp,
'history_data' => json_encode($payload),
'workspace' => $this->workspaceId,
'correlation_id' => (string)$this->createCorrelationId($table, $uid, $correlationId),
];
if ($correlationId !== null) {
$data['correlation_id'] = $correlationId;
}
$this->getDatabaseConnection()->insert('sys_history', $data);
return $this->getDatabaseConnection()->lastInsertId('sys_history');
}
......@@ -111,7 +110,7 @@ class RecordHistoryStore
* @param string|null $correlationId
* @return string
*/
public function modifyRecord(string $table, int $uid, array $payload, string $correlationId = null): string
public function modifyRecord(string $table, int $uid, array $payload, CorrelationId $correlationId = null): string
{
$data = [
'actiontype' => self::ACTION_MODIFY,
......@@ -123,10 +122,8 @@ class RecordHistoryStore
'tstamp' => $this->tstamp,
'history_data' => json_encode($payload),
'workspace' => $this->workspaceId,
'correlation_id' => (string)$this->createCorrelationId($table, $uid, $correlationId),
];
if ($correlationId !== null) {
$data['correlation_id'] = $correlationId;
}
$this->getDatabaseConnection()->insert('sys_history', $data);
return $this->getDatabaseConnection()->lastInsertId('sys_history');
}
......@@ -137,7 +134,7 @@ class RecordHistoryStore
* @param string|null $correlationId
* @return string
*/
public function deleteRecord(string $table, int $uid, string $correlationId = null): string
public function deleteRecord(string $table, int $uid, CorrelationId $correlationId = null): string
{
$data = [
'actiontype' => self::ACTION_DELETE,
......@@ -148,10 +145,8 @@ class RecordHistoryStore
'recuid' => $uid,
'tstamp' => $this->tstamp,
'workspace' => $this->workspaceId,
'correlation_id' => (string)$this->createCorrelationId($table, $uid, $correlationId),
];
if ($correlationId !== null) {
$data['correlation_id'] = $correlationId;
}
$this->getDatabaseConnection()->insert('sys_history', $data);
return $this->getDatabaseConnection()->lastInsertId('sys_history');
}
......@@ -162,7 +157,7 @@ class RecordHistoryStore
* @param string|null $correlationId
* @return string
*/
public function undeleteRecord(string $table, int $uid, string $correlationId = null): string
public function undeleteRecord(string $table, int $uid, CorrelationId $correlationId = null): string
{
$data = [
'actiontype' => self::ACTION_UNDELETE,
......@@ -173,10 +168,8 @@ class RecordHistoryStore
'recuid' => $uid,
'tstamp' => $this->tstamp,
'workspace' => $this->workspaceId,
'correlation_id' => (string)$this->createCorrelationId($table, $uid, $correlationId),
];
if ($correlationId !== null) {
$data['correlation_id'] = $correlationId;
}
$this->getDatabaseConnection()->insert('sys_history', $data);
return $this->getDatabaseConnection()->lastInsertId('sys_history');
}
......@@ -188,7 +181,7 @@ class RecordHistoryStore
* @param string|null $correlationId
* @return string
*/
public function moveRecord(string $table, int $uid, array $payload, string $correlationId = null): string
public function moveRecord(string $table, int $uid, array $payload, CorrelationId $correlationId = null): string
{
$data = [
'actiontype' => self::ACTION_MOVE,
......@@ -200,14 +193,21 @@ class RecordHistoryStore
'tstamp' => $this->tstamp,
'history_data' => json_encode($payload),
'workspace' => $this->workspaceId,
'correlation_id' => (string)$this->createCorrelationId($table, $uid, $correlationId),
];
if ($correlationId !== null) {
$data['correlation_id'] = $correlationId;
}
$this->getDatabaseConnection()->insert('sys_history', $data);
return $this->getDatabaseConnection()->lastInsertId('sys_history');
}
protected function createCorrelationId(string $tableName, int $uid, ?CorrelationId $correlationId): CorrelationId
{
if ($correlationId !== null && $correlationId->getSubject() !== null) {
return $correlationId;
}
$subject = md5($tableName . ':' . $uid);
return $correlationId !== null ? $correlationId->withSubject($subject) : CorrelationId::forSubject($subject);
}
/**
* @return Connection
*/
......
<?php
declare(strict_types = 1);
namespace TYPO3\CMS\Core\DataHandling\Model;
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* CorrelationId representation
*
* @todo Check internal state during v10 development
* @internal
*/
class CorrelationId implements \JsonSerializable
{
protected const DEFAULT_VERSION = 1;
protected const PATTERN_V1 = '#^(?P<flags>[[:xdigit:]]{4})\$(?:(?P<scope>[[:alnum:]]+):)?(?P<subject>[[:alnum:]]+)(?P<aspects>(?:\/[[:alnum:]._-]+)*)$#';
/**
* @var int
*/
protected $version = self::DEFAULT_VERSION;
/**
* @var string
*/
protected $scope;
/**
* @var int
*/
protected $capabilities = 0;
/**
* @var string
*/
protected $subject;
/**
* @var string[]
*/
protected $aspects = [];
/**
* @param string $scope
* @return static
*/
public static function forScope(string $scope): self
{
$target = static::create();
$target->scope = $scope;
return $target;
}
public static function forSubject(string $subject, string ...$aspects): self
{
return static::create()
->withSubject($subject)
->withAspects(...$aspects);
}
/**
* @param string $correlationId
* @return static
*/
public static function fromString(string $correlationId): self
{
if (!preg_match(self::PATTERN_V1, $correlationId, $matches, PREG_UNMATCHED_AS_NULL)) {
throw new \InvalidArgumentException('Unknown format', 1569620858);
}
$flags = (int)unpack('n', $matches['flags']);
$aspects = !empty($matches['aspects']) ? explode('/', ltrim($matches['aspects'] ?? '', '/')) : [];
$target = static::create()
->withSubject($matches['subject'])
->withAspects(...$aspects);
$target->scope = $matches['scope'] ?? null;
$target->version = $flags >> 10;
$target->capabilities = $flags & ((1 << 10) - 1);
return $target;
}
/**
* @return static
*/
protected static function create(): self
{
return GeneralUtility::makeInstance(static::class);
}
public function __toString(): string
{
if ($this->subject === null) {
throw new \LogicException('Cannot serialize for empty subject', 1569668681);
}
return $this->serialize();
}
public function jsonSerialize(): string
{
return (string)$this;
}
public function withSubject(string $subject): self
{
if ($this->subject === $subject) {
return $this;
}
$target = clone $this;
$target->subject = $subject;
return $target;
}
public function withAspects(string ...$aspects): self
{
if ($this->aspects === $aspects) {
return $this;
}
$target = clone $this;
$target->aspects = $aspects;
return $target;
}
/**
* @return string|null
*/
public function getScope(): ?string
{
return $this->scope;
}
/**
* @return string|null
*/
public function getSubject(): ?string
{
return $this->subject;
}
/**
* @return string[]
*/
public function getAspects(): array
{
return $this->aspects;
}
/**
* v1 specs (eBNF)
* + FLAGS "$" [ SCOPE ":" ] SUBJECT { "/" ASPECT }
* + FLAGS ::= XDIGIT (* 16-bit integer big-endian)
* + SCOPE ::= ALNUM { ALNUM }
* + SUBJECT ::= ALNUM { ALNUM }
* + ASPECT ::= ( ALNUM | '.' | '_' | '-' ) { ( ALNUM | '.' | '_' | '-' ) }
*/
protected function serialize(): string
{
// 6-bit version 10-bit capabilities
$flags = $this->version << 10 + $this->capabilities;
return sprintf(
'%s$%s%s%s',
bin2hex(pack('n', $flags)),
$this->scope ? $this->scope . ':' : '',
$this->subject,
$this->aspects ? '/' . implode('/', $this->aspects) : ''
);
}
}
......@@ -15,7 +15,9 @@ of the DataHandler instance.
.. code-block:: php
$correlationId = StringUtility::getUniqueId('slug_');
$correlationId = CorrelationId::forSubject(
StringUtility::getUniqueId('slug_')
);
$data['pages'][$uid]['slug'] = $newSlug;
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->setCorrelationId($correlationId);
......@@ -24,4 +26,11 @@ of the DataHandler instance.
After this DataHandler operation the created RecordHistory entry contains the $correlationId.
:php:`CorrelationId` model requires mandatory :php:`$subject` and allows optional :php:`$aspects` which
can be serialized into string like e.g. `0400$12ae0b042a5d75e3f2744f4b3faf8068/5d8e6e70/slug`
* `0400$` is a flag prefix containing an internal version number for possible schema validations
* `12ae0b042a5d75e3f2744f4b3faf8068` is a unique subject
* `/5d8e6e70/slug` are aspects, separated by slashes
.. index:: Backend, Database, PHP-API, ext:core
<?php
declare(strict_types = 1);
namespace TYPO3\CMS\Core\Tests\Unit\DataHandling\Model;
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
use TYPO3\CMS\Core\DataHandling\Model\CorrelationId;
use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
/**
* Test case
*/
class CorrelationIdTest extends UnitTestCase
{
public function canBeParsedDataProvider(): array
{
return [
[
'0400$subject',
['scope' => null, 'subject' => 'subject', 'aspects' => []],
],
[
'0400$scope:subject',
['scope' => 'scope', 'subject' => 'subject', 'aspects' => []],
],
[
'0400$scope:subject/aspect-a',
['scope' => 'scope', 'subject' => 'subject', 'aspects' => ['aspect-a']],
],
[
'0400$scope:subject/aspect-a/aspect-b',
['scope' => 'scope', 'subject' => 'subject', 'aspects' => ['aspect-a', 'aspect-b']],
],
];
}
/**
* @param string $string
* @param array $expectations
*
* @test
* @dataProvider canBeParsedDataProvider
*/
public function canBeParsed(string $string, array $expectations): void
{
$correlationId = CorrelationId::fromString($string);
foreach ($expectations as $propertyName => $propertyValue) {
static::assertSame(
$propertyValue,
$correlationId->{'get' . ucfirst($propertyName)}()
);
}
}
/**
* @test
*/
public function subjectIsConsidered(): void
{
$correlationId = CorrelationId::forSubject('subject')
->withAspects('aspect-a');
static::assertSame('0400$subject/aspect-a', (string)$correlationId);
}
/**
* @test
*/
public function scopeIsConsidered(): void
{
$correlationId = CorrelationId::forScope('scope')
->withSubject('subject')
->withAspects('aspect-a');
static::assertSame('0400$scope:subject/aspect-a', (string)$correlationId);
}
}
......@@ -344,7 +344,7 @@ CREATE TABLE sys_history (
tablename varchar(255) DEFAULT '' NOT NULL,
history_data mediumtext,
workspace int(11) DEFAULT '0',
correlation_id varchar(36) DEFAULT '' NOT NULL,
correlation_id varchar(255) DEFAULT '' NOT NULL,
PRIMARY KEY (uid),
KEY recordident_1 (tablename(100),recuid),
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment