Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Build/phpunit/UnitTests.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
displayDetailsOnTestsThatTriggerErrors="true"
displayDetailsOnTestsThatTriggerNotices="true"
displayDetailsOnTestsThatTriggerWarnings="true"
displayDetailsOnPhpunitDeprecations="true"
failOnDeprecation="true"
failOnNotice="true"
failOnRisky="true"
Expand Down
140 changes: 140 additions & 0 deletions Classes/Core/Functional/FunctionalTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,17 @@
* The TYPO3 project - inspiring people to share!
*/

use Doctrine\DBAL\Types\BooleanType;
use Doctrine\DBAL\Types\DateTimeImmutableType;
use Doctrine\DBAL\Types\DateTimeType;
use Doctrine\DBAL\Types\DateType;
use Doctrine\DBAL\Types\EnumType;
use Doctrine\DBAL\Types\JsonType;
use Doctrine\DBAL\Types\StringType;
use Doctrine\DBAL\Types\TextType;
use Doctrine\DBAL\Types\TimeImmutableType;
use Doctrine\DBAL\Types\TimeType;
use Doctrine\DBAL\Types\Type;
use PHPUnit\Framework\ExpectationFailedException;
use Psr\Container\ContainerInterface;
use Psr\Http\Message\ResponseInterface;
Expand All @@ -29,6 +40,10 @@
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Schema\Types\DateTimeType as Typo3DateTimeType;
use TYPO3\CMS\Core\Database\Schema\Types\DateType as Typo3DateType;
use TYPO3\CMS\Core\Database\Schema\Types\SetType as Typo3SetType;
use TYPO3\CMS\Core\Database\Schema\Types\TimeType as Typo3TimeType;
use TYPO3\CMS\Core\Http\NormalizedParams;
use TYPO3\CMS\Core\Http\ServerRequest;
use TYPO3\CMS\Core\Http\Stream;
Expand Down Expand Up @@ -715,10 +730,135 @@ protected function assertCSVDataSet(string $fileName): void
}

if (!empty($failMessages)) {
if ((bool)((int)getenv('TYPO3_TESTING_EXPORT_DATASETS_ON_FAILED_ASSERTION'))) {
$exportTables = [];
foreach ($dataSet->getTableNames() as $tableName) {
$exportTables[$tableName] = $dataSet->getFields($tableName) ?? [];
}
$exportPath = preg_replace('/\.csv$/', '_actual.csv', $fileName);
$this->exportCSVDataSet($exportPath, $exportTables);
$failMessages[] = 'Actual database state exported to: ' . $exportPath;
}
self::fail(implode(LF, $failMessages));
}
}

/**
* Export current database state of given tables to a CSV file.
*
* The output format matches the CSV fixture format used by importCSVDataSet()
* and assertCSVDataSet(), so exported files can be used directly as test fixtures.
*
* @param string $path Absolute path for the output CSV file
* @param array $tables Tables to export. Supports two styles:
* - Simple list: ['pages', 'tt_content'] exports all columns
* - With field spec: ['pages' => ['uid', 'pid', 'title']] exports only listed columns
* - Mixed: both styles in one array
*/
protected function exportCSVDataSet(string $path, array $tables): void
{
$targetDirectory = dirname($path);
if (!is_dir($targetDirectory)) {
throw new \RuntimeException(
'Target directory "' . $targetDirectory . '" does not exist.',
1732006381
);
}

// Normalize $tables into ['tableName' => ['field1', ...] | []] structure
$normalizedTables = [];
foreach ($tables as $key => $value) {
if (is_int($key)) {
// Simple list style: ['pages', 'tt_content']
$normalizedTables[$value] = [];
} else {
// With field spec: ['pages' => ['uid', 'pid', 'title']]
$normalizedTables[$key] = $value;
}
}

$output = '';
$firstTable = true;
foreach ($normalizedTables as $tableName => $fields) {
$connection = $this->getConnectionPool()->getConnectionForTable($tableName);
$tableColumns = [];
foreach ($connection->createSchemaManager()->listTableColumns($tableName) as $column) {
$tableColumns[$column->getName()] = $column;
}
$queryBuilder = $connection->createQueryBuilder();
$queryBuilder->getRestrictions()->removeAll();
$statement = $queryBuilder->select('*')->from($tableName)->executeQuery();
$records = $statement->fetchAllAssociative();

// Determine fields to export
if ($fields === []) {
if ($records !== []) {
$fields = array_keys($records[0]);
} else {
// Empty table with no field spec: use schema introspection
$fields = array_keys($tableColumns);
}
}

// Sort records by uid if available, otherwise by hash
if (in_array('uid', $fields, true)) {
usort($records, static fn(array $a, array $b) => $a['uid'] <=> $b['uid']);
} elseif (in_array('hash', $fields, true)) {
usort($records, static fn(array $a, array $b) => $a['hash'] <=> $b['hash']);
}

if (!$firstTable) {
$output .= LF;
}
$firstTable = false;

// Table name line with trailing commas
$output .= '"' . $tableName . '",' . LF;
// Field names line with leading comma
$output .= ',' . implode(',', $fields) . LF;
// Data rows
foreach ($records as $record) {
$values = [];
foreach ($fields as $field) {
$values[] = $this->formatCsvValue($record[$field] ?? null, ($tableColumns[$field] ?? null)?->getType());
}
$output .= ',' . implode(',', $values) . LF;
}
}

file_put_contents($path, $output);
}

/**
* Format a single value for CSV export.
*
* Handles NULL values, quoting of special characters, and plain string casting.
*/
protected function formatCsvValue(mixed $value, ?Type $columnDoctrineType = null): string
{
return match (true) {
// Simply escape NULL value
$value === null => '\\NULL',
// TYPO3 custom types
$columnDoctrineType instanceof Typo3DateTimeType,
$columnDoctrineType instanceof Typo3DateType,
$columnDoctrineType instanceof Typo3TimeType,
$columnDoctrineType instanceof Typo3SetType,
// Native Doctrine DBAL types
$columnDoctrineType instanceof EnumType,
$columnDoctrineType instanceof StringType,
$columnDoctrineType instanceof TextType,
$columnDoctrineType instanceof JsonType,
$columnDoctrineType instanceof DateTimeType,
$columnDoctrineType instanceof DateTimeImmutableType,
$columnDoctrineType instanceof DateType,
$columnDoctrineType instanceof TimeType,
$columnDoctrineType instanceof TimeImmutableType => '"' . str_replace('"', '""', (string)$value) . '"',
$columnDoctrineType instanceof BooleanType => (string)(int)($value),
default => (string)$value,
};
}

/**
* Check if $expectedRecord is present in $actualRecords array
* and compares if all column values from matches
Expand Down
8 changes: 8 additions & 0 deletions Resources/Core/Build/FunctionalTests.xml
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,13 @@
<php>
<ini name="display_errors" value="1"/>
<env name="TYPO3_CONTEXT" value="Testing"/>
<!--
Comment following line out to export actual database dataset for failed assertions
in functional tests OR set it as environment variable. Do not use provide it here
if you need to control this from the outside.

Default: 0 (false)
-->
<!-- <env name="TYPO3_TESTING_EXPORT_DATASETS_ON_FAILED_ASSERTION" value="1"/> -->
</php>
</phpunit>
Loading