error = ''; $this->errno = 0; $this->affected_rows = 0; list($file, $status, $headers, $error) = get_url($this->url . "/?database=" . rawurlencode($db), stream_context_create(array('http' => array( 'method' => 'POST', 'content' => $query, 'header' => array( 'Authorization: Basic ' . $this->authorization, 'Content-Type: text/plain; charset=UTF-8', 'X-ClickHouse-Format: JSONCompact', ), 'ignore_errors' => 1, 'follow_location' => 0, 'max_redirects' => 0, )))); if ($status == 401 || $status == 403) { $this->error = lang('Invalid credentials.'); $this->errno = $status; return false; } if ($file === false) { $this->error = ($error ?: 'Unable to connect to the ClickHouse server.'); return false; } if ($status < 200 || $status >= 300) { if (preg_match('~Code:\s*(\d+)~', $file, $match)) { $this->errno = (int) $match[1]; } else { $this->errno = (int) $status; } $this->error = trim($file); if ($this->error === '') { $this->error = "ClickHouse HTTP error $status."; } return false; } foreach ($headers as $header) { // the header repeats with progress, the last one is final; it is missing for some commands if (preg_match('~^X-ClickHouse-Summary:\s*(.+)~i', $header, $match)) { $this->affected_rows = (int) idx((array) json_decode($match[1], true), 'written_rows', 0); } } if (trim($file) === '') { return true; } $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 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 { $this->url = rtrim((preg_match('~^https?://~i', $server) ? $server : "http://$server"), '/'); if (!preg_match('~:\d+$~', $this->url)) { // connect() allows no path so this can be only the port $this->url .= (preg_match('~^https://~i', $this->url) ? ":8443" : ":8123"); } $this->authorization = base64_encode("$username:$password"); $return = $this->query('SELECT version()'); if (!$return) { return $this->error; } $row = $return->fetch_row(); $this->server_info = ($row ? $row[0] : ''); return ''; } function select_db($database) { $this->_db = $database; return true; } function quote($string): 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(), $rowOffset = 0, $fieldOffset = 0; function __construct($result) { $this->meta = (array) $result['meta']; foreach ((array) $result['data'] as $item) { $row = array(); 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, 256 | 64) // JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES available since PHP 5.4 ); } $this->rows[] = $row; } $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 } 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() { if (!isset($this->rows[$this->rowOffset])) { return false; } return array_combine($this->columns, $this->rows[$this->rowOffset++]); } function fetch_row() { return (isset($this->rows[$this->rowOffset]) ? $this->rows[$this->rowOffset++] : false); } function fetch_field(): \stdClass { $column = $this->fieldOffset++; $return = new \stdClass; if ($column < count($this->columns)) { $return->name = $this->meta[$column]['name']; $return->type = $this->meta[$column]['type']; $return->charsetnr = 0; } return $return; } function seek($offset) { $this->rowOffset = max(0, (int) $offset); } } } class Driver extends SqlDriver { static $extensions = array("allow_url_fopen"); static $jush = "clickhouse"; 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?://)?(\[[\da-f:.]+\]|[-\w.]+)(:\d+)?/?$~i', $server)) { return lang('Invalid server.'); } return parent::connect($server, $username, $password); } function hasCStyleEscapes(): bool { return true; } function supportsAlterIndex(array $table_status): bool { return false; // indexes are listed for information only, altering them requires ClickHouse specific syntax } function slowQuery(string $query, int $timeout) { return "$query SETTINGS max_execution_time = $timeout"; } function __construct(Db $connection) { parent::__construct($connection); $this->types = array( lang('Numbers') => array( "Int8" => 3, "Int16" => 5, "Int32" => 10, "Int64" => 19, "UInt8" => 3, "UInt16" => 5, "UInt32" => 10, "UInt64" => 20, "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, ), ); } 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(): array { $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'; } return queries("ALTER TABLE " . table($table) . " DELETE $queryWhere"); } function update($table, array $set, $queryWhere, $limit = 0, $separator = "\n") { $values = array(); foreach ($set as $key => $val) { $values[] = "$key = $val"; } $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) { return "`" . str_replace("`", "``", $idf) . "`"; } 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 $connection->query("EXPLAIN $query"); } function found_rows($table_status, $where) { 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) { if ($table === "") { $definitions = array(); foreach ($fields as $field) { if (!empty($field[1])) { $definitions[] = clickhouse_field_definition($field[1]); } } $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; } 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; } if ($table !== $name) { if (!queries("RENAME TABLE " . table($table) . " TO " . table($name))) { return false; } $table = $name; } $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 ($field[0] != "" && $field[1][3] == "" && min_version("20.10")) { // MODIFY COLUMN without DEFAULT keeps the original default value, $newName is used because the column is already renamed if (!queries("ALTER TABLE " . table($table) . " MODIFY COLUMN $newName REMOVE DEFAULT")) { return false; } } } if ($comment !== null) { $result = queries("ALTER TABLE " . table($table) . " MODIFY COMMENT " . q($comment)); } return $result; } function truncate_tables($tables) { return apply_queries("TRUNCATE TABLE", $tables); } function drop_views($views) { return apply_queries("DROP VIEW", $views); } function drop_tables($tables) { return apply_queries("DROP TABLE", $tables); } function get_databases($flush) { $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(); } return $return; } function limit($query, $where, $limit, $offset = 0, $separator = " ") { return " $query$where" . ($limit ? $separator . "LIMIT $limit" . ($offset ? " OFFSET $offset" : "") : ""); } function limit1($table, $query, $where, $separator = "\n") { return limit($query, $where, 1, 0, $separator); } function db_collation($db, $collations) { return null; } function logged_user() { return get_val("SELECT currentUser()"); } function tables_list() { $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']] = ($row['engine'] === 'View' ? 'VIEW' : 'TABLE'); } return $return; } function count_tables($databases) { $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, 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) { // 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) { return false; } function convert_field($field) { } function unconvert_field($field, $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 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) { $return[trim($row['name'])] = clickhouse_field($row); } return $return; } function indexes($table, $connection2 = null) { $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]; // the types are not PRIMARY so that unique_array() doesn't identify rows by them, ClickHouse keys are not unique if ($row["primary_key"] !== "") { $return["PRIMARY KEY"] = clickhouse_index("PRIMARY KEY", $row["primary_key"]); } if ($row["sorting_key"] !== "" && $row["sorting_key"] !== $row["primary_key"]) { $return["SORTING KEY"] = clickhouse_index("SORTING KEY", $row["sorting_key"]); } } static $skipping; // the answer is the same for the whole request, indexes() is called for each table in the schema if ($skipping === null) { $skipping = (bool) get_val("EXISTS TABLE system.data_skipping_indices", 0, $connection2); } if ($skipping) { $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 clickhouse_index($type, $definition) { return array( "type" => $type, "columns" => array($definition), "lengths" => array(null), "descs" => array(null), "algorithm" => "", "partial" => "", ); } function alter_indexes($table, $alter) { // the page is not linked anywhere, see Driver::supportsAlterIndex() connection()->error = 'ClickHouse indexes cannot be altered by 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 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 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(); } function auto_increment() { return ''; } function last_id($result) { return 0; // ClickHouse doesn't have it } function support($feature) { return (bool) preg_match( "~^(columns|comment|copy|database|drop_col|dump|indexes|kill|move_col|processlist|sql|status|table|variables|view)$~", $feature ); } }