silverstripe-sqlite3/code/SQLite3SchemaManager.php
NightjarNZ 418c1178a1 FIX preserve enum values with correct escaping
Enum values are themselves enumerated in sqlite as they are not supported
as a type. This leads to values being stored in their own table, and a
regular TEXT field being used in a MySQL ENUM's stead. The default value
for this field was being escaped with custom string replacement, and
erroneously relacing the backslash (a redundant operation). This lead
to invalid Fully Qualified Class Names in SilverStripe 4, which is a
required trait for polymorphic relationships. As a result any polymorphic
relationship not set on first write would then proceed to cause an execution
error the next time the dataobject with the relationship was fetched from
the database. By using the PHP supplied escape function for SQLite3 we can
avoid this, and restore functionality.

Relevant section of SQLite documentation to justify the removal of escaping
various characters, such as the backslash:

A string constant is formed by enclosing the string in single quotes (').
A single quote within the string can be encoded by putting two single quotes
in a row - as in Pascal. C-style escapes using the backslash character are
not supported because they are not standard SQL.

https://www.sqlite.org/lang_expr.html
2018-10-08 23:09:24 +13:00

731 lines
22 KiB
PHP

<?php
namespace SilverStripe\SQLite;
use Exception;
use SilverStripe\Control\Director;
use SilverStripe\Dev\Debug;
use SilverStripe\ORM\Connect\DBSchemaManager;
use SQLite3;
/**
* SQLite schema manager class
*/
class SQLite3SchemaManager extends DBSchemaManager
{
/**
* Instance of the database controller this schema belongs to
*
* @var SQLite3Database
*/
protected $database = null;
/**
* Flag indicating whether or not the database has been checked and repaired
*
* @var boolean
*/
protected static $checked_and_repaired = false;
/**
* Should schema be vacuumed during checkeAndRepairTable?
*
* @var boolean
*/
public static $vacuum = true;
public function createDatabase($name)
{
// Ensure that any existing database is cleared before connection
$this->dropDatabase($name);
}
public function dropDatabase($name)
{
// No need to delete database files if operating purely within memory
if ($this->database->getLivesInMemory()) {
return;
}
// If using file based database ensure any existing file is removed
$path = $this->database->getPath();
$fullpath = $path . '/' . $name . SQLite3Database::database_extension();
if (is_writable($fullpath)) {
unlink($fullpath);
}
}
public function databaseList()
{
// If in-memory use the current database name only
if ($this->database->getLivesInMemory()) {
return array(
$this->database->getConnector()->getSelectedDatabase()
?: 'database'
);
}
// If using file based database enumerate files in the database directory
$directory = $this->database->getPath();
$files = scandir($directory);
// Filter each file in this directory
$databases = array();
if ($files !== false) {
foreach ($files as $file) {
// Filter non-files
if (!is_file("$directory/$file")) {
continue;
}
// Filter those with correct extension
if (!SQLite3Database::is_valid_database_name($file)) {
continue;
}
if ($extension = SQLite3Database::database_extension()) {
$databases[] = substr($file, 0, -strlen($extension));
} else {
$databases[] = $file;
}
}
}
return $databases;
}
public function databaseExists($name)
{
$databases = $this->databaseList();
return in_array($name, $databases);
}
/**
* Empties any cached enum values
*/
public function flushCache()
{
$this->enum_map = array();
}
public function schemaUpdate($callback)
{
// Set locking mode
$this->database->setPragma('locking_mode', 'EXCLUSIVE');
$this->checkAndRepairTable();
$this->flushCache();
// Initiate schema update
$error = null;
try {
parent::schemaUpdate($callback);
} catch (Exception $ex) {
$error = $ex;
}
// Revert locking mode
$this->database->setPragma('locking_mode', SQLite3Database::$default_pragma['locking_mode']);
if ($error) {
throw $error;
}
}
/**
* Empty a specific table
*
* @param string $table
*/
public function clearTable($table)
{
if ($table != 'SQLiteEnums') {
$this->query("DELETE FROM \"$table\"");
}
}
public function createTable($table, $fields = null, $indexes = null, $options = null, $advancedOptions = null)
{
if (!isset($fields['ID'])) {
$fields['ID'] = $this->IdColumn();
}
$fieldSchemata = array();
if ($fields) {
foreach ($fields as $k => $v) {
$fieldSchemata[] = "\"$k\" $v";
}
}
$fieldSchemas = implode(",\n", $fieldSchemata);
// Switch to "CREATE TEMPORARY TABLE" for temporary tables
$temporary = empty($options['temporary']) ? "" : "TEMPORARY";
$this->query("CREATE $temporary TABLE \"$table\" (
$fieldSchemas
)");
if ($indexes) {
foreach ($indexes as $indexName => $indexDetails) {
$this->createIndex($table, $indexName, $indexDetails);
}
}
return $table;
}
public function alterTable(
$tableName,
$newFields = null,
$newIndexes = null,
$alteredFields = null,
$alteredIndexes = null,
$alteredOptions = null,
$advancedOptions = null
) {
if ($newFields) {
foreach ($newFields as $fieldName => $fieldSpec) {
$this->createField($tableName, $fieldName, $fieldSpec);
}
}
if ($alteredFields) {
foreach ($alteredFields as $fieldName => $fieldSpec) {
$this->alterField($tableName, $fieldName, $fieldSpec);
}
}
if ($newIndexes) {
foreach ($newIndexes as $indexName => $indexSpec) {
$this->createIndex($tableName, $indexName, $indexSpec);
}
}
if ($alteredIndexes) {
foreach ($alteredIndexes as $indexName => $indexSpec) {
$this->alterIndex($tableName, $indexName, $indexSpec);
}
}
}
public function renameTable($oldTableName, $newTableName)
{
$this->query("ALTER TABLE \"$oldTableName\" RENAME TO \"$newTableName\"");
}
public function checkAndRepairTable($tableName = null)
{
$ok = true;
if (!self::$checked_and_repaired) {
$this->alterationMessage("Checking database integrity", "repaired");
// Check for any tables with failed integrity
if ($messages = $this->query('PRAGMA integrity_check')) {
foreach ($messages as $message) {
if ($message['integrity_check'] != 'ok') {
Debug::show($message['integrity_check']);
$ok = false;
}
}
}
// If enabled vacuum (clean and rebuild) the database
if (self::$vacuum) {
$this->query('VACUUM', E_USER_NOTICE);
$message = $this->database->getConnector()->getLastError();
if (preg_match('/authoriz/', $message)) {
$this->alterationMessage("VACUUM | $message", "error");
} else {
$this->alterationMessage("VACUUMing", "repaired");
}
}
self::$checked_and_repaired = true;
}
return $ok;
}
public function createField($table, $field, $spec)
{
$this->query("ALTER TABLE \"$table\" ADD \"$field\" $spec");
}
/**
* Change the database type of the given field.
* @param string $tableName The name of the tbale the field is in.
* @param string $fieldName The name of the field to change.
* @param string $fieldSpec The new field specification
*/
public function alterField($tableName, $fieldName, $fieldSpec)
{
$oldFieldList = $this->fieldList($tableName);
$fieldNameList = '"' . implode('","', array_keys($oldFieldList)) . '"';
if (!empty($_REQUEST['avoidConflict']) && Director::isDev()) {
$fieldSpec = preg_replace('/\snot null\s/i', ' NOT NULL ON CONFLICT REPLACE ', $fieldSpec);
}
// Skip non-existing columns
if (!array_key_exists($fieldName, $oldFieldList)) {
return;
}
// Update field spec
$newColsSpec = array();
foreach ($oldFieldList as $name => $oldSpec) {
$newColsSpec[] = "\"$name\" " . ($name == $fieldName ? $fieldSpec : $oldSpec);
}
$queries = array(
"BEGIN TRANSACTION",
"CREATE TABLE \"{$tableName}_alterfield_{$fieldName}\"(" . implode(',', $newColsSpec) . ")",
"INSERT INTO \"{$tableName}_alterfield_{$fieldName}\" SELECT {$fieldNameList} FROM \"$tableName\"",
"DROP TABLE \"$tableName\"",
"ALTER TABLE \"{$tableName}_alterfield_{$fieldName}\" RENAME TO \"$tableName\"",
"COMMIT"
);
// Remember original indexes
$indexList = $this->indexList($tableName);
// Then alter the table column
foreach ($queries as $query) {
$this->query($query.';');
}
// Recreate the indexes
foreach ($indexList as $indexName => $indexSpec) {
$this->createIndex($tableName, $indexName, $indexSpec);
}
}
public function renameField($tableName, $oldName, $newName)
{
$oldFieldList = $this->fieldList($tableName);
// Skip non-existing columns
if (!array_key_exists($oldName, $oldFieldList)) {
return;
}
// Determine column mappings
$oldCols = array();
$newColsSpec = array();
foreach ($oldFieldList as $name => $spec) {
$oldCols[] = "\"$name\"" . (($name == $oldName) ? " AS $newName" : '');
$newColsSpec[] = "\"" . (($name == $oldName) ? $newName : $name) . "\" $spec";
}
// SQLite doesn't support direct renames through ALTER TABLE
$oldColsStr = implode(',', $oldCols);
$newColsSpecStr = implode(',', $newColsSpec);
$queries = array(
"BEGIN TRANSACTION",
"CREATE TABLE \"{$tableName}_renamefield_{$oldName}\" ({$newColsSpecStr})",
"INSERT INTO \"{$tableName}_renamefield_{$oldName}\" SELECT {$oldColsStr} FROM \"$tableName\"",
"DROP TABLE \"$tableName\"",
"ALTER TABLE \"{$tableName}_renamefield_{$oldName}\" RENAME TO \"$tableName\"",
"COMMIT"
);
// Remember original indexes
$oldIndexList = $this->indexList($tableName);
// Then alter the table column
foreach ($queries as $query) {
$this->query($query.';');
}
// Recreate the indexes
foreach ($oldIndexList as $indexName => $indexSpec) {
// Map index columns
$columns = array_filter(array_map(function ($column) use ($newName, $oldName) {
// Unchanged
if ($column !== $oldName) {
return $column;
}
// Skip obsolete fields
if (stripos($newName, '_obsolete_') === 0) {
return null;
}
return $newName;
}, $indexSpec['columns']));
// Create index if column count unchanged
if (count($columns) === count($indexSpec['columns'])) {
$indexSpec['columns'] = $columns;
$this->createIndex($tableName, $indexName, $indexSpec);
}
}
}
public function fieldList($table)
{
$sqlCreate = $this->preparedQuery(
'SELECT "sql" FROM "sqlite_master" WHERE "type" = ? AND "name" = ?',
array('table', $table)
)->record();
$fieldList = array();
if ($sqlCreate && $sqlCreate['sql']) {
preg_match(
'/^[\s]*CREATE[\s]+TABLE[\s]+[\'"]?[a-zA-Z0-9_\\\]+[\'"]?[\s]*\((.+)\)[\s]*$/ims',
$sqlCreate['sql'],
$matches
);
$fields = isset($matches[1])
? preg_split('/,(?=(?:[^\'"]*$)|(?:[^\'"]*[\'"][^\'"]*[\'"][^\'"]*)*$)/x', $matches[1])
: array();
foreach ($fields as $field) {
$details = preg_split('/\s/', trim($field));
$name = array_shift($details);
$name = str_replace('"', '', trim($name));
$fieldList[$name] = implode(' ', $details);
}
}
return $fieldList;
}
/**
* Create an index on a table.
*
* @param string $tableName The name of the table.
* @param string $indexName The name of the index.
* @param array $indexSpec The specification of the index, see Database::requireIndex() for more details.
*/
public function createIndex($tableName, $indexName, $indexSpec)
{
$sqliteName = $this->buildSQLiteIndexName($tableName, $indexName);
$columns = $this->implodeColumnList($indexSpec['columns']);
$unique = ($indexSpec['type'] == 'unique') ? 'UNIQUE' : '';
$this->query("CREATE $unique INDEX IF NOT EXISTS \"$sqliteName\" ON \"$tableName\" ($columns)");
}
public function alterIndex($tableName, $indexName, $indexSpec)
{
// Drop existing index
$sqliteName = $this->buildSQLiteIndexName($tableName, $indexName);
$this->query("DROP INDEX IF EXISTS \"$sqliteName\"");
// Create the index
$this->createIndex($tableName, $indexName, $indexSpec);
}
/**
* Builds the internal SQLLite index name given the silverstripe table and index name.
*
* The name is built using the table and index name in order to prevent name collisions
* between indexes of the same name across multiple tables
*
* @param string $tableName
* @param string $indexName
* @return string The SQLite3 name of the index
*/
protected function buildSQLiteIndexName($tableName, $indexName)
{
return "{$tableName}_{$indexName}";
}
public function indexKey($table, $index, $spec)
{
return $this->buildSQLiteIndexName($table, $index);
}
public function indexList($table)
{
$indexList = array();
// Enumerate each index and related fields
foreach ($this->query("PRAGMA index_list(\"$table\")") as $index) {
// The SQLite internal index name, not the actual Silverstripe name
$indexName = $index["name"];
$indexType = $index['unique'] ? 'unique' : 'index';
// Determine a clean list of column names within this index
$list = array();
foreach ($this->query("PRAGMA index_info(\"$indexName\")") as $details) {
$list[] = preg_replace('/^"?(.*)"?$/', '$1', $details['name']);
}
// Safely encode this spec
$indexList[$indexName] = array(
'name' => $indexName,
'columns' => $list,
'type' => $indexType,
);
}
return $indexList;
}
public function tableList()
{
$tables = array();
$result = $this->preparedQuery('SELECT name FROM sqlite_master WHERE type = ?', array('table'));
foreach ($result as $record) {
$table = reset($record);
$tables[strtolower($table)] = $table;
}
return $tables;
}
/**
* Return a boolean type-formatted string
*
* @param array $values Contains a tokenised list of info about this data type
* @return string
*/
public function boolean($values)
{
$default = empty($values['default']) ? 0 : (int)$values['default'];
return "BOOL NOT NULL DEFAULT $default";
}
/**
* Return a date type-formatted string
*
* @param array $values Contains a tokenised list of info about this data type
* @return string
*/
public function date($values)
{
return "TEXT";
}
/**
* Return a decimal type-formatted string
*
* @param array $values Contains a tokenised list of info about this data type
* @return string
*/
public function decimal($values)
{
$default = isset($values['default']) && is_numeric($values['default']) ? $values['default'] : 0;
return "NUMERIC NOT NULL DEFAULT $default";
}
/**
* Cached list of enum values indexed by table.column
*
* @var array
*/
protected $enum_map = array();
/**
* Return a enum type-formatted string
*
* enums are not supported. as a workaround to store allowed values we creates an additional table
*
* @param array $values Contains a tokenised list of info about this data type
* @return string
*/
public function enum($values)
{
$tablefield = $values['table'] . '.' . $values['name'];
$enumValues = implode(',', $values['enums']);
// Ensure the cache table exists
if (empty($this->enum_map)) {
$this->query(
"CREATE TABLE IF NOT EXISTS \"SQLiteEnums\" (\"TableColumn\" TEXT PRIMARY KEY, \"EnumList\" TEXT)"
);
}
// Ensure the table row exists
if (empty($this->enum_map[$tablefield]) || $this->enum_map[$tablefield] != $enumValues) {
$this->preparedQuery(
"REPLACE INTO SQLiteEnums (TableColumn, EnumList) VALUES (?, ?)",
array($tablefield, $enumValues)
);
$this->enum_map[$tablefield] = $enumValues;
}
// Set default
if (!empty($values['default'])) {
/*
On escaping strings:
https://www.sqlite.org/lang_expr.html
"A string constant is formed by enclosing the string in single quotes ('). A single quote within
the string can be encoded by putting two single quotes in a row - as in Pascal. C-style escapes
using the backslash character are not supported because they are not standard SQL."
Also, there is a nifty PHP function for this. However apparently one must still be cautious of
the null character ('\0' or 0x0), as per https://bugs.php.net/bug.php?id=63419
*/
$default = SQLite3::escapeString(str_replace("\0", "", $values['default']));
return "TEXT DEFAULT '$default'";
} else {
return 'TEXT';
}
}
/**
* Return a set type-formatted string
* This type doesn't exist in SQLite either
*
* @see SQLite3SchemaManager::enum()
*
* @param array $values Contains a tokenised list of info about this data type
* @return string
*/
public function set($values)
{
return $this->enum($values);
}
/**
* Return a float type-formatted string
*
* @param array $values Contains a tokenised list of info about this data type
* @return string
*/
public function float($values)
{
return "REAL";
}
/**
* Return a Double type-formatted string
*
* @param array $values Contains a tokenised list of info about this data type
* @return string
*/
public function double($values)
{
return "REAL";
}
/**
* Return a int type-formatted string
*
* @param array $values Contains a tokenised list of info about this data type
* @return string
*/
public function int($values)
{
return "INTEGER({$values['precision']}) " . strtoupper($values['null']) . " DEFAULT " . (int)$values['default'];
}
/**
* Return a bigint type-formatted string
*
* @param array $values Contains a tokenised list of info about this data type
* @return string
*/
public function bigint($values)
{
return $this->int($values);
}
/**
* Return a datetime type-formatted string
* For SQLite3, we simply return the word 'TEXT', no other parameters are necessary
*
* @param array $values Contains a tokenised list of info about this data type
* @return string
*/
public function datetime($values)
{
return "DATETIME";
}
/**
* Return a text type-formatted string
*
* @param array $values Contains a tokenised list of info about this data type
* @return string
*/
public function text($values)
{
return 'TEXT';
}
/**
* Return a time type-formatted string
*
* @param array $values Contains a tokenised list of info about this data type
* @return string
*/
public function time($values)
{
return "TEXT";
}
/**
* Return a varchar type-formatted string
*
* @param array $values Contains a tokenised list of info about this data type
* @return string
*/
public function varchar($values)
{
return "VARCHAR({$values['precision']}) COLLATE NOCASE";
}
/*
* Return a 4 digit numeric type. MySQL has a proprietary 'Year' type.
* For SQLite3 we use TEXT
*/
public function year($values, $asDbValue = false)
{
return "TEXT";
}
public function IdColumn($asDbValue = false, $hasAutoIncPK = true)
{
return 'INTEGER PRIMARY KEY AUTOINCREMENT';
}
public function hasTable($tableName)
{
return (bool)$this->preparedQuery(
'SELECT "name" FROM "sqlite_master" WHERE "type" = ? AND "name" = ?',
array('table', $tableName)
)->first();
}
/**
* Return enum values for the given field
*
* @param string $tableName
* @param string $fieldName
* @return array
*/
public function enumValuesForField($tableName, $fieldName)
{
$tablefield = "$tableName.$fieldName";
// Check already cached values for this field
if (!empty($this->enum_map[$tablefield])) {
return explode(',', $this->enum_map[$tablefield]);
}
// Retrieve and cache these details from the database
$classnameinfo = $this->preparedQuery(
"SELECT EnumList FROM SQLiteEnums WHERE TableColumn = ?",
array($tablefield)
)->first();
if ($classnameinfo) {
$valueList = $classnameinfo['EnumList'];
$this->enum_map[$tablefield] = $valueList;
return explode(',', $valueList);
}
// Fallback to empty list
return array();
}
public function dbDataType($type)
{
$values = array(
'unsigned integer' => 'INT'
);
if (isset($values[$type])) {
return $values[$type];
} else {
return '';
}
}
}