|
17 | 17 | * The TYPO3 project - inspiring people to share! |
18 | 18 | */ |
19 | 19 |
|
| 20 | +use Doctrine\DBAL\Types\BooleanType; |
| 21 | +use Doctrine\DBAL\Types\DateTimeImmutableType; |
| 22 | +use Doctrine\DBAL\Types\DateTimeType; |
| 23 | +use Doctrine\DBAL\Types\DateType; |
| 24 | +use Doctrine\DBAL\Types\EnumType; |
| 25 | +use Doctrine\DBAL\Types\JsonType; |
| 26 | +use Doctrine\DBAL\Types\StringType; |
| 27 | +use Doctrine\DBAL\Types\TextType; |
| 28 | +use Doctrine\DBAL\Types\TimeImmutableType; |
| 29 | +use Doctrine\DBAL\Types\TimeType; |
| 30 | +use Doctrine\DBAL\Types\Type; |
20 | 31 | use PHPUnit\Framework\ExpectationFailedException; |
21 | 32 | use Psr\Container\ContainerInterface; |
22 | 33 | use Psr\Http\Message\ResponseInterface; |
|
29 | 40 | use TYPO3\CMS\Core\Core\Environment; |
30 | 41 | use TYPO3\CMS\Core\Database\Connection; |
31 | 42 | use TYPO3\CMS\Core\Database\ConnectionPool; |
| 43 | +use TYPO3\CMS\Core\Database\Schema\Types\DateTimeType as Typo3DateTimeType; |
| 44 | +use TYPO3\CMS\Core\Database\Schema\Types\DateType as Typo3DateType; |
| 45 | +use TYPO3\CMS\Core\Database\Schema\Types\SetType as Typo3SetType; |
| 46 | +use TYPO3\CMS\Core\Database\Schema\Types\TimeType as Typo3TimeType; |
32 | 47 | use TYPO3\CMS\Core\Http\NormalizedParams; |
33 | 48 | use TYPO3\CMS\Core\Http\ServerRequest; |
34 | 49 | use TYPO3\CMS\Core\Http\Stream; |
@@ -715,10 +730,135 @@ protected function assertCSVDataSet(string $fileName): void |
715 | 730 | } |
716 | 731 |
|
717 | 732 | if (!empty($failMessages)) { |
| 733 | + if ((bool)((int)getenv('TYPO3_TESTING_EXPORT_DATASETS_ON_FAILED_ASSERTION'))) { |
| 734 | + $exportTables = []; |
| 735 | + foreach ($dataSet->getTableNames() as $tableName) { |
| 736 | + $exportTables[$tableName] = $dataSet->getFields($tableName) ?? []; |
| 737 | + } |
| 738 | + $exportPath = preg_replace('/\.csv$/', '_actual.csv', $fileName); |
| 739 | + $this->exportCSVDataSet($exportPath, $exportTables); |
| 740 | + $failMessages[] = 'Actual database state exported to: ' . $exportPath; |
| 741 | + } |
718 | 742 | self::fail(implode(LF, $failMessages)); |
719 | 743 | } |
720 | 744 | } |
721 | 745 |
|
| 746 | + /** |
| 747 | + * Export current database state of given tables to a CSV file. |
| 748 | + * |
| 749 | + * The output format matches the CSV fixture format used by importCSVDataSet() |
| 750 | + * and assertCSVDataSet(), so exported files can be used directly as test fixtures. |
| 751 | + * |
| 752 | + * @param string $path Absolute path for the output CSV file |
| 753 | + * @param array $tables Tables to export. Supports two styles: |
| 754 | + * - Simple list: ['pages', 'tt_content'] exports all columns |
| 755 | + * - With field spec: ['pages' => ['uid', 'pid', 'title']] exports only listed columns |
| 756 | + * - Mixed: both styles in one array |
| 757 | + */ |
| 758 | + protected function exportCSVDataSet(string $path, array $tables): void |
| 759 | + { |
| 760 | + $targetDirectory = dirname($path); |
| 761 | + if (!is_dir($targetDirectory)) { |
| 762 | + throw new \RuntimeException( |
| 763 | + 'Target directory "' . $targetDirectory . '" does not exist.', |
| 764 | + 1732006381 |
| 765 | + ); |
| 766 | + } |
| 767 | + |
| 768 | + // Normalize $tables into ['tableName' => ['field1', ...] | []] structure |
| 769 | + $normalizedTables = []; |
| 770 | + foreach ($tables as $key => $value) { |
| 771 | + if (is_int($key)) { |
| 772 | + // Simple list style: ['pages', 'tt_content'] |
| 773 | + $normalizedTables[$value] = []; |
| 774 | + } else { |
| 775 | + // With field spec: ['pages' => ['uid', 'pid', 'title']] |
| 776 | + $normalizedTables[$key] = $value; |
| 777 | + } |
| 778 | + } |
| 779 | + |
| 780 | + $output = ''; |
| 781 | + $firstTable = true; |
| 782 | + foreach ($normalizedTables as $tableName => $fields) { |
| 783 | + $connection = $this->getConnectionPool()->getConnectionForTable($tableName); |
| 784 | + $tableColumns = []; |
| 785 | + foreach ($connection->createSchemaManager()->listTableColumns($tableName) as $column) { |
| 786 | + $tableColumns[$column->getName()] = $column; |
| 787 | + } |
| 788 | + $queryBuilder = $connection->createQueryBuilder(); |
| 789 | + $queryBuilder->getRestrictions()->removeAll(); |
| 790 | + $statement = $queryBuilder->select('*')->from($tableName)->executeQuery(); |
| 791 | + $records = $statement->fetchAllAssociative(); |
| 792 | + |
| 793 | + // Determine fields to export |
| 794 | + if ($fields === []) { |
| 795 | + if ($records !== []) { |
| 796 | + $fields = array_keys($records[0]); |
| 797 | + } else { |
| 798 | + // Empty table with no field spec: use schema introspection |
| 799 | + $fields = array_keys($tableColumns); |
| 800 | + } |
| 801 | + } |
| 802 | + |
| 803 | + // Sort records by uid if available, otherwise by hash |
| 804 | + if (in_array('uid', $fields, true)) { |
| 805 | + usort($records, static fn(array $a, array $b) => $a['uid'] <=> $b['uid']); |
| 806 | + } elseif (in_array('hash', $fields, true)) { |
| 807 | + usort($records, static fn(array $a, array $b) => $a['hash'] <=> $b['hash']); |
| 808 | + } |
| 809 | + |
| 810 | + if (!$firstTable) { |
| 811 | + $output .= LF; |
| 812 | + } |
| 813 | + $firstTable = false; |
| 814 | + |
| 815 | + // Table name line with trailing commas |
| 816 | + $output .= '"' . $tableName . '",' . LF; |
| 817 | + // Field names line with leading comma |
| 818 | + $output .= ',' . implode(',', $fields) . LF; |
| 819 | + // Data rows |
| 820 | + foreach ($records as $record) { |
| 821 | + $values = []; |
| 822 | + foreach ($fields as $field) { |
| 823 | + $values[] = $this->formatCsvValue($record[$field] ?? null, ($tableColumns[$field] ?? null)?->getType()); |
| 824 | + } |
| 825 | + $output .= ',' . implode(',', $values) . LF; |
| 826 | + } |
| 827 | + } |
| 828 | + |
| 829 | + file_put_contents($path, $output); |
| 830 | + } |
| 831 | + |
| 832 | + /** |
| 833 | + * Format a single value for CSV export. |
| 834 | + * |
| 835 | + * Handles NULL values, quoting of special characters, and plain string casting. |
| 836 | + */ |
| 837 | + protected function formatCsvValue(mixed $value, ?Type $columnDoctrineType = null): string |
| 838 | + { |
| 839 | + return match (true) { |
| 840 | + // Simply escape NULL value |
| 841 | + $value === null => '\\NULL', |
| 842 | + // TYPO3 custom types |
| 843 | + $columnDoctrineType instanceof Typo3DateTimeType, |
| 844 | + $columnDoctrineType instanceof Typo3DateType, |
| 845 | + $columnDoctrineType instanceof Typo3TimeType, |
| 846 | + $columnDoctrineType instanceof Typo3SetType, |
| 847 | + // Native Doctrine DBAL types |
| 848 | + $columnDoctrineType instanceof EnumType, |
| 849 | + $columnDoctrineType instanceof StringType, |
| 850 | + $columnDoctrineType instanceof TextType, |
| 851 | + $columnDoctrineType instanceof JsonType, |
| 852 | + $columnDoctrineType instanceof DateTimeType, |
| 853 | + $columnDoctrineType instanceof DateTimeImmutableType, |
| 854 | + $columnDoctrineType instanceof DateType, |
| 855 | + $columnDoctrineType instanceof TimeType, |
| 856 | + $columnDoctrineType instanceof TimeImmutableType => '"' . str_replace('"', '""', (string)$value) . '"', |
| 857 | + $columnDoctrineType instanceof BooleanType => (string)(int)($value), |
| 858 | + default => (string)$value, |
| 859 | + }; |
| 860 | + } |
| 861 | + |
722 | 862 | /** |
723 | 863 | * Check if $expectedRecord is present in $actualRecords array |
724 | 864 | * and compares if all column values from matches |
|
0 commit comments