phpmyadmin/phpmyadmin

View on GitHub
src/Table/ColumnsDefinition.php

Summary

Maintainability
F
3 days
Test Coverage
<?php

declare(strict_types=1);

namespace PhpMyAdmin\Table;

use PhpMyAdmin\Charsets;
use PhpMyAdmin\Config;
use PhpMyAdmin\ConfigStorage\Relation;
use PhpMyAdmin\Current;
use PhpMyAdmin\DatabaseInterface;
use PhpMyAdmin\Partitioning\Partition;
use PhpMyAdmin\Partitioning\TablePartitionDefinition;
use PhpMyAdmin\Query\Compatibility;
use PhpMyAdmin\StorageEngine;
use PhpMyAdmin\Transformations;
use PhpMyAdmin\UserPrivileges;
use PhpMyAdmin\Util;

use function array_keys;
use function array_merge;
use function bin2hex;
use function count;
use function explode;
use function in_array;
use function is_array;
use function mb_strtoupper;
use function preg_quote;
use function preg_replace;
use function rtrim;
use function str_ends_with;
use function stripcslashes;
use function substr;
use function trim;

/**
 * Displays the form used to define the structure of the table
 */
final class ColumnsDefinition
{
    public function __construct(
        private DatabaseInterface $dbi,
        private Relation $relation,
        private Transformations $transformations,
    ) {
    }

