* @author Richard McIntyre */ class SQLiteAdapter extends PdoAdapter { public const MEMORY = ':memory:'; /** * List of supported Phinx column types with their SQL equivalents * some types have an affinity appended to ensure they do not receive NUMERIC affinity * * @var string[] */ protected static $supportedColumnTypes = [ self::PHINX_TYPE_BIG_INTEGER => 'biginteger', self::PHINX_TYPE_BINARY => 'binary_blob', self::PHINX_TYPE_BINARYUUID => 'binary_blob', self::PHINX_TYPE_BLOB => 'blob', self::PHINX_TYPE_BOOLEAN => 'boolean_integer', self::PHINX_TYPE_CHAR => 'char', self::PHINX_TYPE_DATE => 'date_text', self::PHINX_TYPE_DATETIME => 'datetime_text', self::PHINX_TYPE_DECIMAL => 'decimal', self::PHINX_TYPE_DOUBLE => 'double', self::PHINX_TYPE_FLOAT => 'float', self::PHINX_TYPE_INTEGER => 'integer', self::PHINX_TYPE_JSON => 'json_text', self::PHINX_TYPE_JSONB => 'jsonb_text', self::PHINX_TYPE_SMALL_INTEGER => 'smallinteger', self::PHINX_TYPE_STRING => 'varchar', self::PHINX_TYPE_TEXT => 'text', self::PHINX_TYPE_TIME => 'time_text', self::PHINX_TYPE_TIMESTAMP => 'timestamp_text', self::PHINX_TYPE_TINY_INTEGER => 'tinyinteger', self::PHINX_TYPE_UUID => 'uuid_text', self::PHINX_TYPE_VARBINARY => 'varbinary_blob', ]; /** * List of aliases of supported column types * * @var string[] */ protected static $supportedColumnTypeAliases = [ 'varchar' => self::PHINX_TYPE_STRING, 'tinyint' => self::PHINX_TYPE_TINY_INTEGER, 'tinyinteger' => self::PHINX_TYPE_TINY_INTEGER, 'smallint' => self::PHINX_TYPE_SMALL_INTEGER, 'int' => self::PHINX_TYPE_INTEGER, 'mediumint' => self::PHINX_TYPE_INTEGER, 'mediuminteger' => self::PHINX_TYPE_INTEGER, 'bigint' => self::PHINX_TYPE_BIG_INTEGER, 'tinytext' => self::PHINX_TYPE_TEXT, 'mediumtext' => self::PHINX_TYPE_TEXT, 'longtext' => self::PHINX_TYPE_TEXT, 'tinyblob' => self::PHINX_TYPE_BLOB, 'mediumblob' => self::PHINX_TYPE_BLOB, 'longblob' => self::PHINX_TYPE_BLOB, 'real' => self::PHINX_TYPE_FLOAT, ]; /** * List of known but unsupported Phinx column types * * @var string[] */ protected static $unsupportedColumnTypes = [ self::PHINX_TYPE_BIT, self::PHINX_TYPE_CIDR, self::PHINX_TYPE_ENUM, self::PHINX_TYPE_FILESTREAM, self::PHINX_TYPE_GEOMETRY, self::PHINX_TYPE_INET, self::PHINX_TYPE_INTERVAL, self::PHINX_TYPE_LINESTRING, self::PHINX_TYPE_MACADDR, self::PHINX_TYPE_POINT, self::PHINX_TYPE_POLYGON, self::PHINX_TYPE_SET, ]; /** * @var string[] */ protected $definitionsWithLimits = [ 'CHAR', 'CHARACTER', 'VARCHAR', 'VARYING CHARACTER', 'NCHAR', 'NATIVE CHARACTER', 'NVARCHAR', ]; /** * @var string */ protected $suffix = '.sqlite3'; /** * Indicates whether the database library version is at least the specified version * * @param string $ver The version to check against e.g. '3.28.0' * @return bool */ public function databaseVersionAtLeast($ver): bool { $actual = $this->query('SELECT sqlite_version()')->fetchColumn(); return version_compare($actual, $ver, '>='); } /** * {@inheritDoc} * * @throws \RuntimeException * @throws \InvalidArgumentException * @return void */ public function connect(): void { if ($this->connection === null) { if (!class_exists('PDO') || !in_array('sqlite', PDO::getAvailableDrivers(), true)) { // @codeCoverageIgnoreStart throw new RuntimeException('You need to enable the PDO_SQLITE extension for Phinx to run properly.'); // @codeCoverageIgnoreEnd } $options = $this->getOptions(); if (PHP_VERSION_ID < 80100 && (!empty($options['mode']) || !empty($options['cache']))) { throw new RuntimeException('SQLite URI support requires PHP 8.1.'); } elseif ((!empty($options['mode']) || !empty($options['cache'])) && !empty($options['memory'])) { throw new RuntimeException('Memory must not be set when cache or mode are.'); } elseif (PHP_VERSION_ID >= 80100 && (!empty($options['mode']) || !empty($options['cache']))) { $params = []; if (!empty($options['cache'])) { $params[] = 'cache=' . $options['cache']; } if (!empty($options['mode'])) { $params[] = 'mode=' . $options['mode']; } $dsn = 'sqlite:file:' . ($options['name'] ?? '') . '?' . implode('&', $params); } else { // use a memory database if the option was specified if (!empty($options['memory']) || $options['name'] === static::MEMORY) { $dsn = 'sqlite:' . static::MEMORY; } else { $dsn = 'sqlite:' . $options['name'] . $this->suffix; } } $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']; } $db = $this->createPdoConnection($dsn, null, null, $driverOptions); $this->setConnection($db); } } /** * @inheritDoc */ public function setOptions(array $options): AdapterInterface { parent::setOptions($options); if (isset($options['suffix'])) { $this->suffix = $options['suffix']; } //don't "fix" the file extension if it is blank, some people //might want a SQLITE db file with absolutely no extension. if ($this->suffix !== '' && strpos($this->suffix, '.') !== 0) { $this->suffix = '.' . $this->suffix; } return $this; } /** * @inheritDoc */ public function disconnect(): void { $this->connection = null; } /** * @inheritDoc */ public function hasTransactions(): bool { return true; } /** * @inheritDoc */ public function beginTransaction(): void { $this->getConnection()->beginTransaction(); } /** * @inheritDoc */ public function commitTransaction(): void { $this->getConnection()->commit(); } /** * @inheritDoc */ public function rollbackTransaction(): void { $this->getConnection()->rollBack(); } /** * @inheritDoc */ public function quoteTableName($tableName): string { return str_replace('.', '`.`', $this->quoteColumnName($tableName)); } /** * @inheritDoc */ public function quoteColumnName($columnName): string { return '`' . str_replace('`', '``', $columnName) . '`'; } /** * @param string $tableName Table name * @param bool $quoted Whether to return the schema name and table name escaped and quoted. If quoted, the schema (if any) will also be appended with a dot * @return array */ protected function getSchemaName(string $tableName, bool $quoted = false): array { if (preg_match("/.\.([^\.]+)$/", $tableName, $match)) { $table = $match[1]; $schema = substr($tableName, 0, strlen($tableName) - strlen($match[0]) + 1); $result = ['schema' => $schema, 'table' => $table]; } else { $result = ['schema' => '', 'table' => $tableName]; } if ($quoted) { $result['schema'] = $result['schema'] !== '' ? $this->quoteColumnName($result['schema']) . '.' : ''; $result['table'] = $this->quoteColumnName($result['table']); } return $result; } /** * Retrieves information about a given table from one of the SQLite pragmas * * @param string $tableName The table to query * @param string $pragma The pragma to query * @return array */ protected function getTableInfo(string $tableName, string $pragma = 'table_info'): array { $info = $this->getSchemaName($tableName, true); return $this->fetchAll(sprintf('PRAGMA %s%s(%s)', $info['schema'], $pragma, $info['table'])); } /** * Searches through all available schemata to find a table and returns an array * containing the bare schema name and whether the table exists at all. * If no schema was specified and the table does not exist the "main" schema is returned * * @param string $tableName The name of the table to find * @return array */ protected function resolveTable(string $tableName): array { $info = $this->getSchemaName($tableName); if ($info['schema'] === '') { // if no schema is specified we search all schemata $rows = $this->fetchAll('PRAGMA database_list;'); // the temp schema is always first to be searched $schemata = ['temp']; foreach ($rows as $row) { if (strtolower($row['name']) !== 'temp') { $schemata[] = $row['name']; } } $defaultSchema = 'main'; } else { // otherwise we search just the specified schema $schemata = (array)$info['schema']; $defaultSchema = $info['schema']; } $table = strtolower($info['table']); foreach ($schemata as $schema) { if (strtolower($schema) === 'temp') { $master = 'sqlite_temp_master'; } else { $master = sprintf('%s.%s', $this->quoteColumnName($schema), 'sqlite_master'); } try { $rows = $this->fetchAll(sprintf("SELECT name FROM %s WHERE type='table' AND lower(name) = %s", $master, $this->quoteString($table))); } catch (PDOException $e) { // an exception can occur if the schema part of the table refers to a database which is not attached break; } // this somewhat pedantic check with strtolower is performed because the SQL lower function may be redefined, // and can act on all Unicode characters if the ICU extension is loaded, while SQL identifiers are only case-insensitive for ASCII foreach ($rows as $row) { if (strtolower($row['name']) === $table) { return ['schema' => $schema, 'table' => $row['name'], 'exists' => true]; } } } return ['schema' => $defaultSchema, 'table' => $info['table'], 'exists' => false]; } /** * @inheritDoc */ public function hasTable(string $tableName): bool { return $this->hasCreatedTable($tableName) || $this->resolveTable($tableName)['exists']; } /** * @inheritDoc */ public function createTable(Table $table, array $columns = [], array $indexes = []): void { // Add the default primary key $options = $table->getOptions(); 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(['identity' => true]); array_unshift($columns, $column); } $sql = 'CREATE TABLE '; $sql .= $this->quoteTableName($table->getName()) . ' ('; foreach ($columns as $column) { $sql .= $this->quoteColumnName($column->getName()) . ' ' . $this->getColumnSqlDefinition($column) . ', '; if (isset($options['primary_key']) && $column->getIdentity()) { //remove column from the primary key array as it is already defined as an autoincrement //primary id $identityColumnIndex = array_search($column->getName(), $options['primary_key'], true); if ($identityColumnIndex !== false) { unset($options['primary_key'][$identityColumnIndex]); if (empty($options['primary_key'])) { //The last primary key has been removed unset($options['primary_key']); } } } } // set the primary key(s) if (isset($options['primary_key'])) { $sql = rtrim($sql); $sql .= ' PRIMARY KEY ('; if (is_string($options['primary_key'])) { // handle primary_key => 'id' $sql .= $this->quoteColumnName($options['primary_key']); } elseif (is_array($options['primary_key'])) { // handle primary_key => array('tag_id', 'resource_id') $sql .= implode(',', array_map([$this, 'quoteColumnName'], $options['primary_key'])); } $sql .= ')'; } else { $sql = substr(rtrim($sql), 0, -1); // no primary keys } $sql = rtrim($sql) . ');'; // execute the sql $this->execute($sql); foreach ($indexes as $index) { $this->addIndex($table, $index); } $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)) { $instructions->merge( // FIXME: array access is a hack to make this incomplete implementation work with a correct getPrimaryKey implementation $this->getDropPrimaryKeyInstructions($table, $primaryKey[0], false) ); } // Add the primary key(s) if (!empty($newColumns)) { if (!is_string($newColumns)) { throw new InvalidArgumentException(sprintf( 'Invalid value for primary key: %s', json_encode($newColumns) )); } $instructions->merge( $this->getAddPrimaryKeyInstructions($table, $newColumns) ); } return $instructions; } /** * {@inheritDoc} * * SQLiteAdapter does not implement this functionality, and so will always throw an exception if used. * * @throws \BadMethodCallException */ protected function getChangeCommentInstructions(Table $table, $newComment): AlterInstructions { throw new BadMethodCallException('SQLite does not have table comments'); } /** * @inheritDoc */ protected function getRenameTableInstructions(string $tableName, string $newTableName): AlterInstructions { $this->updateCreatedTableName($tableName, $newTableName); $sql = sprintf( 'ALTER TABLE %s RENAME 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 { $info = $this->resolveTable($tableName); // first try deleting the rows $this->execute(sprintf( 'DELETE FROM %s.%s', $this->quoteColumnName($info['schema']), $this->quoteColumnName($info['table']) )); // assuming no error occurred, reset the autoincrement (if any) if ($this->hasTable($info['schema'] . '.sqlite_sequence')) { $this->execute(sprintf( 'DELETE FROM %s.%s where name = %s', $this->quoteColumnName($info['schema']), 'sqlite_sequence', $this->quoteString($info['table']) )); } } /** * Parses a default-value expression to yield either a Literal representing * a string value, a string representing an expression, or some other scalar * * @param mixed $default The default-value expression to interpret * @param string $columnType The Phinx type of the column * @return mixed */ protected function parseDefaultValue($default, string $columnType) { if ($default === null) { return null; } // split the input into tokens $trimChars = " \t\n\r\0\x0B"; $pattern = <<getTableInfo($tableName) as $col) { $type = strtolower($col['type']); if ($col['pk'] > 1) { // the table has a composite primary key return null; } elseif ($col['pk'] == 0) { // the column is not a primary key column and is thus not relevant continue; } elseif ($type !== 'integer') { // if the primary key's type is not exactly INTEGER, it cannot be a row ID alias return null; } else { // the column is a candidate for a row ID alias $result = $col['name']; } } // if there is no suitable PK column, stop now if ($result === null) { return null; } // make sure the table does not have a PK-origin autoindex // such an autoindex would indicate either that the primary key was specified as descending, or that this is a WITHOUT ROWID table foreach ($this->getTableInfo($tableName, 'index_list') as $idx) { if ($idx['origin'] === 'pk') { return null; } } return $result; } /** * @inheritDoc */ public function getColumns(string $tableName): array { $columns = []; $rows = $this->getTableInfo($tableName); $identity = $this->resolveIdentity($tableName); foreach ($rows as $columnInfo) { $column = new Column(); $type = $this->getPhinxType($columnInfo['type']); $default = $this->parseDefaultValue($columnInfo['dflt_value'], $type['name']); $column->setName($columnInfo['name']) // SQLite on PHP 8.1 returns int for notnull, older versions return a string ->setNull((int)$columnInfo['notnull'] !== 1) ->setDefault($default) ->setType($type['name']) ->setLimit($type['limit']) ->setScale($type['scale']) ->setIdentity($columnInfo['name'] === $identity); $columns[] = $column; } return $columns; } /** * @inheritDoc */ public function hasColumn(string $tableName, string $columnName): bool { $rows = $this->getTableInfo($tableName); foreach ($rows as $column) { if (strcasecmp($column['name'], $columnName) === 0) { return true; } } return false; } /** * @inheritDoc */ protected function getAddColumnInstructions(Table $table, Column $column): AlterInstructions { $tableName = $table->getName(); $instructions = $this->beginAlterByCopyTable($tableName); $instructions->addPostStep(function ($state) use ($tableName, $column) { // we use the final column to anchor our regex to insert the new column, // as the alternative is unwinding all possible table constraints which // gets messy quickly with CHECK constraints. $columns = $this->getColumns($tableName); if (!$columns) { return $state; } $finalColumnName = end($columns)->getName(); $sql = preg_replace( sprintf( "/(%s(?:\/\*.*?\*\/|\([^)]+\)|'[^']*?'|[^,])+)([,)])/", $this->quoteColumnName($finalColumnName) ), sprintf( '$1, %s %s$2', $this->quoteColumnName($column->getName()), $this->getColumnSqlDefinition($column) ), $state['createSQL'], 1 ); $this->execute($sql); return $state; }); $instructions->addPostStep(function ($state) use ($tableName) { $newState = $this->calculateNewTableColumns($tableName, false, false); return $newState + $state; }); return $this->copyAndDropTmpTable($instructions, $tableName); } /** * Returns the original CREATE statement for the give table * * @param string $tableName The table name to get the create statement for * @return string */ protected function getDeclaringSql(string $tableName): string { $rows = $this->fetchAll("SELECT * FROM sqlite_master WHERE `type` = 'table'"); $sql = ''; foreach ($rows as $table) { if ($table['tbl_name'] === $tableName) { $sql = $table['sql']; } } $columnsInfo = $this->getTableInfo($tableName); foreach ($columnsInfo as $column) { $columnName = $column['name']; $columnNamePattern = "\"$columnName\"|`$columnName`|\\[$columnName\\]|$columnName"; $columnNamePattern = "#([\(,]+\\s*)($columnNamePattern)(\\s)#iU"; $sql = preg_replace($columnNamePattern, "$1`$columnName`$3", $sql); } $tableNamePattern = "\"$tableName\"|`$tableName`|\\[$tableName\\]|$tableName"; $tableNamePattern = "#^(CREATE TABLE)\s*($tableNamePattern)\s*(\()#Ui"; $sql = preg_replace($tableNamePattern, "$1 `$tableName` $3", $sql, 1); return $sql; } /** * Returns the original CREATE statement for the give index * * @param string $tableName The table name to get the create statement for * @param string $indexName The table index * @return string */ protected function getDeclaringIndexSql(string $tableName, string $indexName): string { $rows = $this->fetchAll("SELECT * FROM sqlite_master WHERE `type` = 'index'"); $sql = ''; foreach ($rows as $table) { if ($table['tbl_name'] === $tableName && $table['name'] === $indexName) { $sql = $table['sql'] . '; '; } } return $sql; } /** * Copies all the data from a tmp table to another table * * @param string $tableName The table name to copy the data to * @param string $tmpTableName The tmp table name where the data is stored * @param string[] $writeColumns The list of columns in the target table * @param string[] $selectColumns The list of columns in the tmp table * @return void */ protected function copyDataToNewTable(string $tableName, string $tmpTableName, array $writeColumns, array $selectColumns): void { $sql = sprintf( 'INSERT INTO %s(%s) SELECT %s FROM %s', $this->quoteTableName($tableName), implode(', ', $writeColumns), implode(', ', $selectColumns), $this->quoteTableName($tmpTableName) ); $this->execute($sql); } /** * Modifies the passed instructions to copy all data from the table into * the provided tmp table and then drops the table and rename tmp table. * * @param \Phinx\Db\Util\AlterInstructions $instructions The instructions to modify * @param string $tableName The table name to copy the data to * @param bool $validateForeignKeys Whether to validate foreign keys after the copy and drop operations. Note that * enabling this option only has an effect when the `foreign_keys` PRAGMA is set to `ON`! * @return \Phinx\Db\Util\AlterInstructions */ protected function copyAndDropTmpTable( AlterInstructions $instructions, string $tableName, bool $validateForeignKeys = true ): AlterInstructions { $instructions->addPostStep(function ($state) use ($tableName, $validateForeignKeys) { $this->copyDataToNewTable( $state['tmpTableName'], $tableName, $state['writeColumns'], $state['selectColumns'] ); $rows = $this->fetchAll( sprintf( " SELECT * FROM sqlite_master WHERE (`type` = 'index' OR `type` = 'trigger') AND tbl_name = %s AND sql IS NOT NULL ", $this->quoteValue($tableName) ) ); $foreignKeysEnabled = (bool)$this->fetchRow('PRAGMA foreign_keys')['foreign_keys']; if ($foreignKeysEnabled) { $this->execute('PRAGMA foreign_keys = OFF'); } $this->execute(sprintf('DROP TABLE %s', $this->quoteTableName($tableName))); if ($foreignKeysEnabled) { $this->execute('PRAGMA foreign_keys = ON'); } $this->execute(sprintf( 'ALTER TABLE %s RENAME TO %s', $this->quoteTableName($state['tmpTableName']), $this->quoteTableName($tableName) )); foreach ($rows as $row) { $this->execute($row['sql']); } if ( $foreignKeysEnabled && $validateForeignKeys ) { $this->validateForeignKeys($tableName); } return $state; }); return $instructions; } /** * Validates the foreign key constraints of the given table, and of those * tables whose constraints are targeting it. * * @param string $tableName The name of the table for which to check constraints. * @return void * @throws \RuntimeException In case of a foreign key constraint violation. */ protected function validateForeignKeys(string $tableName): void { $tablesToCheck = [ $tableName, ]; $otherTables = $this ->query( "SELECT name FROM sqlite_master WHERE type = 'table' AND name != ?", [$tableName] ) ->fetchAll(); foreach ($otherTables as $otherTable) { $foreignKeyList = $this->getTableInfo($otherTable['name'], 'foreign_key_list'); foreach ($foreignKeyList as $foreignKey) { if (strcasecmp($foreignKey['table'], $tableName) === 0) { $tablesToCheck[] = $otherTable['name']; break; } } } $tablesToCheck = array_unique(array_map('strtolower', $tablesToCheck)); foreach ($tablesToCheck as $tableToCheck) { $schema = $this->getSchemaName($tableToCheck, true)['schema']; $stmt = $this->query( sprintf('PRAGMA %sforeign_key_check(%s)', $schema, $this->quoteTableName($tableToCheck)) ); $row = $stmt->fetch(); $stmt->closeCursor(); if (is_array($row)) { throw new RuntimeException(sprintf( 'Integrity constraint violation: FOREIGN KEY constraint on `%s` failed.', $tableToCheck )); } } } /** * Returns the columns and type to use when copying a table to another in the process * of altering a table * * @param string $tableName The table to modify * @param string|false $columnName The column name that is about to change * @param string|false $newColumnName Optionally the new name for the column * @throws \InvalidArgumentException * @return array */ protected function calculateNewTableColumns(string $tableName, $columnName, $newColumnName): array { $columns = $this->fetchAll(sprintf('pragma table_info(%s)', $this->quoteTableName($tableName))); $selectColumns = []; $writeColumns = []; $columnType = null; $found = false; foreach ($columns as $column) { $selectName = $column['name']; $writeName = $selectName; if ($selectName === $columnName) { $writeName = $newColumnName; $found = true; $columnType = $column['type']; $selectName = $newColumnName === false ? $newColumnName : $selectName; } $selectColumns[] = $selectName; $writeColumns[] = $writeName; } $selectColumns = array_filter($selectColumns, 'strlen'); $writeColumns = array_filter($writeColumns, 'strlen'); $selectColumns = array_map([$this, 'quoteColumnName'], $selectColumns); $writeColumns = array_map([$this, 'quoteColumnName'], $writeColumns); if ($columnName && !$found) { throw new InvalidArgumentException(sprintf( 'The specified column doesn\'t exist: ' . $columnName )); } return compact('writeColumns', 'selectColumns', 'columnType'); } /** * Returns the initial instructions to alter a table using the * create-copy-drop strategy * * @param string $tableName The table to modify * @return \Phinx\Db\Util\AlterInstructions */ protected function beginAlterByCopyTable(string $tableName): AlterInstructions { $instructions = new AlterInstructions(); $instructions->addPostStep(function ($state) use ($tableName) { $tmpTableName = "tmp_{$tableName}"; $createSQL = $this->getDeclaringSql($tableName); // Table name in SQLite can be hilarious inside declaring SQL: // - tableName // - `tableName` // - "tableName" // - [this is a valid table name too!] // - etc. // Just remove all characters before first "(" and build them again $createSQL = preg_replace( "/^CREATE TABLE .* \(/Ui", '', $createSQL ); $createSQL = "CREATE TABLE {$this->quoteTableName($tmpTableName)} ({$createSQL}"; return compact('createSQL', 'tmpTableName') + $state; }); return $instructions; } /** * @inheritDoc */ protected function getRenameColumnInstructions(string $tableName, string $columnName, string $newColumnName): AlterInstructions { $instructions = $this->beginAlterByCopyTable($tableName); $instructions->addPostStep(function ($state) use ($columnName, $newColumnName) { $sql = str_replace( $this->quoteColumnName($columnName), $this->quoteColumnName($newColumnName), $state['createSQL'] ); $this->execute($sql); return $state; }); $instructions->addPostStep(function ($state) use ($columnName, $newColumnName, $tableName) { $newState = $this->calculateNewTableColumns($tableName, $columnName, $newColumnName); return $newState + $state; }); return $this->copyAndDropTmpTable($instructions, $tableName); } /** * @inheritDoc */ protected function getChangeColumnInstructions(string $tableName, string $columnName, Column $newColumn): AlterInstructions { $instructions = $this->beginAlterByCopyTable($tableName); $newColumnName = $newColumn->getName(); $instructions->addPostStep(function ($state) use ($columnName, $newColumn) { $sql = preg_replace( sprintf("/%s(?:\/\*.*?\*\/|\([^)]+\)|'[^']*?'|[^,])+([,)])/", $this->quoteColumnName($columnName)), sprintf('%s %s$1', $this->quoteColumnName($newColumn->getName()), $this->getColumnSqlDefinition($newColumn)), $state['createSQL'], 1 ); $this->execute($sql); return $state; }); $instructions->addPostStep(function ($state) use ($columnName, $newColumnName, $tableName) { $newState = $this->calculateNewTableColumns($tableName, $columnName, $newColumnName); return $newState + $state; }); return $this->copyAndDropTmpTable($instructions, $tableName); } /** * @inheritDoc */ protected function getDropColumnInstructions(string $tableName, string $columnName): AlterInstructions { $instructions = $this->beginAlterByCopyTable($tableName); $instructions->addPostStep(function ($state) use ($tableName, $columnName) { $newState = $this->calculateNewTableColumns($tableName, $columnName, false); return $newState + $state; }); $instructions->addPostStep(function ($state) use ($columnName) { $sql = preg_replace( sprintf("/%s\s%s.*(,\s(?!')|\)$)/U", preg_quote($this->quoteColumnName($columnName)), preg_quote($state['columnType'])), '', $state['createSQL'] ); if (substr($sql, -2) === ', ') { $sql = substr($sql, 0, -2) . ')'; } $this->execute($sql); return $state; }); return $this->copyAndDropTmpTable($instructions, $tableName); } /** * Get an array of indexes from a particular table. * * @param string $tableName Table name * @return array */ protected function getIndexes(string $tableName): array { $indexes = []; $schema = $this->getSchemaName($tableName, true)['schema']; $indexList = $this->getTableInfo($tableName, 'index_list'); foreach ($indexList as $index) { $indexData = $this->fetchAll(sprintf('pragma %sindex_info(%s)', $schema, $this->quoteColumnName($index['name']))); $cols = []; foreach ($indexData as $indexItem) { $cols[] = $indexItem['name']; } $indexes[$index['name']] = $cols; } return $indexes; } /** * Finds the names of a table's indexes matching the supplied columns * * @param string $tableName The table to which the index belongs * @param string|string[] $columns The columns of the index * @return array */ protected function resolveIndex(string $tableName, $columns): array { $columns = array_map('strtolower', (array)$columns); $indexes = $this->getIndexes($tableName); $matches = []; foreach ($indexes as $name => $index) { $indexCols = array_map('strtolower', $index); if ($columns == $indexCols) { $matches[] = $name; } } return $matches; } /** * @inheritDoc */ public function hasIndex(string $tableName, $columns): bool { return (bool)$this->resolveIndex($tableName, $columns); } /** * @inheritDoc */ public function hasIndexByName(string $tableName, string $indexName): bool { $indexName = strtolower($indexName); $indexes = $this->getIndexes($tableName); foreach (array_keys($indexes) as $index) { if ($indexName === strtolower($index)) { return true; } } return false; } /** * @inheritDoc */ protected function getAddIndexInstructions(Table $table, Index $index): AlterInstructions { $indexColumnArray = []; foreach ($index->getColumns() as $column) { $indexColumnArray[] = sprintf('`%s` ASC', $column); } $indexColumns = implode(',', $indexColumnArray); $sql = sprintf( 'CREATE %s ON %s (%s)', $this->getIndexSqlDefinition($table, $index), $this->quoteTableName($table->getName()), $indexColumns ); return new AlterInstructions([], [$sql]); } /** * @inheritDoc */ protected function getDropIndexByColumnsInstructions(string $tableName, $columns): AlterInstructions { $instructions = new AlterInstructions(); $indexNames = $this->resolveIndex($tableName, $columns); $schema = $this->getSchemaName($tableName, true)['schema']; foreach ($indexNames as $indexName) { if (strpos($indexName, 'sqlite_autoindex_') !== 0) { $instructions->addPostStep(sprintf( 'DROP INDEX %s%s', $schema, $this->quoteColumnName($indexName) )); } } return $instructions; } /** * @inheritDoc */ protected function getDropIndexByNameInstructions(string $tableName, string $indexName): AlterInstructions { $instructions = new AlterInstructions(); $indexName = strtolower($indexName); $indexes = $this->getIndexes($tableName); $found = false; foreach (array_keys($indexes) as $index) { if ($indexName === strtolower($index)) { $found = true; break; } } if ($found) { $schema = $this->getSchemaName($tableName, true)['schema']; $instructions->addPostStep(sprintf( 'DROP INDEX %s%s', $schema, $this->quoteColumnName($indexName) )); } return $instructions; } /** * {@inheritDoc} * * @throws \InvalidArgumentException */ public function hasPrimaryKey(string $tableName, $columns, ?string $constraint = null): bool { if ($constraint !== null) { throw new InvalidArgumentException('SQLite does not support named constraints.'); } $columns = array_map('strtolower', (array)$columns); $primaryKey = array_map('strtolower', $this->getPrimaryKey($tableName)); if (array_diff($primaryKey, $columns) || array_diff($columns, $primaryKey)) { return false; } return true; } /** * Get the primary key from a particular table. * * @param string $tableName Table name * @return string[] */ protected function getPrimaryKey(string $tableName): array { $primaryKey = []; $rows = $this->getTableInfo($tableName); foreach ($rows as $row) { if ($row['pk'] > 0) { $primaryKey[$row['pk'] - 1] = $row['name']; } } return $primaryKey; } /** * @inheritDoc */ public function hasForeignKey(string $tableName, $columns, ?string $constraint = null): bool { if ($constraint !== null) { return preg_match( "/,?\sCONSTRAINT\s" . preg_quote($this->quoteColumnName($constraint)) . ' FOREIGN KEY/', $this->getDeclaringSql($tableName) ) === 1; } $columns = array_map('strtolower', (array)$columns); $foreignKeys = $this->getForeignKeys($tableName); foreach ($foreignKeys as $key) { $key = array_map('strtolower', $key); if (array_diff($key, $columns) || array_diff($columns, $key)) { continue; } 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 { $foreignKeys = []; $rows = $this->getTableInfo($tableName, 'foreign_key_list'); foreach ($rows as $row) { if (!isset($foreignKeys[$row['id']])) { $foreignKeys[$row['id']] = []; } $foreignKeys[$row['id']][$row['seq']] = $row['from']; } return $foreignKeys; } /** * @param \Phinx\Db\Table\Table $table The Table * @param string $column Column Name * @return \Phinx\Db\Util\AlterInstructions */ protected function getAddPrimaryKeyInstructions(Table $table, string $column): AlterInstructions { $instructions = $this->beginAlterByCopyTable($table->getName()); $tableName = $table->getName(); $instructions->addPostStep(function ($state) use ($column) { $matchPattern = "/(`$column`)\s+(\w+(\(\d+\))?)\s+((NOT )?NULL)/"; $sql = $state['createSQL']; if (preg_match($matchPattern, $state['createSQL'], $matches)) { if (isset($matches[2])) { if ($matches[2] === 'INTEGER') { $replace = '$1 INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT'; } else { $replace = '$1 $2 NOT NULL PRIMARY KEY'; } $sql = preg_replace($matchPattern, $replace, $state['createSQL'], 1); } } $this->execute($sql); return $state; }); $instructions->addPostStep(function ($state) { $columns = $this->fetchAll(sprintf('pragma table_info(%s)', $this->quoteTableName($state['tmpTableName']))); $names = array_map([$this, 'quoteColumnName'], array_column($columns, 'name')); $selectColumns = $writeColumns = $names; return compact('selectColumns', 'writeColumns') + $state; }); return $this->copyAndDropTmpTable($instructions, $tableName); } /** * @param \Phinx\Db\Table\Table $table Table * @param string $column Column Name * @param bool $validateForeignKeys Whether to validate foreign keys after the copy and drop operations. Note that * enabling this option only has an effect when the `foreign_keys` PRAGMA is set to `ON`! * @return \Phinx\Db\Util\AlterInstructions */ protected function getDropPrimaryKeyInstructions( Table $table, string $column, bool $validateForeignKeys = true ): AlterInstructions { $instructions = $this->beginAlterByCopyTable($table->getName()); $instructions->addPostStep(function ($state) { $search = "/(,?\s*PRIMARY KEY\s*\([^\)]*\)|\s+PRIMARY KEY(\s+AUTOINCREMENT)?)/"; $sql = preg_replace($search, '', $state['createSQL'], 1); if ($sql) { $this->execute($sql); } return $state; }); $instructions->addPostStep(function ($state) use ($column) { $newState = $this->calculateNewTableColumns($state['tmpTableName'], $column, $column); return $newState + $state; }); return $this->copyAndDropTmpTable($instructions, $table->getName(), $validateForeignKeys); } /** * @inheritDoc */ protected function getAddForeignKeyInstructions(Table $table, ForeignKey $foreignKey): AlterInstructions { $instructions = $this->beginAlterByCopyTable($table->getName()); $tableName = $table->getName(); $instructions->addPostStep(function ($state) use ($foreignKey, $tableName) { $this->execute('pragma foreign_keys = ON'); $sql = substr($state['createSQL'], 0, -1) . ',' . $this->getForeignKeySqlDefinition($foreignKey) . '); '; //Delete indexes from original table and recreate them in temporary table $schema = $this->getSchemaName($tableName, true)['schema']; $tmpTableName = $state['tmpTableName']; $indexes = $this->getIndexes($tableName); foreach (array_keys($indexes) as $indexName) { if (strpos($indexName, 'sqlite_autoindex_') !== 0) { $sql .= sprintf( 'DROP INDEX %s%s; ', $schema, $this->quoteColumnName($indexName) ); $createIndexSQL = $this->getDeclaringIndexSQL($tableName, $indexName); $sql .= preg_replace( "/\b{$tableName}\b/", $tmpTableName, $createIndexSQL ); } } $this->execute($sql); return $state; }); $instructions->addPostStep(function ($state) { $columns = $this->fetchAll(sprintf('pragma table_info(%s)', $this->quoteTableName($state['tmpTableName']))); $names = array_map([$this, 'quoteColumnName'], array_column($columns, 'name')); $selectColumns = $writeColumns = $names; return compact('selectColumns', 'writeColumns') + $state; }); return $this->copyAndDropTmpTable($instructions, $tableName); } /** * {@inheritDoc} * * SQLiteAdapter does not implement this functionality, and so will always throw an exception if used. * * @throws \BadMethodCallException */ protected function getDropForeignKeyInstructions(string $tableName, string $constraint): AlterInstructions { throw new BadMethodCallException('SQLite does not have named foreign keys'); } /** * {@inheritDoc} * * @throws \InvalidArgumentException */ protected function getDropForeignKeyByColumnsInstructions(string $tableName, array $columns): AlterInstructions { $instructions = $this->beginAlterByCopyTable($tableName); $instructions->addPostStep(function ($state) use ($columns) { $sql = ''; foreach ($columns as $columnName) { $search = sprintf( "/,[^,]*\(%s(?:,`?(.*)`?)?\) REFERENCES[^,]*\([^\)]*\)[^,)]*/", $this->quoteColumnName($columnName) ); $sql = preg_replace($search, '', $state['createSQL'], 1); } if ($sql) { $this->execute($sql); } return $state; }); $instructions->addPostStep(function ($state) use ($columns) { $newState = $this->calculateNewTableColumns($state['tmpTableName'], $columns[0], $columns[0]); $selectColumns = $newState['selectColumns']; $columns = array_map([$this, 'quoteColumnName'], $columns); $diff = array_diff($columns, $selectColumns); if (!empty($diff)) { throw new InvalidArgumentException(sprintf( 'The specified columns don\'t exist: ' . implode(', ', $diff) )); } return $newState + $state; }); return $this->copyAndDropTmpTable($instructions, $tableName); } /** * {@inheritDoc} * * @throws \Phinx\Db\Adapter\UnsupportedColumnTypeException */ public function getSqlType($type, ?int $limit = null): array { $typeLC = strtolower($type); if ($type instanceof Literal) { $name = $type; } elseif (isset(static::$supportedColumnTypes[$typeLC])) { $name = static::$supportedColumnTypes[$typeLC]; } elseif (in_array($typeLC, static::$unsupportedColumnTypes, true)) { throw new UnsupportedColumnTypeException('Column type "' . $type . '" is not supported by SQLite.'); } else { throw new UnsupportedColumnTypeException('Column type "' . $type . '" is not known by SQLite.'); } return ['name' => $name, 'limit' => $limit]; } /** * Returns Phinx type by SQL type * * @param string|null $sqlTypeDef SQL Type definition * @return array */ public function getPhinxType(?string $sqlTypeDef): array { $limit = null; $scale = null; if ($sqlTypeDef === null) { // in SQLite columns can legitimately have null as a type, which is distinct from the empty string $name = null; } elseif (!preg_match('/^([a-z]+)(_(?:integer|float|text|blob))?(?:\((\d+)(?:,(\d+))?\))?$/i', $sqlTypeDef, $match)) { // doesn't match the pattern of a type we'd know about $name = Literal::from($sqlTypeDef); } else { // possibly a known type $type = $match[1]; $typeLC = strtolower($type); $affinity = $match[2] ?? ''; $limit = isset($match[3]) && strlen($match[3]) ? (int)$match[3] : null; $scale = isset($match[4]) && strlen($match[4]) ? (int)$match[4] : null; if (in_array($typeLC, ['tinyint', 'tinyinteger'], true) && $limit === 1) { // the type is a MySQL-style boolean $name = static::PHINX_TYPE_BOOLEAN; $limit = null; } elseif (isset(static::$supportedColumnTypes[$typeLC])) { // the type is an explicitly supported type $name = $typeLC; } elseif (isset(static::$supportedColumnTypeAliases[$typeLC])) { // the type is an alias for a supported type $name = static::$supportedColumnTypeAliases[$typeLC]; } elseif (in_array($typeLC, static::$unsupportedColumnTypes, true)) { // unsupported but known types are passed through lowercased, and without appended affinity $name = Literal::from($typeLC); } else { // unknown types are passed through as-is $name = Literal::from($type . $affinity); } } return [ 'name' => $name, 'limit' => $limit, 'scale' => $scale, ]; } /** * @inheritDoc */ public function createDatabase(string $name, array $options = []): void { touch($name . $this->suffix); } /** * @inheritDoc */ public function hasDatabase(string $name): bool { return is_file($name . $this->suffix); } /** * @inheritDoc */ public function dropDatabase(string $name): void { $this->createdTables = []; if ($this->getOption('memory')) { $this->disconnect(); $this->connect(); } if (file_exists($name . $this->suffix)) { unlink($name . $this->suffix); } } /** * Gets the SQLite Column Definition for a Column object. * * @param \Phinx\Db\Table\Column $column Column * @return string */ protected function getColumnSqlDefinition(Column $column): string { $isLiteralType = $column->getType() instanceof Literal; if ($isLiteralType) { $def = (string)$column->getType(); } else { $sqlType = $this->getSqlType($column->getType()); $def = strtoupper($sqlType['name']); $limitable = in_array(strtoupper($sqlType['name']), $this->definitionsWithLimits, true); if (($column->getLimit() || isset($sqlType['limit'])) && $limitable) { $def .= '(' . ($column->getLimit() ?: $sqlType['limit']) . ')'; } } if ($column->getPrecision() && $column->getScale()) { $def .= '(' . $column->getPrecision() . ',' . $column->getScale() . ')'; } $default = $column->getDefault(); $def .= $column->isNull() ? ' NULL' : ' NOT NULL'; $def .= $this->getDefaultValueDefinition($default, $column->getType()); $def .= $column->isIdentity() ? ' PRIMARY KEY AUTOINCREMENT' : ''; $def .= $this->getCommentDefinition($column); return $def; } /** * Gets the comment Definition for a Column object. * * @param \Phinx\Db\Table\Column $column Column * @return string */ protected function getCommentDefinition(Column $column): string { if ($column->getComment()) { return ' /* ' . $column->getComment() . ' */ '; } return ''; } /** * Gets the SQLite Index Definition for an Index object. * * @param \Phinx\Db\Table\Table $table Table * @param \Phinx\Db\Table\Index $index Index * @return string */ protected function getIndexSqlDefinition(Table $table, Index $index): string { if ($index->getType() === Index::UNIQUE) { $def = 'UNIQUE INDEX'; } else { $def = 'INDEX'; } if (is_string($index->getName())) { $indexName = $index->getName(); } else { $indexName = $table->getName() . '_'; foreach ($index->getColumns() as $column) { $indexName .= $column . '_'; } $indexName .= 'index'; } $def .= ' `' . $indexName . '`'; return $def; } /** * @inheritDoc */ public function getColumnTypes(): array { return array_keys(static::$supportedColumnTypes); } /** * Gets the SQLite Foreign Key Definition for an ForeignKey object. * * @param \Phinx\Db\Table\ForeignKey $foreignKey Foreign key * @return string */ protected function getForeignKeySqlDefinition(ForeignKey $foreignKey): string { $def = ''; if ($foreignKey->getConstraint()) { $def .= ' CONSTRAINT ' . $this->quoteColumnName($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; } /** * @inheritDoc */ public function getDecoratedConnection(): Connection { $options = $this->getOptions(); $options['quoteIdentifiers'] = true; if (!empty($options['name'])) { $options['database'] = $options['name']; if (file_exists($options['name'] . $this->suffix)) { $options['database'] = $options['name'] . $this->suffix; } } if ($this->connection === null) { throw new RuntimeException('You need to connect first.'); } $driver = new SqliteDriver($options); $driver->setConnection($this->connection); return new Connection(['driver' => $driver] + $options); } }