1<?php
2declare(strict_types = 1);
3
4namespace TYPO3\CMS\Core\Database\Schema\Parser;
5
6/*
7 * This file is part of the TYPO3 CMS project.
8 *
9 * It is free software; you can redistribute it and/or modify it under
10 * the terms of the GNU General Public License, either version 2
11 * of the License, or any later version.
12 *
13 * For the full copyright and license information, please read the
14 * LICENSE.txt file that was distributed with this source code.
15 *
16 * The TYPO3 project - inspiring people to share!
17 */
18
19use Doctrine\DBAL\Platforms\AbstractPlatform;
20use Doctrine\DBAL\Platforms\MySqlPlatform;
21use Doctrine\DBAL\Schema\Column;
22use Doctrine\DBAL\Schema\Index;
23use Doctrine\DBAL\Schema\Table;
24use Doctrine\DBAL\Types\Type;
25use Doctrine\DBAL\Types\Types;
26use TYPO3\CMS\Core\Database\ConnectionPool;
27use TYPO3\CMS\Core\Database\Schema\Parser\AST\CreateColumnDefinitionItem;
28use TYPO3\CMS\Core\Database\Schema\Parser\AST\CreateForeignKeyDefinitionItem;
29use TYPO3\CMS\Core\Database\Schema\Parser\AST\CreateIndexDefinitionItem;
30use TYPO3\CMS\Core\Database\Schema\Parser\AST\CreateTableStatement;
31use TYPO3\CMS\Core\Database\Schema\Parser\AST\DataType;
32use TYPO3\CMS\Core\Database\Schema\Parser\AST\IndexColumnName;
33use TYPO3\CMS\Core\Database\Schema\Parser\AST\ReferenceDefinition;
34use TYPO3\CMS\Core\Database\Schema\Types\EnumType;
35use TYPO3\CMS\Core\Database\Schema\Types\SetType;
36use TYPO3\CMS\Core\Utility\GeneralUtility;
37
38/**
39 * Converts a CreateTableStatement syntax node into a Doctrine Table
40 * object that represents the table defined in the original SQL statement.
41 */
42class TableBuilder
43{
44    /**
45     * @var Table
46     */
47    protected $table;
48
49    /**
50     * @var AbstractPlatform
51     */
52    protected $platform;
53
54    /**
55     * TableBuilder constructor.
56     *
57     * @param \Doctrine\DBAL\Platforms\AbstractPlatform $platform
58     * @throws \InvalidArgumentException
59     * @throws \Doctrine\DBAL\DBALException
60     */
61    public function __construct(AbstractPlatform $platform = null)
62    {
63        // Register custom data types as no connection might have
64        // been established yet so the types would not be available
65        // when building tables/columns.
66        $connectionPool = GeneralUtility::makeInstance(ConnectionPool::class);
67
68        foreach ($connectionPool->getCustomDoctrineTypes() as $type => $className) {
69            if (!Type::hasType($type)) {
70                Type::addType($type, $className);
71            }
72        }
73        $this->platform = $platform ?: GeneralUtility::makeInstance(MySqlPlatform::class);
74    }
75
76    /**
77     * Create a Doctrine Table object based on the parsed MySQL SQL command.
78     *
79     * @param \TYPO3\CMS\Core\Database\Schema\Parser\AST\CreateTableStatement $tableStatement
80     * @return \Doctrine\DBAL\Schema\Table
81     * @throws \Doctrine\DBAL\Schema\SchemaException
82     * @throws \RuntimeException
83     * @throws \InvalidArgumentException
84     */
85    public function create(CreateTableStatement $tableStatement): Table
86    {
87        $this->table = GeneralUtility::makeInstance(
88            Table::class,
89            $tableStatement->tableName->getQuotedName(),
90            [],
91            [],
92            [],
93            0,
94            $this->buildTableOptions($tableStatement->tableOptions)
95        );
96
97        foreach ($tableStatement->createDefinition->items as $item) {
98            switch (get_class($item)) {
99                case CreateColumnDefinitionItem::class:
100                    $this->addColumn($item);
101                    break;
102                case CreateIndexDefinitionItem::class:
103                    $this->addIndex($item);
104                    break;
105                case CreateForeignKeyDefinitionItem::class:
106                    $this->addForeignKey($item);
107                    break;
108                default:
109                    throw new \RuntimeException(
110                        'Unknown item definition of type "' . get_class($item) . '" encountered.',
111                        1472044085
112                    );
113            }
114        }
115
116        return $this->table;
117    }
118
119    /**
120     * @param \TYPO3\CMS\Core\Database\Schema\Parser\AST\CreateColumnDefinitionItem $item
121     * @return \Doctrine\DBAL\Schema\Column
122     * @throws \Doctrine\DBAL\Schema\SchemaException
123     * @throws \RuntimeException
124     */
125    protected function addColumn(CreateColumnDefinitionItem $item): Column
126    {
127        $column = $this->table->addColumn(
128            $item->columnName->getQuotedName(),
129            $this->getDoctrineColumnTypeName($item->dataType)
130        );
131
132        $column->setNotnull(!$item->allowNull);
133        $column->setAutoincrement((bool)$item->autoIncrement);
134        $column->setComment($item->comment);
135
136        // Set default value (unless it's an auto increment column)
137        if ($item->hasDefaultValue && !$column->getAutoincrement()) {
138            $column->setDefault($item->defaultValue);
139        }
140
141        if ($item->dataType->getLength()) {
142            $column->setLength($item->dataType->getLength());
143        }
144
145        if ($item->dataType->getPrecision() >= 0) {
146            $column->setPrecision($item->dataType->getPrecision());
147        }
148
149        if ($item->dataType->getScale() >= 0) {
150            $column->setScale($item->dataType->getScale());
151        }
152
153        if ($item->dataType->isUnsigned()) {
154            $column->setUnsigned(true);
155        }
156
157        // Select CHAR/VARCHAR or BINARY/VARBINARY
158        if ($item->dataType->isFixed()) {
159            $column->setFixed(true);
160        }
161
162        if ($item->dataType instanceof DataType\EnumDataType
163            || $item->dataType instanceof DataType\SetDataType
164        ) {
165            $column->setPlatformOption('unquotedValues', $item->dataType->getValues());
166        }
167
168        if ($item->index) {
169            $this->table->addIndex([$item->columnName->getQuotedName()]);
170        }
171
172        if ($item->unique) {
173            $this->table->addUniqueIndex([$item->columnName->getQuotedName()]);
174        }
175
176        if ($item->primary) {
177            $this->table->setPrimaryKey([$item->columnName->getQuotedName()]);
178        }
179
180        if ($item->reference !== null) {
181            $this->addForeignKeyConstraint(
182                [$item->columnName->getQuotedName()],
183                $item->reference
184            );
185        }
186
187        return $column;
188    }
189
190    /**
191     * @param \TYPO3\CMS\Core\Database\Schema\Parser\AST\CreateIndexDefinitionItem $item
192     * @return \Doctrine\DBAL\Schema\Index
193     * @throws \Doctrine\DBAL\Schema\SchemaException
194     * @throws \InvalidArgumentException
195     */
196    protected function addIndex(CreateIndexDefinitionItem $item): Index
197    {
198        $indexName = $item->indexName->getQuotedName();
199
200        $columnNames = array_map(
201            function (IndexColumnName $columnName) {
202                if ($columnName->length) {
203                    return $columnName->columnName->getQuotedName() . '(' . $columnName->length . ')';
204                }
205                return $columnName->columnName->getQuotedName();
206            },
207            $item->columnNames
208        );
209
210        if ($item->isPrimary) {
211            $this->table->setPrimaryKey($columnNames);
212            $index = $this->table->getPrimaryKey();
213        } else {
214            $index = GeneralUtility::makeInstance(
215                Index::class,
216                $indexName,
217                $columnNames,
218                $item->isUnique,
219                $item->isPrimary
220            );
221
222            if ($item->isFulltext) {
223                $index->addFlag('fulltext');
224            } elseif ($item->isSpatial) {
225                $index->addFlag('spatial');
226            }
227
228            $this->table = GeneralUtility::makeInstance(
229                Table::class,
230                $this->table->getQuotedName($this->platform),
231                $this->table->getColumns(),
232                array_merge($this->table->getIndexes(), [strtolower($indexName) => $index]),
233                $this->table->getForeignKeys(),
234                0,
235                $this->table->getOptions()
236            );
237        }
238
239        return $index;
240    }
241
242    /**
243     * Prepare a explicit foreign key definition item to be added to the table being built.
244     *
245     * @param \TYPO3\CMS\Core\Database\Schema\Parser\AST\CreateForeignKeyDefinitionItem $item
246     */
247    protected function addForeignKey(CreateForeignKeyDefinitionItem $item)
248    {
249        $indexName = $item->indexName->getQuotedName() ?: null;
250        $localColumnNames = array_map(
251            function (IndexColumnName $columnName) {
252                return $columnName->columnName->getQuotedName();
253            },
254            $item->columnNames
255        );
256        $this->addForeignKeyConstraint($localColumnNames, $item->reference, $indexName);
257    }
258
259    /**
260     * Add a foreign key constraint to the table being built.
261     *
262     * @param string[] $localColumnNames
263     * @param \TYPO3\CMS\Core\Database\Schema\Parser\AST\ReferenceDefinition $referenceDefinition
264     * @param string $indexName
265     */
266    protected function addForeignKeyConstraint(
267        array $localColumnNames,
268        ReferenceDefinition $referenceDefinition,
269        string $indexName = null
270    ) {
271        $foreignTableName = $referenceDefinition->tableName->getQuotedName();
272        $foreignColumNames = array_map(
273            function (IndexColumnName $columnName) {
274                return $columnName->columnName->getQuotedName();
275            },
276            $referenceDefinition->columnNames
277        );
278
279        $options = [
280            'onDelete' => $referenceDefinition->onDelete,
281            'onUpdate' => $referenceDefinition->onUpdate,
282        ];
283
284        $this->table->addForeignKeyConstraint(
285            $foreignTableName,
286            $localColumnNames,
287            $foreignColumNames,
288            $options,
289            $indexName
290        );
291    }
292
293    /**
294     * @param \TYPO3\CMS\Core\Database\Schema\Parser\AST\DataType\AbstractDataType $dataType
295     * @return string
296     * @throws \RuntimeException
297     */
298    protected function getDoctrineColumnTypeName(DataType\AbstractDataType $dataType): string
299    {
300        $doctrineType = null;
301        switch (get_class($dataType)) {
302            case DataType\TinyIntDataType::class:
303                // TINYINT is MySQL specific and mapped to a standard SMALLINT
304            case DataType\SmallIntDataType::class:
305                $doctrineType = Types::SMALLINT;
306                break;
307            case DataType\MediumIntDataType::class:
308                // MEDIUMINT is MySQL specific and mapped to a standard INT
309            case DataType\IntegerDataType::class:
310                $doctrineType = Types::INTEGER;
311                break;
312            case DataType\BigIntDataType::class:
313                $doctrineType = Types::BIGINT;
314                break;
315            case DataType\BinaryDataType::class:
316            case DataType\VarBinaryDataType::class:
317                // CHAR/VARCHAR is determined by "fixed" column property
318                $doctrineType = Types::BINARY;
319                break;
320            case DataType\TinyBlobDataType::class:
321            case DataType\MediumBlobDataType::class:
322            case DataType\BlobDataType::class:
323            case DataType\LongBlobDataType::class:
324                // Actual field type is determined by field length
325                $doctrineType = Types::BLOB;
326                break;
327            case DataType\DateDataType::class:
328                $doctrineType = Types::DATE_MUTABLE;
329                break;
330            case DataType\TimestampDataType::class:
331            case DataType\DateTimeDataType::class:
332                // TIMESTAMP or DATETIME are determined by "version" column property
333                $doctrineType = Types::DATETIME_MUTABLE;
334                break;
335            case DataType\NumericDataType::class:
336            case DataType\DecimalDataType::class:
337                $doctrineType = Types::DECIMAL;
338                break;
339            case DataType\RealDataType::class:
340            case DataType\FloatDataType::class:
341            case DataType\DoubleDataType::class:
342                $doctrineType = Types::FLOAT;
343                break;
344            case DataType\TimeDataType::class:
345                $doctrineType = Types::TIME_MUTABLE;
346                break;
347            case DataType\TinyTextDataType::class:
348            case DataType\MediumTextDataType::class:
349            case DataType\TextDataType::class:
350            case DataType\LongTextDataType::class:
351                $doctrineType = Types::TEXT;
352                break;
353            case DataType\CharDataType::class:
354            case DataType\VarCharDataType::class:
355                $doctrineType = Types::STRING;
356                break;
357            case DataType\EnumDataType::class:
358                $doctrineType = EnumType::TYPE;
359                break;
360            case DataType\SetDataType::class:
361                $doctrineType = SetType::TYPE;
362                break;
363            case DataType\JsonDataType::class:
364                // JSON is not supported in Doctrine 2.5, mapping to the more generic TEXT type
365                $doctrineType = Types::TEXT;
366                break;
367            case DataType\YearDataType::class:
368                // The YEAR data type is MySQL specific and offers little to no benefit.
369                // The two-digit year logic implemented in this data type (1-69 mapped to
370                // 2001-2069, 70-99 mapped to 1970-1999) can be easily implemented in the
371                // application and for all other accounts it's an integer with a valid
372                // range of 1901 to 2155.
373                // Using a SMALLINT covers the value range and ensures database compatibility.
374                $doctrineType = Types::SMALLINT;
375                break;
376            default:
377                throw new \RuntimeException(
378                    'Unsupported data type: ' . get_class($dataType) . '!',
379                    1472046376
380                );
381        }
382
383        return $doctrineType;
384    }
385
386    /**
387     * Build the table specific options as far as they are supported by Doctrine.
388     *
389     * @param array $tableOptions
390     * @return array
391     */
392    protected function buildTableOptions(array $tableOptions): array
393    {
394        $options = [];
395
396        if (!empty($tableOptions['engine'])) {
397            $options['engine'] = (string)$tableOptions['engine'];
398        }
399        if (!empty($tableOptions['character_set'])) {
400            $options['charset'] = (string)$tableOptions['character_set'];
401        }
402        if (!empty($tableOptions['collation'])) {
403            $options['collate'] = (string)$tableOptions['collation'];
404        }
405        if (!empty($tableOptions['auto_increment'])) {
406            $options['auto_increment'] = (string)$tableOptions['auto_increment'];
407        }
408        if (!empty($tableOptions['comment'])) {
409            $options['comment'] = (string)$tableOptions['comment'];
410        }
411        if (!empty($tableOptions['row_format'])) {
412            $options['row_format'] = (string)$tableOptions['row_format'];
413        }
414
415        return $options;
416    }
417}
418