    /**
     * @param mixed[]|null $selected  Selected
     * @param int          $numFields The number of fields
     * @psalm-param list<array{
     *  Field: string,
     *  Type: string,
     *  Collation: string|null,
     *  Null:'YES'|'NO',
     *  Key: string,
     *  Default: string|null,
     *  Extra: string,
     *  Privileges: string,
     *  Comment: string
     * }>|null $fieldsMeta Fields meta
     * @psalm-param '/table/create'|'/table/add-field'|'/table/structure/save' $action
     *
     * @return array<string, mixed>
     */
    public function displayForm(
        UserPrivileges $userPrivileges,
        string $action,
        int $numFields = 0,
        array|null $selected = null,
        array|null $fieldsMeta = null,
    ): array {
        $GLOBALS['mime_map'] ??= null;

        $regenerate = false;
        $lengthValuesInputSize = 8;
        $contentCells = [];
        $formParams = ['db' => Current::$database];

        if ($action === '/table/create') {
            $formParams['reload'] = 1;
        } else {
            if ($action === '/table/add-field') {
                $formParams = array_merge($formParams, ['field_where' => $_POST['field_where'] ?? null]);
                if (isset($_POST['field_where'])) {
                    $formParams['after_field'] = (string) $_POST['after_field'];
                }
            }

            $formParams['table'] = Current::$table;
        }

        $formParams['orig_num_fields'] = $numFields;

        $formParams = array_merge(
            $formParams,
            ['orig_field_where' => $_POST['field_where'] ?? null, 'orig_after_field' => $_POST['after_field'] ?? null],
        );

        if (is_array($selected)) {
            foreach ($selected as $oFldNr => $oFldVal) {
                $formParams['selected[' . $oFldNr . ']'] = $oFldVal;
            }
        }

        $isBackup = $action !== '/table/create' && $action !== '/table/add-field';

        $relationParameters = $this->relation->getRelationParameters();

        $commentsMap = $this->relation->getComments(Current::$database, Current::$table);

        $moveColumns = [];
        if ($fieldsMeta !== null) {
            $moveColumns = $this->dbi->getTable(Current::$database, Current::$table)->getColumnsMeta();
        }

        $availableMime = [];
        $config = Config::getInstance();
        if ($relationParameters->browserTransformationFeature !== null && $config->settings['BrowseMIME']) {
            $GLOBALS['mime_map'] = $this->transformations->getMime(Current::$database, Current::$table);
            $availableMime = $this->transformations->getAvailableMimeTypes();
        }

        $mimeTypes = ['input_transformation', 'transformation'];
        foreach ($mimeTypes as $mimeType) {
            if (! isset($availableMime[$mimeType])) {
                continue;
            }

            foreach (array_keys($availableMime[$mimeType]) as $mimekey) {
                $availableMime[$mimeType . '_file_quoted'][$mimekey] = preg_quote(
                    $availableMime[$mimeType . '_file'][$mimekey],
                    '@',
                );
            }
        }

        if (isset($_POST['submit_num_fields']) || isset($_POST['submit_partition_change'])) {
            //if adding new fields, set regenerate to keep the original values
            $regenerate = true;
        }

        $foreigners = $this->relation->getForeigners(Current::$database, Current::$table, '', 'foreign');
        $childReferences = null;
        // From MySQL 5.6.6 onwards columns with foreign keys can be renamed.
        // Hence, no need to get child references
        if ($this->dbi->getVersion() < 50606) {
            $childReferences = $this->relation->getChildReferences(Current::$database, Current::$table);
        }

        /** @infection-ignore-all */
        for ($columnNumber = 0; $columnNumber < $numFields; $columnNumber++) {
            $type = '';
            $length = '';
            $columnMeta = [];
            $submitAttribute = null;
            $extractedColumnSpec = [];

            if ($regenerate) {
                $columnMeta = $this->getColumnMetaForRegeneratedFields($columnNumber);

                $length = Util::getValueByKey($_POST, ['field_length', $columnNumber], $length);
                $submitAttribute = Util::getValueByKey($_POST, ['field_attribute', $columnNumber], false);
                $commentsMap[$columnMeta['Field']] = Util::getValueByKey($_POST, ['field_comments', $columnNumber]);

                $GLOBALS['mime_map'][$columnMeta['Field']] = array_merge(
                    $GLOBALS['mime_map'][$columnMeta['Field']] ?? [],
                    [
                        'mimetype' => Util::getValueByKey($_POST, ['field_mimetype', $columnNumber]),
                        'transformation' => Util::getValueByKey(
                            $_POST,
                            ['field_transformation' , $columnNumber],
                        ),
                        'transformation_options' => Util::getValueByKey(
                            $_POST,
                            ['field_transformation_options' , $columnNumber],
                        ),
                    ],
                );
            } elseif (isset($fieldsMeta[$columnNumber])) {
                $columnMeta = $fieldsMeta[$columnNumber];
                $virtual = ['VIRTUAL', 'PERSISTENT', 'VIRTUAL GENERATED', 'STORED GENERATED'];
                if (in_array($columnMeta['Extra'], $virtual, true)) {
                    $tableObj = new Table(Current::$table, Current::$database, $this->dbi);
                    $expressions = $tableObj->getColumnGenerationExpression($columnMeta['Field']);
                    $columnMeta['Expression'] = is_array($expressions) ? $expressions[$columnMeta['Field']] : null;
                }

                $columnMetaDefault = self::decorateColumnMetaDefault(
                    $columnMeta['Type'],
                    $columnMeta['Default'],
                    $columnMeta['Null'] === 'YES',
                );
                $columnMeta = array_merge($columnMeta, $columnMetaDefault);
            }

            if (isset($columnMeta['Type'])) {
                $extractedColumnSpec = Util::extractColumnSpec($columnMeta['Type']);
                if ($extractedColumnSpec['type'] === 'bit') {
                    $columnMeta['Default'] = Util::convertBitDefaultValue($columnMeta['Default']);
                }

                $type = $extractedColumnSpec['type'];
                if ($length == '') {
                    $length = $extractedColumnSpec['spec_in_brackets'];
                }
            } else {
                // creating a column
                $columnMeta['Type'] = '';
            }

            // Variable tell if current column is bound in a foreign key constraint or not.
            // MySQL version from 5.6.6 allow renaming columns with foreign keys
            if (isset($columnMeta['Field'], $formParams['table']) && $this->dbi->getVersion() < 50606) {
                $columnMeta['column_status'] = $this->relation->checkChildForeignReferences(
                    $formParams['db'],
                    $formParams['table'],
                    $columnMeta['Field'],
                    $foreigners,
                    $childReferences,
                );
            }

            // some types, for example longtext, are reported as
            // "longtext character set latin7" when their charset and / or collation
            // differs from the ones of the corresponding database.
            // rtrim the type, for cases like "float unsigned"
            $type = rtrim(
                preg_replace('/[\s]character set[\s][\S]+/', '', $type),
            );

            /**
             * old column attributes
             */
            if ($isBackup) {
                // old column name
                if (isset($columnMeta['Field'])) {
                    $formParams['field_orig[' . $columnNumber . ']'] = $columnMeta['Field'];
                    if (isset($columnMeta['column_status']) && ! $columnMeta['column_status']['isEditable']) {
                        $formParams['field_name[' . $columnNumber . ']'] = $columnMeta['Field'];
                    }
                } else {
                    $formParams['field_orig[' . $columnNumber . ']'] = '';
                }

                // old column type
                // keep in uppercase because the new type will be in uppercase
                $formParams['field_type_orig[' . $columnNumber . ']'] = mb_strtoupper($type);
                if (isset($columnMeta['column_status']) && ! $columnMeta['column_status']['isEditable']) {
                    $formParams['field_type[' . $columnNumber . ']'] = mb_strtoupper($type);
                }

                // old column length
                $formParams['field_length_orig[' . $columnNumber . ']'] = $length;

                // old column default
                $formParams = array_merge(
                    $formParams,
                    [
                        'field_default_value_orig[' . $columnNumber . ']' => $columnMeta['Default'] ?? '',
                        'field_default_type_orig[' . $columnNumber . ']' => $columnMeta['DefaultType'] ?? '',
                        'field_collation_orig[' . $columnNumber . ']' => $columnMeta['Collation'] ?? '',
                        'field_attribute_orig[' . $columnNumber . ']' => trim($extractedColumnSpec['attribute'] ?? ''),
                        'field_null_orig[' . $columnNumber . ']' => $columnMeta['Null'] ?? '',
                        'field_extra_orig[' . $columnNumber . ']' => $columnMeta['Extra'] ?? '',
                        'field_comments_orig[' . $columnNumber . ']' => $columnMeta['Comment'] ?? '',
                        'field_virtuality_orig[' . $columnNumber . ']' => $columnMeta['Virtuality'] ?? '',
                        'field_expression_orig[' . $columnNumber . ']' => $columnMeta['Expression'] ?? '',
                    ],
                );
            }

            $defaultValue = '';
            $typeUpper = mb_strtoupper($type);

            // For a TIMESTAMP, do not show the string "CURRENT_TIMESTAMP" as a default value
            if (isset($columnMeta['DefaultValue'])) {
                $defaultValue = $columnMeta['DefaultValue'];
            }

            if ($typeUpper === 'BIT') {
                $defaultValue = ! empty($columnMeta['DefaultValue'])
                    ? Util::convertBitDefaultValue($columnMeta['DefaultValue'])
                    : '';
            } elseif ($typeUpper === 'BINARY' || $typeUpper === 'VARBINARY') {
                $defaultValue = bin2hex((string) $columnMeta['DefaultValue']);
            }

            $contentCells[$columnNumber] = [
                'column_number' => $columnNumber,
                'column_meta' => $columnMeta,
                'type_upper' => $typeUpper,
                'default_value' => $defaultValue,
                'length_values_input_size' => $lengthValuesInputSize,
                'length' => $length,
                'extracted_columnspec' => $extractedColumnSpec,
                'submit_attribute' => $submitAttribute,
                'comments_map' => $commentsMap,
                'fields_meta' => $fieldsMeta ?? null,
                'is_backup' => $isBackup,
                'move_columns' => $moveColumns,
                'available_mime' => $availableMime,
                'mime_map' => $GLOBALS['mime_map'] ?? [],
            ];
        }

        $partitionDetails = TablePartitionDefinition::getDetails();

        $charsets = Charsets::getCharsets($this->dbi, $config->selectedServer['DisableIS']);
        $collations = Charsets::getCollations($this->dbi, $config->selectedServer['DisableIS']);
        $charsetsList = [];
        foreach ($charsets as $charset) {
            $collationsList = [];
            foreach ($collations[$charset->getName()] as $collation) {
                $collationsList[] = ['name' => $collation->getName(), 'description' => $collation->getDescription()];
            }

            $charsetsList[] = [
                'name' => $charset->getName(),
                'description' => $charset->getDescription(),
                'collations' => $collationsList,
            ];
        }

        $storageEngines = StorageEngine::getArray();
        $isIntegersLengthRestricted = Compatibility::isIntegersLengthRestricted($this->dbi);

        return [
            'is_backup' => $isBackup,
            'fields_meta' => $fieldsMeta ?? null,
            'relation_parameters' => $relationParameters,
            'action' => $action,
            'form_params' => $formParams,
            'content_cells' => $contentCells,
            'partition_details' => $partitionDetails,
            'primary_indexes' => $_POST['primary_indexes'] ?? null,
            'unique_indexes' => $_POST['unique_indexes'] ?? null,
            'indexes' => $_POST['indexes'] ?? null,
            'fulltext_indexes' => $_POST['fulltext_indexes'] ?? null,
            'spatial_indexes' => $_POST['spatial_indexes'] ?? null,
            'table' => $_POST['table'] ?? null,
            'comment' => $_POST['comment'] ?? null,
            'tbl_collation' => $_POST['tbl_collation'] ?? null,
            'charsets' => $charsetsList,
            'tbl_storage_engine' => $_POST['tbl_storage_engine'] ?? null,
            'storage_engines' => $storageEngines,
            'connection' => $_POST['connection'] ?? null,
            'change_column' => $_POST['change_column'] ?? $_GET['change_column'] ?? null,
            'is_virtual_columns_supported' => Compatibility::isVirtualColumnsSupported($this->dbi->getVersion()),
            'is_integers_length_restricted' => $isIntegersLengthRestricted,
            'browse_mime' => $config->settings['BrowseMIME'] ?? null,
            'supports_stored_keyword' => Compatibility::supportsStoredKeywordForVirtualColumns(
                $this->dbi->getVersion(),
            ),
            'server_version' => $this->dbi->getVersion(),
            'max_rows' => (int) $config->settings['MaxRows'],
            'char_editing' => $config->settings['CharEditing'] ?? null,
            'attribute_types' => $this->dbi->types->getAttributes(),
            'privs_available' => $userPrivileges->column && $userPrivileges->isReload,
            'max_length' => $this->dbi->getVersion() >= 50503 ? 1024 : 255,
            'have_partitioning' => Partition::havePartitioning(),
            'disable_is' => $config->selectedServer['DisableIS'],
        ];
    }

