. * * @copyright (c) Visman * @license The MIT License (MIT) */ declare(strict_types=1); namespace ForkBB\Core\DB; use ForkBB\Core\DB; use PDO; use PDOStatement; use PDOException; class Sqlite { /** * Массив замены типов полей таблицы */ protected array $dbTypeRepl = [ '%^.*?INT.*$%i' => 'INTEGER', '%^.*?(?:CHAR|CLOB|TEXT).*$%i' => 'TEXT', '%^.*?BLOB.*$%i' => 'BLOB', '%^.*?(?:REAL|FLOA|DOUB).*$%i' => 'REAL', '%^.*?(?:NUMERIC|DECIMAL).*$%i' => 'NUMERIC', '%^.*?BOOL.*$%i' => 'BOOLEAN', // ???? не соответствует SQLite '%^SERIAL$%i' => 'INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL', ]; /** * Подстановка типов полей для карты БД */ protected array $types = [ 'boolean' => 'b', 'integer' => 'i', 'real' => 'f', 'numeric' => 'f', ]; public function __construct(protected DB $db, protected string $dbPrefix) { $this->nameCheck($dbPrefix); } /** * Перехват неизвестных методов */ public function __call(string $name, array $args) { throw new PDOException("Method '{$name}' not found in DB driver"); } /** * Проверяет минимально допустимую версию */ protected function vComp(string $version): bool { return \version_compare($this->db->getAttribute(PDO::ATTR_SERVER_VERSION), $version, '>='); } /** * Проверяет имя таблицы/индекса/поля на допустимые символы */ protected function nameCheck(string $str): void { if (\preg_match('%[^\w]%', $str)) { throw new PDOException("Name '{$str}' have bad characters"); } } /** * Обрабатывает имя таблицы с одновременной проверкой */ protected function tName(string $name): string { if (\str_starts_with($name, '::')) { $name = $this->dbPrefix . \substr($name, 2); } $this->nameCheck($name); return $name; } /** * Операции над полями индексов: проверка, замена */ protected function replIdxs(array $arr): string { foreach ($arr as &$value) { if (\preg_match('%^(.*)\s*(\(\d+\))$%', $value, $matches)) { $this->nameCheck($matches[1]); $value = "\"{$matches[1]}\""; } else { $this->nameCheck($value); $value = "\"{$value}\""; } } unset($value); return \implode(',', $arr); } /** * Замена типа поля в соответствии с dbTypeRepl */ protected function replType(string $type): string { return \preg_replace(\array_keys($this->dbTypeRepl), \array_values($this->dbTypeRepl), $type); } /** * Конвертирует данные в строку для DEFAULT */ protected function convToStr(mixed $data): string { if (\is_string($data)) { return $this->db->quote($data); } elseif (\is_numeric($data)) { return (string) $data; } elseif (\is_bool($data)) { return $data ? 'true' : 'false'; } else { throw new PDOException('Invalid data type for DEFAULT'); } } /** * Формирует строку для одного поля таблицы */ protected function buildColumn(string $name, array $data): string { $this->nameCheck($name); // имя и тип $query = '"' . $name . '" ' . $this->replType($data[0]); if ('SERIAL' !== \strtoupper($data[0])) { // сравнение if (\preg_match('%^(?:CHAR|VARCHAR|TINYTEXT|TEXT|MEDIUMTEXT|LONGTEXT|ENUM|SET)\b%i', $data[0])) { $query .= ' COLLATE '; if ( isset($data[3]) && \is_string($data[3]) && \preg_match('%bin%i', $data[3]) ) { $query .= 'BINARY'; } else { $query .= 'NOCASE'; } } // не NULL if (empty($data[1])) { $query .= ' NOT NULL'; } // значение по умолчанию if (isset($data[2])) { $query .= ' DEFAULT ' . $this->convToStr($data[2]); } } return $query; } /** * Строит структуру таблицы + запросы на создание индексов */ protected function tableSchema(string $table): array { $fields = []; $stmt = $this->db->query("PRAGMA table_info({$table})"); while ($row = $stmt->fetch()) { $fields[$row['name']] = $row['name']; } if (empty($fields)) { throw new PDOException("No '{$table}' table data"); } $vars = [ ':tname' => $table, ]; $query = 'SELECT * FROM sqlite_master WHERE tbl_name=?s:tname'; $stmt = $this->db->query($query, $vars); $result = []; while ($row = $stmt->fetch()) { switch ($row['type']) { case 'table': $result['TABLE']['sql'] = $row['sql']; break; default: if (! empty($row['sql'])) { $result[$row['name']] = $row['sql']; } break; } } if (empty($result['TABLE']['sql'])) { throw new PDOException("No '{$table}' table sql data"); } if (! \preg_match("%^CREATE\s+TABLE\s+\"?{$table}\b.*?\((.+)\)[^()]*$%", $result['TABLE']['sql'], $matches)) { throw new PDOException("Bad sql in '{$table}' table"); } $subSchema = $matches[1]; $result['TABLE']['CREATE'] = \str_replace($subSchema, '_STRUCTURE_', $result['TABLE']['sql']); $result['TABLE']['FIELDS'] = []; $result['TABLE']['OTHERS'] = []; do { $tmp = $fields ? '"?\b(?:' . \implode('|', $fields) . ')\b\"?|' : ''; $pattern = "%^ \s* ( (?: {$tmp} CONSTRAINT | PRIMARY | UNIQUE | CHECK | FOREIGN ) ) .*? (?: , (?= \s* (?: {$tmp} CONSTRAINT | PRIMARY | UNIQUE | CHECK | FOREIGN ) ) | $ )%x"; if (! \preg_match($pattern, $subSchema, $matches)) { throw new PDOException("Bad subSchema in '{$table}' table: {$subSchema}"); } $subSchema = \substr($subSchema, \strlen($matches[0])); $value = \trim($matches[0], ' ,'); $key = $matches[1]; switch ($key) { case 'CONSTRAINT': case 'PRIMARY': case 'UNIQUE': case 'CHECK': case 'FOREIGN': $result['TABLE']['OTHERS'][] = $value; break; default: if ( '"' === $key[0] && '"' === $key[-1] ) { $key = \substr($key, 1, -1); } if (! isset($fields[$key])) { throw new PDOException("Bad field in '{$table}' table: {$key}"); } $result['TABLE']['FIELDS'][$key] = $value; unset($fields[$key]); break; } } while ('' != \trim($subSchema)); return $result; } /** * Создает временную таблицу */ protected function createTmpTable(array $schema, string $table): ?string { $tmpTable = $table . '_tmp' . \time(); $createQuery = \str_replace($table, $tmpTable, $schema['TABLE']['CREATE'], $count); if (1 !== $count) { return null; } $structure = \implode(', ', $schema['TABLE']['FIELDS'] + $schema['TABLE']['OTHERS']); $createQuery = \str_replace('_STRUCTURE_', $structure, $createQuery, $count); if (1 !== $count) { return null; } return false !== $this->db->exec($createQuery) ? $tmpTable : null; } /** * Пересоздает таблицу из временной с помощью insert запроса */ protected function tmpToTable(array $schema, string $insertQuery): bool { if (! \preg_match('%^INSERT INTO "(.*?)".+FROM "(.*?)"%s', $insertQuery, $matches)) { return false; } $tmpTable = $matches[1]; $table = $matches[2]; $result = false !== $this->db->exec($insertQuery); $result = $result && $this->dropTable($table); $result = $result && $this->renameTable($tmpTable, $table); foreach ($schema as $key => $query) { if ('TABLE' === $key) { continue; } $result = $result && false !== $this->db->exec($query); } return $result; } /** * Проверяет наличие таблицы в базе */ public function tableExists(string $table): bool { $vars = [ ':tname' => $this->tName($table), ':ttype' => 'table', ]; $query = 'SELECT 1 FROM sqlite_master WHERE tbl_name=?s:tname AND type=?s:ttype'; $stmt = $this->db->query($query, $vars); $result = $stmt->fetch(); $stmt->closeCursor(); return ! empty($result); } /** * Проверяет наличие поля в таблице */ public function fieldExists(string $table, string $field): bool { $table = $this->tName($table); $stmt = $this->db->query("PRAGMA table_info({$table})"); while ($row = $stmt->fetch()) { if ($field === $row['name']) { $stmt->closeCursor(); return true; } } return false; } /** * Проверяет наличие индекса в таблице */ public function indexExists(string $table, string $index): bool { $table = $this->tName($table); if ('PRIMARY' === $index) { $stmt = $this->db->query("PRAGMA table_info('{$table}')"); while ($row = $stmt->fetch()) { if ($row['pk'] > 0) { $stmt->closeCursor(); return true; } } return false; } else { $vars = [ ':tname' => $table, ':iname' => $table . '_' . $index, ':itype' => 'index', ]; $query = 'SELECT 1 FROM sqlite_master WHERE name=?s:iname AND tbl_name=?s:tname AND type=?s:itype'; $stmt = $this->db->query($query, $vars); $result = $stmt->fetch(); $stmt->closeCursor(); return ! empty($result); } } /** * Создает таблицу */ public function createTable(string $table, array $schema): bool { $table = $this->tName($table); $query = "CREATE TABLE IF NOT EXISTS \"{$table}\" ("; foreach ($schema['FIELDS'] as $field => $data) { $query .= $this->buildColumn($field, $data) . ', '; } if ( isset($schema['PRIMARY KEY']) && false === \strpos($query, 'PRIMARY KEY') // если не было поля с типом SERIAL ) { $query .= 'PRIMARY KEY (' . $this->replIdxs($schema['PRIMARY KEY']) . '), '; } $query = \rtrim($query, ', ') . ")"; $result = false !== $this->db->exec($query); // вынесено отдельно для сохранения имен индексов if ($result && isset($schema['UNIQUE KEYS'])) { foreach ($schema['UNIQUE KEYS'] as $key => $fields) { $result = $result && $this->addIndex($table, $key, $fields, true); } } if ($result && isset($schema['INDEXES'])) { foreach ($schema['INDEXES'] as $index => $fields) { $result = $result && $this->addIndex($table, $index, $fields, false); } } return $result; } /** * Удаляет таблицу */ public function dropTable(string $table): bool { $table = $this->tName($table); return false !== $this->db->exec("DROP TABLE IF EXISTS \"{$table}\""); } /** * Переименовывает таблицу */ public function renameTable(string $old, string $new): bool { $old = $this->tName($old); $new = $this->tName($new); if ( $this->tableExists($new) && ! $this->tableExists($old) ) { return true; } return false !== $this->db->exec("ALTER TABLE \"{$old}\" RENAME TO \"{$new}\""); } /** * Добавляет поле в таблицу */ public function addField(string $table, string $field, string $type, bool $allowNull, mixed $default = null, string $collate = null, string $after = null): bool { $table = $this->tName($table); if ($this->fieldExists($table, $field)) { return true; } $query = "ALTER TABLE \"{$table}\" ADD COLUMN " . $this->buildColumn($field, [$type, $allowNull, $default, $collate]); return false !== $this->db->exec($query); } /** * Модифицирует поле в таблице */ public function alterField(string $table, string $field, string $type, bool $allowNull, mixed $default = null, string $collate = null, string $after = null): bool { $this->nameCheck($field); $table = $this->tName($table); if (! $this->fieldExists($table, $field)) { return false; } $schema = $this->tableSchema($table); $schema['TABLE']['FIELDS'][$field] = $this->buildColumn($field, [$type, $allowNull, $default, $collate]); $tmpTable = $this->createTmpTable($schema, $table); if (! \is_string($tmpTable)) { return false; } $tmp = '"' . \implode('", "', \array_keys($schema['TABLE']['FIELDS'])) . '"'; $query = "INSERT INTO \"{$tmpTable}\" ({$tmp}) SELECT {$tmp} FROM \"{$table}\""; return $this->tmpToTable($schema, $query); } /** * Удаляет поле из таблицы */ public function dropField(string $table, string $field): bool { $table = $this->tName($table); $this->nameCheck($field); if (! $this->fieldExists($table, $field)) { return true; } // 3.35.1 and 3.35.5 have fixes if ($this->vComp('3.36.0')) { return false !== $this->db->exec("ALTER TABLE \"{$table}\" DROP COLUMN \"{$field}\""); // add 2021-03-12 (3.35.0) } $schema = $this->tableSchema($table); unset($schema['TABLE']['FIELDS'][$field]); $tmpTable = $this->createTmpTable($schema, $table); if (! \is_string($tmpTable)) { return false; } $tmp = '"' . \implode('", "', \array_keys($schema['TABLE']['FIELDS'])) . '"'; $query = "INSERT INTO \"{$tmpTable}\" ({$tmp}) SELECT {$tmp} FROM \"{$table}\""; return $this->tmpToTable($schema, $query); } /** * Переименование поля в таблице */ public function renameField(string $table, string $old, string $new): bool { $table = $this->tName($table); $this->nameCheck($old); $this->nameCheck($new); if ( $this->fieldExists($table, $new) && ! $this->fieldExists($table, $old) ) { return true; } return false !== $this->db->exec("ALTER TABLE \"{$table}\" RENAME COLUMN \"{$old}\" TO \"{$new}\""); // add 2018-09-15 (3.25.0) } /** * Добавляет индекс в таблицу */ public function addIndex(string $table, string $index, array $fields, bool $unique = false): bool { $table = $this->tName($table); if ($this->indexExists($table, $index)) { return true; } if ('PRIMARY' === $index) { $schema = $this->tableSchema($table); $schema['TABLE']['OTHERS'][] = 'PRIMARY KEY (' . $this->replIdxs($fields) . ')'; $tmpTable = $this->createTmpTable($schema, $table); if (! \is_string($tmpTable)) { return false; } $tmp = '"' . \implode('", "', \array_keys($schema['TABLE']['FIELDS'])) . '"'; $query = "INSERT INTO \"{$tmpTable}\" ({$tmp}) SELECT {$tmp} FROM \"{$table}\""; return $this->tmpToTable($schema, $query); } else { $index = $table . '_' . $index; $this->nameCheck($index); $unique = $unique ? 'UNIQUE' : ''; $query = "CREATE {$unique} INDEX \"{$index}\" ON \"{$table}\" (" . $this->replIdxs($fields) . ')'; return false !== $this->db->exec($query); } } /** * Удаляет индекс из таблицы */ public function dropIndex(string $table, string $index): bool { $table = $this->tName($table); if (! $this->indexExists($table, $index)) { return true; } if ('PRIMARY' === $index) { $schema = $this->tableSchema($table); foreach ($schema['TABLE']['FIELDS'] as &$value) { $value = \preg_replace( '%\bPRIMARY\s+KEY\s+(?:(?:ASC|DESC)\s+)?(?:ON\s+CONFLICT\s+(?:ROLLBACK|ABORT|FAIL|IGNORE|REPLACE)\s+)?(?:AUTOINCREMENT\s+)?%si', '', $value ); } unset($value); $tmp = []; foreach ($schema['TABLE']['OTHERS'] as $value) { if (\preg_match('%\bPRIMARY\s+KEY\b%si', $value)) { continue; } $tmp[] = $value; } $schema['TABLE']['OTHERS'] = $tmp; $tmpTable = $this->createTmpTable($schema, $table); if (! \is_string($tmpTable)) { return false; } $tmp = '"' . \implode('", "', \array_keys($schema['TABLE']['FIELDS'])) . '"'; $query = "INSERT INTO \"{$tmpTable}\" ({$tmp}) SELECT {$tmp} FROM \"{$table}\""; return $this->tmpToTable($schema, $query); } else { $this->nameCheck($index); return false !== $this->db->exec("DROP INDEX \"{$table}_{$index}\""); } } /** * Очищает таблицу */ public function truncateTable(string $table): bool { $table = $this->tName($table); if (false !== $this->db->exec("DELETE FROM \"{$table}\"")) { if (! $this->tableExists('SQLITE_SEQUENCE')) { return true; } $vars = [ ':tname' => $table, ]; $query = 'DELETE FROM SQLITE_SEQUENCE WHERE name=?s:tname'; return false !== $this->db->exec($query, $vars); } return false; } /** * Возвращает статистику */ public function statistics(): array { $vars = [ ':tname' => \str_replace('_', '#_', $this->dbPrefix) . '%', ':ttype' => 'table', ]; $query = 'SELECT tbl_name FROM sqlite_master WHERE tbl_name LIKE ?s:tname ESCAPE \'#\' AND type=?s:ttype'; $tables = $this->db->query($query, $vars)->fetchAll(\PDO::FETCH_COLUMN); $records = 0; foreach ($tables as $table) { $this->nameCheck($table); $count = null; $fields = $this->db->query("PRAGMA table_info('{$table}')")->fetchAll(); foreach ($fields as $field) { if (1 !== (int) $field['pk']) { continue; } $this->nameCheck($field['name']); $count = $this->db->query("SELECT COUNT({$field['name']}) FROM {$table}")->fetchColumn(); break; } if (null === $count) { $count = $this->db->query("SELECT COUNT(*) FROM {$table}")->fetchColumn(); } $records += $count; } $size = (int) $this->db->query('PRAGMA page_count;')->fetchColumn(); $size *= (int) $this->db->query('PRAGMA page_size;')->fetchColumn(); return [ 'db' => 'SQLite (PDO) v.' . $this->db->getAttribute(PDO::ATTR_SERVER_VERSION), 'tables' => (string) \count($tables), 'records' => $records, 'size' => $size, # 'server info' => $this->db->getAttribute(PDO::ATTR_SERVER_INFO), 'encoding' => $this->db->query('PRAGMA encoding;')->fetchColumn(), 'journal_mode' => $this->db->query('PRAGMA journal_mode;')->fetchColumn(), 'synchronous' => $this->db->query('PRAGMA synchronous;')->fetchColumn(), 'busy_timeout' => $this->db->query('PRAGMA busy_timeout;')->fetchColumn(), ]; } /** * Формирует карту базы данных */ public function getMap(): array { $vars = [ ':tname' => \str_replace('_', '#_', $this->dbPrefix) . '%', ]; $query = 'SELECT m.name AS table_name, p.name AS column_name, p.type AS data_type FROM sqlite_master AS m INNER JOIN pragma_table_info(m.name) AS p WHERE table_name LIKE ?s:tname ESCAPE \'#\' ORDER BY m.name, p.cid'; $stmt = $this->db->query($query, $vars); $result = []; $table = null; $prfLen = \strlen($this->dbPrefix); while ($row = $stmt->fetch()) { if ($table !== $row['table_name']) { $table = $row['table_name']; $tableNoPref = \substr($table, $prfLen); $result[$tableNoPref] = []; } $type = \strtolower($row['data_type']); $result[$tableNoPref][$row['column_name']] = $this->types[$type] ?? 's'; } return $result; } }