diff --git a/CHANGELOG.md b/CHANGELOG.md index 01d0d7ad8..f11ac4725 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,7 @@ - MS SQL: Hide table actions and row editing in the sys schema - Elasticsearch, ClickHouse: Use default port - ClickHouse: Fix nullable columns, fix default values, show server version +- ClickHouse: Support schema management, introspection and administration - MongoDB: Authenticate against the database used in login - MongoDB: Do not treat the string NULL as the NULL value - MongoDB: Show the primary key column when altering indexes (regression from 5.4.0) diff --git a/plugins/drivers/clickhouse.php b/plugins/drivers/clickhouse.php index aa91d8f1d..a6502f207 100644 --- a/plugins/drivers/clickhouse.php +++ b/plugins/drivers/clickhouse.php @@ -1,23 +1,35 @@ url/?database=$db", stream_context_create(array('http' => array( + $this->error = ''; + $this->errno = 0; + $this->affected_rows = 0; + list($file, $status) = get_url($this->url . "/?database=" . rawurlencode($db), stream_context_create(array('http' => array( 'method' => 'POST', 'content' => $query, 'header' => array( - 'Content-Type: application/x-www-form-urlencoded', + 'Authorization: Basic ' . $this->authorization, + 'Content-Type: text/plain; charset=UTF-8', 'X-ClickHouse-Format: JSONCompact', ), 'ignore_errors' => 1, @@ -25,58 +37,65 @@ function rootQuery($db, $query) { 'max_redirects' => 0, )))); - if ($file === false || $status == 403) { + if ($status == 401 || $status == 403) { $this->error = lang('Invalid credentials.'); + $this->errno = $status; return false; } - $return = json_decode($file, true); - if ($return === null) { - if (!$this->isQuerySelectLike($query) && $file === '') { - return true; - } - - $this->errno = json_last_error(); - if (function_exists('json_last_error_msg')) { - $this->error = json_last_error_msg(); + if ($file === false) { + $lastError = error_get_last(); + $this->error = ($lastError && $lastError['message'] + ? preg_replace('~^file_get_contents\([^)]*\):\s*~', '', $lastError['message']) + : lang('Unable to connect.') + ); + return false; + } + if ($status < 200 || $status >= 300) { + if (preg_match('~Code:\s*(\d+)~', $file, $match)) { + $this->errno = (int) $match[1]; } else { - $constants = get_defined_constants(true); - foreach ($constants['json'] as $name => $value) { - if ($value == $this->errno && preg_match('~^JSON_ERROR_~', $name)) { - $this->error = $name; - break; - } - } + $this->errno = (int) $status; + } + $this->error = trim($file); + if ($this->error === '') { + $this->error = "ClickHouse HTTP error $status."; } + return false; + } + + if (trim($file) === '') { + return true; } - // 400 == Syntax error - // 404 == Unknown expression identifier - // 500 == Column 'x' is not under aggregate function and not in GROUP BY keys - if (preg_match('~^[45]~', $status)) { - $this->error = $return['exception']; + + $return = json_decode($file, true); + if (!is_array($return) || !isset($return['data']) || !isset($return['meta'])) { + $this->errno = json_last_error(); + $this->error = ($this->errno && function_exists('json_last_error_msg') + ? json_last_error_msg() + : 'Unexpected response returned by ClickHouse.' + ); return false; } return new Result($return); } - function isQuerySelectLike($query) { - return (bool) preg_match('~^\s*(select|show|with)~i', $query); - } - function query($query, $unbuffered = false) { + if (preg_match('~^\s*USE\s+(?:`((?:``|[^`])+)`|([A-Za-z_][A-Za-z0-9_]*))\s*;?\s*$~i', $query, $match)) { + $this->_db = str_replace("``", "`", ($match[1] !== '' ? $match[1] : $match[2])); + return true; + } return $this->rootQuery($this->_db, $query); } function attach($server, $username, $password): string { - preg_match('~^(https?://)?(.*)~', $server, $match); - if (!strpos($match[2], ":")) { - $match[2] .= ":8123"; - } - $this->url = ($match[1] ?: "http://") . urlencode($username) . ":" . urlencode($password) . "@$match[2]"; - $version = get_val('SELECT version()', 0, $this); // also verifies the connection - if ($version === false) { + $this->url = rtrim((preg_match('~^https?://~i', $server) ? $server : "http://$server"), '/'); + $this->authorization = base64_encode("$username:$password"); + $return = $this->query('SELECT version()'); + if (!$return) { return $this->error; } - $this->server_info = $version; + $row = $return->fetch_row(); + $this->server_info = ($row ? $row[0] : ''); return ''; } @@ -86,52 +105,77 @@ function select_db($database) { } function quote($string): string { - return "'" . addcslashes($string, "\\'") . "'"; + return "'" . strtr($string, array( + "\\" => "\\\\", + "'" => "\\'", + "\0" => "\\0", + "\b" => "\\b", + "\f" => "\\f", + "\n" => "\\n", + "\r" => "\\r", + "\t" => "\\t", + )) . "'"; } } class Result { public $num_rows, $columns, $meta; - private $rows = array(), $offset = 0; + private $rows = array(), $rowOffset = 0, $fieldOffset = 0; function __construct($result) { - foreach ($result['data'] as $item) { + $this->meta = (array) $result['meta']; + foreach ((array) $result['data'] as $item) { $row = array(); - foreach ($item as $key => $val) { - $row[$key] = is_scalar($val) ? $val : json_encode($val, 256); // 256 - JSON_UNESCAPED_UNICODE available since PHP 5.4 + foreach ((array) $item as $key => $val) { + $type = (isset($this->meta[$key]['type']) ? $this->meta[$key]['type'] : ''); + $row[$key] = ($val === null || is_scalar($val) + ? $this->normalizeValue($val, $type) + : json_encode($val, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) + ); } $this->rows[] = $row; } - $this->num_rows = $result['rows']; - $this->meta = $result['meta']; + $this->num_rows = (isset($result['rows']) ? $result['rows'] : count($this->rows)); $this->columns = array_map(function ($column) { return $column['name']; }, $this->meta); // array_column() is available since PHP 5.5 - reset($this->rows); + } + + private function normalizeValue($value, $type) { + // FixedString is NUL-padded to its declared width. The padding is + // storage detail rather than user data and breaks Adminer links and + // form controls if it is allowed through to the HTML response. + if (is_string($value) && preg_match('~(?:^|\()FixedString\(\d+\)~', $type)) { + return rtrim($value, "\0"); + } + return $value; } function fetch_assoc() { - $row = current($this->rows); - next($this->rows); - return $row === false ? false : array_combine($this->columns, $row); + if (!isset($this->rows[$this->rowOffset])) { + return false; + } + return array_combine($this->columns, $this->rows[$this->rowOffset++]); } function fetch_row() { - $row = current($this->rows); - next($this->rows); - return $row; + return (isset($this->rows[$this->rowOffset]) ? $this->rows[$this->rowOffset++] : false); } function fetch_field(): \stdClass { - $column = $this->offset++; + $column = $this->fieldOffset++; $return = new \stdClass; if ($column < count($this->columns)) { $return->name = $this->meta[$column]['name']; - $return->type = $this->meta[$column]['type']; //! map to MySQL numbers + $return->type = $this->meta[$column]['type']; $return->charsetnr = 0; } return $return; } + + function seek($offset) { + $this->rowOffset = max(0, (int) $offset); + } } } @@ -139,31 +183,82 @@ class Driver extends SqlDriver { static $extensions = array("allow_url_fopen"); static $jush = "clickhouse"; - public $operators = array("=", "<", ">", "<=", ">=", "!=", "~", "!~", "LIKE", "LIKE %%", "IN", "IS NULL", "NOT LIKE", "NOT IN", "IS NOT NULL", "SQL"); + public $operators = array("=", "<", ">", "<=", ">=", "!=", "LIKE", "LIKE %%", "ILIKE", "ILIKE %%", "IN", "IS NULL", "NOT LIKE", "NOT ILIKE", "NOT IN", "IS NOT NULL", "SQL"); + public $functions = array("length", "lower", "round", "toDate", "toDateTime", "toString", "upper"); public $grouping = array("avg", "count", "count distinct", "max", "min", "sum"); + public $insertFunctions = array("Date|DateTime" => "now"); + public $editFunctions = array( + "Int|UInt|Float|Decimal" => "+/-", + "String|FixedString" => "concat", + ); + public $generated = array("MATERIALIZED", "ALIAS", "EPHEMERAL"); static function connect($server, $username, $password) { - if (!preg_match('~^(https?://)?[-a-z\d.]+(:\d+)?$~', $server)) { + $url = (preg_match('~^https?://~i', $server) ? $server : "http://$server"); + $parts = @parse_url($url); + if ( + !is_array($parts) + || !isset($parts['scheme'], $parts['host']) + || !in_array(strtolower($parts['scheme']), array('http', 'https'), true) + || isset($parts['user']) || isset($parts['pass']) || isset($parts['query']) || isset($parts['fragment']) + || (isset($parts['path']) && $parts['path'] !== '' && $parts['path'] !== '/') + ) { return lang('Invalid server.'); } return parent::connect($server, $username, $password); } + function hasCStyleEscapes(): bool { + return true; + } + function __construct(Db $connection) { parent::__construct($connection); - $this->types = array( //! arrays + $this->types = array( lang('Numbers') => array( "Int8" => 3, "Int16" => 5, "Int32" => 10, "Int64" => 19, "UInt8" => 3, "UInt16" => 5, "UInt32" => 10, "UInt64" => 20, - "Float32" => 7, "Float64" => 16, - 'Decimal' => 38, 'Decimal32' => 9, 'Decimal64' => 18, 'Decimal128' => 38, + "Int128" => 39, "Int256" => 78, "UInt128" => 39, "UInt256" => 78, + "Float32" => 14, "Float64" => 23, "BFloat16" => 7, "Bool" => 1, + "Decimal" => 76, "Decimal32" => 9, "Decimal64" => 18, + "Decimal128" => 38, "Decimal256" => 76, + ), + lang('Date and time') => array( + "Date" => 10, "Date32" => 10, "DateTime" => 19, "DateTime64" => 29, + ), + lang('Strings') => array("String" => 0, "FixedString" => 0), + lang('Other') => array( + "UUID" => 36, "IPv4" => 15, "IPv6" => 39, + "Enum8" => 0, "Enum16" => 0, "Array" => 0, "Map" => 0, + "Tuple" => 0, "Nested" => 0, "LowCardinality" => 0, + "AggregateFunction" => 0, "SimpleAggregateFunction" => 0, + "Variant" => 0, "Dynamic" => 0, "JSON" => 0, ), - lang('Date and time') => array("Date" => 13, "DateTime" => 20), - lang('Strings') => array("String" => 0), - lang('Binary') => array("FixedString" => 0), ); } + function engines(): array { + $engines = get_vals("SELECT name FROM system.table_engines ORDER BY name"); + return ($engines ?: array("MergeTree", "ReplacingMergeTree", "Memory", "Log", "TinyLog")); + } + + function allFields() { + $return = array(); + $rows = get_rows( + "SELECT c." . idf_escape('table') . " AS " . idf_escape('table') + . ", c.name, c.type, c.default_kind, c.default_expression, c.comment, " + . "c.is_in_primary_key, c.is_in_sorting_key, t.engine AS table_engine " + . "FROM system.columns AS c LEFT JOIN system.tables AS t " + . "ON c.database = t.database AND c." . idf_escape('table') . " = t.name " + . "WHERE c.database = " . q($this->conn->_db) + . " ORDER BY c." . idf_escape('table') . ", c.position" + ); + foreach ($rows as $row) { + $return[$row['table']][] = clickhouse_field($row); + } + return $return; + } + function delete($table, $queryWhere, $limit = 0) { if ($queryWhere === '') { $queryWhere = 'WHERE 1=1'; @@ -179,6 +274,14 @@ function update($table, array $set, $queryWhere, $limit = 0, $separator = "\n") $query = $separator . implode(",$separator", $values); return queries("ALTER TABLE " . table($table) . " UPDATE $query$queryWhere"); } + + function insert($table, array $set) { + if (!$set) { + $this->conn->error = 'ClickHouse does not support DEFAULT VALUES without an explicit column list.'; + return false; + } + return parent::insert($table, $set); + } } function idf_escape($idf) { @@ -189,55 +292,161 @@ function table($idf) { return idf_escape($idf); } + function clickhouse_qualified($database, $name) { + return idf_escape($database) . "." . idf_escape($name); + } + + function clickhouse_type_info($fullType) { + $fullType = trim($fullType); + $type = $fullType; + $nullable = false; + if (preg_match('~^Nullable\((.*)\)$~s', $type, $match)) { + $nullable = true; + $type = $match[1]; + } + if (preg_match('~^([A-Za-z][A-Za-z0-9_]*)(?:\((.*)\))?$~s', $type, $match)) { + return array($match[1], isset($match[2]) ? $match[2] : '', $nullable); + } + return array($type, '', $nullable); + } + + function clickhouse_default_value($expression) { + if (preg_match("~^'(.*)'$~s", $expression, $match)) { + return stripcslashes(str_replace("''", "'", $match[1])); + } + return $expression; + } + + function clickhouse_field($row) { + list($type, $length, $nullable) = clickhouse_type_info($row['type']); + $defaultKind = strtoupper(trim($row['default_kind'])); + $generated = (in_array($defaultKind, array("MATERIALIZED", "ALIAS", "EPHEMERAL"), true) + ? $defaultKind + : '' + ); + $engine = (isset($row['table_engine']) ? $row['table_engine'] : ''); + $isView = (bool) preg_match('~View$~', $engine); + $privileges = array("select" => 1, "where" => 1, "order" => 1); + if (!$generated && !$isView) { + $privileges["insert"] = 1; + } + if (!$generated && preg_match('~MergeTree$~', $engine)) { + $privileges["update"] = 1; + } + return array( + "field" => trim($row['name']), + "full_type" => trim($row['type']), + "type" => $type, + "length" => $length, + "default" => ($defaultKind ? clickhouse_default_value(trim($row['default_expression'])) : null), + "null" => $nullable, + "auto_increment" => false, + "on_update" => "", + "collation" => "", + "privileges" => $privileges, + "comment" => trim($row['comment']), + "primary" => false, // ClickHouse primary keys do not guarantee uniqueness. + "generated" => $generated, + ); + } + + function clickhouse_field_definition($parts) { + $name = $parts[0]; + $type = trim($parts[1]); + if (isset($parts[2]) && trim($parts[2]) === "NULL" && strpos($type, 'Nullable(') !== 0) { + $type = "Nullable($type)"; + } + $default = (isset($parts[3]) ? $parts[3] : ''); + if (preg_match('~^\s*GENERATED ALWAYS AS \((.*)\)\s+(MATERIALIZED|ALIAS|EPHEMERAL)\s*$~s', $default, $match)) { + $default = " $match[2] $match[1]"; + } + $comment = (isset($parts[5]) && preg_match('~^\s*COMMENT\b~i', $parts[5]) + ? $parts[5] + : (isset($parts[4]) && preg_match('~^\s*COMMENT\b~i', $parts[4]) ? $parts[4] : '') + ); + return "$name $type$default$comment"; + } + function explain($connection, $query) { - return ''; + return $connection->query("EXPLAIN $query"); } function found_rows($table_status, $where) { - $rows = get_vals("SELECT COUNT(*) FROM " . idf_escape($table_status["Name"]) . ($where ? " WHERE " . implode(" AND ", $where) : "")); - return empty($rows) ? false : $rows[0]; + return get_val("SELECT count() FROM " . table($table_status["Name"]) . ($where ? " WHERE " . implode(" AND ", $where) : "")); } function alter_table($table, $name, $fields, $foreign, $comment, $engine, $collation, $auto_increment, $partitioning) { - $alter = $order = $remove = array(); - foreach ($fields as $field) { - if ($field[1][2] === " NULL") { - $field[1][1] = " Nullable(" . ltrim($field[1][1]) . ")"; // NULL is not allowed after the type - $field[1][2] = ''; - } elseif ($field[1][2] === ' NOT NULL') { - $field[1][2] = ''; + if ($table === "") { + $definitions = array(); + foreach ($fields as $field) { + if (!empty($field[1])) { + $definitions[] = clickhouse_field_definition($field[1]); + } } - - if ($field[1] && $field[1][3] == "" && $table != "" && $field[0] != "" && min_version("20.10")) { - // MODIFY COLUMN without DEFAULT keeps the original default value - $remove[] = "MODIFY COLUMN " . idf_escape($field[0]) . " REMOVE DEFAULT"; + $engine = ($engine ?: "MergeTree"); + if (!preg_match('~^[A-Za-z][A-Za-z0-9_]*$~', $engine)) { + connection()->error = 'Invalid ClickHouse table engine.'; + return false; } + $status = " ENGINE = $engine"; + if (preg_match('~MergeTree$~', $engine)) { + $status .= " ORDER BY tuple()"; + } + $result = queries("CREATE TABLE " . table($name) . " (\n" . implode(",\n", $definitions) . "\n)$status"); + if ($result && $comment !== null && $comment !== '') { + $result = queries("ALTER TABLE " . table($name) . " MODIFY COMMENT " . q($comment)); + } + return $result; + } - $alter[] = ($field[1] - ? ($table != "" ? ($field[0] != "" ? "MODIFY COLUMN " : "ADD COLUMN ") : " ") . implode($field[1]) - : "DROP COLUMN " . idf_escape($field[0]) - ); - - $order[] = $field[1][0]; + if ($foreign) { + connection()->error = 'ClickHouse does not support foreign keys.'; + return false; + } + if ($engine !== '') { + connection()->error = 'ClickHouse cannot change a table engine with ALTER TABLE.'; + return false; + } + if ($collation !== '' || $auto_increment !== '') { + connection()->error = 'ClickHouse does not support table collations or auto-increment values.'; + return false; } - $alter = array_merge($alter, $remove, $foreign); - $status = ($engine ? " ENGINE " . $engine : ""); - if ($table == "") { - return queries("CREATE TABLE " . table($name) . " (\n" . implode(",\n", $alter) . "\n)$status$partitioning" . ' ORDER BY (' . implode(',', $order) . ')'); + if ($table !== $name) { + if (!queries("RENAME TABLE " . table($table) . " TO " . table($name))) { + return false; + } + $table = $name; } - if ($table != $name) { - $result = queries("RENAME TABLE " . table($table) . " TO " . table($name)); - if ($alter) { - $table = $name; - } else { - return $result; + + $result = true; + foreach ($fields as $field) { + if (empty($field[1])) { + $result = queries("ALTER TABLE " . table($table) . " DROP COLUMN " . idf_escape($field[0])); + if (!$result) { + return false; + } + continue; + } + + $newName = $field[1][0]; + if ($field[0] !== "" && idf_escape($field[0]) !== $newName) { + if (!queries("ALTER TABLE " . table($table) . " RENAME COLUMN " . idf_escape($field[0]) . " TO $newName")) { + return false; + } + } + $operation = ($field[0] === "" ? "ADD COLUMN" : "MODIFY COLUMN"); + $order = (isset($field[2]) ? $field[2] : ''); + $result = queries("ALTER TABLE " . table($table) . " $operation " . clickhouse_field_definition($field[1]) . $order); + if (!$result) { + return false; } } - if ($status) { - $alter[] = ltrim($status); + + if ($comment !== null) { + $result = queries("ALTER TABLE " . table($table) . " MODIFY COMMENT " . q($comment)); } - return ($alter || $partitioning ? queries("ALTER TABLE " . table($table) . "\n" . implode(",\n", $alter) . $partitioning) : true); + return $result; } function truncate_tables($tables) { @@ -245,7 +454,7 @@ function truncate_tables($tables) { } function drop_views($views) { - return drop_tables($views); + return apply_queries("DROP VIEW", $views); } function drop_tables($tables) { @@ -253,18 +462,18 @@ function drop_tables($tables) { } function get_databases($flush) { - $result = get_rows('SHOW DATABASES'); - - $return = array(); - foreach ($result as $row) { - $return[] = $row['name']; + $return = get_session("dbs"); + if ($flush || $return === null) { + $return = get_vals("SELECT name FROM system.databases ORDER BY name"); + restart_session(); + set_session("dbs", $return); + stop_session(); } - sort($return); return $return; } function limit($query, $where, $limit, $offset = 0, $separator = " ") { - return " $query$where" . ($limit ? $separator . "LIMIT " . ($offset ? "$offset, " : "") . $limit : ""); + return " $query$where" . ($limit ? $separator . "LIMIT $limit" . ($offset ? " OFFSET $offset" : "") : ""); } function limit1($table, $query, $where, $separator = "\n") { @@ -272,41 +481,69 @@ function limit1($table, $query, $where, $separator = "\n") { } function db_collation($db, $collations) { + return null; } function logged_user() { - $credentials = adminer()->credentials(); - return $credentials[1]; + return get_val("SELECT currentUser()"); } function tables_list() { - $result = get_rows('SHOW TABLES'); + $result = get_rows( + "SELECT name, engine FROM system.tables WHERE database = " . q(connection()->_db) . " ORDER BY name" + ); $return = array(); foreach ($result as $row) { - $return[$row['name']] = 'table'; + $return[$row['name']] = ($row['engine'] === 'View' ? 'VIEW' : 'TABLE'); } - ksort($return); return $return; } function count_tables($databases) { - return array(); + $return = array_fill_keys($databases, 0); + if (!$databases) { + return $return; + } + $quoted = array_map('Adminer\q', $databases); + foreach ( + get_rows( + "SELECT database, count() AS tables FROM system.tables " + . "WHERE database IN (" . implode(", ", $quoted) . ") GROUP BY database" + ) as $row + ) { + $return[$row['database']] = $row['tables']; + } + return $return; } function table_status($name = "", $fast = false) { $return = array(); - $tables = get_rows("SELECT name, engine FROM system.tables WHERE database = " . q(connection()->_db) . ($name != "" ? " AND name = " . q($name) : "")); - foreach ($tables as $table) { - $return[$table['name']] = array( - 'Name' => $table['name'], - 'Engine' => $table['engine'], + $tables = get_rows( + "SELECT name, engine, total_rows, total_bytes, comment, sorting_key " + . "FROM system.tables WHERE database = " . q(connection()->_db) + . ($name != "" ? " AND name = " . q($name) : " ORDER BY name") + ); + foreach ($tables as $row) { + $return[$row['name']] = array( + 'Name' => $row['name'], + 'Engine' => $row['engine'], + 'Comment' => $row['comment'], + 'Rows' => $row['total_rows'], + 'Data_length' => $row['total_bytes'], + 'Index_length' => 0, + 'Data_free' => 0, + 'Auto_increment' => '', + 'Collation' => '', + 'Create_options' => ($row['sorting_key'] ? "ORDER BY $row[sorting_key]" : ''), ); } return $return; } function is_view($table_status) { - return false; + // Adminer's generic editor can safely replace ordinary views. Materialized + // views need ClickHouse-specific ENGINE/TO clauses, so expose them as tables. + return $table_status['Engine'] === 'View'; } function fk_support($table_status) { @@ -317,56 +554,241 @@ function convert_field($field) { } function unconvert_field($field, $return) { - if (in_array($field['type'], array("Int8", "Int16", "Int32", "Int64", "UInt8", "UInt16", "UInt32", "UInt64", "Float32", "Float64"))) { - return "to$field[type]($return)"; + if ($return !== "NULL" && in_array($field['type'], array("Array", "Map", "Tuple"), true)) { + return "JSONExtract($return, " . q($field['full_type']) . ")"; + } + if ($return !== "NULL" && $field['full_type'] !== "String") { + return "CAST($return AS $field[full_type])"; } return $return; } function fields($table) { $return = array(); - $result = get_rows("SELECT name, type, default_expression FROM system.columns WHERE " . idf_escape('table') . " = " . q($table)); + $result = get_rows( + "SELECT c.name, c.type, c.default_kind, c.default_expression, c.comment, " + . "c.is_in_primary_key, c.is_in_sorting_key, t.engine AS table_engine " + . "FROM system.columns AS c LEFT JOIN system.tables AS t " + . "ON c.database = t.database AND c." . idf_escape('table') . " = t.name " + . "WHERE c.database = " . q(connection()->_db) + . " AND c." . idf_escape('table') . " = " . q($table) + . " ORDER BY c.position" + ); foreach ($result as $row) { - $type = trim($row['type']); - $nullable = preg_match('~^Nullable\((.+)\)$~', $type, $match); - if ($nullable) { - $type = $match[1]; // the NULL checkbox is displayed instead + $return[trim($row['name'])] = clickhouse_field($row); + } + return $return; + } + + function indexes($table, $connection2 = null) { + // Only expose these on the structure page. ClickHouse primary and sorting + // keys are not unique, so returning them during row editing would make + // Adminer build unsafe, incomplete WHERE clauses. + if (!isset($_GET["table"])) { + return array(); + } + + $conn = connection($connection2); + $return = array(); + $rows = get_rows( + "SELECT primary_key, sorting_key FROM system.tables " + . "WHERE database = " . q($conn->_db) . " AND name = " . q($table), + $connection2 + ); + if ($rows) { + $row = $rows[0]; + if ($row["primary_key"] !== "") { + $return["PRIMARY KEY"] = clickhouse_index("PRIMARY", $row["primary_key"]); + } + if ($row["sorting_key"] !== "" && $row["sorting_key"] !== $row["primary_key"]) { + $return["SORTING KEY"] = clickhouse_index("SORTING KEY", $row["sorting_key"]); } - $default = trim($row['default_expression']); - $return[trim($row['name'])] = array( - "field" => trim($row['name']), - "full_type" => $type, - "type" => $type, - "default" => ($default != "" ? preg_replace("~^'(.*)'$~", '$1', $default) : null), // null - no default value - "null" => $nullable, - "auto_increment" => '0', - "privileges" => array("insert" => 1, "select" => 1, "update" => 0, "where" => 1, "order" => 1), - ); } + if (get_val("EXISTS TABLE system.data_skipping_indices", 0, $connection2)) { + $rows = get_rows( + "SELECT name, expr, type_full, granularity FROM system.data_skipping_indices " + . "WHERE database = " . q($conn->_db) . " AND " . idf_escape('table') . " = " . q($table) + . " ORDER BY name", + $connection2 + ); + foreach ($rows as $row) { + $definition = "$row[expr] TYPE $row[type_full] GRANULARITY $row[granularity]"; + $return[$row["name"]] = clickhouse_index("INDEX", $definition); + } + } return $return; } - function indexes($table, $connection2 = null) { - return array(); + function clickhouse_index($type, $definition) { + return array( + "type" => $type, + "columns" => array($definition), + "lengths" => array(null), + "descs" => array(null), + "algorithm" => "", + "partial" => "", + ); + } + + function alter_indexes($table, $alter) { + connection()->error = 'ClickHouse index editing is disabled in Adminer; use the SQL command page.'; + return false; } function foreign_keys($table) { return array(); } + function view($name) { + $create = get_val( + "SELECT create_table_query FROM system.tables WHERE database = " . q(connection()->_db) + . " AND name = " . q($name) + ); + if (preg_match('~\s+AS\s+(?=(?:SELECT|WITH)\b)~i', $create, $match, PREG_OFFSET_CAPTURE)) { + $offset = $match[0][1] + strlen($match[0][0]); + return array("select" => substr($create, $offset)); + } + return array("select" => ""); + } + function collations() { return array(); } function information_schema($db) { - return false; + return in_array($db, array("system", "information_schema", "INFORMATION_SCHEMA"), true); } function error() { return h(connection()->error); } + function create_database($db, $collation) { + $return = queries("CREATE DATABASE " . idf_escape($db)); + if ($return) { + restart_session(); + set_session("dbs", null); + } + return $return; + } + + function drop_databases($databases) { + $return = apply_queries("DROP DATABASE", $databases, 'Adminer\idf_escape'); + restart_session(); + set_session("dbs", null); + return $return; + } + + function rename_database($name, $collation) { + $return = queries("RENAME DATABASE " . idf_escape(connection()->_db) . " TO " . idf_escape($name)); + if ($return) { + connection()->_db = $name; + restart_session(); + set_session("dbs", null); + } + return (bool) $return; + } + + function move_tables($tables, $views, $target) { + $source = connection()->_db; + foreach (array_merge($tables, $views) as $name) { + if ( + !queries( + "RENAME TABLE " . clickhouse_qualified($source, $name) + . " TO " . clickhouse_qualified($target, $name) + ) + ) { + return false; + } + } + return true; + } + + function copy_tables($tables, $views, $target) { + $source = connection()->_db; + $overwrite = !empty($_POST["overwrite"]); + foreach ($tables as $name) { + $destination = clickhouse_qualified($target, $name); + if ( + ($overwrite && !queries("DROP TABLE IF EXISTS $destination")) + || !queries("CREATE TABLE $destination AS " . clickhouse_qualified($source, $name)) + || !queries("INSERT INTO $destination SELECT * FROM " . clickhouse_qualified($source, $name)) + ) { + return false; + } + } + foreach ($views as $name) { + $destination = clickhouse_qualified($target, $name); + $definition = view($name); + if ( + ($overwrite && !queries("DROP VIEW IF EXISTS $destination")) + || !$definition["select"] + || !queries("CREATE VIEW $destination AS $definition[select]") + ) { + return false; + } + } + return true; + } + + function create_sql($table, $auto_increment, $style) { + return get_val( + "SELECT create_table_query FROM system.tables WHERE database = " . q(connection()->_db) + . " AND name = " . q($table) + ); + } + + function truncate_sql($table) { + return "TRUNCATE TABLE " . table($table); + } + + function use_sql($database, $style = "") { + $name = idf_escape($database); + $return = ""; + if (preg_match('~CREATE~', $style)) { + if ($style === "DROP+CREATE") { + $return .= "DROP DATABASE IF EXISTS $name;\n"; + } + $return .= "CREATE DATABASE IF NOT EXISTS $name;\n"; + } + return $return . "USE $name"; + } + + function show_variables() { + return get_rows( + "SELECT name, value, changed, description FROM system.settings ORDER BY name" + ); + } + + function show_status() { + return get_rows( + "SELECT metric AS name, toString(value) AS value, description " + . "FROM system.metrics ORDER BY metric" + ); + } + + function process_list() { + return get_rows( + "SELECT query_id AS pid, user, address, elapsed, read_rows, read_bytes, " + . "written_rows, written_bytes, memory_usage, query " + . "FROM system.processes ORDER BY elapsed DESC" + ); + } + + function kill_process($id) { + return queries("KILL QUERY WHERE query_id = " . q($id) . " SYNC"); + } + + function connection_id() { + return "SELECT query_id FROM system.processes WHERE query_id != '' ORDER BY elapsed DESC LIMIT 1"; + } + + function max_connections() { + $value = get_val("SELECT value FROM system.settings WHERE name = 'max_concurrent_queries'"); + return ($value === false ? "0" : $value); + } + function types(): array { return array(); } @@ -380,6 +802,12 @@ function last_id($result) { } function support($feature) { - return preg_match("~^(columns|sql|status|table|drop_col)$~", $feature); + if ($feature === "indexes") { + return isset($_GET["table"]); + } + return (bool) preg_match( + "~^(columns|comment|copy|database|drop_col|dump|kill|move_col|processlist|sql|status|table|variables|view)$~", + $feature + ); } }