    /**
     * Set default type and default value according to the column metadata
     *
     * @return array{DefaultType:string, DefaultValue: string, Default?: string}
     */
    public static function decorateColumnMetaDefault(string $type, string|null $default, bool $isNull): array
    {
        $metaDefault = ['DefaultType' => 'USER_DEFINED', 'DefaultValue' => ''];

        switch ($default) {
            case null:
                $metaDefault['DefaultType'] = $isNull ? 'NULL' : 'NONE';

                break;
            case 'CURRENT_TIMESTAMP':
            case 'current_timestamp()':
                $metaDefault['DefaultType'] = 'CURRENT_TIMESTAMP';

                break;
            case 'UUID':
            case 'uuid()':
                $metaDefault['DefaultType'] = 'UUID';

                break;
            default:
                $metaDefault['DefaultValue'] = $default;

                if (str_ends_with($type, 'text')) {
                    $textDefault = substr($default, 1, -1);
                    $metaDefault['Default'] = stripcslashes($textDefault);
                }

                break;
        }

        return $metaDefault;
    }

    /** @return mixed[] */
    private function getColumnMetaForRegeneratedFields(int $columnNumber): array
    {
        $columnMeta = [
            'Field' => Util::getValueByKey($_POST, ['field_name', $columnNumber]),
            'Type' => Util::getValueByKey($_POST, ['field_type', $columnNumber]),
            'Collation' => Util::getValueByKey($_POST, ['field_collation', $columnNumber], ''),
            'Null' => Util::getValueByKey($_POST, ['field_null', $columnNumber], ''),
            'DefaultType' => Util::getValueByKey($_POST, ['field_default_type', $columnNumber], 'NONE'),
            'DefaultValue' => Util::getValueByKey($_POST, ['field_default_value', $columnNumber], ''),
            'Extra' => Util::getValueByKey($_POST, ['field_extra', $columnNumber]),
            'Virtuality' => Util::getValueByKey($_POST, ['field_virtuality', $columnNumber], ''),
            'Expression' => Util::getValueByKey($_POST, ['field_expression', $columnNumber], ''),
            'Key' => '',
            'Comment' => false,
        ];

        $parts = explode(
            '_',
            Util::getValueByKey($_POST, ['field_key', $columnNumber], ''),
            2,
        );
        if (count($parts) === 2 && $parts[1] == $columnNumber) {
            $columnMeta['Key'] = match ($parts[0]) {
                'primary' => 'PRI',
                'index' => 'MUL',
                'unique' => 'UNI',
                'fulltext' => 'FULLTEXT',
                'spatial' => 'SPATIAL',
                default => '',
            };
        }

        $columnMeta['Default'] = match ($columnMeta['DefaultType']) {
            'NONE' => null,
            'USER_DEFINED' => $columnMeta['DefaultValue'],
            default => $columnMeta['DefaultType'],
        };

        return $columnMeta;
    }
}