diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 4176bbda..38b73f77 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -16,29 +16,24 @@ parameters: path: src/Command/BakeMigrationSnapshotCommand.php - - message: "#^Parameter \\#1 \\$table of method Phinx\\\\Db\\\\Adapter\\\\AdapterInterface\\:\\:createTable\\(\\) expects Phinx\\\\Db\\\\Table\\\\Table, Migrations\\\\Db\\\\Table\\\\Table given\\.$#" + message: "#^Offset 'id' on non\\-empty\\-array\\ in isset\\(\\) always exists and is not nullable\\.$#" count: 2 - path: src/Db/Plan/Plan.php + path: src/Db/Adapter/MysqlAdapter.php - - message: "#^Parameter \\#1 \\$table of method Phinx\\\\Db\\\\Adapter\\\\AdapterInterface\\:\\:executeActions\\(\\) expects Phinx\\\\Db\\\\Table\\\\Table, Migrations\\\\Db\\\\Table\\\\Table given\\.$#" - count: 2 - path: src/Db/Plan/Plan.php - - - - message: "#^Parameter \\#2 \\$actions of method Phinx\\\\Db\\\\Adapter\\\\AdapterInterface\\:\\:executeActions\\(\\) expects array\\, array\\ given\\.$#" - count: 2 - path: src/Db/Plan/Plan.php + message: "#^Parameter \\#4 \\$options of method Migrations\\\\Db\\\\Adapter\\\\PdoAdapter\\:\\:createPdoConnection\\(\\) expects array\\, array\\ given\\.$#" + count: 1 + path: src/Db/Adapter/MysqlAdapter.php - - message: "#^Parameter \\#2 \\$columns of method Phinx\\\\Db\\\\Adapter\\\\AdapterInterface\\:\\:createTable\\(\\) expects array\\, array\\ given\\.$#" - count: 2 - path: src/Db/Plan/Plan.php + message: "#^Right side of && is always true\\.$#" + count: 1 + path: src/Db/Adapter/MysqlAdapter.php - - message: "#^Parameter \\#3 \\$indexes of method Phinx\\\\Db\\\\Adapter\\\\AdapterInterface\\:\\:createTable\\(\\) expects array\\, array\\ given\\.$#" - count: 2 - path: src/Db/Plan/Plan.php + message: "#^Access to an undefined property PDO\\:\\:\\$connection\\.$#" + count: 1 + path: src/Db/Adapter/PdoAdapter.php - message: "#^Possibly invalid array key type Cake\\\\Database\\\\Schema\\\\TableSchemaInterface\\|string\\.$#" diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 8149e7b8..6c80ff9c 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -10,19 +10,16 @@ setInput - - - getColumns()]]> - getColumns()]]> - getIndexes()]]> - getIndexes()]]> - getTable()]]> - getTable()]]> - getActions()]]> - getActions()]]> - getTable()]]> - getTable()]]> - + + + output)]]> + + + + + $opened + is_array($newColumns) + diff --git a/src/Db/Adapter/AbstractAdapter.php b/src/Db/Adapter/AbstractAdapter.php new file mode 100644 index 00000000..a6ac82d8 --- /dev/null +++ b/src/Db/Adapter/AbstractAdapter.php @@ -0,0 +1,413 @@ + + */ + protected array $options = []; + + /** + * @var \Symfony\Component\Console\Input\InputInterface|null + */ + protected ?InputInterface $input = null; + + /** + * @var \Symfony\Component\Console\Output\OutputInterface + */ + protected OutputInterface $output; + + /** + * @var string[] + */ + protected array $createdTables = []; + + /** + * @var string + */ + protected string $schemaTableName = 'phinxlog'; + + /** + * @var array + */ + protected array $dataDomain = []; + + /** + * Class Constructor. + * + * @param array $options Options + * @param \Symfony\Component\Console\Input\InputInterface|null $input Input Interface + * @param \Symfony\Component\Console\Output\OutputInterface|null $output Output Interface + */ + public function __construct(array $options, ?InputInterface $input = null, ?OutputInterface $output = null) + { + $this->setOptions($options); + if ($input !== null) { + $this->setInput($input); + } + if ($output !== null) { + $this->setOutput($output); + } + } + + /** + * @inheritDoc + */ + public function setOptions(array $options): AdapterInterface + { + $this->options = $options; + + if (isset($options['default_migration_table'])) { + trigger_error('The default_migration_table setting for adapter has been deprecated since 0.13.0. Use `migration_table` instead.', E_USER_DEPRECATED); + if (!isset($options['migration_table'])) { + $options['migration_table'] = $options['default_migration_table']; + } + } + + if (isset($options['migration_table'])) { + $this->setSchemaTableName($options['migration_table']); + } + + if (isset($options['data_domain'])) { + $this->setDataDomain($options['data_domain']); + } + + return $this; + } + + /** + * @inheritDoc + */ + public function getOptions(): array + { + return $this->options; + } + + /** + * @inheritDoc + */ + public function hasOption(string $name): bool + { + return isset($this->options[$name]); + } + + /** + * @inheritDoc + */ + public function getOption(string $name): mixed + { + if (!$this->hasOption($name)) { + return null; + } + + return $this->options[$name]; + } + + /** + * @inheritDoc + */ + public function setInput(InputInterface $input): AdapterInterface + { + $this->input = $input; + + return $this; + } + + /** + * @inheritDoc + */ + public function getInput(): ?InputInterface + { + return $this->input; + } + + /** + * @inheritDoc + */ + public function setOutput(OutputInterface $output): AdapterInterface + { + $this->output = $output; + + return $this; + } + + /** + * @inheritDoc + */ + public function getOutput(): OutputInterface + { + if (!isset($this->output)) { + $output = new NullOutput(); + $this->setOutput($output); + } + + return $this->output; + } + + /** + * @inheritDoc + * @return array + */ + public function getVersions(): array + { + $rows = $this->getVersionLog(); + + return array_keys($rows); + } + + /** + * Gets the schema table name. + * + * @return string + */ + public function getSchemaTableName(): string + { + return $this->schemaTableName; + } + + /** + * Sets the schema table name. + * + * @param string $schemaTableName Schema Table Name + * @return $this + */ + public function setSchemaTableName(string $schemaTableName) + { + $this->schemaTableName = $schemaTableName; + + return $this; + } + + /** + * Gets the data domain. + * + * @return array + */ + public function getDataDomain(): array + { + return $this->dataDomain; + } + + /** + * Sets the data domain. + * + * @param array $dataDomain Array for the data domain + * @return $this + */ + public function setDataDomain(array $dataDomain) + { + $this->dataDomain = []; + + // Iterate over data domain field definitions and perform initial and + // simple normalization. We make sure the definition as a base 'type' + // and it is compatible with the base Phinx types. + foreach ($dataDomain as $type => $options) { + if (!isset($options['type'])) { + throw new InvalidArgumentException(sprintf( + 'You must specify a type for data domain type "%s".', + $type + )); + } + + // Replace type if it's the name of a Phinx constant + if (defined('static::' . $options['type'])) { + $options['type'] = constant('static::' . $options['type']); + } + + if (!in_array($options['type'], $this->getColumnTypes(), true)) { + throw new InvalidArgumentException(sprintf( + 'An invalid column type "%s" was specified for data domain type "%s".', + $options['type'], + $type + )); + } + + $internal_type = $options['type']; + unset($options['type']); + + // Do a simple replacement for the 'length' / 'limit' option and + // detect hinting values for 'limit'. + if (isset($options['length'])) { + $options['limit'] = $options['length']; + unset($options['length']); + } + + if (isset($options['limit']) && !is_numeric($options['limit'])) { + if (!defined('static::' . $options['limit'])) { + throw new InvalidArgumentException(sprintf( + 'An invalid limit value "%s" was specified for data domain type "%s".', + $options['limit'], + $type + )); + } + + $options['limit'] = constant('static::' . $options['limit']); + } + + // Save the data domain types in a more suitable format + $this->dataDomain[$type] = [ + 'type' => $internal_type, + 'options' => $options, + ]; + } + + return $this; + } + + /** + * @inheritdoc + */ + public function getColumnForType(string $columnName, string $type, array $options): Column + { + $column = new Column(); + $column->setName($columnName); + + if (array_key_exists($type, $this->getDataDomain())) { + $column->setType($this->dataDomain[$type]['type']); + $column->setOptions($this->dataDomain[$type]['options']); + } else { + $column->setType($type); + } + + $column->setOptions($options); + + return $column; + } + + /** + * @inheritDoc + * @throws \InvalidArgumentException + * @return void + */ + public function createSchemaTable(): void + { + try { + $options = [ + 'id' => false, + 'primary_key' => 'version', + ]; + + $table = new Table($this->getSchemaTableName(), $options, $this); + $table->addColumn('version', 'biginteger', ['null' => false]) + ->addColumn('migration_name', 'string', ['limit' => 100, 'default' => null, 'null' => true]) + ->addColumn('start_time', 'timestamp', ['default' => null, 'null' => true]) + ->addColumn('end_time', 'timestamp', ['default' => null, 'null' => true]) + ->addColumn('breakpoint', 'boolean', ['default' => false, 'null' => false]) + ->save(); + } catch (Exception $exception) { + throw new InvalidArgumentException( + 'There was a problem creating the schema table: ' . $exception->getMessage(), + (int)$exception->getCode(), + $exception + ); + } + } + + /** + * @inheritDoc + */ + public function getAdapterType(): string + { + return $this->getOption('adapter'); + } + + /** + * @inheritDoc + */ + public function isValidColumnType(Column $column): bool + { + return $column->getType() instanceof Literal || in_array($column->getType(), $this->getColumnTypes(), true); + } + + /** + * Determines if instead of executing queries a dump to standard output is needed + * + * @return bool + */ + public function isDryRunEnabled(): bool + { + /** @var \Symfony\Component\Console\Input\InputInterface|null $input */ + $input = $this->getInput(); + + return $input && $input->hasOption('dry-run') ? (bool)$input->getOption('dry-run') : false; + } + + /** + * Adds user-created tables (e.g. not phinxlog) to a cached list + * + * @param string $tableName The name of the table + * @return void + */ + protected function addCreatedTable(string $tableName): void + { + $tableName = $this->quoteTableName($tableName); + if (substr_compare($tableName, 'phinxlog', -strlen('phinxlog')) !== 0) { + $this->createdTables[] = $tableName; + } + } + + /** + * Updates the name of the cached table + * + * @param string $tableName Original name of the table + * @param string $newTableName New name of the table + * @return void + */ + protected function updateCreatedTableName(string $tableName, string $newTableName): void + { + $tableName = $this->quoteTableName($tableName); + $newTableName = $this->quoteTableName($newTableName); + $key = array_search($tableName, $this->createdTables, true); + if ($key !== false) { + $this->createdTables[$key] = $newTableName; + } + } + + /** + * Removes table from the cached created list + * + * @param string $tableName The name of the table + * @return void + */ + protected function removeCreatedTable(string $tableName): void + { + $tableName = $this->quoteTableName($tableName); + $key = array_search($tableName, $this->createdTables, true); + if ($key !== false) { + unset($this->createdTables[$key]); + } + } + + /** + * Check if the table is in the cached list of created tables + * + * @param string $tableName The name of the table + * @return bool + */ + protected function hasCreatedTable(string $tableName): bool + { + $tableName = $this->quoteTableName($tableName); + + return in_array($tableName, $this->createdTables, true); + } +} diff --git a/src/Db/Adapter/AdapterInterface.php b/src/Db/Adapter/AdapterInterface.php new file mode 100644 index 00000000..51fb22f0 --- /dev/null +++ b/src/Db/Adapter/AdapterInterface.php @@ -0,0 +1,506 @@ + + */ + public function getVersions(): array; + + /** + * Get all migration log entries, indexed by version creation time and sorted ascendingly by the configuration's + * version order option + * + * @return array + */ + public function getVersionLog(): array; + + /** + * Set adapter configuration options. + * + * @param array $options Options + * @return $this + */ + public function setOptions(array $options); + + /** + * Get all adapter options. + * + * @return array + */ + public function getOptions(): array; + + /** + * Check if an option has been set. + * + * @param string $name Name + * @return bool + */ + public function hasOption(string $name): bool; + + /** + * Get a single adapter option, or null if the option does not exist. + * + * @param string $name Name + * @return mixed + */ + public function getOption(string $name): mixed; + + /** + * Sets the console input. + * + * @param \Symfony\Component\Console\Input\InputInterface $input Input + * @return $this + */ + public function setInput(InputInterface $input); + + /** + * Gets the console input. + * + * @return \Symfony\Component\Console\Input\InputInterface|null + */ + public function getInput(): ?InputInterface; + + /** + * Sets the console output. + * + * @param \Symfony\Component\Console\Output\OutputInterface $output Output + * @return $this + */ + public function setOutput(OutputInterface $output); + + /** + * Gets the console output. + * + * @return \Symfony\Component\Console\Output\OutputInterface + */ + public function getOutput(): OutputInterface; + + /** + * Returns a new Migrations\Db\Table\Column using the existent data domain. + * + * @param string $columnName The desired column name + * @param string $type The type for the column. Can be a data domain type. + * @param array $options Options array + * @return \Migrations\Db\Table\Column + */ + public function getColumnForType(string $columnName, string $type, array $options): Column; + + /** + * Records a migration being run. + * + * @param \Phinx\Migration\MigrationInterface $migration Migration + * @param string $direction Direction + * @param string $startTime Start Time + * @param string $endTime End Time + * @return $this + */ + public function migrated(MigrationInterface $migration, string $direction, string $startTime, string $endTime); + + /** + * Toggle a migration breakpoint. + * + * @param \Phinx\Migration\MigrationInterface $migration Migration + * @return $this + */ + public function toggleBreakpoint(MigrationInterface $migration); + + /** + * Reset all migration breakpoints. + * + * @return int The number of breakpoints reset + */ + public function resetAllBreakpoints(): int; + + /** + * Set a migration breakpoint. + * + * @param \Phinx\Migration\MigrationInterface $migration The migration target for the breakpoint set + * @return $this + */ + public function setBreakpoint(MigrationInterface $migration); + + /** + * Unset a migration breakpoint. + * + * @param \Phinx\Migration\MigrationInterface $migration The migration target for the breakpoint unset + * @return $this + */ + public function unsetBreakpoint(MigrationInterface $migration); + + /** + * Creates the schema table. + * + * @return void + */ + public function createSchemaTable(): void; + + /** + * Returns the adapter type. + * + * @return string + */ + public function getAdapterType(): string; + + /** + * Initializes the database connection. + * + * @throws \RuntimeException When the requested database driver is not installed. + * @return void + */ + public function connect(): void; + + /** + * Closes the database connection. + * + * @return void + */ + public function disconnect(): void; + + /** + * Does the adapter support transactions? + * + * @return bool + */ + public function hasTransactions(): bool; + + /** + * Begin a transaction. + * + * @return void + */ + public function beginTransaction(): void; + + /** + * Commit a transaction. + * + * @return void + */ + public function commitTransaction(): void; + + /** + * Rollback a transaction. + * + * @return void + */ + public function rollbackTransaction(): void; + + /** + * Executes a SQL statement and returns the number of affected rows. + * + * @param string $sql SQL + * @param array $params parameters to use for prepared query + * @return int + */ + public function execute(string $sql, array $params = []): int; + + /** + * Executes a list of migration actions for the given table + * + * @param \Migrations\Db\Table\Table $table The table to execute the actions for + * @param \Migrations\Db\Action\Action[] $actions The table to execute the actions for + * @return void + */ + public function executeActions(Table $table, array $actions): void; + + /** + * Returns a new Query object + * + * @return \Cake\Database\Query + */ + public function getQueryBuilder(string $type): Query; + + /** + * Executes a SQL statement. + * + * The return type depends on the underlying adapter being used. + * + * @param string $sql SQL + * @param array $params parameters to use for prepared query + * @return mixed + */ + public function query(string $sql, array $params = []): mixed; + + /** + * Executes a query and returns only one row as an array. + * + * @param string $sql SQL + * @return array|false + */ + public function fetchRow(string $sql): array|false; + + /** + * Executes a query and returns an array of rows. + * + * @param string $sql SQL + * @return array + */ + public function fetchAll(string $sql): array; + + /** + * Inserts data into a table. + * + * @param \Migrations\Db\Table\Table $table Table where to insert data + * @param array $row Row + * @return void + */ + public function insert(Table $table, array $row): void; + + /** + * Inserts data into a table in a bulk. + * + * @param \Migrations\Db\Table\Table $table Table where to insert data + * @param array $rows Rows + * @return void + */ + public function bulkinsert(Table $table, array $rows): void; + + /** + * Quotes a table name for use in a query. + * + * @param string $tableName Table name + * @return string + */ + public function quoteTableName(string $tableName): string; + + /** + * Quotes a column name for use in a query. + * + * @param string $columnName Table name + * @return string + */ + public function quoteColumnName(string $columnName): string; + + /** + * Checks to see if a table exists. + * + * @param string $tableName Table name + * @return bool + */ + public function hasTable(string $tableName): bool; + + /** + * Creates the specified database table. + * + * @param \Migrations\Db\Table\Table $table Table + * @param \Migrations\Db\Table\Column[] $columns List of columns in the table + * @param \Migrations\Db\Table\Index[] $indexes List of indexes for the table + * @return void + */ + public function createTable(Table $table, array $columns = [], array $indexes = []): void; + + /** + * Truncates the specified table + * + * @param string $tableName Table name + * @return void + */ + public function truncateTable(string $tableName): void; + + /** + * Returns table columns + * + * @param string $tableName Table name + * @return \Migrations\Db\Table\Column[] + */ + public function getColumns(string $tableName): array; + + /** + * Checks to see if a column exists. + * + * @param string $tableName Table name + * @param string $columnName Column name + * @return bool + */ + public function hasColumn(string $tableName, string $columnName): bool; + + /** + * Checks to see if an index exists. + * + * @param string $tableName Table name + * @param string|string[] $columns Column(s) + * @return bool + */ + public function hasIndex(string $tableName, string|array $columns): bool; + + /** + * Checks to see if an index specified by name exists. + * + * @param string $tableName Table name + * @param string $indexName Index name + * @return bool + */ + public function hasIndexByName(string $tableName, string $indexName): bool; + + /** + * Checks to see if the specified primary key exists. + * + * @param string $tableName Table name + * @param string|string[] $columns Column(s) + * @param string|null $constraint Constraint name + * @return bool + */ + public function hasPrimaryKey(string $tableName, string|array $columns, ?string $constraint = null): bool; + + /** + * Checks to see if a foreign key exists. + * + * @param string $tableName Table name + * @param string|string[] $columns Column(s) + * @param string|null $constraint Constraint name + * @return bool + */ + public function hasForeignKey(string $tableName, string|array $columns, ?string $constraint = null): bool; + + /** + * Returns an array of the supported Phinx column types. + * + * @return string[] + */ + public function getColumnTypes(): array; + + /** + * Checks that the given column is of a supported type. + * + * @param \Migrations\Db\Table\Column $column Column + * @return bool + */ + public function isValidColumnType(Column $column): bool; + + /** + * Converts the Phinx logical type to the adapter's SQL type. + * + * @param \Migrations\Db\Literal|string $type Type + * @param int|null $limit Limit + * @return array + */ + public function getSqlType(Literal|string $type, ?int $limit = null): array; + + /** + * Creates a new database. + * + * @param string $name Database Name + * @param array $options Options + * @return void + */ + public function createDatabase(string $name, array $options = []): void; + + /** + * Checks to see if a database exists. + * + * @param string $name Database Name + * @return bool + */ + public function hasDatabase(string $name): bool; + + /** + * Drops the specified database. + * + * @param string $name Database Name + * @return void + */ + public function dropDatabase(string $name): void; + + /** + * Creates the specified schema or throws an exception + * if there is no support for it. + * + * @param string $schemaName Schema Name + * @return void + */ + public function createSchema(string $schemaName = 'public'): void; + + /** + * Drops the specified schema table or throws an exception + * if there is no support for it. + * + * @param string $schemaName Schema name + * @return void + */ + public function dropSchema(string $schemaName): void; + + /** + * Cast a value to a boolean appropriate for the adapter. + * + * @param mixed $value The value to be cast + * @return mixed + */ + public function castToBool(mixed $value): mixed; +} diff --git a/src/Db/Adapter/DirectActionInterface.php b/src/Db/Adapter/DirectActionInterface.php new file mode 100644 index 00000000..67141c3f --- /dev/null +++ b/src/Db/Adapter/DirectActionInterface.php @@ -0,0 +1,140 @@ + true, + self::PHINX_TYPE_TINY_INTEGER => true, + self::PHINX_TYPE_SMALL_INTEGER => true, + self::PHINX_TYPE_MEDIUM_INTEGER => true, + self::PHINX_TYPE_BIG_INTEGER => true, + self::PHINX_TYPE_FLOAT => true, + self::PHINX_TYPE_DECIMAL => true, + self::PHINX_TYPE_DOUBLE => true, + self::PHINX_TYPE_BOOLEAN => true, + ]; + + // These constants roughly correspond to the maximum allowed value for each field, + // except for the `_LONG` and `_BIG` variants, which are maxed at 32-bit + // PHP_INT_MAX value. The `INT_REGULAR` field is just arbitrarily half of INT_BIG + // as its actual value is its regular value is larger than PHP_INT_MAX. We do this + // to keep consistent the type hints for getSqlType and Column::$limit being integers. + public const TEXT_TINY = 255; + public const TEXT_SMALL = 255; /* deprecated, alias of TEXT_TINY */ + public const TEXT_REGULAR = 65535; + public const TEXT_MEDIUM = 16777215; + public const TEXT_LONG = 2147483647; + + // According to https://dev.mysql.com/doc/refman/5.0/en/blob.html BLOB sizes are the same as TEXT + public const BLOB_TINY = 255; + public const BLOB_SMALL = 255; /* deprecated, alias of BLOB_TINY */ + public const BLOB_REGULAR = 65535; + public const BLOB_MEDIUM = 16777215; + public const BLOB_LONG = 2147483647; + + public const INT_TINY = 255; + public const INT_SMALL = 65535; + public const INT_MEDIUM = 16777215; + public const INT_REGULAR = 1073741823; + public const INT_BIG = 2147483647; + + public const INT_DISPLAY_TINY = 4; + public const INT_DISPLAY_SMALL = 6; + public const INT_DISPLAY_MEDIUM = 8; + public const INT_DISPLAY_REGULAR = 11; + public const INT_DISPLAY_BIG = 20; + + public const BIT = 64; + + public const TYPE_YEAR = 'year'; + + public const FIRST = 'FIRST'; + + /** + * {@inheritDoc} + * + * @throws \RuntimeException + * @throws \InvalidArgumentException + * @return void + */ + public function connect(): void + { + if ($this->connection === null) { + if (!class_exists('PDO') || !in_array('mysql', PDO::getAvailableDrivers(), true)) { + // @codeCoverageIgnoreStart + throw new RuntimeException('You need to enable the PDO_Mysql extension for Phinx to run properly.'); + // @codeCoverageIgnoreEnd + } + + $options = $this->getOptions(); + + $dsn = 'mysql:'; + + if (!empty($options['unix_socket'])) { + // use socket connection + $dsn .= 'unix_socket=' . $options['unix_socket']; + } else { + // use network connection + $dsn .= 'host=' . $options['host']; + if (!empty($options['port'])) { + $dsn .= ';port=' . $options['port']; + } + } + + $dsn .= ';dbname=' . $options['name']; + + // charset support + if (!empty($options['charset'])) { + $dsn .= ';charset=' . $options['charset']; + } + + $driverOptions = []; + + // use custom data fetch mode + if (!empty($options['fetch_mode'])) { + $driverOptions[PDO::ATTR_DEFAULT_FETCH_MODE] = constant('\PDO::FETCH_' . strtoupper($options['fetch_mode'])); + } + + // pass \PDO::ATTR_PERSISTENT to driver options instead of useless setting it after instantiation + if (isset($options['attr_persistent'])) { + $driverOptions[PDO::ATTR_PERSISTENT] = $options['attr_persistent']; + } + + // support arbitrary \PDO::MYSQL_ATTR_* driver options and pass them to PDO + // https://php.net/manual/en/ref.pdo-mysql.php#pdo-mysql.constants + foreach ($options as $key => $option) { + if (strpos($key, 'mysql_attr_') === 0) { + $pdoConstant = '\PDO::' . strtoupper($key); + if (!defined($pdoConstant)) { + throw new UnexpectedValueException('Invalid PDO attribute: ' . $key . ' (' . $pdoConstant . ')'); + } + $driverOptions[constant($pdoConstant)] = $option; + } + } + + $db = $this->createPdoConnection($dsn, $options['user'] ?? null, $options['pass'] ?? null, $driverOptions); + + $this->setConnection($db); + } + } + + /** + * @inheritDoc + */ + public function disconnect(): void + { + $this->connection = null; + } + + /** + * @inheritDoc + */ + public function hasTransactions(): bool + { + return true; + } + + /** + * @inheritDoc + */ + public function beginTransaction(): void + { + $this->execute('START TRANSACTION'); + } + + /** + * @inheritDoc + */ + public function commitTransaction(): void + { + $this->execute('COMMIT'); + } + + /** + * @inheritDoc + */ + public function rollbackTransaction(): void + { + $this->execute('ROLLBACK'); + } + + /** + * @inheritDoc + */ + public function quoteTableName(string $tableName): string + { + return str_replace('.', '`.`', $this->quoteColumnName($tableName)); + } + + /** + * @inheritDoc + */ + public function quoteColumnName(string $columnName): string + { + return '`' . str_replace('`', '``', $columnName) . '`'; + } + + /** + * @inheritDoc + */ + public function hasTable(string $tableName): bool + { + if ($this->hasCreatedTable($tableName)) { + return true; + } + + if (strpos($tableName, '.') !== false) { + [$schema, $table] = explode('.', $tableName); + $exists = $this->hasTableWithSchema($schema, $table); + // Only break here on success, because it is possible for table names to contain a dot. + if ($exists) { + return true; + } + } + + $options = $this->getOptions(); + + return $this->hasTableWithSchema($options['name'], $tableName); + } + + /** + * @param string $schema The table schema + * @param string $tableName The table name + * @return bool + */ + protected function hasTableWithSchema(string $schema, string $tableName): bool + { + $result = $this->fetchRow(sprintf( + "SELECT TABLE_NAME + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = '%s' AND TABLE_NAME = '%s'", + $schema, + $tableName + )); + + return !empty($result); + } + + /** + * @inheritDoc + */ + public function createTable(Table $table, array $columns = [], array $indexes = []): void + { + // This method is based on the MySQL docs here: https://dev.mysql.com/doc/refman/5.1/en/create-index.html + $defaultOptions = [ + 'engine' => 'InnoDB', + 'collation' => 'utf8mb4_unicode_ci', + ]; + + $options = array_merge( + $defaultOptions, + array_intersect_key($this->getOptions(), $defaultOptions), + $table->getOptions() + ); + + // Add the default primary key + if (!isset($options['id']) || (isset($options['id']) && $options['id'] === true)) { + $options['id'] = 'id'; + } + + if (isset($options['id']) && is_string($options['id'])) { + // Handle id => "field_name" to support AUTO_INCREMENT + $column = new Column(); + $column->setName($options['id']) + ->setType('integer') + ->setOptions([ + 'signed' => $options['signed'] ?? !FeatureFlags::$unsignedPrimaryKeys, + 'identity' => true, + ]); + + if (isset($options['limit'])) { + $column->setLimit($options['limit']); + } + + array_unshift($columns, $column); + if (isset($options['primary_key']) && (array)$options['id'] !== (array)$options['primary_key']) { + throw new InvalidArgumentException('You cannot enable an auto incrementing ID field and a primary key'); + } + $options['primary_key'] = $options['id']; + } + + // open: process table options like collation etc + + // process table engine (default to InnoDB) + $optionsStr = 'ENGINE = InnoDB'; + if (isset($options['engine'])) { + $optionsStr = sprintf('ENGINE = %s', $options['engine']); + } + + // process table collation + if (isset($options['collation'])) { + $charset = explode('_', $options['collation']); + $optionsStr .= sprintf(' CHARACTER SET %s', $charset[0]); + $optionsStr .= sprintf(' COLLATE %s', $options['collation']); + } + + // set the table comment + if (isset($options['comment'])) { + $optionsStr .= sprintf(' COMMENT=%s ', $this->getConnection()->quote($options['comment'])); + } + + // set the table row format + if (isset($options['row_format'])) { + $optionsStr .= sprintf(' ROW_FORMAT=%s ', $options['row_format']); + } + + $sql = 'CREATE TABLE '; + $sql .= $this->quoteTableName($table->getName()) . ' ('; + foreach ($columns as $column) { + $sql .= $this->quoteColumnName((string)$column->getName()) . ' ' . $this->getColumnSqlDefinition($column) . ', '; + } + + // set the primary key(s) + if (isset($options['primary_key'])) { + /** @var string|array $primaryKey */ + $primaryKey = $options['primary_key']; + $sql = rtrim($sql); + $sql .= ' PRIMARY KEY ('; + if (is_string($primaryKey)) { // handle primary_key => 'id' + $sql .= $this->quoteColumnName($primaryKey); + } elseif (is_array($primaryKey)) { // handle primary_key => array('tag_id', 'resource_id') + $sql .= implode(',', array_map([$this, 'quoteColumnName'], $primaryKey)); + } + $sql .= ')'; + } else { + $sql = substr(rtrim($sql), 0, -1); // no primary keys + } + + // set the indexes + foreach ($indexes as $index) { + $sql .= ', ' . $this->getIndexSqlDefinition($index); + } + + $sql .= ') ' . $optionsStr; + $sql = rtrim($sql); + + // execute the sql + $this->execute($sql); + + $this->addCreatedTable($table->getName()); + } + + /** + * {@inheritDoc} + * + * @throws \InvalidArgumentException + */ + protected function getChangePrimaryKeyInstructions(Table $table, $newColumns): AlterInstructions + { + $instructions = new AlterInstructions(); + + // Drop the existing primary key + $primaryKey = $this->getPrimaryKey($table->getName()); + if (!empty($primaryKey['columns'])) { + $instructions->addAlter('DROP PRIMARY KEY'); + } + + // Add the primary key(s) + if (!empty($newColumns)) { + $sql = 'ADD PRIMARY KEY ('; + if (is_string($newColumns)) { // handle primary_key => 'id' + $sql .= $this->quoteColumnName($newColumns); + } elseif (is_array($newColumns)) { // handle primary_key => array('tag_id', 'resource_id') + $sql .= implode(',', array_map([$this, 'quoteColumnName'], $newColumns)); + } else { + throw new InvalidArgumentException(sprintf( + 'Invalid value for primary key: %s', + json_encode($newColumns) + )); + } + $sql .= ')'; + $instructions->addAlter($sql); + } + + return $instructions; + } + + /** + * @inheritDoc + */ + protected function getChangeCommentInstructions(Table $table, ?string $newComment): AlterInstructions + { + $instructions = new AlterInstructions(); + + // passing 'null' is to remove table comment + $newComment = $newComment ?? ''; + $sql = sprintf(' COMMENT=%s ', $this->getConnection()->quote($newComment)); + $instructions->addAlter($sql); + + return $instructions; + } + + /** + * @inheritDoc + */ + protected function getRenameTableInstructions(string $tableName, string $newTableName): AlterInstructions + { + $this->updateCreatedTableName($tableName, $newTableName); + $sql = sprintf( + 'RENAME TABLE %s TO %s', + $this->quoteTableName($tableName), + $this->quoteTableName($newTableName) + ); + + return new AlterInstructions([], [$sql]); + } + + /** + * @inheritDoc + */ + protected function getDropTableInstructions(string $tableName): AlterInstructions + { + $this->removeCreatedTable($tableName); + $sql = sprintf('DROP TABLE %s', $this->quoteTableName($tableName)); + + return new AlterInstructions([], [$sql]); + } + + /** + * @inheritDoc + */ + public function truncateTable(string $tableName): void + { + $sql = sprintf( + 'TRUNCATE TABLE %s', + $this->quoteTableName($tableName) + ); + + $this->execute($sql); + } + + /** + * @inheritDoc + */ + public function getColumns(string $tableName): array + { + $columns = []; + $rows = $this->fetchAll(sprintf('SHOW FULL COLUMNS FROM %s', $this->quoteTableName($tableName))); + foreach ($rows as $columnInfo) { + $phinxType = $this->getPhinxType($columnInfo['Type']); + + $column = new Column(); + $column->setName($columnInfo['Field']) + ->setNull($columnInfo['Null'] !== 'NO') + ->setType($phinxType['name']) + ->setSigned(strpos($columnInfo['Type'], 'unsigned') === false) + ->setLimit($phinxType['limit']) + ->setScale($phinxType['scale']) + ->setComment($columnInfo['Comment']); + + if ($columnInfo['Extra'] === 'auto_increment') { + $column->setIdentity(true); + } + + if (isset($phinxType['values'])) { + $column->setValues($phinxType['values']); + } + + $default = $columnInfo['Default']; + if ( + is_string($default) && + in_array( + $column->getType(), + array_merge( + static::PHINX_TYPES_GEOSPATIAL, + [static::PHINX_TYPE_BLOB, static::PHINX_TYPE_JSON, static::PHINX_TYPE_TEXT] + ) + ) + ) { + // The default that comes back from MySQL for these types prefixes the collation type and + // surrounds the value with escaped single quotes, for example "_utf8mbf4\'abc\'", and so + // this converts that then down to the default value of "abc" to correspond to what the user + // would have specified in a migration. + $default = preg_replace("/^_(?:[a-zA-Z0-9]+?)\\\'(.*)\\\'$/", '\1', $default); + } + $column->setDefault($default); + + $columns[] = $column; + } + + return $columns; + } + + /** + * @inheritDoc + */ + public function hasColumn(string $tableName, string $columnName): bool + { + $rows = $this->fetchAll(sprintf('SHOW COLUMNS FROM %s', $this->quoteTableName($tableName))); + foreach ($rows as $column) { + if (strcasecmp($column['Field'], $columnName) === 0) { + return true; + } + } + + return false; + } + + /** + * @inheritDoc + */ + protected function getAddColumnInstructions(Table $table, Column $column): AlterInstructions + { + $alter = sprintf( + 'ADD %s %s', + $this->quoteColumnName((string)$column->getName()), + $this->getColumnSqlDefinition($column) + ); + + $alter .= $this->afterClause($column); + + return new AlterInstructions([$alter]); + } + + /** + * Exposes the MySQL syntax to arrange a column `FIRST`. + * + * @param \Migrations\Db\Table\Column $column The column being altered. + * @return string The appropriate SQL fragment. + */ + protected function afterClause(Column $column): string + { + $after = $column->getAfter(); + if (empty($after)) { + return ''; + } + + if ($after === self::FIRST) { + return ' FIRST'; + } + + return ' AFTER ' . $this->quoteColumnName($after); + } + + /** + * {@inheritDoc} + * + * @throws \InvalidArgumentException + */ + protected function getRenameColumnInstructions(string $tableName, string $columnName, string $newColumnName): AlterInstructions + { + $rows = $this->fetchAll(sprintf('SHOW FULL COLUMNS FROM %s', $this->quoteTableName($tableName))); + + foreach ($rows as $row) { + if (strcasecmp($row['Field'], $columnName) === 0) { + $null = $row['Null'] === 'NO' ? 'NOT NULL' : 'NULL'; + $comment = isset($row['Comment']) ? ' COMMENT ' . '\'' . addslashes($row['Comment']) . '\'' : ''; + $extra = ' ' . strtoupper($row['Extra']); + if (($row['Default'] !== null)) { + $extra .= $this->getDefaultValueDefinition($row['Default']); + } + $definition = $row['Type'] . ' ' . $null . $extra . $comment; + + $alter = sprintf( + 'CHANGE COLUMN %s %s %s', + $this->quoteColumnName($columnName), + $this->quoteColumnName($newColumnName), + $definition + ); + + return new AlterInstructions([$alter]); + } + } + + throw new InvalidArgumentException(sprintf( + "The specified column doesn't exist: %s", + $columnName + )); + } + + /** + * @inheritDoc + */ + protected function getChangeColumnInstructions(string $tableName, string $columnName, Column $newColumn): AlterInstructions + { + $alter = sprintf( + 'CHANGE %s %s %s%s', + $this->quoteColumnName($columnName), + $this->quoteColumnName((string)$newColumn->getName()), + $this->getColumnSqlDefinition($newColumn), + $this->afterClause($newColumn) + ); + + return new AlterInstructions([$alter]); + } + + /** + * @inheritDoc + */ + protected function getDropColumnInstructions(string $tableName, string $columnName): AlterInstructions + { + $alter = sprintf('DROP COLUMN %s', $this->quoteColumnName($columnName)); + + return new AlterInstructions([$alter]); + } + + /** + * Get an array of indexes from a particular table. + * + * @param string $tableName Table name + * @return array + */ + protected function getIndexes(string $tableName): array + { + $indexes = []; + $rows = $this->fetchAll(sprintf('SHOW INDEXES FROM %s', $this->quoteTableName($tableName))); + foreach ($rows as $row) { + if (!isset($indexes[$row['Key_name']])) { + $indexes[$row['Key_name']] = ['columns' => []]; + } + $indexes[$row['Key_name']]['columns'][] = strtolower($row['Column_name']); + } + + return $indexes; + } + + /** + * @inheritDoc + */ + public function hasIndex(string $tableName, string|array $columns): bool + { + if (is_string($columns)) { + $columns = [$columns]; // str to array + } + + $columns = array_map('strtolower', $columns); + $indexes = $this->getIndexes($tableName); + + foreach ($indexes as $index) { + if ($columns == $index['columns']) { + return true; + } + } + + return false; + } + + /** + * @inheritDoc + */ + public function hasIndexByName(string $tableName, string $indexName): bool + { + $indexes = $this->getIndexes($tableName); + + foreach ($indexes as $name => $index) { + if ($name === $indexName) { + return true; + } + } + + return false; + } + + /** + * @inheritDoc + */ + protected function getAddIndexInstructions(Table $table, Index $index): AlterInstructions + { + $instructions = new AlterInstructions(); + + if ($index->getType() === Index::FULLTEXT) { + // Must be executed separately + // SQLSTATE[HY000]: General error: 1795 InnoDB presently supports one FULLTEXT index creation at a time + $alter = sprintf( + 'ALTER TABLE %s ADD %s', + $this->quoteTableName($table->getName()), + $this->getIndexSqlDefinition($index) + ); + + $instructions->addPostStep($alter); + } else { + $alter = sprintf( + 'ADD %s', + $this->getIndexSqlDefinition($index) + ); + + $instructions->addAlter($alter); + } + + return $instructions; + } + + /** + * {@inheritDoc} + * + * @throws \InvalidArgumentException + */ + protected function getDropIndexByColumnsInstructions(string $tableName, $columns): AlterInstructions + { + if (is_string($columns)) { + $columns = [$columns]; // str to array + } + + $indexes = $this->getIndexes($tableName); + $columns = array_map('strtolower', $columns); + + foreach ($indexes as $indexName => $index) { + if ($columns == $index['columns']) { + return new AlterInstructions([sprintf( + 'DROP INDEX %s', + $this->quoteColumnName($indexName) + )]); + } + } + + throw new InvalidArgumentException(sprintf( + "The specified index on columns '%s' does not exist", + implode(',', $columns) + )); + } + + /** + * {@inheritDoc} + * + * @throws \InvalidArgumentException + */ + protected function getDropIndexByNameInstructions(string $tableName, $indexName): AlterInstructions + { + $indexes = $this->getIndexes($tableName); + + foreach ($indexes as $name => $index) { + if ($name === $indexName) { + return new AlterInstructions([sprintf( + 'DROP INDEX %s', + $this->quoteColumnName($indexName) + )]); + } + } + + throw new InvalidArgumentException(sprintf( + "The specified index name '%s' does not exist", + $indexName + )); + } + + /** + * @inheritDoc + */ + public function hasPrimaryKey(string $tableName, $columns, ?string $constraint = null): bool + { + $primaryKey = $this->getPrimaryKey($tableName); + + if (empty($primaryKey['constraint'])) { + return false; + } + + if ($constraint) { + return $primaryKey['constraint'] === $constraint; + } else { + if (is_string($columns)) { + $columns = [$columns]; // str to array + } + $missingColumns = array_diff($columns, $primaryKey['columns']); + + return empty($missingColumns); + } + } + + /** + * Get the primary key from a particular table. + * + * @param string $tableName Table name + * @return array + */ + public function getPrimaryKey(string $tableName): array + { + $options = $this->getOptions(); + $rows = $this->fetchAll(sprintf( + "SELECT + k.CONSTRAINT_NAME, + k.COLUMN_NAME + FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS t + JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE k + USING(CONSTRAINT_NAME,TABLE_SCHEMA,TABLE_NAME) + WHERE t.CONSTRAINT_TYPE='PRIMARY KEY' + AND t.TABLE_SCHEMA='%s' + AND t.TABLE_NAME='%s'", + $options['name'], + $tableName + )); + + $primaryKey = [ + 'columns' => [], + ]; + foreach ($rows as $row) { + $primaryKey['constraint'] = $row['CONSTRAINT_NAME']; + $primaryKey['columns'][] = $row['COLUMN_NAME']; + } + + return $primaryKey; + } + + /** + * @inheritDoc + */ + public function hasForeignKey(string $tableName, $columns, ?string $constraint = null): bool + { + $foreignKeys = $this->getForeignKeys($tableName); + if ($constraint) { + if (isset($foreignKeys[$constraint])) { + return !empty($foreignKeys[$constraint]); + } + + return false; + } + + $columns = array_map('mb_strtolower', (array)$columns); + + foreach ($foreignKeys as $key) { + if (array_map('mb_strtolower', $key['columns']) === $columns) { + return true; + } + } + + return false; + } + + /** + * Get an array of foreign keys from a particular table. + * + * @param string $tableName Table name + * @return array + */ + protected function getForeignKeys(string $tableName): array + { + if (strpos($tableName, '.') !== false) { + [$schema, $tableName] = explode('.', $tableName); + } + + $foreignKeys = []; + $rows = $this->fetchAll(sprintf( + "SELECT + CONSTRAINT_NAME, + CONCAT(TABLE_SCHEMA, '.', TABLE_NAME) AS TABLE_NAME, + COLUMN_NAME, + CONCAT(REFERENCED_TABLE_SCHEMA, '.', REFERENCED_TABLE_NAME) AS REFERENCED_TABLE_NAME, + REFERENCED_COLUMN_NAME + FROM information_schema.KEY_COLUMN_USAGE + WHERE REFERENCED_TABLE_NAME IS NOT NULL + AND TABLE_SCHEMA = %s + AND TABLE_NAME = '%s' + ORDER BY POSITION_IN_UNIQUE_CONSTRAINT", + empty($schema) ? 'DATABASE()' : "'$schema'", + $tableName + )); + foreach ($rows as $row) { + $foreignKeys[$row['CONSTRAINT_NAME']]['table'] = $row['TABLE_NAME']; + $foreignKeys[$row['CONSTRAINT_NAME']]['columns'][] = $row['COLUMN_NAME']; + $foreignKeys[$row['CONSTRAINT_NAME']]['referenced_table'] = $row['REFERENCED_TABLE_NAME']; + $foreignKeys[$row['CONSTRAINT_NAME']]['referenced_columns'][] = $row['REFERENCED_COLUMN_NAME']; + } + + return $foreignKeys; + } + + /** + * @inheritDoc + */ + protected function getAddForeignKeyInstructions(Table $table, ForeignKey $foreignKey): AlterInstructions + { + $alter = sprintf( + 'ADD %s', + $this->getForeignKeySqlDefinition($foreignKey) + ); + + return new AlterInstructions([$alter]); + } + + /** + * @inheritDoc + */ + protected function getDropForeignKeyInstructions(string $tableName, string $constraint): AlterInstructions + { + $alter = sprintf( + 'DROP FOREIGN KEY %s', + $constraint + ); + + return new AlterInstructions([$alter]); + } + + /** + * {@inheritDoc} + * + * @throws \InvalidArgumentException + */ + protected function getDropForeignKeyByColumnsInstructions(string $tableName, array $columns): AlterInstructions + { + $instructions = new AlterInstructions(); + + $columns = array_map('mb_strtolower', $columns); + + $matches = []; + $foreignKeys = $this->getForeignKeys($tableName); + foreach ($foreignKeys as $name => $key) { + if (array_map('mb_strtolower', $key['columns']) === $columns) { + $matches[] = $name; + } + } + + if (empty($matches)) { + throw new InvalidArgumentException(sprintf( + 'No foreign key on column(s) `%s` exists', + implode(', ', $columns) + )); + } + + foreach ($matches as $name) { + $instructions->merge( + $this->getDropForeignKeyInstructions($tableName, $name) + ); + } + + return $instructions; + } + + /** + * {@inheritDoc} + * + * @throws \Migrations\Db\Adapter\UnsupportedColumnTypeException + */ + public function getSqlType(Literal|string $type, ?int $limit = null): array + { + $type = (string)$type; + switch ($type) { + case static::PHINX_TYPE_FLOAT: + case static::PHINX_TYPE_DOUBLE: + case static::PHINX_TYPE_DECIMAL: + case static::PHINX_TYPE_DATE: + case static::PHINX_TYPE_ENUM: + case static::PHINX_TYPE_SET: + case static::PHINX_TYPE_JSON: + // Geospatial database types + case static::PHINX_TYPE_GEOMETRY: + case static::PHINX_TYPE_POINT: + case static::PHINX_TYPE_LINESTRING: + case static::PHINX_TYPE_POLYGON: + return ['name' => $type]; + case static::PHINX_TYPE_DATETIME: + case static::PHINX_TYPE_TIMESTAMP: + case static::PHINX_TYPE_TIME: + return ['name' => $type, 'limit' => $limit]; + case static::PHINX_TYPE_STRING: + return ['name' => 'varchar', 'limit' => $limit ?: 255]; + case static::PHINX_TYPE_CHAR: + return ['name' => 'char', 'limit' => $limit ?: 255]; + case static::PHINX_TYPE_TEXT: + if ($limit) { + $sizes = [ + // Order matters! Size must always be tested from longest to shortest! + 'longtext' => static::TEXT_LONG, + 'mediumtext' => static::TEXT_MEDIUM, + 'text' => static::TEXT_REGULAR, + 'tinytext' => static::TEXT_SMALL, + ]; + foreach ($sizes as $name => $length) { + if ($limit >= $length) { + return ['name' => $name]; + } + } + } + + return ['name' => 'text']; + case static::PHINX_TYPE_BINARY: + if ($limit === null) { + $limit = 255; + } + + if ($limit > 255) { + return $this->getSqlType(static::PHINX_TYPE_BLOB, $limit); + } + + return ['name' => 'binary', 'limit' => $limit]; + case static::PHINX_TYPE_BINARYUUID: + return ['name' => 'binary', 'limit' => 16]; + case static::PHINX_TYPE_VARBINARY: + if ($limit === null) { + $limit = 255; + } + + if ($limit > 255) { + return $this->getSqlType(static::PHINX_TYPE_BLOB, $limit); + } + + return ['name' => 'varbinary', 'limit' => $limit]; + case static::PHINX_TYPE_BLOB: + if ($limit !== null) { + // Rework this part as the choosen types were always UNDER the required length + $sizes = [ + 'tinyblob' => static::BLOB_SMALL, + 'blob' => static::BLOB_REGULAR, + 'mediumblob' => static::BLOB_MEDIUM, + ]; + + foreach ($sizes as $name => $length) { + if ($limit <= $length) { + return ['name' => $name]; + } + } + + // For more length requirement, the longblob is used + return ['name' => 'longblob']; + } + + // If not limit is provided, fallback on blob + return ['name' => 'blob']; + case static::PHINX_TYPE_TINYBLOB: + // Automatically reprocess blob type to ensure that correct blob subtype is selected given provided limit + return $this->getSqlType(static::PHINX_TYPE_BLOB, $limit ?: static::BLOB_TINY); + case static::PHINX_TYPE_MEDIUMBLOB: + // Automatically reprocess blob type to ensure that correct blob subtype is selected given provided limit + return $this->getSqlType(static::PHINX_TYPE_BLOB, $limit ?: static::BLOB_MEDIUM); + case static::PHINX_TYPE_LONGBLOB: + // Automatically reprocess blob type to ensure that correct blob subtype is selected given provided limit + return $this->getSqlType(static::PHINX_TYPE_BLOB, $limit ?: static::BLOB_LONG); + case static::PHINX_TYPE_BIT: + return ['name' => 'bit', 'limit' => $limit ?: 64]; + case static::PHINX_TYPE_BIG_INTEGER: + if ($limit === static::INT_BIG) { + $limit = static::INT_DISPLAY_BIG; + } + + return ['name' => 'bigint', 'limit' => $limit ?: 20]; + case static::PHINX_TYPE_MEDIUM_INTEGER: + if ($limit === static::INT_MEDIUM) { + $limit = static::INT_DISPLAY_MEDIUM; + } + + return ['name' => 'mediumint', 'limit' => $limit ?: 8]; + case static::PHINX_TYPE_SMALL_INTEGER: + if ($limit === static::INT_SMALL) { + $limit = static::INT_DISPLAY_SMALL; + } + + return ['name' => 'smallint', 'limit' => $limit ?: 6]; + case static::PHINX_TYPE_TINY_INTEGER: + if ($limit === static::INT_TINY) { + $limit = static::INT_DISPLAY_TINY; + } + + return ['name' => 'tinyint', 'limit' => $limit ?: 4]; + case static::PHINX_TYPE_INTEGER: + if ($limit && $limit >= static::INT_TINY) { + $sizes = [ + // Order matters! Size must always be tested from longest to shortest! + 'bigint' => static::INT_BIG, + 'int' => static::INT_REGULAR, + 'mediumint' => static::INT_MEDIUM, + 'smallint' => static::INT_SMALL, + 'tinyint' => static::INT_TINY, + ]; + $limits = [ + 'tinyint' => static::INT_DISPLAY_TINY, + 'smallint' => static::INT_DISPLAY_SMALL, + 'mediumint' => static::INT_DISPLAY_MEDIUM, + 'int' => static::INT_DISPLAY_REGULAR, + 'bigint' => static::INT_DISPLAY_BIG, + ]; + foreach ($sizes as $name => $length) { + if ($limit >= $length) { + $def = ['name' => $name]; + if (isset($limits[$name])) { + $def['limit'] = $limits[$name]; + } + + return $def; + } + } + } elseif (!$limit) { + $limit = static::INT_DISPLAY_REGULAR; + } + + return ['name' => 'int', 'limit' => $limit]; + case static::PHINX_TYPE_BOOLEAN: + return ['name' => 'tinyint', 'limit' => 1]; + case static::PHINX_TYPE_UUID: + return ['name' => 'char', 'limit' => 36]; + case static::PHINX_TYPE_YEAR: + if (!$limit || in_array($limit, [2, 4])) { + $limit = 4; + } + + return ['name' => 'year', 'limit' => $limit]; + default: + throw new UnsupportedColumnTypeException('Column type "' . $type . '" is not supported by MySQL.'); + } + } + + /** + * Returns Phinx type by SQL type + * + * @internal param string $sqlType SQL type + * @param string $sqlTypeDef SQL Type definition + * @throws \Migrations\Db\Adapter\UnsupportedColumnTypeException + * @return array Phinx type + */ + public function getPhinxType(string $sqlTypeDef): array + { + $matches = []; + if (!preg_match('/^([\w]+)(\(([\d]+)*(,([\d]+))*\))*(.+)*$/', $sqlTypeDef, $matches)) { + throw new UnsupportedColumnTypeException('Column type "' . $sqlTypeDef . '" is not supported by MySQL.'); + } + + $limit = null; + $scale = null; + $type = $matches[1]; + if (count($matches) > 2) { + $limit = $matches[3] ? (int)$matches[3] : null; + } + if (count($matches) > 4) { + $scale = (int)$matches[5]; + } + if ($type === 'tinyint' && $limit === 1) { + $type = static::PHINX_TYPE_BOOLEAN; + $limit = null; + } + switch ($type) { + case 'varchar': + $type = static::PHINX_TYPE_STRING; + if ($limit === 255) { + $limit = null; + } + break; + case 'char': + $type = static::PHINX_TYPE_CHAR; + if ($limit === 255) { + $limit = null; + } + if ($limit === 36) { + $type = static::PHINX_TYPE_UUID; + } + break; + case 'tinyint': + $type = static::PHINX_TYPE_TINY_INTEGER; + break; + case 'smallint': + $type = static::PHINX_TYPE_SMALL_INTEGER; + break; + case 'mediumint': + $type = static::PHINX_TYPE_MEDIUM_INTEGER; + break; + case 'int': + $type = static::PHINX_TYPE_INTEGER; + break; + case 'bigint': + $type = static::PHINX_TYPE_BIG_INTEGER; + break; + case 'bit': + $type = static::PHINX_TYPE_BIT; + if ($limit === 64) { + $limit = null; + } + break; + case 'blob': + $type = static::PHINX_TYPE_BLOB; + $limit = static::BLOB_REGULAR; + break; + case 'tinyblob': + $type = static::PHINX_TYPE_TINYBLOB; + $limit = static::BLOB_TINY; + break; + case 'mediumblob': + $type = static::PHINX_TYPE_MEDIUMBLOB; + $limit = static::BLOB_MEDIUM; + break; + case 'longblob': + $type = static::PHINX_TYPE_LONGBLOB; + $limit = static::BLOB_LONG; + break; + case 'tinytext': + $type = static::PHINX_TYPE_TEXT; + $limit = static::TEXT_TINY; + break; + case 'mediumtext': + $type = static::PHINX_TYPE_TEXT; + $limit = static::TEXT_MEDIUM; + break; + case 'longtext': + $type = static::PHINX_TYPE_TEXT; + $limit = static::TEXT_LONG; + break; + case 'binary': + if ($limit === null) { + $limit = 255; + } + + if ($limit > 255) { + $type = static::PHINX_TYPE_BLOB; + break; + } + + if ($limit === 16) { + $type = static::PHINX_TYPE_BINARYUUID; + } + break; + } + + try { + // Call this to check if parsed type is supported. + $this->getSqlType($type, $limit); + } catch (UnsupportedColumnTypeException $e) { + $type = Literal::from($type); + } + + $phinxType = [ + 'name' => $type, + 'limit' => $limit, + 'scale' => $scale, + ]; + + if ($type === static::PHINX_TYPE_ENUM || $type === static::PHINX_TYPE_SET) { + $values = trim($matches[6], '()'); + $phinxType['values'] = []; + $opened = false; + $escaped = false; + $wasEscaped = false; + $value = ''; + $valuesLength = strlen($values); + for ($i = 0; $i < $valuesLength; $i++) { + $char = $values[$i]; + if ($char === "'" && !$opened) { + $opened = true; + } elseif ( + !$escaped + && ($i + 1) < $valuesLength + && ( + $char === "'" && $values[$i + 1] === "'" + || $char === '\\' && $values[$i + 1] === '\\' + ) + ) { + $escaped = true; + } elseif ($char === "'" && $opened && !$escaped) { + $phinxType['values'][] = $value; + $value = ''; + $opened = false; + } elseif (($char === "'" || $char === '\\') && $opened && $escaped) { + $value .= $char; + $escaped = false; + $wasEscaped = true; + } elseif ($opened) { + if ($values[$i - 1] === '\\' && !$wasEscaped) { + if ($char === 'n') { + $char = "\n"; + } elseif ($char === 'r') { + $char = "\r"; + } elseif ($char === 't') { + $char = "\t"; + } + if ($values[$i] !== $char) { + $value = substr($value, 0, strlen($value) - 1); + } + } + $value .= $char; + $wasEscaped = false; + } + } + } + + return $phinxType; + } + + /** + * @inheritDoc + */ + public function createDatabase(string $name, array $options = []): void + { + $charset = $options['charset'] ?? 'utf8'; + + if (isset($options['collation'])) { + $this->execute(sprintf( + 'CREATE DATABASE `%s` DEFAULT CHARACTER SET `%s` COLLATE `%s`', + $name, + $charset, + $options['collation'] + )); + } else { + $this->execute(sprintf('CREATE DATABASE `%s` DEFAULT CHARACTER SET `%s`', $name, $charset)); + } + } + + /** + * @inheritDoc + */ + public function hasDatabase(string $name): bool + { + $rows = $this->fetchAll( + sprintf( + 'SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = \'%s\'', + $name + ) + ); + + foreach ($rows as $row) { + if (!empty($row)) { + return true; + } + } + + return false; + } + + /** + * @inheritDoc + */ + public function dropDatabase(string $name): void + { + $this->execute(sprintf('DROP DATABASE IF EXISTS `%s`', $name)); + $this->createdTables = []; + } + + /** + * Gets the MySQL Column Definition for a Column object. + * + * @param \Migrations\Db\Table\Column $column Column + * @return string + */ + protected function getColumnSqlDefinition(Column $column): string + { + if ($column->getType() instanceof Literal) { + $def = (string)$column->getType(); + } else { + $sqlType = $this->getSqlType($column->getType(), $column->getLimit()); + $def = strtoupper($sqlType['name']); + } + if ($column->getPrecision() && $column->getScale()) { + $def .= '(' . $column->getPrecision() . ',' . $column->getScale() . ')'; + } elseif (isset($sqlType['limit'])) { + $def .= '(' . $sqlType['limit'] . ')'; + } + + $values = $column->getValues(); + if ($values) { + $def .= '(' . implode(', ', array_map(function ($value) { + // we special case NULL as it's not actually allowed an enum value, + // and we want MySQL to issue an error on the create statement, but + // quote coerces it to an empty string, which will not error + return $value === null ? 'NULL' : $this->getConnection()->quote($value); + }, $values)) . ')'; + } + + $def .= $column->getEncoding() ? ' CHARACTER SET ' . $column->getEncoding() : ''; + $def .= $column->getCollation() ? ' COLLATE ' . $column->getCollation() : ''; + $def .= !$column->isSigned() && isset($this->signedColumnTypes[(string)$column->getType()]) ? ' unsigned' : ''; + $def .= $column->isNull() ? ' NULL' : ' NOT NULL'; + + if ( + version_compare($this->getAttribute(PDO::ATTR_SERVER_VERSION), '8', '>=') + && in_array($column->getType(), static::PHINX_TYPES_GEOSPATIAL) + && !is_null($column->getSrid()) + ) { + $def .= " SRID {$column->getSrid()}"; + } + + $def .= $column->isIdentity() ? ' AUTO_INCREMENT' : ''; + + $default = $column->getDefault(); + // MySQL 8 supports setting default for the following tested types, but only if they are "cast as expressions" + if ( + version_compare($this->getAttribute(PDO::ATTR_SERVER_VERSION), '8', '>=') && + is_string($default) && + in_array( + $column->getType(), + array_merge( + static::PHINX_TYPES_GEOSPATIAL, + [static::PHINX_TYPE_BLOB, static::PHINX_TYPE_JSON, static::PHINX_TYPE_TEXT] + ) + ) + ) { + $default = Literal::from('(' . $this->getConnection()->quote($column->getDefault()) . ')'); + } + $def .= $this->getDefaultValueDefinition($default, (string)$column->getType()); + + if ($column->getComment()) { + $def .= ' COMMENT ' . $this->getConnection()->quote((string)$column->getComment()); + } + + if ($column->getUpdate()) { + $def .= ' ON UPDATE ' . $column->getUpdate(); + } + + return $def; + } + + /** + * Gets the MySQL Index Definition for an Index object. + * + * @param \Migrations\Db\Table\Index $index Index + * @return string + */ + protected function getIndexSqlDefinition(Index $index): string + { + $def = ''; + $limit = ''; + + if ($index->getType() === Index::UNIQUE) { + $def .= ' UNIQUE'; + } + + if ($index->getType() === Index::FULLTEXT) { + $def .= ' FULLTEXT'; + } + + $def .= ' KEY'; + + if (is_string($index->getName())) { + $def .= ' `' . $index->getName() . '`'; + } + + $columnNames = (array)$index->getColumns(); + $order = $index->getOrder() ?? []; + $columnNames = array_map(function ($columnName) use ($order) { + $ret = '`' . $columnName . '`'; + if (isset($order[$columnName])) { + $ret .= ' ' . $order[$columnName]; + } + + return $ret; + }, $columnNames); + + if (!is_array($index->getLimit())) { + if ($index->getLimit()) { + $limit = '(' . $index->getLimit() . ')'; + } + $def .= ' (' . implode(',', $columnNames) . $limit . ')'; + } else { + $columns = (array)$index->getColumns(); + $limits = $index->getLimit(); + $def .= ' ('; + foreach ($columns as $column) { + $limit = !isset($limits[$column]) || $limits[$column] <= 0 ? '' : '(' . $limits[$column] . ')'; + $columnSort = $order[$column] ?? ''; + $def .= '`' . $column . '`' . $limit . ' ' . $columnSort . ', '; + } + $def = rtrim($def, ', '); + $def .= ' )'; + } + + return $def; + } + + /** + * Gets the MySQL Foreign Key Definition for an ForeignKey object. + * + * @param \Migrations\Db\Table\ForeignKey $foreignKey Foreign key + * @return string + */ + protected function getForeignKeySqlDefinition(ForeignKey $foreignKey): string + { + $def = ''; + if ($foreignKey->getConstraint()) { + $def .= ' CONSTRAINT ' . $this->quoteColumnName((string)$foreignKey->getConstraint()); + } + $columnNames = []; + foreach ($foreignKey->getColumns() as $column) { + $columnNames[] = $this->quoteColumnName($column); + } + $def .= ' FOREIGN KEY (' . implode(',', $columnNames) . ')'; + $refColumnNames = []; + foreach ($foreignKey->getReferencedColumns() as $column) { + $refColumnNames[] = $this->quoteColumnName($column); + } + $def .= ' REFERENCES ' . $this->quoteTableName($foreignKey->getReferencedTable()->getName()) . ' (' . implode(',', $refColumnNames) . ')'; + if ($foreignKey->getOnDelete()) { + $def .= ' ON DELETE ' . $foreignKey->getOnDelete(); + } + if ($foreignKey->getOnUpdate()) { + $def .= ' ON UPDATE ' . $foreignKey->getOnUpdate(); + } + + return $def; + } + + /** + * Describes a database table. This is a MySQL adapter specific method. + * + * @param string $tableName Table name + * @return array + */ + public function describeTable(string $tableName): array + { + $options = $this->getOptions(); + + // mysql specific + $sql = sprintf( + "SELECT * + FROM information_schema.tables + WHERE table_schema = '%s' + AND table_name = '%s'", + $options['name'], + $tableName + ); + + $table = $this->fetchRow($sql); + + return $table !== false ? $table : []; + } + + /** + * Returns MySQL column types (inherited and MySQL specified). + * + * @return string[] + */ + public function getColumnTypes(): array + { + return array_merge(parent::getColumnTypes(), static::$specificColumnTypes); + } + + /** + * @inheritDoc + */ + public function getDecoratedConnection(): Connection + { + if (isset($this->decoratedConnection)) { + return $this->decoratedConnection; + } + + $options = $this->getOptions(); + $options = [ + 'username' => $options['user'] ?? null, + 'password' => $options['pass'] ?? null, + 'database' => $options['name'], + 'quoteIdentifiers' => true, + ] + $options; + + return $this->decoratedConnection = $this->buildConnection(MysqlDriver::class, $options); + } +} diff --git a/src/Db/Adapter/PdoAdapter.php b/src/Db/Adapter/PdoAdapter.php new file mode 100644 index 00000000..edfade4e --- /dev/null +++ b/src/Db/Adapter/PdoAdapter.php @@ -0,0 +1,1038 @@ +isDryRunEnabled() && + $this->getOutput()->getVerbosity() < OutputInterface::VERBOSITY_VERY_VERBOSE + ) { + return; + } + + $this->getOutput()->writeln($message); + } + + /** + * Create PDO connection + * + * @param string $dsn Connection string + * @param string|null $username Database username + * @param string|null $password Database password + * @param array $options Connection options + * @return \PDO + */ + protected function createPdoConnection(string $dsn, ?string $username = null, ?string $password = null, array $options = []): PDO + { + $adapterOptions = $this->getOptions() + [ + 'attr_errmode' => PDO::ERRMODE_EXCEPTION, + ]; + + try { + $db = new PDO($dsn, $username, $password, $options); + + foreach ($adapterOptions as $key => $option) { + if (strpos($key, 'attr_') === 0) { + $pdoConstant = '\PDO::' . strtoupper($key); + if (!defined($pdoConstant)) { + throw new UnexpectedValueException('Invalid PDO attribute: ' . $key . ' (' . $pdoConstant . ')'); + } + $db->setAttribute(constant($pdoConstant), $option); + } + } + } catch (PDOException $e) { + throw new InvalidArgumentException(sprintf( + 'There was a problem connecting to the database: %s', + $e->getMessage() + ), 0, $e); + } + + return $db; + } + + /** + * @inheritDoc + */ + public function setOptions(array $options): AdapterInterface + { + parent::setOptions($options); + + if (isset($options['connection'])) { + $this->setConnection($options['connection']); + } + + return $this; + } + + /** + * Sets the database connection. + * + * @param \PDO $connection Connection + * @return \Migrations\Db\Adapter\AdapterInterface + */ + public function setConnection(PDO $connection): AdapterInterface + { + $this->connection = $connection; + + // Create the schema table if it doesn't already exist + if (!$this->hasTable($this->getSchemaTableName())) { + $this->createSchemaTable(); + } else { + $table = new DbTable($this->getSchemaTableName(), [], $this); + if (!$table->hasColumn('migration_name')) { + $table + ->addColumn( + 'migration_name', + 'string', + ['limit' => 100, 'after' => 'version', 'default' => null, 'null' => true] + ) + ->save(); + } + if (!$table->hasColumn('breakpoint')) { + $table + ->addColumn('breakpoint', 'boolean', ['default' => false, 'null' => false]) + ->save(); + } + } + + return $this; + } + + /** + * Gets the database connection + * + * @return \PDO + */ + public function getConnection(): PDO + { + if ($this->connection === null) { + $this->connect(); + } + + /** @var \PDO $this->connection */ + return $this->connection; + } + + /** + * @inheritDoc + */ + abstract public function connect(): void; + + /** + * @inheritDoc + */ + abstract public function disconnect(): void; + + /** + * @inheritDoc + */ + public function execute(string $sql, array $params = []): int + { + $sql = rtrim($sql, "; \t\n\r\0\x0B") . ';'; + $this->verboseLog($sql); + + if ($this->isDryRunEnabled()) { + return 0; + } + + if (empty($params)) { + $result = $this->getConnection()->exec($sql); + + return is_int($result) ? $result : 0; + } + + $stmt = $this->getConnection()->prepare($sql); + $result = $stmt->execute($params); + + return $result ? $stmt->rowCount() : 0; + } + + /** + * Returns the Cake\Database connection object using the same underlying + * PDO object as this connection. + * + * @return \Cake\Database\Connection + */ + abstract public function getDecoratedConnection(): Connection; + + /** + * Build connection instance. + * + * @param class-string<\Cake\Database\Driver> $driverClass Driver class name. + * @param array $options Options. + * @return \Cake\Database\Connection + */ + protected function buildConnection(string $driverClass, array $options): Connection + { + $driver = new $driverClass($options); + $prop = new ReflectionProperty($driver, 'pdo'); + $prop->setValue($driver, $this->connection); + + return new Connection(['driver' => $driver] + $options); + } + + /** + * @inheritDoc + */ + public function getQueryBuilder(string $type): Query + { + return match ($type) { + Query::TYPE_SELECT => $this->getDecoratedConnection()->selectQuery(), + Query::TYPE_INSERT => $this->getDecoratedConnection()->insertQuery(), + Query::TYPE_UPDATE => $this->getDecoratedConnection()->updateQuery(), + Query::TYPE_DELETE => $this->getDecoratedConnection()->deleteQuery(), + default => throw new InvalidArgumentException( + 'Query type must be one of: `select`, `insert`, `update`, `delete`.' + ) + }; + } + + /** + * Executes a query and returns PDOStatement. + * + * @param string $sql SQL + * @return mixed + */ + public function query(string $sql, array $params = []): mixed + { + if (empty($params)) { + return $this->getConnection()->query($sql); + } + $stmt = $this->getConnection()->prepare($sql); + $result = $stmt->execute($params); + + return $result ? $stmt : false; + } + + /** + * @inheritDoc + */ + public function fetchRow(string $sql): array|false + { + return $this->query($sql)->fetch(); + } + + /** + * @inheritDoc + */ + public function fetchAll(string $sql): array + { + return $this->query($sql)->fetchAll(); + } + + /** + * @inheritDoc + */ + public function insert(Table $table, array $row): void + { + $sql = sprintf( + 'INSERT INTO %s ', + $this->quoteTableName($table->getName()) + ); + $columns = array_keys($row); + $sql .= '(' . implode(', ', array_map([$this, 'quoteColumnName'], $columns)) . ')'; + + foreach ($row as $column => $value) { + if (is_bool($value)) { + $row[$column] = $this->castToBool($value); + } + } + + if ($this->isDryRunEnabled()) { + $sql .= ' VALUES (' . implode(', ', array_map([$this, 'quoteValue'], $row)) . ');'; + $this->output->writeln($sql); + } else { + $sql .= ' VALUES (' . implode(', ', array_fill(0, count($columns), '?')) . ')'; + $stmt = $this->getConnection()->prepare($sql); + $stmt->execute(array_values($row)); + } + } + + /** + * Quotes a database value. + * + * @param mixed $value The value to quote + * @return mixed + */ + protected function quoteValue(mixed $value): mixed + { + if (is_numeric($value)) { + return $value; + } + + if ($value === null) { + return 'null'; + } + + return $this->getConnection()->quote($value); + } + + /** + * Quotes a database string. + * + * @param string $value The string to quote + * @return string + */ + protected function quoteString(string $value): string + { + return $this->getConnection()->quote($value); + } + + /** + * @inheritDoc + */ + public function bulkinsert(Table $table, array $rows): void + { + $sql = sprintf( + 'INSERT INTO %s ', + $this->quoteTableName($table->getName()) + ); + $current = current($rows); + $keys = array_keys($current); + + $callback = fn ($key) => $this->quoteColumnName($key); + $sql .= '(' . implode(', ', array_map($callback, $keys)) . ') VALUES '; + + if ($this->isDryRunEnabled()) { + $values = array_map(function ($row) { + return '(' . implode(', ', array_map([$this, 'quoteValue'], $row)) . ')'; + }, $rows); + $sql .= implode(', ', $values) . ';'; + $this->output->writeln($sql); + } else { + $count_keys = count($keys); + $query = '(' . implode(', ', array_fill(0, $count_keys, '?')) . ')'; + $count_vars = count($rows); + $queries = array_fill(0, $count_vars, $query); + $sql .= implode(',', $queries); + $stmt = $this->getConnection()->prepare($sql); + $vals = []; + + foreach ($rows as $row) { + foreach ($row as $v) { + if (is_bool($v)) { + $vals[] = $this->castToBool($v); + } else { + $vals[] = $v; + } + } + } + + $stmt->execute($vals); + } + } + + /** + * @inheritDoc + */ + public function getVersions(): array + { + $rows = $this->getVersionLog(); + + return array_keys($rows); + } + + /** + * {@inheritDoc} + * + * @throws \RuntimeException + */ + public function getVersionLog(): array + { + $result = []; + + switch ($this->options['version_order']) { + case Config::VERSION_ORDER_CREATION_TIME: + $orderBy = 'version ASC'; + break; + case Config::VERSION_ORDER_EXECUTION_TIME: + $orderBy = 'start_time ASC, version ASC'; + break; + default: + throw new RuntimeException('Invalid version_order configuration option'); + } + + // This will throw an exception if doing a --dry-run without any migrations as phinxlog + // does not exist, so in that case, we can just expect to trivially return empty set + try { + $rows = $this->fetchAll(sprintf('SELECT * FROM %s ORDER BY %s', $this->quoteTableName($this->getSchemaTableName()), $orderBy)); + } catch (PDOException $e) { + if (!$this->isDryRunEnabled()) { + throw $e; + } + $rows = []; + } + + foreach ($rows as $version) { + $result[(int)$version['version']] = $version; + } + + return $result; + } + + /** + * @inheritDoc + */ + public function migrated(MigrationInterface $migration, string $direction, string $startTime, string $endTime): AdapterInterface + { + if (strcasecmp($direction, MigrationInterface::UP) === 0) { + // up + $sql = sprintf( + "INSERT INTO %s (%s, %s, %s, %s, %s) VALUES ('%s', '%s', '%s', '%s', %s);", + $this->quoteTableName($this->getSchemaTableName()), + $this->quoteColumnName('version'), + $this->quoteColumnName('migration_name'), + $this->quoteColumnName('start_time'), + $this->quoteColumnName('end_time'), + $this->quoteColumnName('breakpoint'), + $migration->getVersion(), + substr($migration->getName(), 0, 100), + $startTime, + $endTime, + $this->castToBool(false) + ); + + $this->execute($sql); + } else { + // down + $sql = sprintf( + "DELETE FROM %s WHERE %s = '%s'", + $this->quoteTableName($this->getSchemaTableName()), + $this->quoteColumnName('version'), + $migration->getVersion() + ); + + $this->execute($sql); + } + + return $this; + } + + /** + * @inheritDoc + */ + public function toggleBreakpoint(MigrationInterface $migration): AdapterInterface + { + $this->query( + sprintf( + 'UPDATE %1$s SET %2$s = CASE %2$s WHEN %3$s THEN %4$s ELSE %3$s END, %7$s = %7$s WHERE %5$s = \'%6$s\';', + $this->quoteTableName($this->getSchemaTableName()), + $this->quoteColumnName('breakpoint'), + $this->castToBool(true), + $this->castToBool(false), + $this->quoteColumnName('version'), + $migration->getVersion(), + $this->quoteColumnName('start_time') + ) + ); + + return $this; + } + + /** + * @inheritDoc + */ + public function resetAllBreakpoints(): int + { + return $this->execute( + sprintf( + 'UPDATE %1$s SET %2$s = %3$s, %4$s = %4$s WHERE %2$s <> %3$s;', + $this->quoteTableName($this->getSchemaTableName()), + $this->quoteColumnName('breakpoint'), + $this->castToBool(false), + $this->quoteColumnName('start_time') + ) + ); + } + + /** + * @inheritDoc + */ + public function setBreakpoint(MigrationInterface $migration): AdapterInterface + { + $this->markBreakpoint($migration, true); + + return $this; + } + + /** + * @inheritDoc + */ + public function unsetBreakpoint(MigrationInterface $migration): AdapterInterface + { + $this->markBreakpoint($migration, false); + + return $this; + } + + /** + * Mark a migration breakpoint. + * + * @param \Phinx\Migration\MigrationInterface $migration The migration target for the breakpoint + * @param bool $state The required state of the breakpoint + * @return \Migrations\Db\Adapter\AdapterInterface + */ + protected function markBreakpoint(MigrationInterface $migration, bool $state): AdapterInterface + { + $this->query( + sprintf( + 'UPDATE %1$s SET %2$s = %3$s, %4$s = %4$s WHERE %5$s = \'%6$s\';', + $this->quoteTableName($this->getSchemaTableName()), + $this->quoteColumnName('breakpoint'), + $this->castToBool($state), + $this->quoteColumnName('start_time'), + $this->quoteColumnName('version'), + $migration->getVersion() + ) + ); + + return $this; + } + + /** + * {@inheritDoc} + * + * @throws \BadMethodCallException + * @return void + */ + public function createSchema(string $schemaName = 'public'): void + { + throw new BadMethodCallException('Creating a schema is not supported'); + } + + /** + * {@inheritDoc} + * + * @throws \BadMethodCallException + * @return void + */ + public function dropSchema(string $schemaName): void + { + throw new BadMethodCallException('Dropping a schema is not supported'); + } + + /** + * @inheritDoc + */ + public function getColumnTypes(): array + { + return [ + 'string', + 'char', + 'text', + 'tinyinteger', + 'smallinteger', + 'integer', + 'biginteger', + 'bit', + 'float', + 'decimal', + 'double', + 'datetime', + 'timestamp', + 'time', + 'date', + 'blob', + 'binary', + 'varbinary', + 'boolean', + 'uuid', + // Geospatial data types + 'geometry', + 'point', + 'linestring', + 'polygon', + ]; + } + + /** + * @inheritDoc + */ + public function castToBool($value): mixed + { + return (bool)$value ? 1 : 0; + } + + /** + * Retrieve a database connection attribute + * + * @see https://php.net/manual/en/pdo.getattribute.php + * @param int $attribute One of the PDO::ATTR_* constants + * @return mixed + */ + public function getAttribute(int $attribute): mixed + { + return $this->getConnection()->getAttribute($attribute); + } + + /** + * Get the definition for a `DEFAULT` statement. + * + * @param mixed $default Default value + * @param string|null $columnType column type added + * @return string + */ + protected function getDefaultValueDefinition(mixed $default, ?string $columnType = null): string + { + if ($default instanceof Literal) { + $default = (string)$default; + } elseif (is_string($default) && stripos($default, 'CURRENT_TIMESTAMP') !== 0) { + // Ensure a defaults of CURRENT_TIMESTAMP(3) is not quoted. + $default = $this->getConnection()->quote($default); + } elseif (is_bool($default)) { + $default = $this->castToBool($default); + } elseif ($default !== null && $columnType === static::PHINX_TYPE_BOOLEAN) { + $default = $this->castToBool((bool)$default); + } + + return isset($default) ? " DEFAULT $default" : ''; + } + + /** + * Executes all the ALTER TABLE instructions passed for the given table + * + * @param string $tableName The table name to use in the ALTER statement + * @param \Migrations\Db\AlterInstructions $instructions The object containing the alter sequence + * @return void + */ + protected function executeAlterSteps(string $tableName, AlterInstructions $instructions): void + { + $alter = sprintf('ALTER TABLE %s %%s', $this->quoteTableName($tableName)); + $instructions->execute($alter, [$this, 'execute']); + } + + /** + * @inheritDoc + */ + public function addColumn(Table $table, Column $column): void + { + $instructions = $this->getAddColumnInstructions($table, $column); + $this->executeAlterSteps($table->getName(), $instructions); + } + + /** + * Returns the instructions to add the specified column to a database table. + * + * @param \Migrations\Db\Table\Table $table Table + * @param \Migrations\Db\Table\Column $column Column + * @return \Migrations\Db\AlterInstructions + */ + abstract protected function getAddColumnInstructions(Table $table, Column $column): AlterInstructions; + + /** + * @inheritdoc + */ + public function renameColumn(string $tableName, string $columnName, string $newColumnName): void + { + $instructions = $this->getRenameColumnInstructions($tableName, $columnName, $newColumnName); + $this->executeAlterSteps($tableName, $instructions); + } + + /** + * Returns the instructions to rename the specified column. + * + * @param string $tableName Table name + * @param string $columnName Column Name + * @param string $newColumnName New Column Name + * @return \Migrations\Db\AlterInstructions + */ + abstract protected function getRenameColumnInstructions(string $tableName, string $columnName, string $newColumnName): AlterInstructions; + + /** + * @inheritdoc + */ + public function changeColumn(string $tableName, string $columnName, Column $newColumn): void + { + $instructions = $this->getChangeColumnInstructions($tableName, $columnName, $newColumn); + $this->executeAlterSteps($tableName, $instructions); + } + + /** + * Returns the instructions to change a table column type. + * + * @param string $tableName Table name + * @param string $columnName Column Name + * @param \Migrations\Db\Table\Column $newColumn New Column + * @return \Migrations\Db\AlterInstructions + */ + abstract protected function getChangeColumnInstructions(string $tableName, string $columnName, Column $newColumn): AlterInstructions; + + /** + * @inheritdoc + */ + public function dropColumn(string $tableName, string $columnName): void + { + $instructions = $this->getDropColumnInstructions($tableName, $columnName); + $this->executeAlterSteps($tableName, $instructions); + } + + /** + * Returns the instructions to drop the specified column. + * + * @param string $tableName Table name + * @param string $columnName Column Name + * @return \Migrations\Db\AlterInstructions + */ + abstract protected function getDropColumnInstructions(string $tableName, string $columnName): AlterInstructions; + + /** + * @inheritdoc + */ + public function addIndex(Table $table, Index $index): void + { + $instructions = $this->getAddIndexInstructions($table, $index); + $this->executeAlterSteps($table->getName(), $instructions); + } + + /** + * Returns the instructions to add the specified index to a database table. + * + * @param \Migrations\Db\Table\Table $table Table + * @param \Migrations\Db\Table\Index $index Index + * @return \Migrations\Db\AlterInstructions + */ + abstract protected function getAddIndexInstructions(Table $table, Index $index): AlterInstructions; + + /** + * @inheritdoc + */ + public function dropIndex(string $tableName, $columns): void + { + $instructions = $this->getDropIndexByColumnsInstructions($tableName, $columns); + $this->executeAlterSteps($tableName, $instructions); + } + + /** + * Returns the instructions to drop the specified index from a database table. + * + * @param string $tableName The name of of the table where the index is + * @param string|string[] $columns Column(s) + * @return \Migrations\Db\AlterInstructions + */ + abstract protected function getDropIndexByColumnsInstructions(string $tableName, string|array $columns): AlterInstructions; + + /** + * @inheritdoc + */ + public function dropIndexByName(string $tableName, string $indexName): void + { + $instructions = $this->getDropIndexByNameInstructions($tableName, $indexName); + $this->executeAlterSteps($tableName, $instructions); + } + + /** + * Returns the instructions to drop the index specified by name from a database table. + * + * @param string $tableName The table name whe the index is + * @param string $indexName The name of the index + * @return \Migrations\Db\AlterInstructions + */ + abstract protected function getDropIndexByNameInstructions(string $tableName, string $indexName): AlterInstructions; + + /** + * @inheritdoc + */ + public function addForeignKey(Table $table, ForeignKey $foreignKey): void + { + $instructions = $this->getAddForeignKeyInstructions($table, $foreignKey); + $this->executeAlterSteps($table->getName(), $instructions); + } + + /** + * Returns the instructions to adds the specified foreign key to a database table. + * + * @param \Migrations\Db\Table\Table $table The table to add the constraint to + * @param \Migrations\Db\Table\ForeignKey $foreignKey The foreign key to add + * @return \Migrations\Db\AlterInstructions + */ + abstract protected function getAddForeignKeyInstructions(Table $table, ForeignKey $foreignKey): AlterInstructions; + + /** + * @inheritDoc + */ + public function dropForeignKey(string $tableName, array $columns, ?string $constraint = null): void + { + if ($constraint) { + $instructions = $this->getDropForeignKeyInstructions($tableName, $constraint); + } else { + $instructions = $this->getDropForeignKeyByColumnsInstructions($tableName, $columns); + } + + $this->executeAlterSteps($tableName, $instructions); + } + + /** + * Returns the instructions to drop the specified foreign key from a database table. + * + * @param string $tableName The table where the foreign key constraint is + * @param string $constraint Constraint name + * @return \Migrations\Db\AlterInstructions + */ + abstract protected function getDropForeignKeyInstructions(string $tableName, string $constraint): AlterInstructions; + + /** + * Returns the instructions to drop the specified foreign key from a database table. + * + * @param string $tableName The table where the foreign key constraint is + * @param string[] $columns The list of column names + * @return \Migrations\Db\AlterInstructions + */ + abstract protected function getDropForeignKeyByColumnsInstructions(string $tableName, array $columns): AlterInstructions; + + /** + * @inheritdoc + */ + public function dropTable(string $tableName): void + { + $instructions = $this->getDropTableInstructions($tableName); + $this->executeAlterSteps($tableName, $instructions); + } + + /** + * Returns the instructions to drop the specified database table. + * + * @param string $tableName Table name + * @return \Migrations\Db\AlterInstructions + */ + abstract protected function getDropTableInstructions(string $tableName): AlterInstructions; + + /** + * @inheritdoc + */ + public function renameTable(string $tableName, string $newName): void + { + $instructions = $this->getRenameTableInstructions($tableName, $newName); + $this->executeAlterSteps($tableName, $instructions); + } + + /** + * Returns the instructions to rename the specified database table. + * + * @param string $tableName Table name + * @param string $newTableName New Name + * @return \Migrations\Db\AlterInstructions + */ + abstract protected function getRenameTableInstructions(string $tableName, string $newTableName): AlterInstructions; + + /** + * @inheritdoc + */ + public function changePrimaryKey(Table $table, $newColumns): void + { + $instructions = $this->getChangePrimaryKeyInstructions($table, $newColumns); + $this->executeAlterSteps($table->getName(), $instructions); + } + + /** + * Returns the instructions to change the primary key for the specified database table. + * + * @param \Migrations\Db\Table\Table $table Table + * @param string|string[]|null $newColumns Column name(s) to belong to the primary key, or null to drop the key + * @return \Migrations\Db\AlterInstructions + */ + abstract protected function getChangePrimaryKeyInstructions(Table $table, string|array|null $newColumns): AlterInstructions; + + /** + * @inheritdoc + */ + public function changeComment(Table $table, $newComment): void + { + $instructions = $this->getChangeCommentInstructions($table, $newComment); + $this->executeAlterSteps($table->getName(), $instructions); + } + + /** + * Returns the instruction to change the comment for the specified database table. + * + * @param \Migrations\Db\Table\Table $table Table + * @param string|null $newComment New comment string, or null to drop the comment + * @return \Migrations\Db\AlterInstructions + */ + abstract protected function getChangeCommentInstructions(Table $table, ?string $newComment): AlterInstructions; + + /** + * {@inheritDoc} + * + * @throws \InvalidArgumentException + * @return void + */ + public function executeActions(Table $table, array $actions): void + { + $instructions = new AlterInstructions(); + + foreach ($actions as $action) { + switch (true) { + case $action instanceof AddColumn: + /** @var \Migrations\Db\Action\AddColumn $action */ + $instructions->merge($this->getAddColumnInstructions($table, $action->getColumn())); + break; + + case $action instanceof AddIndex: + /** @var \Migrations\Db\Action\AddIndex $action */ + $instructions->merge($this->getAddIndexInstructions($table, $action->getIndex())); + break; + + case $action instanceof AddForeignKey: + /** @var \Migrations\Db\Action\AddForeignKey $action */ + $instructions->merge($this->getAddForeignKeyInstructions($table, $action->getForeignKey())); + break; + + case $action instanceof ChangeColumn: + /** @var \Migrations\Db\Action\ChangeColumn $action */ + $instructions->merge($this->getChangeColumnInstructions( + $table->getName(), + $action->getColumnName(), + $action->getColumn() + )); + break; + + case $action instanceof DropForeignKey && !$action->getForeignKey()->getConstraint(): + /** @var \Migrations\Db\Action\DropForeignKey $action */ + $instructions->merge($this->getDropForeignKeyByColumnsInstructions( + $table->getName(), + $action->getForeignKey()->getColumns() + )); + break; + + case $action instanceof DropForeignKey && $action->getForeignKey()->getConstraint(): + /** @var \Migrations\Db\Action\DropForeignKey $action */ + $instructions->merge($this->getDropForeignKeyInstructions( + $table->getName(), + (string)$action->getForeignKey()->getConstraint() + )); + break; + + case $action instanceof DropIndex && $action->getIndex()->getName() !== null: + /** @var \Migrations\Db\Action\DropIndex $action */ + $instructions->merge($this->getDropIndexByNameInstructions( + $table->getName(), + (string)$action->getIndex()->getName() + )); + break; + + case $action instanceof DropIndex && $action->getIndex()->getName() == null: + /** @var \Migrations\Db\Action\DropIndex $action */ + $instructions->merge($this->getDropIndexByColumnsInstructions( + $table->getName(), + (array)$action->getIndex()->getColumns() + )); + break; + + case $action instanceof DropTable: + /** @var \Migrations\Db\Action\DropTable $action */ + $instructions->merge($this->getDropTableInstructions( + $table->getName() + )); + break; + + case $action instanceof RemoveColumn: + /** @var \Migrations\Db\Action\RemoveColumn $action */ + $instructions->merge($this->getDropColumnInstructions( + $table->getName(), + (string)$action->getColumn()->getName() + )); + break; + + case $action instanceof RenameColumn: + /** @var \Migrations\Db\Action\RenameColumn $action */ + $instructions->merge($this->getRenameColumnInstructions( + $table->getName(), + (string)$action->getColumn()->getName(), + $action->getNewName() + )); + break; + + case $action instanceof RenameTable: + /** @var \Migrations\Db\Action\RenameTable $action */ + $instructions->merge($this->getRenameTableInstructions( + $table->getName(), + $action->getNewName() + )); + break; + + case $action instanceof ChangePrimaryKey: + /** @var \Migrations\Db\Action\ChangePrimaryKey $action */ + $instructions->merge($this->getChangePrimaryKeyInstructions( + $table, + $action->getNewColumns() + )); + break; + + case $action instanceof ChangeComment: + /** @var \Migrations\Db\Action\ChangeComment $action */ + $instructions->merge($this->getChangeCommentInstructions( + $table, + $action->getNewComment() + )); + break; + + default: + throw new InvalidArgumentException( + sprintf("Don't know how to execute action: '%s'", get_class($action)) + ); + } + } + + $this->executeAlterSteps($table->getName(), $instructions); + } +} diff --git a/src/Db/Adapter/UnsupportedColumnTypeException.php b/src/Db/Adapter/UnsupportedColumnTypeException.php new file mode 100644 index 00000000..a3b28311 --- /dev/null +++ b/src/Db/Adapter/UnsupportedColumnTypeException.php @@ -0,0 +1,18 @@ +alterParts = $alterParts; + $this->postSteps = $postSteps; + } + + /** + * Adds another part to the single ALTER instruction + * + * @param string $part The SQL snipped to add as part of the ALTER instruction + * @return void + */ + public function addAlter(string $part): void + { + $this->alterParts[] = $part; + } + + /** + * Adds a SQL command to be executed after the ALTER instruction. + * This method allows a callable, with will get an empty array as state + * for the first time and will pass the return value of the callable to + * the next callable, if present. + * + * This allows to keep a single state across callbacks. + * + * @param string|callable $sql The SQL to run after, or a callable to execute + * @return void + */ + public function addPostStep(string|callable $sql): void + { + $this->postSteps[] = $sql; + } + + /** + * Returns the alter SQL snippets + * + * @return string[] + */ + public function getAlterParts(): array + { + return $this->alterParts; + } + + /** + * Returns the SQL commands to run after the ALTER instruction + * + * @return (string|callable)[] + */ + public function getPostSteps(): array + { + return $this->postSteps; + } + + /** + * Merges another AlterInstructions object to this one + * + * @param \Migrations\Db\AlterInstructions $other The other collection of instructions to merge in + * @return void + */ + public function merge(AlterInstructions $other): void + { + $this->alterParts = array_merge($this->alterParts, $other->getAlterParts()); + $this->postSteps = array_merge($this->postSteps, $other->getPostSteps()); + } + + /** + * Executes the ALTER instruction and all of the post steps. + * + * @param string $alterTemplate The template for the alter instruction + * @param callable $executor The function to be used to execute all instructions + * @return void + */ + public function execute(string $alterTemplate, callable $executor): void + { + if ($this->alterParts) { + $alter = sprintf($alterTemplate, implode(', ', $this->alterParts)); + $executor($alter); + } + + $state = []; + + foreach ($this->postSteps as $instruction) { + if (is_callable($instruction)) { + $state = $instruction($state); + continue; + } + + $executor($instruction); + } + } +} diff --git a/src/Db/Plan/Plan.php b/src/Db/Plan/Plan.php index 7ac791fa..4a489697 100644 --- a/src/Db/Plan/Plan.php +++ b/src/Db/Plan/Plan.php @@ -22,9 +22,9 @@ use Migrations\Db\Action\RemoveColumn; use Migrations\Db\Action\RenameColumn; use Migrations\Db\Action\RenameTable; +use Migrations\Db\Adapter\AdapterInterface; use Migrations\Db\Plan\Solver\ActionSplitter; use Migrations\Db\Table\Table; -use Phinx\Db\Adapter\AdapterInterface; /** * A Plan takes an Intent and transforms int into a sequence of @@ -138,7 +138,7 @@ protected function inverseUpdatesSequence(): array /** * Executes this plan using the given AdapterInterface * - * @param \Phinx\Db\Adapter\AdapterInterface $executor The executor object for the plan + * @param \Migrations\Db\Adapter\AdapterInterface $executor The executor object for the plan * @return void */ public function execute(AdapterInterface $executor): void @@ -157,7 +157,7 @@ public function execute(AdapterInterface $executor): void /** * Executes the inverse plan (rollback the actions) with the given AdapterInterface:w * - * @param \Phinx\Db\Adapter\AdapterInterface $executor The executor object for the plan + * @param \Migrations\Db\Adapter\AdapterInterface $executor The executor object for the plan * @return void */ public function executeInverse(AdapterInterface $executor): void diff --git a/src/Db/Table.php b/src/Db/Table.php new file mode 100644 index 00000000..ad010a1c --- /dev/null +++ b/src/Db/Table.php @@ -0,0 +1,726 @@ + $options Options + * @param \Migrations\Db\Adapter\AdapterInterface|null $adapter Database Adapter + */ + public function __construct(string $name, array $options = [], ?AdapterInterface $adapter = null) + { + $this->table = new TableValue($name, $options); + $this->actions = new Intent(); + + if ($adapter !== null) { + $this->setAdapter($adapter); + } + } + + /** + * Gets the table name. + * + * @return string + */ + public function getName(): string + { + return $this->table->getName(); + } + + /** + * Gets the table options. + * + * @return array + */ + public function getOptions(): array + { + return $this->table->getOptions(); + } + + /** + * Gets the table name and options as an object + * + * @return \Migrations\Db\Table\Table + */ + public function getTable(): TableValue + { + return $this->table; + } + + /** + * Sets the database adapter. + * + * @param \Migrations\Db\Adapter\AdapterInterface $adapter Database Adapter + * @return $this + */ + public function setAdapter(AdapterInterface $adapter) + { + $this->adapter = $adapter; + + return $this; + } + + /** + * Gets the database adapter. + * + * @throws \RuntimeException + * @return \Migrations\Db\Adapter\AdapterInterface + */ + public function getAdapter(): AdapterInterface + { + if (!$this->adapter) { + throw new RuntimeException('There is no database adapter set yet, cannot proceed'); + } + + return $this->adapter; + } + + /** + * Does the table have pending actions? + * + * @return bool + */ + public function hasPendingActions(): bool + { + return count($this->actions->getActions()) > 0 || count($this->data) > 0; + } + + /** + * Does the table exist? + * + * @return bool + */ + public function exists(): bool + { + return $this->getAdapter()->hasTable($this->getName()); + } + + /** + * Drops the database table. + * + * @return $this + */ + public function drop() + { + $this->actions->addAction(new DropTable($this->table)); + + return $this; + } + + /** + * Renames the database table. + * + * @param string $newTableName New Table Name + * @return $this + */ + public function rename(string $newTableName) + { + $this->actions->addAction(new RenameTable($this->table, $newTableName)); + + return $this; + } + + /** + * Changes the primary key of the database table. + * + * @param string|string[]|null $columns Column name(s) to belong to the primary key, or null to drop the key + * @return $this + */ + public function changePrimaryKey(string|array|null $columns) + { + $this->actions->addAction(new ChangePrimaryKey($this->table, $columns)); + + return $this; + } + + /** + * Checks to see if a primary key exists. + * + * @param string|string[] $columns Column(s) + * @param string|null $constraint Constraint names + * @return bool + */ + public function hasPrimaryKey(string|array $columns, ?string $constraint = null): bool + { + return $this->getAdapter()->hasPrimaryKey($this->getName(), $columns, $constraint); + } + + /** + * Changes the comment of the database table. + * + * @param string|null $comment New comment string, or null to drop the comment + * @return $this + */ + public function changeComment(?string $comment) + { + $this->actions->addAction(new ChangeComment($this->table, $comment)); + + return $this; + } + + /** + * Gets an array of the table columns. + * + * @return \Migrations\Db\Table\Column[] + */ + public function getColumns(): array + { + return $this->getAdapter()->getColumns($this->getName()); + } + + /** + * Gets a table column if it exists. + * + * @param string $name Column name + * @return \Migrations\Db\Table\Column|null + */ + public function getColumn(string $name): ?Column + { + $columns = array_filter( + $this->getColumns(), + function ($column) use ($name) { + return $column->getName() === $name; + } + ); + + return array_pop($columns); + } + + /** + * Sets an array of data to be inserted. + * + * @param array $data Data + * @return $this + */ + public function setData(array $data) + { + $this->data = $data; + + return $this; + } + + /** + * Gets the data waiting to be inserted. + * + * @return array + */ + public function getData(): array + { + return $this->data; + } + + /** + * Resets all of the pending data to be inserted + * + * @return void + */ + public function resetData(): void + { + $this->setData([]); + } + + /** + * Resets all of the pending table changes. + * + * @return void + */ + public function reset(): void + { + $this->actions = new Intent(); + $this->resetData(); + } + + /** + * Add a table column. + * + * Type can be: string, text, integer, float, decimal, datetime, timestamp, + * time, date, binary, boolean. + * + * Valid options can be: limit, default, null, precision or scale. + * + * @param string|\Migrations\Db\Table\Column $columnName Column Name + * @param string|\Migrations\Db\Literal|null $type Column Type + * @param array $options Column Options + * @throws \InvalidArgumentException + * @return $this + */ + public function addColumn(string|Column $columnName, string|Literal|null $type = null, array $options = []) + { + assert($columnName instanceof Column || $type !== null); + if ($columnName instanceof Column) { + $action = new AddColumn($this->table, $columnName); + } elseif ($type instanceof Literal) { + $action = AddColumn::build($this->table, $columnName, $type, $options); + } else { + $action = new AddColumn($this->table, $this->getAdapter()->getColumnForType($columnName, $type, $options)); + } + + // Delegate to Adapters to check column type + if (!$this->getAdapter()->isValidColumnType($action->getColumn())) { + throw new InvalidArgumentException(sprintf( + 'An invalid column type "%s" was specified for column "%s".', + (string)$action->getColumn()->getType(), + (string)$action->getColumn()->getName() + )); + } + + $this->actions->addAction($action); + + return $this; + } + + /** + * Remove a table column. + * + * @param string $columnName Column Name + * @return $this + */ + public function removeColumn(string $columnName) + { + $action = RemoveColumn::build($this->table, $columnName); + $this->actions->addAction($action); + + return $this; + } + + /** + * Rename a table column. + * + * @param string $oldName Old Column Name + * @param string $newName New Column Name + * @return $this + */ + public function renameColumn(string $oldName, string $newName) + { + $action = RenameColumn::build($this->table, $oldName, $newName); + $this->actions->addAction($action); + + return $this; + } + + /** + * Change a table column type. + * + * @param string $columnName Column Name + * @param string|\Migrations\Db\Table\Column|\Migrations\Db\Literal $newColumnType New Column Type + * @param array $options Options + * @return $this + */ + public function changeColumn(string $columnName, string|Column|Literal $newColumnType, array $options = []) + { + if ($newColumnType instanceof Column) { + $action = new ChangeColumn($this->table, $columnName, $newColumnType); + } else { + $action = ChangeColumn::build($this->table, $columnName, $newColumnType, $options); + } + $this->actions->addAction($action); + + return $this; + } + + /** + * Checks to see if a column exists. + * + * @param string $columnName Column Name + * @return bool + */ + public function hasColumn(string $columnName): bool + { + return $this->getAdapter()->hasColumn($this->getName(), $columnName); + } + + /** + * Add an index to a database table. + * + * In $options you can specify unique = true/false, and name (index name). + * + * @param string|array|\Migrations\Db\Table\Index $columns Table Column(s) + * @param array $options Index Options + * @return $this + */ + public function addIndex(string|array|Index $columns, array $options = []) + { + $action = AddIndex::build($this->table, $columns, $options); + $this->actions->addAction($action); + + return $this; + } + + /** + * Removes the given index from a table. + * + * @param string|string[] $columns Columns + * @return $this + */ + public function removeIndex(string|array $columns) + { + $action = DropIndex::build($this->table, is_string($columns) ? [$columns] : $columns); + $this->actions->addAction($action); + + return $this; + } + + /** + * Removes the given index identified by its name from a table. + * + * @param string $name Index name + * @return $this + */ + public function removeIndexByName(string $name) + { + $action = DropIndex::buildFromName($this->table, $name); + $this->actions->addAction($action); + + return $this; + } + + /** + * Checks to see if an index exists. + * + * @param string|string[] $columns Columns + * @return bool + */ + public function hasIndex(string|array $columns): bool + { + return $this->getAdapter()->hasIndex($this->getName(), $columns); + } + + /** + * Checks to see if an index specified by name exists. + * + * @param string $indexName Index name + * @return bool + */ + public function hasIndexByName(string $indexName): bool + { + return $this->getAdapter()->hasIndexByName($this->getName(), $indexName); + } + + /** + * Add a foreign key to a database table. + * + * In $options you can specify on_delete|on_delete = cascade|no_action .., + * on_update, constraint = constraint name. + * + * @param string|string[] $columns Columns + * @param string|\Migrations\Db\Table\Table $referencedTable Referenced Table + * @param string|string[] $referencedColumns Referenced Columns + * @param array $options Options + * @return $this + */ + public function addForeignKey(string|array $columns, string|TableValue $referencedTable, string|array $referencedColumns = ['id'], array $options = []) + { + $action = AddForeignKey::build($this->table, $columns, $referencedTable, $referencedColumns, $options); + $this->actions->addAction($action); + + return $this; + } + + /** + * Add a foreign key to a database table with a given name. + * + * In $options you can specify on_delete|on_delete = cascade|no_action .., + * on_update, constraint = constraint name. + * + * @param string $name The constraint name + * @param string|string[] $columns Columns + * @param string|\Migrations\Db\Table\Table $referencedTable Referenced Table + * @param string|string[] $referencedColumns Referenced Columns + * @param array $options Options + * @return $this + */ + public function addForeignKeyWithName(string $name, string|array $columns, string|TableValue $referencedTable, string|array $referencedColumns = ['id'], array $options = []) + { + $action = AddForeignKey::build( + $this->table, + $columns, + $referencedTable, + $referencedColumns, + $options, + $name + ); + $this->actions->addAction($action); + + return $this; + } + + /** + * Removes the given foreign key from the table. + * + * @param string|string[] $columns Column(s) + * @param string|null $constraint Constraint names + * @return $this + */ + public function dropForeignKey(string|array $columns, ?string $constraint = null) + { + $action = DropForeignKey::build($this->table, $columns, $constraint); + $this->actions->addAction($action); + + return $this; + } + + /** + * Checks to see if a foreign key exists. + * + * @param string|string[] $columns Column(s) + * @param string|null $constraint Constraint names + * @return bool + */ + public function hasForeignKey(string|array $columns, ?string $constraint = null): bool + { + return $this->getAdapter()->hasForeignKey($this->getName(), $columns, $constraint); + } + + /** + * Add timestamp columns created_at and updated_at to the table. + * + * @param string|false|null $createdAt Alternate name for the created_at column + * @param string|false|null $updatedAt Alternate name for the updated_at column + * @param bool $withTimezone Whether to set the timezone option on the added columns + * @return $this + */ + public function addTimestamps(string|false|null $createdAt = 'created_at', string|false|null $updatedAt = 'updated_at', bool $withTimezone = false) + { + $createdAt = $createdAt ?? 'created_at'; + $updatedAt = $updatedAt ?? 'updated_at'; + + if (!$createdAt && !$updatedAt) { + throw new RuntimeException('Cannot set both created_at and updated_at columns to false'); + } + + if ($createdAt) { + $this->addColumn($createdAt, 'timestamp', [ + 'null' => false, + 'default' => 'CURRENT_TIMESTAMP', + 'update' => '', + 'timezone' => $withTimezone, + ]); + } + if ($updatedAt) { + $this->addColumn($updatedAt, 'timestamp', [ + 'null' => true, + 'default' => null, + 'update' => 'CURRENT_TIMESTAMP', + 'timezone' => $withTimezone, + ]); + } + + return $this; + } + + /** + * Alias that always sets $withTimezone to true + * + * @see addTimestamps + * @param string|false|null $createdAt Alternate name for the created_at column + * @param string|false|null $updatedAt Alternate name for the updated_at column + * @return $this + */ + public function addTimestampsWithTimezone(string|false|null $createdAt = null, string|false|null $updatedAt = null) + { + $this->addTimestamps($createdAt, $updatedAt, true); + + return $this; + } + + /** + * Insert data into the table. + * + * @param array $data array of data in the form: + * array( + * array("col1" => "value1", "col2" => "anotherValue1"), + * array("col2" => "value2", "col2" => "anotherValue2"), + * ) + * or array("col1" => "value1", "col2" => "anotherValue1") + * @return $this + */ + public function insert(array $data) + { + // handle array of array situations + $keys = array_keys($data); + $firstKey = array_shift($keys); + if ($firstKey !== null && is_array($data[$firstKey])) { + foreach ($data as $row) { + $this->data[] = $row; + } + + return $this; + } + + if (count($data) > 0) { + $this->data[] = $data; + } + + return $this; + } + + /** + * Creates a table from the object instance. + * + * @return void + */ + public function create(): void + { + $this->executeActions(false); + $this->saveData(); + $this->reset(); // reset pending changes + } + + /** + * Updates a table from the object instance. + * + * @return void + */ + public function update(): void + { + $this->executeActions(true); + $this->saveData(); + $this->reset(); // reset pending changes + } + + /** + * Commit the pending data waiting for insertion. + * + * @return void + */ + public function saveData(): void + { + $rows = $this->getData(); + if (empty($rows)) { + return; + } + + $bulk = true; + $row = current($rows); + $c = array_keys($row); + foreach ($this->getData() as $row) { + $k = array_keys($row); + if ($k != $c) { + $bulk = false; + break; + } + } + + if ($bulk) { + $this->getAdapter()->bulkinsert($this->table, $this->getData()); + } else { + foreach ($this->getData() as $row) { + $this->getAdapter()->insert($this->table, $row); + } + } + + $this->resetData(); + } + + /** + * Immediately truncates the table. This operation cannot be undone + * + * @return void + */ + public function truncate(): void + { + $this->getAdapter()->truncateTable($this->getName()); + } + + /** + * Commits the table changes. + * + * If the table doesn't exist it is created otherwise it is updated. + * + * @return void + */ + public function save(): void + { + if ($this->exists()) { + $this->update(); // update the table + } else { + $this->create(); // create the table + } + } + + /** + * Executes all the pending actions for this table + * + * @param bool $exists Whether or not the table existed prior to executing this method + * @return void + */ + protected function executeActions(bool $exists): void + { + // Renaming a table is tricky, specially when running a reversible migration + // down. We will just assume the table already exists if the user commands a + // table rename. + if (!$exists) { + foreach ($this->actions->getActions() as $action) { + if ($action instanceof RenameTable) { + $exists = true; + break; + } + } + } + + // If the table does not exist, the last command in the chain needs to be + // a CreateTable action. + if (!$exists) { + $this->actions->addAction(new CreateTable($this->table)); + } + + $plan = new Plan($this->actions); + $plan->execute($this->getAdapter()); + } +} diff --git a/tests/TestCase/Db/Adapter/MysqlAdapterTest.php b/tests/TestCase/Db/Adapter/MysqlAdapterTest.php new file mode 100644 index 00000000..08e89306 --- /dev/null +++ b/tests/TestCase/Db/Adapter/MysqlAdapterTest.php @@ -0,0 +1,2560 @@ +markTestSkipped('Mysql tests disabled.'); + } + + $this->adapter = new MysqlAdapter(MYSQL_DB_CONFIG, new ArrayInput([]), new NullOutput()); + + // ensure the database is empty for each test + $this->adapter->dropDatabase(MYSQL_DB_CONFIG['name']); + $this->adapter->createDatabase(MYSQL_DB_CONFIG['name']); + + // leave the adapter in a disconnected state for each test + $this->adapter->disconnect(); + } + + protected function tearDown(): void + { + unset($this->adapter); + } + + private function usingMysql8(): bool + { + return version_compare($this->adapter->getAttribute(PDO::ATTR_SERVER_VERSION), '8.0.0', '>='); + } + + public function testConnection() + { + $this->assertInstanceOf('PDO', $this->adapter->getConnection()); + $this->assertSame(PDO::ERRMODE_EXCEPTION, $this->adapter->getConnection()->getAttribute(PDO::ATTR_ERRMODE)); + } + + public function testConnectionWithFetchMode() + { + $options = $this->adapter->getOptions(); + $options['fetch_mode'] = 'assoc'; + $this->adapter->setOptions($options); + $this->assertInstanceOf('PDO', $this->adapter->getConnection()); + $this->assertSame(PDO::FETCH_ASSOC, $this->adapter->getConnection()->getAttribute(PDO::ATTR_DEFAULT_FETCH_MODE)); + } + + public function testConnectionWithoutPort() + { + $options = $this->adapter->getOptions(); + unset($options['port']); + $this->adapter->setOptions($options); + $this->assertInstanceOf('PDO', $this->adapter->getConnection()); + } + + public function testConnectionWithInvalidCredentials() + { + $options = ['user' => 'invalid', 'pass' => 'invalid'] + MYSQL_DB_CONFIG; + + try { + $adapter = new MysqlAdapter($options, new ArrayInput([]), new NullOutput()); + $adapter->connect(); + $this->fail('Expected the adapter to throw an exception'); + } catch (InvalidArgumentException $e) { + $this->assertInstanceOf( + 'InvalidArgumentException', + $e, + 'Expected exception of type InvalidArgumentException, got ' . get_class($e) + ); + $this->assertStringContainsString('There was a problem connecting to the database', $e->getMessage()); + } + } + + public function testConnectionWithSocketConnection() + { + if (!getenv('MYSQL_UNIX_SOCKET')) { + $this->markTestSkipped('MySQL socket connection skipped.'); + } + + $options = ['unix_socket' => getenv('MYSQL_UNIX_SOCKET')] + MYSQL_DB_CONFIG; + $adapter = new MysqlAdapter($options, new ArrayInput([]), new NullOutput()); + $adapter->connect(); + + $this->assertInstanceOf('\PDO', $this->adapter->getConnection()); + } + + public function testCreatingTheSchemaTableOnConnect() + { + $this->adapter->connect(); + $this->assertTrue($this->adapter->hasTable($this->adapter->getSchemaTableName())); + $this->adapter->dropTable($this->adapter->getSchemaTableName()); + $this->assertFalse($this->adapter->hasTable($this->adapter->getSchemaTableName())); + $this->adapter->disconnect(); + $this->adapter->connect(); + $this->assertTrue($this->adapter->hasTable($this->adapter->getSchemaTableName())); + } + + public function testSchemaTableIsCreatedWithPrimaryKey() + { + $this->adapter->connect(); + new Table($this->adapter->getSchemaTableName(), [], $this->adapter); + $this->assertTrue($this->adapter->hasIndex($this->adapter->getSchemaTableName(), ['version'])); + } + + public function testQuoteTableName() + { + $this->assertEquals('`test_table`', $this->adapter->quoteTableName('test_table')); + } + + public function testQuoteColumnName() + { + $this->assertEquals('`test_column`', $this->adapter->quoteColumnName('test_column')); + } + + public function testHasTableUnderstandsSchemaNotation() + { + $this->assertTrue($this->adapter->hasTable('performance_schema.threads'), 'Failed asserting hasTable understands tables in another schema.'); + $this->assertFalse($this->adapter->hasTable('performance_schema.unknown_table')); + $this->assertFalse($this->adapter->hasTable('unknown_schema.phinxlog')); + } + + public function testHasTableRespectsDotInTableName() + { + $sql = "CREATE TABLE `discouraged.naming.convention` + (id INT(11) NOT NULL) + ENGINE = InnoDB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"; + $this->adapter->execute($sql); + $this->assertTrue($this->adapter->hasTable('discouraged.naming.convention')); + } + + public function testCreateTable() + { + $table = new Table('ntable', [], $this->adapter); + $table->addColumn('realname', 'string') + ->addColumn('email', 'integer') + ->save(); + $this->assertTrue($this->adapter->hasTable('ntable')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'id')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'realname')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'email')); + $this->assertFalse($this->adapter->hasColumn('ntable', 'address')); + + $columns = $this->adapter->getColumns('ntable'); + $this->assertCount(3, $columns); + $this->assertSame('id', $columns[0]->getName()); + $this->assertFalse($columns[0]->isSigned()); + } + + public function testCreateTableWithComment() + { + $tableComment = 'Table comment'; + $table = new Table('ntable', ['comment' => $tableComment], $this->adapter); + $table->addColumn('realname', 'string') + ->save(); + $this->assertTrue($this->adapter->hasTable('ntable')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'id')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'realname')); + $this->assertFalse($this->adapter->hasColumn('ntable', 'address')); + + $rows = $this->adapter->fetchAll(sprintf( + "SELECT TABLE_COMMENT FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA='%s' AND TABLE_NAME='ntable'", + MYSQL_DB_CONFIG['name'] + )); + $comment = $rows[0]; + + $this->assertEquals($tableComment, $comment['TABLE_COMMENT'], 'Dont set table comment correctly'); + } + + public function testCreateTableWithForeignKeys() + { + $tag_table = new Table('ntable_tag', [], $this->adapter); + $tag_table->addColumn('realname', 'string') + ->save(); + + $table = new Table('ntable', [], $this->adapter); + $table->addColumn('realname', 'string') + ->addColumn('tag_id', 'integer', ['signed' => false]) + ->addForeignKey('tag_id', 'ntable_tag', 'id', ['delete' => 'NO_ACTION', 'update' => 'NO_ACTION']) + ->save(); + + $this->assertTrue($this->adapter->hasTable('ntable')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'id')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'realname')); + $this->assertFalse($this->adapter->hasColumn('ntable', 'address')); + + $rows = $this->adapter->fetchAll(sprintf( + "SELECT TABLE_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE TABLE_SCHEMA='%s' AND REFERENCED_TABLE_NAME='ntable_tag'", + MYSQL_DB_CONFIG['name'] + )); + $foreignKey = $rows[0]; + + $this->assertEquals($foreignKey['TABLE_NAME'], 'ntable'); + $this->assertEquals($foreignKey['COLUMN_NAME'], 'tag_id'); + $this->assertEquals($foreignKey['REFERENCED_TABLE_NAME'], 'ntable_tag'); + $this->assertEquals($foreignKey['REFERENCED_COLUMN_NAME'], 'id'); + } + + public function testCreateTableCustomIdColumn() + { + $table = new Table('ntable', ['id' => 'custom_id'], $this->adapter); + $table->addColumn('realname', 'string') + ->addColumn('email', 'integer') + ->save(); + $this->assertTrue($this->adapter->hasTable('ntable')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'custom_id')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'realname')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'email')); + $this->assertFalse($this->adapter->hasColumn('ntable', 'address')); + } + + public function testCreateTableWithNoPrimaryKey() + { + $options = [ + 'id' => false, + ]; + $table = new Table('atable', $options, $this->adapter); + $table->addColumn('user_id', 'integer') + ->save(); + $this->assertFalse($this->adapter->hasColumn('atable', 'id')); + } + + public function testCreateTableWithConflictingPrimaryKeys() + { + $options = [ + 'primary_key' => 'user_id', + ]; + $table = new Table('atable', $options, $this->adapter); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('You cannot enable an auto incrementing ID field and a primary key'); + $table->addColumn('user_id', 'integer')->save(); + } + + public function testCreateTableWithPrimaryKeySetToImplicitId() + { + $options = [ + 'primary_key' => 'id', + ]; + $table = new Table('ztable', $options, $this->adapter); + $table->addColumn('user_id', 'integer')->save(); + $this->assertTrue($this->adapter->hasColumn('ztable', 'id')); + $this->assertTrue($this->adapter->hasIndex('ztable', 'id')); + $this->assertTrue($this->adapter->hasColumn('ztable', 'user_id')); + } + + public function testCreateTableWithPrimaryKeyArraySetToImplicitId() + { + $options = [ + 'primary_key' => ['id'], + ]; + $table = new Table('ztable', $options, $this->adapter); + $table->addColumn('user_id', 'integer')->save(); + $this->assertTrue($this->adapter->hasColumn('ztable', 'id')); + $this->assertTrue($this->adapter->hasIndex('ztable', 'id')); + $this->assertTrue($this->adapter->hasColumn('ztable', 'user_id')); + } + + public function testCreateTableWithMultiplePrimaryKeyArraySetToImplicitId() + { + $options = [ + 'primary_key' => ['id', 'user_id'], + ]; + $table = new Table('ztable', $options, $this->adapter); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('You cannot enable an auto incrementing ID field and a primary key'); + $table->addColumn('user_id', 'integer')->save(); + } + + public function testCreateTableWithMultiplePrimaryKeys() + { + $options = [ + 'id' => false, + 'primary_key' => ['user_id', 'tag_id'], + ]; + $table = new Table('table1', $options, $this->adapter); + $table->addColumn('user_id', 'integer', ['null' => false]) + ->addColumn('tag_id', 'integer', ['null' => false]) + ->save(); + $this->assertTrue($this->adapter->hasIndex('table1', ['user_id', 'tag_id'])); + $this->assertTrue($this->adapter->hasIndex('table1', ['USER_ID', 'tag_id'])); + $this->assertFalse($this->adapter->hasIndex('table1', ['tag_id', 'user_id'])); + $this->assertFalse($this->adapter->hasIndex('table1', ['tag_id', 'user_email'])); + } + + /** + * @return void + */ + public function testCreateTableWithPrimaryKeyAsUuid() + { + $options = [ + 'id' => false, + 'primary_key' => 'id', + ]; + $table = new Table('ztable', $options, $this->adapter); + $table->addColumn('id', 'uuid', ['null' => false])->save(); + $table->addColumn('user_id', 'integer')->save(); + $this->assertTrue($this->adapter->hasColumn('ztable', 'id')); + $this->assertTrue($this->adapter->hasIndex('ztable', 'id')); + $this->assertTrue($this->adapter->hasColumn('ztable', 'user_id')); + } + + /** + * @return void + */ + public function testCreateTableWithPrimaryKeyAsBinaryUuid() + { + $options = [ + 'id' => false, + 'primary_key' => 'id', + ]; + $table = new Table('ztable', $options, $this->adapter); + $table->addColumn('id', 'binaryuuid', ['null' => false])->save(); + $table->addColumn('user_id', 'integer')->save(); + $this->assertTrue($this->adapter->hasColumn('ztable', 'id')); + $this->assertTrue($this->adapter->hasIndex('ztable', 'id')); + $this->assertTrue($this->adapter->hasColumn('ztable', 'user_id')); + } + + public function testCreateTableWithMultipleIndexes() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->addColumn('name', 'string') + ->addIndex('email') + ->addIndex('name') + ->save(); + $this->assertTrue($this->adapter->hasIndex('table1', ['email'])); + $this->assertTrue($this->adapter->hasIndex('table1', ['name'])); + $this->assertFalse($this->adapter->hasIndex('table1', ['email', 'user_email'])); + $this->assertFalse($this->adapter->hasIndex('table1', ['email', 'user_name'])); + } + + public function testCreateTableWithUniqueIndexes() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('email', 'string', ['limit' => 191]) + ->addIndex('email', ['unique' => true]) + ->save(); + $this->assertTrue($this->adapter->hasIndex('table1', ['email'])); + $this->assertFalse($this->adapter->hasIndex('table1', ['email', 'user_email'])); + } + + public function testCreateTableWithFullTextIndex() + { + $table = new Table('table1', ['engine' => 'MyISAM'], $this->adapter); + $table->addColumn('email', 'string') + ->addIndex('email', ['type' => 'fulltext']) + ->save(); + $this->assertTrue($this->adapter->hasIndex('table1', ['email'])); + $this->assertFalse($this->adapter->hasIndex('table1', ['email', 'user_email'])); + } + + public function testCreateTableWithNamedIndex() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->addIndex('email', ['name' => 'myemailindex']) + ->save(); + $this->assertTrue($this->adapter->hasIndex('table1', ['email'])); + $this->assertFalse($this->adapter->hasIndex('table1', ['email', 'user_email'])); + $this->assertTrue($this->adapter->hasIndexByName('table1', 'myemailindex')); + } + + public function testCreateTableWithMultiplePKsAndUniqueIndexes() + { + $this->markTestIncomplete(); + } + + public function testCreateTableWithMyISAMEngine() + { + $table = new Table('ntable', ['engine' => 'MyISAM'], $this->adapter); + $table->addColumn('realname', 'string') + ->save(); + $this->assertTrue($this->adapter->hasTable('ntable')); + $row = $this->adapter->fetchRow(sprintf("SHOW TABLE STATUS WHERE Name = '%s'", 'ntable')); + $this->assertEquals('MyISAM', $row['Engine']); + } + + public function testCreateTableAndInheritDefaultCollation() + { + $options = MYSQL_DB_CONFIG + [ + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + ]; + $adapter = new MysqlAdapter($options, new ArrayInput([]), new NullOutput()); + + // Ensure the database is empty and the adapter is in a disconnected state + $adapter->dropDatabase($options['name']); + $adapter->createDatabase($options['name']); + $adapter->disconnect(); + + $table = new Table('table_with_default_collation', [], $adapter); + $table->addColumn('name', 'string') + ->save(); + $this->assertTrue($adapter->hasTable('table_with_default_collation')); + $row = $adapter->fetchRow(sprintf("SHOW TABLE STATUS WHERE Name = '%s'", 'table_with_default_collation')); + $this->assertEquals('utf8mb4_unicode_ci', $row['Collation']); + } + + public function testCreateTableWithLatin1Collate() + { + $table = new Table('latin1_table', ['collation' => 'latin1_general_ci'], $this->adapter); + $table->addColumn('name', 'string') + ->save(); + $this->assertTrue($this->adapter->hasTable('latin1_table')); + $row = $this->adapter->fetchRow(sprintf("SHOW TABLE STATUS WHERE Name = '%s'", 'latin1_table')); + $this->assertEquals('latin1_general_ci', $row['Collation']); + } + + public function testCreateTableWithSignedPK() + { + $table = new Table('ntable', ['signed' => true], $this->adapter); + $table->addColumn('realname', 'string') + ->addColumn('email', 'integer') + ->save(); + $this->assertTrue($this->adapter->hasTable('ntable')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'id')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'realname')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'email')); + $this->assertFalse($this->adapter->hasColumn('ntable', 'address')); + $column_definitions = $this->adapter->getColumns('ntable'); + foreach ($column_definitions as $column_definition) { + if ($column_definition->getName() === 'id') { + $this->assertTrue($column_definition->getSigned()); + } + } + } + + public function testCreateTableWithUnsignedPK() + { + $table = new Table('ntable', ['signed' => false], $this->adapter); + $table->addColumn('realname', 'string') + ->addColumn('email', 'integer') + ->save(); + $this->assertTrue($this->adapter->hasTable('ntable')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'id')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'realname')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'email')); + $this->assertFalse($this->adapter->hasColumn('ntable', 'address')); + $column_definitions = $this->adapter->getColumns('ntable'); + foreach ($column_definitions as $column_definition) { + if ($column_definition->getName() === 'id') { + $this->assertFalse($column_definition->getSigned()); + } + } + } + + public function testCreateTableWithUnsignedNamedPK() + { + $table = new Table('ntable', ['id' => 'named_id', 'signed' => false], $this->adapter); + $table->addColumn('realname', 'string') + ->addColumn('email', 'integer') + ->save(); + $this->assertTrue($this->adapter->hasTable('ntable')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'named_id')); + $column_definitions = $this->adapter->getColumns('ntable'); + foreach ($column_definitions as $column_definition) { + if ($column_definition->getName() === 'named_id') { + $this->assertFalse($column_definition->getSigned()); + } + } + $this->assertTrue($this->adapter->hasColumn('ntable', 'realname')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'email')); + $this->assertFalse($this->adapter->hasColumn('ntable', 'address')); + } + + /** + * @runInSeparateProcess + */ + public function testUnsignedPksFeatureFlag() + { + $this->adapter->connect(); + + FeatureFlags::$unsignedPrimaryKeys = false; + + $table = new Table('table1', [], $this->adapter); + $table->create(); + + $columns = $this->adapter->getColumns('table1'); + $this->assertCount(1, $columns); + $this->assertSame('id', $columns[0]->getName()); + $this->assertTrue($columns[0]->getSigned()); + } + + public function testCreateTableWithLimitPK() + { + $table = new Table('ntable', ['id' => 'id', 'limit' => 4], $this->adapter); + $table->save(); + $this->assertTrue($this->adapter->hasTable('ntable')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'id')); + $column_definitions = $this->adapter->getColumns('ntable'); + $this->assertSame($this->usingMysql8() ? null : 4, $column_definitions[0]->getLimit()); + } + + public function testCreateTableWithSchema() + { + $table = new Table(MYSQL_DB_CONFIG['name'] . '.ntable', [], $this->adapter); + $table->addColumn('realname', 'string') + ->addColumn('email', 'integer') + ->save(); + $this->assertTrue($this->adapter->hasTable('ntable')); + } + + public function testAddPrimarykey() + { + $table = new Table('table1', ['id' => false], $this->adapter); + $table + ->addColumn('column1', 'integer') + ->save(); + + $table + ->changePrimaryKey('column1') + ->save(); + + $this->assertTrue($this->adapter->hasPrimaryKey('table1', ['column1'])); + } + + public function testChangePrimaryKey() + { + $table = new Table('table1', ['id' => false, 'primary_key' => 'column1'], $this->adapter); + $table + ->addColumn('column1', 'integer', ['null' => false]) + ->addColumn('column2', 'integer') + ->addColumn('column3', 'integer') + ->save(); + + $table + ->changePrimaryKey(['column2', 'column3']) + ->save(); + + $this->assertFalse($this->adapter->hasPrimaryKey('table1', ['column1'])); + $this->assertTrue($this->adapter->hasPrimaryKey('table1', ['column2', 'column3'])); + } + + public function testDropPrimaryKey() + { + $table = new Table('table1', ['id' => false, 'primary_key' => 'column1'], $this->adapter); + $table + ->addColumn('column1', 'integer', ['null' => false]) + ->save(); + + $table + ->changePrimaryKey(null) + ->save(); + + $this->assertFalse($this->adapter->hasPrimaryKey('table1', ['column1'])); + } + + public function testAddComment() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + + $table + ->changeComment('comment1') + ->save(); + + $rows = $this->adapter->fetchAll( + sprintf( + "SELECT TABLE_COMMENT + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA='%s' + AND TABLE_NAME='%s'", + MYSQL_DB_CONFIG['name'], + 'table1' + ) + ); + $this->assertEquals('comment1', $rows[0]['TABLE_COMMENT']); + } + + public function testChangeComment() + { + $table = new Table('table1', ['comment' => 'comment1'], $this->adapter); + $table->save(); + + $table + ->changeComment('comment2') + ->save(); + + $rows = $this->adapter->fetchAll( + sprintf( + "SELECT TABLE_COMMENT + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA='%s' + AND TABLE_NAME='%s'", + MYSQL_DB_CONFIG['name'], + 'table1' + ) + ); + $this->assertEquals('comment2', $rows[0]['TABLE_COMMENT']); + } + + public function testDropComment() + { + $table = new Table('table1', ['comment' => 'comment1'], $this->adapter); + $table->save(); + + $table + ->changeComment(null) + ->save(); + + $rows = $this->adapter->fetchAll( + sprintf( + "SELECT TABLE_COMMENT + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA='%s' + AND TABLE_NAME='%s'", + MYSQL_DB_CONFIG['name'], + 'table1' + ) + ); + $this->assertEquals('', $rows[0]['TABLE_COMMENT']); + } + + public function testRenameTable() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $this->assertTrue($this->adapter->hasTable('table1')); + $this->assertFalse($this->adapter->hasTable('table2')); + + $table->rename('table2')->save(); + $this->assertFalse($this->adapter->hasTable('table1')); + $this->assertTrue($this->adapter->hasTable('table2')); + } + + public function testAddColumn() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $this->assertFalse($table->hasColumn('email')); + $table->addColumn('email', 'string') + ->save(); + $this->assertTrue($table->hasColumn('email')); + $table->addColumn('realname', 'string', ['after' => 'id']) + ->save(); + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM table1'); + $this->assertEquals('realname', $rows[1]['Field']); + } + + public function testAddColumnWithDefaultValue() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $table->addColumn('default_zero', 'string', ['default' => 'test']) + ->save(); + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM table1'); + $this->assertEquals('test', $rows[1]['Default']); + } + + public function testAddColumnWithDefaultZero() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $table->addColumn('default_zero', 'integer', ['default' => 0]) + ->save(); + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM table1'); + $this->assertNotNull($rows[1]['Default']); + $this->assertEquals('0', $rows[1]['Default']); + } + + public function testAddColumnWithDefaultEmptyString() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $table->addColumn('default_empty', 'string', ['default' => '']) + ->save(); + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM table1'); + $this->assertEquals('', $rows[1]['Default']); + } + + public function testAddColumnWithDefaultBoolean() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $table->addColumn('default_true', 'boolean', ['default' => true]) + ->addColumn('default_false', 'boolean', ['default' => false]) + ->addColumn('default_null', 'boolean', ['default' => null, 'null' => true]) + ->save(); + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM table1'); + $this->assertEquals('1', $rows[1]['Default']); + $this->assertEquals('0', $rows[2]['Default']); + $this->assertNull($rows[3]['Default']); + } + + public function testAddColumnWithDefaultLiteral() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $table->addColumn('default_ts', 'timestamp', ['default' => Literal::from('CURRENT_TIMESTAMP')]) + ->save(); + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM table1'); + // MariaDB returns current_timestamp() + $this->assertTrue($rows[1]['Default'] === 'CURRENT_TIMESTAMP' || $rows[1]['Default'] === 'current_timestamp()'); + } + + public function testAddColumnWithCustomType() + { + $this->adapter->setDataDomain([ + 'custom1' => [ + 'type' => 'enum', + 'null' => true, + 'values' => 'a,b,c', + ], + 'custom2' => [ + 'type' => 'enum', + 'null' => true, + 'values' => ['a', 'b', 'c'], + ], + ]); + + (new Table('table1', [], $this->adapter)) + ->addColumn('custom1', 'custom1') + ->addColumn('custom2', 'custom2') + ->addColumn('custom_ext', 'custom2', [ + 'null' => false, + 'values' => ['d', 'e', 'f'], + ]) + ->save(); + + $this->assertTrue($this->adapter->hasTable('table1')); + + $columns = $this->adapter->getColumns('table1'); + + $this->assertArrayHasKey(1, $columns); + $this->assertArrayHasKey(2, $columns); + $this->assertArrayHasKey(3, $columns); + + foreach ([1, 2] as $index) { + $column = $this->adapter->getColumns('table1')[$index]; + $this->assertSame("custom{$index}", $column->getName()); + $this->assertSame('enum', $column->getType()); + $this->assertSame(['a', 'b', 'c'], $column->getValues()); + $this->assertTrue($column->getNull()); + } + + $column = $this->adapter->getColumns('table1')[3]; + $this->assertSame('custom_ext', $column->getName()); + $this->assertSame('enum', $column->getType()); + $this->assertSame(['d', 'e', 'f'], $column->getValues()); + $this->assertFalse($column->getNull()); + } + + public function testAddColumnFirst() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $table->addColumn('new_id', 'integer', ['after' => MysqlAdapter::FIRST]) + ->save(); + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM table1'); + $this->assertSame('new_id', $rows[0]['Field']); + } + + public static function integerDataProvider() + { + return [ + ['integer', [], 'int', '11', ''], + ['integer', ['signed' => false], 'int', '11', ' unsigned'], + ['integer', ['limit' => 8], 'int', '8', ''], + ['smallinteger', [], 'smallint', '6', ''], + ['smallinteger', ['signed' => false], 'smallint', '6', ' unsigned'], + ['smallinteger', ['limit' => 3], 'smallint', '3', ''], + ['biginteger', [], 'bigint', '20', ''], + ['biginteger', ['signed' => false], 'bigint', '20', ' unsigned'], + ['biginteger', ['limit' => 12], 'bigint', '12', ''], + ]; + } + + /** + * @dataProvider integerDataProvider + */ + public function testIntegerColumnTypes($phinx_type, $options, $sql_type, $width, $extra) + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $this->assertFalse($table->hasColumn('user_id')); + $table->addColumn('user_id', $phinx_type, $options) + ->save(); + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM table1'); + + $type = $sql_type; + if (!$this->usingMysql8()) { + $type .= '(' . $width . ')'; + } + $type .= $extra; + $this->assertEquals($type, $rows[1]['Type']); + } + + public function testAddDoubleColumnWithDefaultSigned() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $this->assertFalse($table->hasColumn('user_id')); + $table->addColumn('foo', 'double') + ->save(); + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM table1'); + $this->assertEquals('double', $rows[1]['Type']); + } + + public function testAddDoubleColumnWithSignedEqualsFalse() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $this->assertFalse($table->hasColumn('user_id')); + $table->addColumn('foo', 'double', ['signed' => false]) + ->save(); + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM table1'); + $this->assertEquals('double unsigned', $rows[1]['Type']); + } + + public function testAddBooleanColumnWithSignedEqualsFalse() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $this->assertFalse($table->hasColumn('test_boolean')); + $table->addColumn('test_boolean', 'boolean', ['signed' => false]) + ->save(); + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM table1'); + + $type = $this->usingMysql8() ? 'tinyint' : 'tinyint(1)'; + $this->assertEquals($type . ' unsigned', $rows[1]['Type']); + } + + public function testAddStringColumnWithSignedEqualsFalse() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $this->assertFalse($table->hasColumn('user_id')); + $table->addColumn('user_id', 'string', ['signed' => false]) + ->save(); + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM table1'); + $this->assertEquals('varchar(255)', $rows[1]['Type']); + } + + public function testAddStringColumnWithCustomCollation() + { + $table = new Table('table_custom_collation', ['collation' => 'utf8mb4_unicode_ci'], $this->adapter); + $table->save(); + $this->assertFalse($table->hasColumn('string_collation_default')); + $this->assertFalse($table->hasColumn('string_collation_custom')); + $table->addColumn('string_collation_default', 'string', [])->save(); + $table->addColumn('string_collation_custom', 'string', ['collation' => 'utf8mb4_unicode_ci'])->save(); + $rows = $this->adapter->fetchAll('SHOW FULL COLUMNS FROM table_custom_collation'); + $this->assertEquals('utf8mb4_unicode_ci', $rows[1]['Collation']); + $this->assertEquals('utf8mb4_unicode_ci', $rows[2]['Collation']); + } + + public function testRenameColumn() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string') + ->save(); + $this->assertTrue($this->adapter->hasColumn('t', 'column1')); + $this->assertFalse($this->adapter->hasColumn('t', 'column2')); + + $table->renameColumn('column1', 'column2')->save(); + $this->assertFalse($this->adapter->hasColumn('t', 'column1')); + $this->assertTrue($this->adapter->hasColumn('t', 'column2')); + } + + public function testRenameColumnPreserveComment() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string', ['comment' => 'comment1']) + ->save(); + + $this->assertTrue($this->adapter->hasColumn('t', 'column1')); + $this->assertFalse($this->adapter->hasColumn('t', 'column2')); + $columns = $this->adapter->fetchAll('SHOW FULL COLUMNS FROM t'); + $this->assertEquals('comment1', $columns[1]['Comment']); + + $table->renameColumn('column1', 'column2')->save(); + + $this->assertFalse($this->adapter->hasColumn('t', 'column1')); + $this->assertTrue($this->adapter->hasColumn('t', 'column2')); + $columns = $this->adapter->fetchAll('SHOW FULL COLUMNS FROM t'); + $this->assertEquals('comment1', $columns[1]['Comment']); + } + + public function testRenamingANonExistentColumn() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string') + ->save(); + + try { + $table->renameColumn('column2', 'column1')->save(); + $this->fail('Expected the adapter to throw an exception'); + } catch (InvalidArgumentException $e) { + $this->assertInstanceOf( + 'InvalidArgumentException', + $e, + 'Expected exception of type InvalidArgumentException, got ' . get_class($e) + ); + $this->assertEquals('The specified column doesn\'t exist: column2', $e->getMessage()); + } + } + + public function testChangeColumn() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string') + ->save(); + $this->assertTrue($this->adapter->hasColumn('t', 'column1')); + $table->changeColumn('column1', 'string')->save(); + $this->assertTrue($this->adapter->hasColumn('t', 'column1')); + + $newColumn2 = new Column(); + $newColumn2->setName('column2') + ->setType('string'); + $table->changeColumn('column1', $newColumn2)->save(); + $this->assertFalse($this->adapter->hasColumn('t', 'column1')); + $this->assertTrue($this->adapter->hasColumn('t', 'column2')); + } + + public function testChangeColumnDefaultValue() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string', ['default' => 'test']) + ->save(); + $newColumn1 = new Column(); + $newColumn1->setDefault('test1') + ->setType('string'); + $table->changeColumn('column1', $newColumn1)->save(); + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM t'); + $this->assertNotNull($rows[1]['Default']); + $this->assertEquals('test1', $rows[1]['Default']); + } + + public function testChangeColumnDefaultToZero() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'integer') + ->save(); + $newColumn1 = new Column(); + $newColumn1->setDefault(0) + ->setType('integer'); + $table->changeColumn('column1', $newColumn1)->save(); + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM t'); + $this->assertNotNull($rows[1]['Default']); + $this->assertEquals('0', $rows[1]['Default']); + } + + public function testChangeColumnDefaultToNull() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string', ['default' => 'test']) + ->save(); + $newColumn1 = new Column(); + $newColumn1->setDefault(null) + ->setType('string'); + $table->changeColumn('column1', $newColumn1)->save(); + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM t'); + $this->assertNull($rows[1]['Default']); + } + + public static function sqlTypeIntConversionProvider() + { + return [ + // tinyint + [AdapterInterface::PHINX_TYPE_TINY_INTEGER, null, 'tinyint', 4], + [AdapterInterface::PHINX_TYPE_TINY_INTEGER, 2, 'tinyint', 2], + [AdapterInterface::PHINX_TYPE_TINY_INTEGER, MysqlAdapter::INT_TINY, 'tinyint', 4], + // smallint + [AdapterInterface::PHINX_TYPE_SMALL_INTEGER, null, 'smallint', 6], + [AdapterInterface::PHINX_TYPE_SMALL_INTEGER, 3, 'smallint', 3], + [AdapterInterface::PHINX_TYPE_SMALL_INTEGER, MysqlAdapter::INT_SMALL, 'smallint', 6], + // medium + [AdapterInterface::PHINX_TYPE_MEDIUM_INTEGER, null, 'mediumint', 8], + [AdapterInterface::PHINX_TYPE_MEDIUM_INTEGER, 2, 'mediumint', 2], + [AdapterInterface::PHINX_TYPE_MEDIUM_INTEGER, MysqlAdapter::INT_MEDIUM, 'mediumint', 8], + // integer + [AdapterInterface::PHINX_TYPE_INTEGER, null, 'int', 11], + [AdapterInterface::PHINX_TYPE_INTEGER, 4, 'int', 4], + [AdapterInterface::PHINX_TYPE_INTEGER, MysqlAdapter::INT_TINY, 'tinyint', 4], + [AdapterInterface::PHINX_TYPE_INTEGER, MysqlAdapter::INT_SMALL, 'smallint', 6], + [AdapterInterface::PHINX_TYPE_INTEGER, MysqlAdapter::INT_MEDIUM, 'mediumint', 8], + [AdapterInterface::PHINX_TYPE_INTEGER, MysqlAdapter::INT_REGULAR, 'int', 11], + [AdapterInterface::PHINX_TYPE_INTEGER, MysqlAdapter::INT_BIG, 'bigint', 20], + // bigint + [AdapterInterface::PHINX_TYPE_BIG_INTEGER, null, 'bigint', 20], + [AdapterInterface::PHINX_TYPE_BIG_INTEGER, 4, 'bigint', 4], + [AdapterInterface::PHINX_TYPE_BIG_INTEGER, MysqlAdapter::INT_BIG, 'bigint', 20], + ]; + } + + /** + * @dataProvider sqlTypeIntConversionProvider + * The second argument is not typed as MysqlAdapter::INT_BIG is a float, and all other values are integers + */ + public function testGetSqlTypeIntegerConversion(string $type, $limit, string $expectedType, int $expectedLimit) + { + $sqlType = $this->adapter->getSqlType($type, $limit); + $this->assertSame($expectedType, $sqlType['name']); + $this->assertSame($expectedLimit, $sqlType['limit']); + } + + public function testLongTextColumn() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'text', ['limit' => MysqlAdapter::TEXT_LONG]) + ->save(); + $columns = $table->getColumns(); + $sqlType = $this->adapter->getSqlType($columns[1]->getType(), $columns[1]->getLimit()); + $this->assertEquals('longtext', $sqlType['name']); + } + + public function testMediumTextColumn() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'text', ['limit' => MysqlAdapter::TEXT_MEDIUM]) + ->save(); + $columns = $table->getColumns(); + $sqlType = $this->adapter->getSqlType($columns[1]->getType(), $columns[1]->getLimit()); + $this->assertEquals('mediumtext', $sqlType['name']); + } + + public function testTinyTextColumn() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'text', ['limit' => MysqlAdapter::TEXT_TINY]) + ->save(); + $columns = $table->getColumns(); + $sqlType = $this->adapter->getSqlType($columns[1]->getType(), $columns[1]->getLimit()); + $this->assertEquals('tinytext', $sqlType['name']); + } + + public static function binaryToBlobAutomaticConversionData() + { + return [ + [null, 'binary', 255], + [64, 'binary', 64], + [MysqlAdapter::BLOB_REGULAR - 20, 'blob', MysqlAdapter::BLOB_REGULAR], + [MysqlAdapter::BLOB_REGULAR, 'blob', MysqlAdapter::BLOB_REGULAR], + [MysqlAdapter::BLOB_REGULAR + 20, 'mediumblob', MysqlAdapter::BLOB_MEDIUM], + [MysqlAdapter::BLOB_MEDIUM, 'mediumblob', MysqlAdapter::BLOB_MEDIUM], + [MysqlAdapter::BLOB_MEDIUM + 20, 'longblob', MysqlAdapter::BLOB_LONG], + [MysqlAdapter::BLOB_LONG, 'longblob', MysqlAdapter::BLOB_LONG], + [MysqlAdapter::BLOB_LONG + 20, 'longblob', MysqlAdapter::BLOB_LONG], + ]; + } + + /** @dataProvider binaryToBlobAutomaticConversionData */ + public function testBinaryToBlobAutomaticConversion(?int $limit, string $expectedType, int $expectedLimit) + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'binary', ['limit' => $limit]) + ->save(); + $columns = $table->getColumns(); + $sqlType = $this->adapter->getSqlType($columns[1]->getType(), $columns[1]->getLimit()); + $this->assertSame($expectedType, $sqlType['name']); + $this->assertSame($expectedLimit, $columns[1]->getLimit()); + } + + public static function varbinaryToBlobAutomaticConversionData() + { + return [ + [null, 'varbinary', 255], + [64, 'varbinary', 64], + [MysqlAdapter::BLOB_REGULAR - 20, 'blob', MysqlAdapter::BLOB_REGULAR], + [MysqlAdapter::BLOB_REGULAR, 'blob', MysqlAdapter::BLOB_REGULAR], + [MysqlAdapter::BLOB_REGULAR + 20, 'mediumblob', MysqlAdapter::BLOB_MEDIUM], + [MysqlAdapter::BLOB_MEDIUM, 'mediumblob', MysqlAdapter::BLOB_MEDIUM], + [MysqlAdapter::BLOB_MEDIUM + 20, 'longblob', MysqlAdapter::BLOB_LONG], + [MysqlAdapter::BLOB_LONG, 'longblob', MysqlAdapter::BLOB_LONG], + [MysqlAdapter::BLOB_LONG + 20, 'longblob', MysqlAdapter::BLOB_LONG], + ]; + } + + /** @dataProvider varbinaryToBlobAutomaticConversionData */ + public function testVarbinaryToBlobAutomaticConversion(?int $limit, string $expectedType, int $expectedLimit) + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'varbinary', ['limit' => $limit]) + ->save(); + $columns = $table->getColumns(); + $sqlType = $this->adapter->getSqlType($columns[1]->getType(), $columns[1]->getLimit()); + $this->assertSame($expectedType, $sqlType['name']); + $this->assertSame($expectedLimit, $columns[1]->getLimit()); + } + + public static function blobColumnsData() + { + return [ + // Tiny blobs + ['tinyblob', 'tinyblob', null, MysqlAdapter::BLOB_TINY], + ['tinyblob', 'tinyblob', MysqlAdapter::BLOB_TINY, MysqlAdapter::BLOB_TINY], + ['tinyblob', 'blob', MysqlAdapter::BLOB_TINY + 20, MysqlAdapter::BLOB_REGULAR], + ['tinyblob', 'mediumblob', MysqlAdapter::BLOB_MEDIUM, MysqlAdapter::BLOB_MEDIUM], + ['tinyblob', 'longblob', MysqlAdapter::BLOB_LONG, MysqlAdapter::BLOB_LONG], + // Regular blobs + ['blob', 'tinyblob', MysqlAdapter::BLOB_TINY, MysqlAdapter::BLOB_TINY], + ['blob', 'blob', null, MysqlAdapter::BLOB_REGULAR], + ['blob', 'blob', MysqlAdapter::BLOB_REGULAR, MysqlAdapter::BLOB_REGULAR], + ['blob', 'mediumblob', MysqlAdapter::BLOB_MEDIUM, MysqlAdapter::BLOB_MEDIUM], + ['blob', 'longblob', MysqlAdapter::BLOB_LONG, MysqlAdapter::BLOB_LONG], + // medium blobs + ['mediumblob', 'tinyblob', MysqlAdapter::BLOB_TINY, MysqlAdapter::BLOB_TINY], + ['mediumblob', 'blob', MysqlAdapter::BLOB_REGULAR, MysqlAdapter::BLOB_REGULAR], + ['mediumblob', 'mediumblob', null, MysqlAdapter::BLOB_MEDIUM], + ['mediumblob', 'mediumblob', MysqlAdapter::BLOB_MEDIUM, MysqlAdapter::BLOB_MEDIUM], + ['mediumblob', 'longblob', MysqlAdapter::BLOB_LONG, MysqlAdapter::BLOB_LONG], + // long blobs + ['longblob', 'tinyblob', MysqlAdapter::BLOB_TINY, MysqlAdapter::BLOB_TINY], + ['longblob', 'blob', MysqlAdapter::BLOB_REGULAR, MysqlAdapter::BLOB_REGULAR], + ['longblob', 'mediumblob', MysqlAdapter::BLOB_MEDIUM, MysqlAdapter::BLOB_MEDIUM], + ['longblob', 'longblob', null, MysqlAdapter::BLOB_LONG], + ['longblob', 'longblob', MysqlAdapter::BLOB_LONG, MysqlAdapter::BLOB_LONG], + ]; + } + + /** @dataProvider blobColumnsData */ + public function testblobColumns(string $type, string $expectedType, ?int $limit, int $expectedLimit) + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', $type, ['limit' => $limit]) + ->save(); + $columns = $table->getColumns(); + $sqlType = $this->adapter->getSqlType($columns[1]->getType(), $columns[1]->getLimit()); + $this->assertSame($expectedType, $sqlType['name']); + $this->assertSame($expectedLimit, $columns[1]->getLimit()); + } + + public function testBigIntegerColumn() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'integer', ['limit' => MysqlAdapter::INT_BIG]) + ->save(); + $columns = $table->getColumns(); + $sqlType = $this->adapter->getSqlType($columns[1]->getType(), $columns[1]->getLimit()); + $this->assertEquals('bigint', $sqlType['name']); + } + + public function testMediumIntegerColumn() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'integer', ['limit' => MysqlAdapter::INT_MEDIUM]) + ->save(); + $columns = $table->getColumns(); + $sqlType = $this->adapter->getSqlType($columns[1]->getType(), $columns[1]->getLimit()); + $this->assertEquals('mediumint', $sqlType['name']); + } + + public function testSmallIntegerColumn() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'integer', ['limit' => MysqlAdapter::INT_SMALL]) + ->save(); + $columns = $table->getColumns(); + $sqlType = $this->adapter->getSqlType($columns[1]->getType(), $columns[1]->getLimit()); + $this->assertEquals('smallint', $sqlType['name']); + } + + public function testTinyIntegerColumn() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'integer', ['limit' => MysqlAdapter::INT_TINY]) + ->save(); + $columns = $table->getColumns(); + $sqlType = $this->adapter->getSqlType($columns[1]->getType(), $columns[1]->getLimit()); + $this->assertEquals('tinyint', $sqlType['name']); + } + + public function testIntegerColumnLimit() + { + $limit = 8; + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'integer', ['limit' => $limit]) + ->save(); + $columns = $table->getColumns(); + $sqlType = $this->adapter->getSqlType($columns[1]->getType(), $columns[1]->getLimit()); + $this->assertEquals($this->usingMysql8() ? 11 : $limit, $sqlType['limit']); + } + + public function testDatetimeColumn() + { + $this->adapter->connect(); + if (version_compare($this->adapter->getAttribute(PDO::ATTR_SERVER_VERSION), '5.6.4') === -1) { + $this->markTestSkipped('Cannot test datetime limit on versions less than 5.6.4'); + } + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'datetime')->save(); + $columns = $table->getColumns(); + $sqlType = $this->adapter->getSqlType($columns[1]->getType(), $columns[1]->getLimit()); + $this->assertNull($sqlType['limit']); + } + + public function testDatetimeColumnLimit() + { + $this->adapter->connect(); + if (version_compare($this->adapter->getAttribute(PDO::ATTR_SERVER_VERSION), '5.6.4') === -1) { + $this->markTestSkipped('Cannot test datetime limit on versions less than 5.6.4'); + } + $limit = 6; + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'datetime', ['limit' => $limit])->save(); + $columns = $table->getColumns(); + $sqlType = $this->adapter->getSqlType($columns[1]->getType(), $columns[1]->getLimit()); + $this->assertEquals($limit, $sqlType['limit']); + } + + public function testTimeColumnLimit() + { + $this->adapter->connect(); + if (version_compare($this->adapter->getAttribute(PDO::ATTR_SERVER_VERSION), '5.6.4') === -1) { + $this->markTestSkipped('Cannot test datetime limit on versions less than 5.6.4'); + } + $limit = 3; + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'time', ['limit' => $limit])->save(); + $columns = $table->getColumns(); + $sqlType = $this->adapter->getSqlType($columns[1]->getType(), $columns[1]->getLimit()); + $this->assertEquals($limit, $sqlType['limit']); + } + + public function testTimestampColumnLimit() + { + $this->adapter->connect(); + if (version_compare($this->adapter->getAttribute(PDO::ATTR_SERVER_VERSION), '5.6.4') === -1) { + $this->markTestSkipped('Cannot test datetime limit on versions less than 5.6.4'); + } + $limit = 1; + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'timestamp', ['limit' => $limit])->save(); + $columns = $table->getColumns(); + $sqlType = $this->adapter->getSqlType($columns[1]->getType(), $columns[1]->getLimit()); + $this->assertEquals($limit, $sqlType['limit']); + } + + public function testTimestampInvalidLimit() + { + $this->adapter->connect(); + if (version_compare($this->adapter->getAttribute(PDO::ATTR_SERVER_VERSION), '5.6.4') === -1) { + $this->markTestSkipped('Cannot test datetime limit on versions less than 5.6.4'); + } + $table = new Table('t', [], $this->adapter); + + $this->expectException(PDOException::class); + + $table->addColumn('column1', 'timestamp', ['limit' => 7])->save(); + } + + public function testDropColumn() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string') + ->save(); + $this->assertTrue($this->adapter->hasColumn('t', 'column1')); + + $table->removeColumn('column1')->save(); + $this->assertFalse($this->adapter->hasColumn('t', 'column1')); + } + + public static function columnsProvider() + { + return [ + ['column1', 'string', []], + ['column2', 'smallinteger', []], + ['column3', 'integer', []], + ['column4', 'biginteger', []], + ['column5', 'text', []], + ['column6', 'float', []], + ['column7', 'decimal', []], + ['decimal_precision_scale', 'decimal', ['precision' => 10, 'scale' => 2]], + ['decimal_limit', 'decimal', ['limit' => 10]], + ['decimal_precision', 'decimal', ['precision' => 10]], + ['column8', 'datetime', []], + ['column9', 'time', []], + ['column10', 'timestamp', []], + ['column11', 'date', []], + ['column12', 'binary', []], + ['column13', 'boolean', ['comment' => 'Lorem ipsum']], + ['column14', 'string', ['limit' => 10]], + ['column16', 'geometry', []], + ['column17', 'point', []], + ['column18', 'linestring', []], + ['column19', 'polygon', []], + ['column20', 'uuid', []], + ['column21', 'set', ['values' => ['one', 'two']]], + ['column22', 'enum', ['values' => ['three', 'four']]], + ['enum_quotes', 'enum', ['values' => [ + "'", '\'\n', '\\', ',', '', "\\\n", '\\n', "\n", "\r", "\r\n", '/', ',,', "\t", + ]]], + ['column23', 'bit', []], + ]; + } + + /** + * @dataProvider columnsProvider + */ + public function testGetColumns($colName, $type, $options) + { + $table = new Table('t', [], $this->adapter); + $table->addColumn($colName, $type, $options)->save(); + + $columns = $this->adapter->getColumns('t'); + $this->assertCount(2, $columns); + $this->assertEquals($colName, $columns[1]->getName()); + $this->assertEquals($type, $columns[1]->getType()); + + if (isset($options['limit'])) { + $this->assertEquals($options['limit'], $columns[1]->getLimit()); + } + + if (isset($options['values'])) { + $this->assertEquals($options['values'], $columns[1]->getValues()); + } + + if (isset($options['precision'])) { + $this->assertEquals($options['precision'], $columns[1]->getPrecision()); + } + + if (isset($options['scale'])) { + $this->assertEquals($options['scale'], $columns[1]->getScale()); + } + + if (isset($options['comment'])) { + $this->assertEquals($options['comment'], $columns[1]->getComment()); + } + } + + public function testGetColumnsInteger() + { + $colName = 'column15'; + $type = 'integer'; + $options = ['limit' => 10]; + $table = new Table('t', [], $this->adapter); + $table->addColumn($colName, $type, $options)->save(); + + $columns = $this->adapter->getColumns('t'); + $this->assertCount(2, $columns); + $this->assertEquals($colName, $columns[1]->getName()); + $this->assertEquals($type, $columns[1]->getType()); + + $this->assertEquals($this->usingMysql8() ? null : 10, $columns[1]->getLimit()); + } + + public function testDescribeTable() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string'); + $table->save(); + + $described = $this->adapter->describeTable('t'); + + $this->assertContains($described['TABLE_TYPE'], ['VIEW', 'BASE TABLE']); + $this->assertEquals($described['TABLE_NAME'], 't'); + $this->assertEquals($described['TABLE_SCHEMA'], MYSQL_DB_CONFIG['name']); + $this->assertEquals($described['TABLE_ROWS'], 0); + } + + public function testGetColumnsReservedTableName() + { + $table = new Table('group', [], $this->adapter); + $table->addColumn('column1', 'string')->save(); + $columns = $this->adapter->getColumns('group'); + $this->assertCount(2, $columns); + } + + public function testAddIndex() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->save(); + $this->assertFalse($table->hasIndex('email')); + $table->addIndex('email') + ->save(); + $this->assertTrue($table->hasIndex('email')); + } + + public function testAddIndexWithSort() + { + $this->adapter->connect(); + if (!$this->usingMysql8()) { + $this->markTestSkipped('Cannot test index order on mysql versions less than 8'); + } + $table = new Table('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->addColumn('username', 'string') + ->save(); + $this->assertFalse($table->hasIndexByName('table1_email_username')); + $table->addIndex(['email', 'username'], ['name' => 'table1_email_username', 'order' => ['email' => 'DESC', 'username' => 'ASC']]) + ->save(); + $this->assertTrue($table->hasIndexByName('table1_email_username')); + $rows = $this->adapter->fetchAll("SHOW INDEXES FROM table1 WHERE Key_name = 'table1_email_username' AND Column_name = 'email'"); + $emailOrder = $rows[0]['Collation']; + $this->assertEquals($emailOrder, 'D'); + + $rows = $this->adapter->fetchAll("SHOW INDEXES FROM table1 WHERE Key_name = 'table1_email_username' AND Column_name = 'username'"); + $emailOrder = $rows[0]['Collation']; + $this->assertEquals($emailOrder, 'A'); + } + + public function testAddMultipleFulltextIndex() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->addColumn('username', 'string') + ->addColumn('bio', 'text') + ->save(); + $this->assertFalse($table->hasIndex('email')); + $this->assertFalse($table->hasIndex('username')); + $this->assertFalse($table->hasIndex('address')); + $table->addIndex('email') + ->addIndex('username', ['type' => 'fulltext']) + ->addIndex('bio', ['type' => 'fulltext']) + ->addIndex(['email', 'bio'], ['type' => 'fulltext']) + ->save(); + $this->assertTrue($table->hasIndex('email')); + $this->assertTrue($table->hasIndex('username')); + $this->assertTrue($table->hasIndex('bio')); + $this->assertTrue($table->hasIndex(['email', 'bio'])); + } + + public function testAddIndexWithLimit() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->save(); + $this->assertFalse($table->hasIndex('email')); + $table->addIndex('email', ['limit' => 50]) + ->save(); + $this->assertTrue($table->hasIndex('email')); + $index_data = $this->adapter->query(sprintf( + 'SELECT SUB_PART FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = "%s" AND TABLE_NAME = "table1" AND INDEX_NAME = "email"', + MYSQL_DB_CONFIG['name'] + ))->fetch(PDO::FETCH_ASSOC); + $expected_limit = $index_data['SUB_PART']; + $this->assertEquals($expected_limit, 50); + } + + public function testAddMultiIndexesWithLimitSpecifier() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->addColumn('username', 'string') + ->save(); + $this->assertFalse($table->hasIndex(['email', 'username'])); + $table->addIndex(['email', 'username'], ['limit' => [ 'email' => 3, 'username' => 2 ]]) + ->save(); + $this->assertTrue($table->hasIndex(['email', 'username'])); + $index_data = $this->adapter->query(sprintf( + 'SELECT SUB_PART FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = "%s" AND TABLE_NAME = "table1" AND INDEX_NAME = "email" AND COLUMN_NAME = "email"', + MYSQL_DB_CONFIG['name'] + ))->fetch(PDO::FETCH_ASSOC); + $expected_limit = $index_data['SUB_PART']; + $this->assertEquals($expected_limit, 3); + $index_data = $this->adapter->query(sprintf( + 'SELECT SUB_PART FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = "%s" AND TABLE_NAME = "table1" AND INDEX_NAME = "email" AND COLUMN_NAME = "username"', + MYSQL_DB_CONFIG['name'] + ))->fetch(PDO::FETCH_ASSOC); + $expected_limit = $index_data['SUB_PART']; + $this->assertEquals($expected_limit, 2); + } + + public function testAddSingleIndexesWithLimitSpecifier() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->addColumn('username', 'string') + ->save(); + $this->assertFalse($table->hasIndex('email')); + $table->addIndex('email', ['limit' => [ 'email' => 3, 2 ]]) + ->save(); + $this->assertTrue($table->hasIndex('email')); + $index_data = $this->adapter->query(sprintf( + 'SELECT SUB_PART FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = "%s" AND TABLE_NAME = "table1" AND INDEX_NAME = "email" AND COLUMN_NAME = "email"', + MYSQL_DB_CONFIG['name'] + ))->fetch(PDO::FETCH_ASSOC); + $expected_limit = $index_data['SUB_PART']; + $this->assertEquals($expected_limit, 3); + } + + public function testDropIndex() + { + // single column index + $table = new Table('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->addIndex('email') + ->save(); + $this->assertTrue($table->hasIndex('email')); + $table->removeIndex(['email'])->save(); + $this->assertFalse($table->hasIndex('email')); + + // multiple column index + $table2 = new Table('table2', [], $this->adapter); + $table2->addColumn('fname', 'string') + ->addColumn('lname', 'string') + ->addIndex(['fname', 'lname']) + ->save(); + $this->assertTrue($table2->hasIndex(['fname', 'lname'])); + $table2->removeIndex(['fname', 'lname'])->save(); + $this->assertFalse($table2->hasIndex(['fname', 'lname'])); + + // index with name specified, but dropping it by column name + $table3 = new Table('table3', [], $this->adapter); + $table3->addColumn('email', 'string') + ->addIndex('email', ['name' => 'someindexname']) + ->save(); + $this->assertTrue($table3->hasIndex('email')); + $table3->removeIndex(['email'])->save(); + $this->assertFalse($table3->hasIndex('email')); + + // multiple column index with name specified + $table4 = new Table('table4', [], $this->adapter); + $table4->addColumn('fname', 'string') + ->addColumn('lname', 'string') + ->addIndex(['fname', 'lname'], ['name' => 'multiname']) + ->save(); + $this->assertTrue($table4->hasIndex(['fname', 'lname'])); + $table4->removeIndex(['fname', 'lname'])->save(); + $this->assertFalse($table4->hasIndex(['fname', 'lname'])); + + // don't drop multiple column index when dropping single column + $table2 = new Table('table5', [], $this->adapter); + $table2->addColumn('fname', 'string') + ->addColumn('lname', 'string') + ->addIndex(['fname', 'lname']) + ->save(); + $this->assertTrue($table2->hasIndex(['fname', 'lname'])); + + try { + $table2->removeIndex(['fname'])->save(); + } catch (InvalidArgumentException $e) { + } + $this->assertTrue($table2->hasIndex(['fname', 'lname'])); + + // don't drop multiple column index with name specified when dropping + // single column + $table4 = new Table('table6', [], $this->adapter); + $table4->addColumn('fname', 'string') + ->addColumn('lname', 'string') + ->addIndex(['fname', 'lname'], ['name' => 'multiname']) + ->save(); + $this->assertTrue($table4->hasIndex(['fname', 'lname'])); + + try { + $table4->removeIndex(['fname'])->save(); + } catch (InvalidArgumentException $e) { + } + + $this->assertTrue($table4->hasIndex(['fname', 'lname'])); + } + + public function testDropIndexByName() + { + // single column index + $table = new Table('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->addIndex('email', ['name' => 'myemailindex']) + ->save(); + $this->assertTrue($table->hasIndex('email')); + $table->removeIndexByName('myemailindex')->save(); + $this->assertFalse($table->hasIndex('email')); + + // multiple column index + $table2 = new Table('table2', [], $this->adapter); + $table2->addColumn('fname', 'string') + ->addColumn('lname', 'string') + ->addIndex(['fname', 'lname'], ['name' => 'twocolumnindex']) + ->save(); + $this->assertTrue($table2->hasIndex(['fname', 'lname'])); + $table2->removeIndexByName('twocolumnindex')->save(); + $this->assertFalse($table2->hasIndex(['fname', 'lname'])); + } + + public function testAddForeignKey() + { + $refTable = new Table('ref_table', [], $this->adapter); + $refTable->addColumn('field1', 'string')->save(); + + $table = new Table('table', [], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer', ['signed' => false]) + ->addForeignKey(['ref_table_id'], 'ref_table', ['id']) + ->save(); + + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), ['ref_table_id'])); + } + + public function testAddForeignKeyForTableWithSignedPK() + { + $refTable = new Table('ref_table', ['signed' => true], $this->adapter); + $refTable->addColumn('field1', 'string')->save(); + + $table = new Table('table', [], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer') + ->addForeignKey(['ref_table_id'], 'ref_table', ['id']) + ->save(); + + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), ['ref_table_id'])); + } + + public function testDropForeignKey() + { + $refTable = new Table('ref_table', [], $this->adapter); + $refTable->addColumn('field1', 'string')->save(); + + $table = new Table('table', [], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer', ['signed' => false]) + ->addForeignKey(['ref_table_id'], 'ref_table', ['id']) + ->save(); + + $table->dropForeignKey(['ref_table_id'])->save(); + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), ['ref_table_id'])); + } + + public function testDropForeignKeyWithMultipleColumns() + { + $refTable = new Table('ref_table', [], $this->adapter); + $refTable + ->addColumn('field1', 'string', ['limit' => 8]) + ->addColumn('field2', 'string', ['limit' => 8]) + ->addIndex(['id', 'field1'], ['unique' => true]) + ->addIndex(['field1', 'id'], ['unique' => true]) + ->addIndex(['id', 'field1', 'field2'], ['unique' => true]) + ->save(); + + $table = new Table('table', [], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer', ['signed' => false]) + ->addColumn('ref_table_field1', 'string', ['limit' => 8]) + ->addColumn('ref_table_field2', 'string', ['limit' => 8]) + ->addForeignKey( + ['ref_table_id', 'ref_table_field1'], + 'ref_table', + ['id', 'field1'] + ) + ->addForeignKey( + ['ref_table_field1', 'ref_table_id'], + 'ref_table', + ['field1', 'id'] + ) + ->addForeignKey( + ['ref_table_id', 'ref_table_field1', 'ref_table_field2'], + 'ref_table', + ['id', 'field1', 'field2'] + ) + ->save(); + + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), ['ref_table_id', 'ref_table_field1'])); + $this->adapter->dropForeignKey($table->getName(), ['ref_table_id', 'ref_table_field1']); + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), ['ref_table_id', 'ref_table_field1'])); + $this->assertTrue( + $this->adapter->hasForeignKey($table->getName(), ['ref_table_id', 'ref_table_field1', 'ref_table_field2']), + 'dropForeignKey() should only affect foreign keys that comprise of exactly the given columns' + ); + $this->assertTrue( + $this->adapter->hasForeignKey($table->getName(), ['ref_table_field1', 'ref_table_id']), + 'dropForeignKey() should only affect foreign keys that comprise of columns in exactly the given order' + ); + + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), ['ref_table_field1', 'ref_table_id'])); + $this->adapter->dropForeignKey($table->getName(), ['ref_table_field1', 'ref_table_id']); + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), ['ref_table_field1', 'ref_table_id'])); + } + + public function testDropForeignKeyWithIdenticalMultipleColumns() + { + $refTable = new Table('ref_table', [], $this->adapter); + $refTable + ->addColumn('field1', 'string', ['limit' => 8]) + ->addIndex(['id', 'field1'], ['unique' => true]) + ->save(); + + $table = new Table('table', [], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer', ['signed' => false]) + ->addColumn('ref_table_field1', 'string', ['limit' => 8]) + ->addForeignKeyWithName( + 'ref_table_fk_1', + ['ref_table_id', 'ref_table_field1'], + 'ref_table', + ['id', 'field1'], + ) + ->addForeignKeyWithName( + 'ref_table_fk_2', + ['ref_table_id', 'ref_table_field1'], + 'ref_table', + ['id', 'field1'] + ) + ->save(); + + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), ['ref_table_id', 'ref_table_field1'])); + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), [], 'ref_table_fk_1')); + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), [], 'ref_table_fk_2')); + + $this->adapter->dropForeignKey($table->getName(), ['ref_table_id', 'ref_table_field1']); + + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), ['ref_table_id', 'ref_table_field1'])); + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), [], 'ref_table_fk_1')); + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), [], 'ref_table_fk_2')); + } + + public static function nonExistentForeignKeyColumnsProvider(): array + { + return [ + [['ref_table_id']], + [['ref_table_field1']], + [['ref_table_field1', 'ref_table_id']], + [['non_existent_column']], + ]; + } + + /** + * @dataProvider nonExistentForeignKeyColumnsProvider + * @param array $columns + */ + public function testDropForeignKeyByNonExistentKeyColumns(array $columns) + { + $refTable = new Table('ref_table', [], $this->adapter); + $refTable + ->addColumn('field1', 'string', ['limit' => 8]) + ->addIndex(['id', 'field1']) + ->save(); + + $table = new Table('table', [], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer', ['signed' => false]) + ->addColumn('ref_table_field1', 'string', ['limit' => 8]) + ->addForeignKey( + ['ref_table_id', 'ref_table_field1'], + 'ref_table', + ['id', 'field1'] + ) + ->save(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(sprintf( + 'No foreign key on column(s) `%s` exists', + implode(', ', $columns) + )); + + $this->adapter->dropForeignKey($table->getName(), $columns); + } + + public function testDropForeignKeyCaseInsensitivity() + { + $refTable = new Table('ref_table', [], $this->adapter); + $refTable->save(); + + $table = new Table('table', [], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer', ['signed' => false]) + ->addForeignKey(['ref_table_id'], 'ref_table', ['id']) + ->save(); + + $this->adapter->dropForeignKey($table->getName(), ['REF_TABLE_ID']); + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), ['ref_table_id'])); + } + + public function testDropForeignKeyByName() + { + $refTable = new Table('ref_table', [], $this->adapter); + $refTable->save(); + + $table = new Table('table', [], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer', ['signed' => false]) + ->addForeignKeyWithName('my_constraint', ['ref_table_id'], 'ref_table', ['id']) + ->save(); + + $this->adapter->dropForeignKey($table->getName(), [], 'my_constraint'); + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), ['ref_table_id'])); + } + + public function testDropForeignKeyForTableWithSignedPK() + { + $refTable = new Table('ref_table', ['signed' => true], $this->adapter); + $refTable->addColumn('field1', 'string')->save(); + + $table = new Table('table', [], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer') + ->addForeignKey(['ref_table_id'], 'ref_table', ['id']) + ->save(); + + $table->dropForeignKey(['ref_table_id'])->save(); + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), ['ref_table_id'])); + } + + public function testDropForeignKeyAsString() + { + $refTable = new Table('ref_table', [], $this->adapter); + $refTable->addColumn('field1', 'string')->save(); + + $table = new Table('table', [], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer', ['signed' => false]) + ->addForeignKey(['ref_table_id'], 'ref_table', ['id']) + ->save(); + + $table->dropForeignKey('ref_table_id')->save(); + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), ['ref_table_id'])); + } + + /** + * @dataProvider provideForeignKeysToCheck + */ + public function testHasForeignKey($tableDef, $key, $exp) + { + $conn = $this->adapter->getConnection(); + $conn->exec('CREATE TABLE other(a int, b int, c int, key(a), key(b), key(a,b), key(a,b,c));'); + $conn->exec($tableDef); + $this->assertSame($exp, $this->adapter->hasForeignKey('t', $key)); + } + + public static function provideForeignKeysToCheck() + { + return [ + ['create table t(a int)', 'a', false], + ['create table t(a int)', [], false], + ['create table t(a int primary key)', 'a', false], + ['create table t(a int, foreign key (a) references other(a))', 'a', true], + ['create table t(a int, foreign key (a) references other(b))', 'a', true], + ['create table t(a int, foreign key (a) references other(b))', ['a'], true], + ['create table t(a int, foreign key (a) references other(b))', ['a', 'a'], false], + ['create table t(a int, foreign key(a) references other(a))', 'a', true], + ['create table t(a int, b int, foreign key(a,b) references other(a,b))', 'a', false], + ['create table t(a int, b int, foreign key(a,b) references other(a,b))', ['a', 'b'], true], + ['create table t(a int, b int, foreign key(a,b) references other(a,b))', ['b', 'a'], false], + ['create table t(a int, `B` int, foreign key(a,`B`) references other(a,b))', ['a', 'b'], true], + ['create table t(a int, b int, foreign key(a,b) references other(a,b))', ['a', 'B'], true], + ['create table t(a int, b int, c int, foreign key(a,b,c) references other(a,b,c))', ['a', 'b'], false], + ['create table t(a int, foreign key(a) references other(a))', ['a', 'b'], false], + ['create table t(a int, b int, foreign key(a) references other(a), foreign key(b) references other(b))', ['a', 'b'], false], + ['create table t(a int, b int, foreign key(a) references other(a), foreign key(b) references other(b))', ['a', 'b'], false], + ['create table t(`0` int, foreign key(`0`) references other(a))', '0', true], + ['create table t(`0` int, foreign key(`0`) references other(a))', '0e0', false], + ['create table t(`0e0` int, foreign key(`0e0`) references other(a))', '0', false], + ]; + } + + public function testHasForeignKeyAsString() + { + $refTable = new Table('ref_table', [], $this->adapter); + $refTable->addColumn('field1', 'string')->save(); + + $table = new Table('table', [], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer', ['signed' => false]) + ->addForeignKey(['ref_table_id'], 'ref_table', ['id']) + ->save(); + + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), 'ref_table_id')); + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), 'ref_table_id2')); + } + + public function testHasNamedForeignKey() + { + $refTable = new Table('ref_table', [], $this->adapter); + $refTable->addColumn('field1', 'string')->save(); + + $table = new Table('table', [], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer', ['signed' => false]) + ->addForeignKeyWithName('my_constraint', ['ref_table_id'], 'ref_table', ['id']) + ->save(); + + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), ['ref_table_id'], 'my_constraint')); + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), ['ref_table_id'], 'my_constraint2')); + + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), [], 'my_constraint')); + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), [], 'my_constraint2')); + } + + public function testHasForeignKeyWithConstraintForTableWithSignedPK() + { + $refTable = new Table('ref_table', ['signed' => true], $this->adapter); + $refTable->addColumn('field1', 'string')->save(); + + $table = new Table('table', [], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer') + ->addForeignKeyWithName('my_constraint', ['ref_table_id'], 'ref_table', ['id']) + ->save(); + + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), ['ref_table_id'], 'my_constraint')); + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), ['ref_table_id'], 'my_constraint2')); + } + + public function testsHasForeignKeyWithSchemaDotTableName() + { + $refTable = new Table('ref_table', [], $this->adapter); + $refTable->addColumn('field1', 'string')->save(); + + $table = new Table('table', [], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer', ['signed' => false]) + ->addForeignKey(['ref_table_id'], 'ref_table', ['id']) + ->save(); + + $this->assertTrue($this->adapter->hasForeignKey(MYSQL_DB_CONFIG['name'] . '.' . $table->getName(), ['ref_table_id'])); + $this->assertFalse($this->adapter->hasForeignKey(MYSQL_DB_CONFIG['name'] . '.' . $table->getName(), ['ref_table_id2'])); + } + + public function testHasDatabase() + { + $this->assertFalse($this->adapter->hasDatabase('fake_database_name')); + $this->assertTrue($this->adapter->hasDatabase(MYSQL_DB_CONFIG['name'])); + } + + public function testDropDatabase() + { + $this->assertFalse($this->adapter->hasDatabase('phinx_temp_database')); + $this->adapter->createDatabase('phinx_temp_database'); + $this->assertTrue($this->adapter->hasDatabase('phinx_temp_database')); + $this->adapter->dropDatabase('phinx_temp_database'); + } + + public function testAddColumnWithComment() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('column1', 'string', ['comment' => $comment = 'Comments from "column1"']) + ->save(); + + $rows = $this->adapter->fetchAll(sprintf( + "SELECT COLUMN_NAME, COLUMN_COMMENT + FROM information_schema.columns + WHERE TABLE_SCHEMA='%s' AND TABLE_NAME='table1' + ORDER BY ORDINAL_POSITION", + MYSQL_DB_CONFIG['name'] + )); + $columnWithComment = $rows[1]; + + $this->assertSame('column1', $columnWithComment['COLUMN_NAME'], "Didn't set column name correctly"); + $this->assertEquals($comment, $columnWithComment['COLUMN_COMMENT'], "Didn't set column comment correctly"); + } + + public function testAddGeoSpatialColumns() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $this->assertFalse($table->hasColumn('geo_geom')); + $table->addColumn('geo_geom', 'geometry') + ->save(); + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM table1'); + $this->assertEquals('geometry', $rows[1]['Type']); + } + + public function testAddSetColumn() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $this->assertFalse($table->hasColumn('set_column')); + $table->addColumn('set_column', 'set', ['values' => ['one', 'two']]) + ->save(); + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM table1'); + $this->assertEquals("set('one','two')", $rows[1]['Type']); + } + + public function testAddEnumColumn() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $this->assertFalse($table->hasColumn('enum_column')); + $table->addColumn('enum_column', 'enum', ['values' => ['one', 'two']]) + ->save(); + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM table1'); + $this->assertEquals("enum('one','two')", $rows[1]['Type']); + } + + public function testEnumColumnValuesFilledUpFromSchema() + { + // Creating column with values + (new Table('table1', [], $this->adapter)) + ->addColumn('enum_column', 'enum', ['values' => ['one', 'two']]) + ->save(); + + // Reading them back + $table = new Table('table1', [], $this->adapter); + $columns = $table->getColumns(); + $enumColumn = end($columns); + $this->assertEquals(AdapterInterface::PHINX_TYPE_ENUM, $enumColumn->getType()); + $this->assertEquals(['one', 'two'], $enumColumn->getValues()); + } + + public function testEnumColumnWithNullValue() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('enum_column', 'enum', ['values' => ['one', 'two', null]]); + + $this->expectException(PDOException::class); + $table->save(); + } + + public function testHasColumn() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('column1', 'string') + ->save(); + + $this->assertFalse($table->hasColumn('column2')); + $this->assertTrue($table->hasColumn('column1')); + } + + public function testHasColumnReservedName() + { + $tableQuoted = new Table('group', [], $this->adapter); + $tableQuoted->addColumn('value', 'string') + ->save(); + + $this->assertFalse($tableQuoted->hasColumn('column2')); + $this->assertTrue($tableQuoted->hasColumn('value')); + } + + public function testBulkInsertData() + { + $data = [ + [ + 'column1' => 'value1', + 'column2' => 1, + ], + [ + 'column1' => 'value2', + 'column2' => 2, + ], + [ + 'column1' => 'value3', + 'column2' => 3, + ], + ]; + $table = new Table('table1', [], $this->adapter); + $table->addColumn('column1', 'string') + ->addColumn('column2', 'integer') + ->addColumn('column3', 'string', ['default' => 'test']) + ->insert($data) + ->save(); + + $rows = $this->adapter->fetchAll('SELECT * FROM table1'); + $this->assertEquals('value1', $rows[0]['column1']); + $this->assertEquals('value2', $rows[1]['column1']); + $this->assertEquals('value3', $rows[2]['column1']); + $this->assertEquals(1, $rows[0]['column2']); + $this->assertEquals(2, $rows[1]['column2']); + $this->assertEquals(3, $rows[2]['column2']); + $this->assertEquals('test', $rows[0]['column3']); + $this->assertEquals('test', $rows[2]['column3']); + } + + public function testInsertData() + { + $data = [ + [ + 'column1' => 'value1', + 'column2' => 1, + ], + [ + 'column1' => 'value2', + 'column2' => 2, + ], + [ + 'column1' => 'value3', + 'column2' => 3, + 'column3' => 'foo', + ], + ]; + $table = new Table('table1', [], $this->adapter); + $table->addColumn('column1', 'string') + ->addColumn('column2', 'integer') + ->addColumn('column3', 'string', ['default' => 'test']) + ->insert($data) + ->save(); + + $rows = $this->adapter->fetchAll('SELECT * FROM table1'); + $this->assertEquals('value1', $rows[0]['column1']); + $this->assertEquals('value2', $rows[1]['column1']); + $this->assertEquals('value3', $rows[2]['column1']); + $this->assertEquals(1, $rows[0]['column2']); + $this->assertEquals(2, $rows[1]['column2']); + $this->assertEquals(3, $rows[2]['column2']); + $this->assertEquals('test', $rows[0]['column3']); + $this->assertEquals('foo', $rows[2]['column3']); + } + + public function testDumpCreateTable() + { + $inputDefinition = new InputDefinition([new InputOption('dry-run')]); + $this->adapter->setInput(new ArrayInput(['--dry-run' => true], $inputDefinition)); + + $consoleOutput = new BufferedOutput(); + $this->adapter->setOutput($consoleOutput); + + $table = new Table('table1', [], $this->adapter); + + $table->addColumn('column1', 'string', ['null' => false]) + ->addColumn('column2', 'integer') + ->addColumn('column3', 'string', ['default' => 'test', 'null' => false]) + ->save(); + + $expectedOutput = <<<'OUTPUT' +CREATE TABLE `table1` (`id` INT(11) unsigned NOT NULL AUTO_INCREMENT, `column1` VARCHAR(255) NOT NULL, `column2` INT(11) NULL, `column3` VARCHAR(255) NOT NULL DEFAULT 'test', PRIMARY KEY (`id`)) ENGINE = InnoDB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +OUTPUT; + $actualOutput = $consoleOutput->fetch(); + $this->assertStringContainsString($expectedOutput, $actualOutput, 'Passing the --dry-run option does not dump create table query to the output'); + } + + /** + * Creates the table "table1". + * Then sets phinx to dry run mode and inserts a record. + * Asserts that phinx outputs the insert statement and doesn't insert a record. + */ + public function testDumpInsert() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('string_col', 'string') + ->addColumn('int_col', 'integer') + ->save(); + + $inputDefinition = new InputDefinition([new InputOption('dry-run')]); + $this->adapter->setInput(new ArrayInput(['--dry-run' => true], $inputDefinition)); + + $consoleOutput = new BufferedOutput(); + $this->adapter->setOutput($consoleOutput); + + $this->adapter->insert($table->getTable(), [ + 'string_col' => 'test data', + ]); + + $this->adapter->insert($table->getTable(), [ + 'string_col' => null, + ]); + + $this->adapter->insert($table->getTable(), [ + 'int_col' => 23, + ]); + + $expectedOutput = <<<'OUTPUT' +INSERT INTO `table1` (`string_col`) VALUES ('test data'); +INSERT INTO `table1` (`string_col`) VALUES (null); +INSERT INTO `table1` (`int_col`) VALUES (23); +OUTPUT; + $actualOutput = $consoleOutput->fetch(); + + // Add this to be LF - CR/LF systems independent + $expectedOutput = preg_replace('~\R~u', '', $expectedOutput); + $actualOutput = preg_replace('~\R~u', '', $actualOutput); + + $this->assertStringContainsString($expectedOutput, trim($actualOutput), 'Passing the --dry-run option doesn\'t dump the insert to the output'); + + $countQuery = $this->adapter->query('SELECT COUNT(*) FROM table1'); + $this->assertTrue($countQuery->execute()); + $res = $countQuery->fetchAll(); + $this->assertEquals(0, $res[0]['COUNT(*)']); + } + + /** + * Creates the table "table1". + * Then sets phinx to dry run mode and inserts some records. + * Asserts that phinx outputs the insert statement and doesn't insert any record. + */ + public function testDumpBulkinsert() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('string_col', 'string') + ->addColumn('int_col', 'integer') + ->save(); + + $inputDefinition = new InputDefinition([new InputOption('dry-run')]); + $this->adapter->setInput(new ArrayInput(['--dry-run' => true], $inputDefinition)); + + $consoleOutput = new BufferedOutput(); + $this->adapter->setOutput($consoleOutput); + + $this->adapter->bulkinsert($table->getTable(), [ + [ + 'string_col' => 'test_data1', + 'int_col' => 23, + ], + [ + 'string_col' => null, + 'int_col' => 42, + ], + ]); + + $expectedOutput = <<<'OUTPUT' +INSERT INTO `table1` (`string_col`, `int_col`) VALUES ('test_data1', 23), (null, 42); +OUTPUT; + $actualOutput = $consoleOutput->fetch(); + $this->assertStringContainsString($expectedOutput, $actualOutput, 'Passing the --dry-run option doesn\'t dump the bulkinsert to the output'); + + $countQuery = $this->adapter->query('SELECT COUNT(*) FROM table1'); + $this->assertTrue($countQuery->execute()); + $res = $countQuery->fetchAll(); + $this->assertEquals(0, $res[0]['COUNT(*)']); + } + + public function testDumpCreateTableAndThenInsert() + { + $inputDefinition = new InputDefinition([new InputOption('dry-run')]); + $this->adapter->setInput(new ArrayInput(['--dry-run' => true], $inputDefinition)); + + $consoleOutput = new BufferedOutput(); + $this->adapter->setOutput($consoleOutput); + + $table = new Table('table1', ['id' => false, 'primary_key' => ['column1']], $this->adapter); + + $table->addColumn('column1', 'string', ['null' => false]) + ->addColumn('column2', 'integer') + ->save(); + + $table = new Table('table1', [], $this->adapter); + $table->insert([ + 'column1' => 'id1', + 'column2' => 1, + ])->save(); + + $expectedOutput = <<<'OUTPUT' +CREATE TABLE `table1` (`column1` VARCHAR(255) NOT NULL, `column2` INT(11) NULL, PRIMARY KEY (`column1`)) ENGINE = InnoDB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +INSERT INTO `table1` (`column1`, `column2`) VALUES ('id1', 1); +OUTPUT; + $actualOutput = $consoleOutput->fetch(); + // Add this to be LF - CR/LF systems independent + $expectedOutput = preg_replace('~\R~u', '', $expectedOutput); + $actualOutput = preg_replace('~\R~u', '', $actualOutput); + $this->assertStringContainsString($expectedOutput, $actualOutput, 'Passing the --dry-run option does not dump create and then insert table queries to the output'); + } + + public function testDumpTransaction() + { + $inputDefinition = new InputDefinition([new InputOption('dry-run')]); + $this->adapter->setInput(new ArrayInput(['--dry-run' => true], $inputDefinition)); + + $consoleOutput = new BufferedOutput(); + $this->adapter->setOutput($consoleOutput); + + $this->adapter->beginTransaction(); + $table = new Table('table1', [], $this->adapter); + + $table->addColumn('column1', 'string') + ->addColumn('column2', 'integer') + ->addColumn('column3', 'string', ['default' => 'test']) + ->save(); + $this->adapter->commitTransaction(); + $this->adapter->rollbackTransaction(); + + $actualOutput = $consoleOutput->fetch(); + // Add this to be LF - CR/LF systems independent + $actualOutput = preg_replace('~\R~u', '', $actualOutput); + $this->assertStringStartsWith('START TRANSACTION;', $actualOutput, 'Passing the --dry-run doesn\'t dump the transaction to the output'); + $this->assertStringEndsWith('COMMIT;ROLLBACK;', $actualOutput, 'Passing the --dry-run doesn\'t dump the transaction to the output'); + } + + /** + * Tests interaction with the query builder + */ + public function testQueryBuilder() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('string_col', 'string') + ->addColumn('int_col', 'integer') + ->save(); + + $builder = $this->adapter->getQueryBuilder(Query::TYPE_INSERT); + $stm = $builder + ->insert(['string_col', 'int_col']) + ->into('table1') + ->values(['string_col' => 'value1', 'int_col' => 1]) + ->values(['string_col' => 'value2', 'int_col' => 2]) + ->execute(); + + $this->assertEquals(2, $stm->rowCount()); + + $builder = $this->adapter->getQueryBuilder(Query::TYPE_SELECT); + $stm = $builder + ->select('*') + ->from('table1') + ->where(['int_col >=' => 2]) + ->execute(); + + $this->assertEquals(1, $stm->rowCount()); + $this->assertEquals( + ['id' => 2, 'string_col' => 'value2', 'int_col' => '2'], + $stm->fetch('assoc') + ); + + $builder = $this->adapter->getQueryBuilder(query::TYPE_DELETE); + $stm = $builder + ->delete('table1') + ->where(['int_col <' => 2]) + ->execute(); + + $this->assertEquals(1, $stm->rowCount()); + } + + public function testQueryWithParams() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('string_col', 'string') + ->addColumn('int_col', 'integer') + ->save(); + + $this->adapter->insert($table->getTable(), [ + 'string_col' => 'test data', + 'int_col' => 10, + ]); + + $this->adapter->insert($table->getTable(), [ + 'string_col' => null, + ]); + + $this->adapter->insert($table->getTable(), [ + 'int_col' => 23, + ]); + + $countQuery = $this->adapter->query('SELECT COUNT(*) AS c FROM table1 WHERE int_col > ?', [5]); + $res = $countQuery->fetchAll(); + $this->assertEquals(2, $res[0]['c']); + + $this->adapter->execute('UPDATE table1 SET int_col = ? WHERE int_col IS NULL', [12]); + + $countQuery->execute([1]); + $res = $countQuery->fetchAll(); + $this->assertEquals(3, $res[0]['c']); + } + + public function testLiteralSupport() + { + $createQuery = <<<'INPUT' +CREATE TABLE `test` (`double_col` double NOT NULL) +INPUT; + $this->adapter->execute($createQuery); + $table = new Table('test', [], $this->adapter); + $columns = $table->getColumns(); + $this->assertCount(1, $columns); + $this->assertEquals(Literal::from('double'), array_pop($columns)->getType()); + } + + public static function geometryTypeProvider() + { + return [ + [MysqlAdapter::PHINX_TYPE_GEOMETRY, 'POINT(0 0)'], + [MysqlAdapter::PHINX_TYPE_POINT, 'POINT(0 0)'], + [MysqlAdapter::PHINX_TYPE_LINESTRING, 'LINESTRING(30 10,10 30,40 40)'], + [MysqlAdapter::PHINX_TYPE_POLYGON, 'POLYGON((30 10,40 40,20 40,10 20,30 10))'], + ]; + } + + /** + * @dataProvider geometryTypeProvider + * @param string $type + * @param string $geom + */ + public function testGeometrySridSupport($type, $geom) + { + $this->adapter->connect(); + if (!$this->usingMysql8()) { + $this->markTestSkipped('Cannot test geometry srid on mysql versions less than 8'); + } + + $table = new Table('table1', [], $this->adapter); + $table + ->addColumn('geom', $type, ['srid' => 4326]) + ->save(); + + $this->adapter->execute("INSERT INTO table1 (`geom`) VALUES (ST_GeomFromText('{$geom}', 4326))"); + $rows = $this->adapter->fetchAll('SELECT ST_AsWKT(geom) as wkt, ST_SRID(geom) as srid FROM table1'); + $this->assertCount(1, $rows); + $this->assertSame($geom, $rows[0]['wkt']); + $this->assertSame(4326, (int)$rows[0]['srid']); + } + + /** + * @dataProvider geometryTypeProvider + * @param string $type + * @param string $geom + */ + public function testGeometrySridThrowsInsertDifferentSrid($type, $geom) + { + $this->adapter->connect(); + if (!$this->usingMysql8()) { + $this->markTestSkipped('Cannot test geometry srid on mysql versions less than 8'); + } + + $table = new Table('table1', [], $this->adapter); + $table + ->addColumn('geom', $type, ['srid' => 4326]) + ->save(); + + $this->expectException(PDOException::class); + $this->expectExceptionMessage("SQLSTATE[HY000]: General error: 3643 The SRID of the geometry does not match the SRID of the column 'geom'. The SRID of the geometry is 4322, but the SRID of the column is 4326. Consider changing the SRID of the geometry or the SRID property of the column."); + $this->adapter->execute("INSERT INTO table1 (`geom`) VALUES (ST_GeomFromText('{$geom}', 4322))"); + } + + /** + * Small check to verify if specific Mysql constants are handled in AdapterInterface + * + * @see https://github.com/cakephp/migrations/issues/359 + */ + public function testMysqlBlobsConstants() + { + $reflector = new ReflectionClass(AdapterInterface::class); + + $validTypes = array_filter($reflector->getConstants(), function ($constant) { + return substr($constant, 0, strlen('PHINX_TYPE_')) === 'PHINX_TYPE_'; + }, ARRAY_FILTER_USE_KEY); + + $this->assertTrue(in_array('tinyblob', $validTypes, true)); + $this->assertTrue(in_array('blob', $validTypes, true)); + $this->assertTrue(in_array('mediumblob', $validTypes, true)); + $this->assertTrue(in_array('longblob', $validTypes, true)); + } + + public static function defaultsCastAsExpressions() + { + return [ + [MysqlAdapter::PHINX_TYPE_BLOB, 'abc'], + [MysqlAdapter::PHINX_TYPE_JSON, '{"a": true}'], + [MysqlAdapter::PHINX_TYPE_TEXT, 'abc'], + [MysqlAdapter::PHINX_TYPE_GEOMETRY, 'POINT(0 0)'], + [MysqlAdapter::PHINX_TYPE_POINT, 'POINT(0 0)'], + [MysqlAdapter::PHINX_TYPE_LINESTRING, 'LINESTRING(30 10,10 30,40 40)'], + [MysqlAdapter::PHINX_TYPE_POLYGON, 'POLYGON((30 10,40 40,20 40,10 20,30 10))'], + ]; + } + + /** + * MySQL 8 added support for specifying defaults for the BLOB, TEXT, GEOMETRY, and JSON data types, + * however requiring that they be wrapped in expressions. + * + * @dataProvider defaultsCastAsExpressions + * @param string $type + * @param string $default + */ + public function testDefaultsCastAsExpressionsForCertainTypes(string $type, string $default): void + { + $this->adapter->connect(); + + $table = new Table('table1', ['id' => false], $this->adapter); + if (!$this->usingMysql8()) { + $this->expectException(PDOException::class); + } + $table + ->addColumn('col_1', $type, ['default' => $default]) + ->create(); + + $columns = $this->adapter->getColumns('table1'); + $this->assertCount(1, $columns); + $this->assertSame('col_1', $columns[0]->getName()); + $this->assertSame($default, $columns[0]->getDefault()); + } + + public function testCreateTableWithPrecisionCurrentTimestamp() + { + $this->adapter->connect(); + (new Table('exampleCurrentTimestamp3', ['id' => false], $this->adapter)) + ->addColumn('timestamp_3', 'timestamp', [ + 'null' => false, + 'default' => 'CURRENT_TIMESTAMP(3)', + 'limit' => 3, + ]) + ->create(); + + $rows = $this->adapter->fetchAll(sprintf( + "SELECT COLUMN_DEFAULT FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA='%s' AND TABLE_NAME='exampleCurrentTimestamp3'", + MYSQL_DB_CONFIG['name'] + )); + $colDef = $rows[0]; + $this->assertEqualsIgnoringCase('CURRENT_TIMESTAMP(3)', $colDef['COLUMN_DEFAULT']); + } + + public static function pdoAttributeProvider() + { + return [ + ['mysql_attr_invalid'], + ['attr_invalid'], + ]; + } + + /** + * @dataProvider pdoAttributeProvider + */ + public function testInvalidPdoAttribute($attribute) + { + $adapter = new MysqlAdapter(MYSQL_DB_CONFIG + [$attribute => true]); + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Invalid PDO attribute: ' . $attribute . ' (\PDO::' . strtoupper($attribute) . ')'); + $adapter->connect(); + } + + public static function integerDataTypesSQLProvider() + { + return [ + // Types without a width should always have a null limit + ['bigint', ['name' => AdapterInterface::PHINX_TYPE_BIG_INTEGER, 'limit' => null, 'scale' => null]], + ['int', ['name' => AdapterInterface::PHINX_TYPE_INTEGER, 'limit' => null, 'scale' => null]], + ['mediumint', ['name' => AdapterInterface::PHINX_TYPE_MEDIUM_INTEGER, 'limit' => null, 'scale' => null]], + ['smallint', ['name' => AdapterInterface::PHINX_TYPE_SMALL_INTEGER, 'limit' => null, 'scale' => null]], + ['tinyint', ['name' => AdapterInterface::PHINX_TYPE_TINY_INTEGER, 'limit' => null, 'scale' => null]], + + // Types which include a width should always have that as their limit + ['bigint(20)', ['name' => AdapterInterface::PHINX_TYPE_BIG_INTEGER, 'limit' => 20, 'scale' => null]], + ['bigint(10)', ['name' => AdapterInterface::PHINX_TYPE_BIG_INTEGER, 'limit' => 10, 'scale' => null]], + ['bigint(1) unsigned', ['name' => AdapterInterface::PHINX_TYPE_BIG_INTEGER, 'limit' => 1, 'scale' => null]], + ['int(11)', ['name' => AdapterInterface::PHINX_TYPE_INTEGER, 'limit' => 11, 'scale' => null]], + ['int(10) unsigned', ['name' => AdapterInterface::PHINX_TYPE_INTEGER, 'limit' => 10, 'scale' => null]], + ['mediumint(6)', ['name' => AdapterInterface::PHINX_TYPE_MEDIUM_INTEGER, 'limit' => 6, 'scale' => null]], + ['mediumint(8) unsigned', ['name' => AdapterInterface::PHINX_TYPE_MEDIUM_INTEGER, 'limit' => 8, 'scale' => null]], + ['smallint(2)', ['name' => AdapterInterface::PHINX_TYPE_SMALL_INTEGER, 'limit' => 2, 'scale' => null]], + ['smallint(5) unsigned', ['name' => AdapterInterface::PHINX_TYPE_SMALL_INTEGER, 'limit' => 5, 'scale' => null]], + ['tinyint(3) unsigned', ['name' => AdapterInterface::PHINX_TYPE_TINY_INTEGER, 'limit' => 3, 'scale' => null]], + ['tinyint(4)', ['name' => AdapterInterface::PHINX_TYPE_TINY_INTEGER, 'limit' => 4, 'scale' => null]], + + // Special case for commonly used boolean type + ['tinyint(1)', ['name' => AdapterInterface::PHINX_TYPE_BOOLEAN, 'limit' => null, 'scale' => null]], + ]; + } + + /** + * @dataProvider integerDataTypesSQLProvider + */ + public function testGetPhinxTypeFromSQLDefinition(string $sqlDefinition, array $expectedResponse) + { + $result = $this->adapter->getPhinxType($sqlDefinition); + + $this->assertSame($expectedResponse['name'], $result['name'], "Type mismatch - got '{$result['name']}' when expecting '{$expectedResponse['name']}'"); + $this->assertSame($expectedResponse['limit'], $result['limit'], "Field upper boundary mismatch - got '{$result['limit']}' when expecting '{$expectedResponse['limit']}'"); + } + + public function testPdoPersistentConnection() + { + $adapter = new MysqlAdapter(MYSQL_DB_CONFIG + ['attr_persistent' => true]); + $this->assertTrue($adapter->getConnection()->getAttribute(PDO::ATTR_PERSISTENT)); + } + + public function testPdoNotPersistentConnection() + { + $adapter = new MysqlAdapter(MYSQL_DB_CONFIG); + $this->assertFalse($adapter->getConnection()->getAttribute(PDO::ATTR_PERSISTENT)); + } +} diff --git a/tests/TestCase/Db/Adapter/PdoAdapterTest.php b/tests/TestCase/Db/Adapter/PdoAdapterTest.php new file mode 100644 index 00000000..e8087de9 --- /dev/null +++ b/tests/TestCase/Db/Adapter/PdoAdapterTest.php @@ -0,0 +1,203 @@ +adapter = $this->getMockForAbstractClass('\Migrations\Db\Adapter\PdoAdapter', [['foo' => 'bar']]); + } + + protected function tearDown(): void + { + unset($this->adapter); + } + + public function testOptions() + { + $options = $this->adapter->getOptions(); + $this->assertArrayHasKey('foo', $options); + $this->assertEquals('bar', $options['foo']); + } + + public function testOptionsSetConnection() + { + $connection = $this->getMockBuilder(PDO::class)->disableOriginalConstructor()->getMock(); + $this->adapter->setOptions(['connection' => $connection]); + + $this->assertSame($connection, $this->adapter->getConnection()); + } + + public function testOptionsSetSchemaTableName() + { + $this->assertEquals('phinxlog', $this->adapter->getSchemaTableName()); + $this->adapter->setOptions(['migration_table' => 'schema_table_test']); + $this->assertEquals('schema_table_test', $this->adapter->getSchemaTableName()); + } + + public function testOptionsSetDefaultMigrationTableThrowsDeprecation() + { + $this->markTestIncomplete('Deprecation assertions are not supported in PHPUnit anymore. We need to adopt the cakephp TestSuite class instead'); + $this->assertEquals('phinxlog', $this->adapter->getSchemaTableName()); + + $this->expectDeprecation(); + $this->expectExceptionMessage('The default_migration_table setting for adapter has been deprecated since 0.13.0. Use `migration_table` instead.'); + $this->adapter->setOptions(['default_migration_table' => 'schema_table_test']); + $this->assertEquals('schema_table_test', $this->adapter->getSchemaTableName()); + } + + public function testSchemaTableName() + { + $this->assertEquals('phinxlog', $this->adapter->getSchemaTableName()); + $this->adapter->setSchemaTableName('schema_table_test'); + $this->assertEquals('schema_table_test', $this->adapter->getSchemaTableName()); + } + + /** + * @dataProvider getVersionLogDataProvider + */ + public function testGetVersionLog($versionOrder, $expectedOrderBy) + { + $adapter = $this->getMockForAbstractClass( + '\Migrations\Db\Adapter\PdoAdapter', + [['version_order' => $versionOrder]], + '', + true, + true, + true, + ['fetchAll', 'getSchemaTableName', 'quoteTableName'] + ); + + $schemaTableName = 'log'; + $adapter->expects($this->once()) + ->method('getSchemaTableName') + ->will($this->returnValue($schemaTableName)); + $adapter->expects($this->once()) + ->method('quoteTableName') + ->with($schemaTableName) + ->will($this->returnValue("'$schemaTableName'")); + + $mockRows = [ + [ + 'version' => '20120508120534', + 'key' => 'value', + ], + [ + 'version' => '20130508120534', + 'key' => 'value', + ], + ]; + + $adapter->expects($this->once()) + ->method('fetchAll') + ->with("SELECT * FROM '$schemaTableName' ORDER BY $expectedOrderBy") + ->will($this->returnValue($mockRows)); + + // we expect the mock rows but indexed by version creation time + $expected = [ + '20120508120534' => [ + 'version' => '20120508120534', + 'key' => 'value', + ], + '20130508120534' => [ + 'version' => '20130508120534', + 'key' => 'value', + ], + ]; + + $this->assertEquals($expected, $adapter->getVersionLog()); + } + + public static function getVersionLogDataProvider() + { + return [ + 'With Creation Time Version Order' => [ + Config::VERSION_ORDER_CREATION_TIME, 'version ASC', + ], + 'With Execution Time Version Order' => [ + Config::VERSION_ORDER_EXECUTION_TIME, 'start_time ASC, version ASC', + ], + ]; + } + + public function testGetVersionLogInvalidVersionOrderKO() + { + $this->expectExceptionMessage('Invalid version_order configuration option'); + $adapter = $this->getMockForAbstractClass( + '\Migrations\Db\Adapter\PdoAdapter', + [['version_order' => 'invalid']] + ); + + $this->expectException(RuntimeException::class); + + $adapter->getVersionLog(); + } + + public function testGetVersionLongDryRun() + { + $adapter = $this->getMockForAbstractClass( + '\Migrations\Db\Adapter\PdoAdapter', + [['version_order' => Config::VERSION_ORDER_CREATION_TIME]], + '', + true, + true, + true, + ['isDryRunEnabled', 'fetchAll', 'getSchemaTableName', 'quoteTableName'] + ); + + $schemaTableName = 'log'; + + $adapter->expects($this->once()) + ->method('isDryRunEnabled') + ->will($this->returnValue(true)); + $adapter->expects($this->once()) + ->method('getSchemaTableName') + ->will($this->returnValue($schemaTableName)); + $adapter->expects($this->once()) + ->method('quoteTableName') + ->with($schemaTableName) + ->will($this->returnValue("'$schemaTableName'")); + $adapter->expects($this->once()) + ->method('fetchAll') + ->with("SELECT * FROM '$schemaTableName' ORDER BY version ASC") + ->will($this->throwException(new PDOException())); + + $this->assertEquals([], $adapter->getVersionLog()); + } + + /** + * Tests that execute() can be called on the adapter, and that the SQL is passed through to the PDO. + */ + public function testExecuteCanBeCalled() + { + /** @var \PDO&\PHPUnit\Framework\MockObject\MockObject $pdo */ + $pdo = $this->getMockBuilder(PDO::class)->disableOriginalConstructor()->onlyMethods(['exec'])->getMock(); + $pdo->expects($this->once())->method('exec')->with('SELECT 1;')->will($this->returnValue(1)); + + $this->adapter->setConnection($pdo); + $this->adapter->execute('SELECT 1'); + } + + public function testExecuteRightTrimsSemiColons() + { + /** @var \PDO&\PHPUnit\Framework\MockObject\MockObject $pdo */ + $pdo = $this->getMockBuilder(PDO::class)->disableOriginalConstructor()->onlyMethods(['exec'])->getMock(); + $pdo->expects($this->once())->method('exec')->with('SELECT 1;')->will($this->returnValue(1)); + + $this->adapter->setConnection($pdo); + $this->adapter->execute('SELECT 1;;'); + } +}