дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
feat(database): add ClickHouse data sources (#865)
Adds ClickHouse to Data Sources over its HTTP interface (HTTP/HTTPS, SSH tunnels, session settings, server-side cancellation): browse databases, tables and columns, inspect DDL, run queries and EXPLAIN plans, with ClickHouse dialect completion. Table data stays read-only and manual transactions are rejected, since ClickHouse keys are not unique and HTTP has no transactions. Results are streamed as JSONCompactEachRowWithNamesAndTypes and parsing stops at the row limit, because result_overflow_mode=break still returns a whole block; errors show only the server's "Code: …" message, including failures raised mid-stream. Fixes #862 Co-authored-by: kkMihai <[email protected]>
Цей коміт міститься в:
1 джерело
c5e188d1e3
коміт
e04e5dd57d
39 files changed
+1921
-120
No files matched your search
Створено
+23
@@ -4154,8 +4154,11 @@ dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"futures",
|
||||
"ide-core",
|
||||
"ide-http",
|
||||
"ide-ssh",
|
||||
"parking_lot",
|
||||
"reqwest",
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
@@ -4166,6 +4169,7 @@ dependencies = [
|
||||
"tokio-util",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7133,6 +7137,7 @@ dependencies = [
|
||||
"rustls-platform-verifier",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
@@ -7491,6 +7496,12 @@ dependencies = [
|
||||
"wait-timeout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "saa"
|
||||
version = "5.6.2"
|
||||
@@ -7795,6 +7806,18 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_with"
|
||||
version = "3.24.0"
|
||||
|
||||
@@ -8,7 +8,7 @@ license.workspace = true
|
||||
[dependencies]
|
||||
ide-core.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
serde_json = { workspace = true, features = ["raw_value"] }
|
||||
specta.workspace = true
|
||||
toml.workspace = true
|
||||
tokio.workspace = true
|
||||
@@ -21,6 +21,10 @@ bb8.workspace = true
|
||||
tokio-util = { workspace = true, features = ["compat"] }
|
||||
dirs.workspace = true
|
||||
ide-ssh.workspace = true
|
||||
ide-http.workspace = true
|
||||
reqwest = { workspace = true, features = ["query"] }
|
||||
rustls.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||
|
||||
@@ -0,0 +1,844 @@
|
||||
use crate::conn::{QueryResult, ResultColumn, Setup};
|
||||
use crate::source::{DataSource, DbSslMode, DbTxMode};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult, error::ErrorKind};
|
||||
use parking_lot::RwLock;
|
||||
use reqwest::{Client, RequestBuilder, StatusCode, Url};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ClickhousePool(Arc<State>);
|
||||
|
||||
struct State {
|
||||
client: RwLock<Option<Client>>,
|
||||
endpoint: Url,
|
||||
user: String,
|
||||
password: String,
|
||||
database: String,
|
||||
settings: Vec<(String, String)>,
|
||||
read_only: bool,
|
||||
session_id: String,
|
||||
session: Mutex<bool>,
|
||||
closed: CancellationToken,
|
||||
}
|
||||
|
||||
/// The response format: a names line, a types line, then one JSON array per row, so rows past
|
||||
/// the grid's limit are skipped without being parsed.
|
||||
const FORMAT: &str = "JSONCompactEachRowWithNamesAndTypes";
|
||||
|
||||
pub(crate) fn transaction_error() -> IdeError {
|
||||
IdeError::invalid("ClickHouse HTTP does not support SQL transactions; use auto-commit and run writes directly")
|
||||
}
|
||||
|
||||
impl ClickhousePool {
|
||||
pub(crate) async fn connect(source: &DataSource, password: Option<&str>, setup: Setup) -> IdeResult<Self> {
|
||||
if source.options.transaction == DbTxMode::Manual {
|
||||
return Err(transaction_error());
|
||||
}
|
||||
for (name, _) in &setup.params {
|
||||
if reserved_setting(name) {
|
||||
return Err(IdeError::invalid(format!("ClickHouse HTTP manages the {name} setting")));
|
||||
}
|
||||
}
|
||||
let secure = matches!(source.ssl, DbSslMode::Require | DbSslMode::VerifyCa | DbSslMode::VerifyFull);
|
||||
let tls_files = [&source.ssl_ca_file, &source.ssl_cert_file, &source.ssl_key_file];
|
||||
if !secure && tls_files.iter().any(|p| !p.trim().is_empty()) {
|
||||
return Err(IdeError::invalid(
|
||||
"ClickHouse TLS certificate files require an SSL mode of Require, Verify CA, or Verify Full",
|
||||
));
|
||||
}
|
||||
let host = source.host.trim();
|
||||
if host.is_empty() || host.contains(['/', '@', '?', '#']) {
|
||||
return Err(IdeError::invalid("ClickHouse host must contain only a hostname or IP address"));
|
||||
}
|
||||
let host = if host.contains(':') && !host.starts_with('[') { format!("[{host}]") } else { host.to_owned() };
|
||||
let scheme = if secure { "https" } else { "http" };
|
||||
let port = source.port.unwrap_or(if secure { 8443 } else { 8123 });
|
||||
let endpoint = Url::parse(&format!("{scheme}://{host}:{port}/"))
|
||||
.map_err(|_| IdeError::invalid("invalid ClickHouse HTTP host or port"))?;
|
||||
let timeout = setup.connect_timeout.unwrap_or(Duration::from_secs(30));
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
let mut builder = Client::builder()
|
||||
.connect_timeout(timeout)
|
||||
.pool_max_idle_per_host(4)
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.user_agent(concat!("OpenIDEA/", env!("CARGO_PKG_VERSION")));
|
||||
let ca = source.ssl_ca_file.trim();
|
||||
if !ca.is_empty() {
|
||||
let pem = read_pem(ca, "CA certificate")?;
|
||||
let certificates = reqwest::Certificate::from_pem_bundle(&pem)
|
||||
.map_err(|_| IdeError::invalid("invalid ClickHouse CA certificate PEM"))?;
|
||||
if certificates.is_empty() {
|
||||
return Err(IdeError::invalid("ClickHouse CA certificate PEM contains no certificates"));
|
||||
}
|
||||
builder = builder.tls_certs_only(certificates);
|
||||
}
|
||||
if source.ssl == DbSslMode::VerifyCa {
|
||||
if ca.is_empty() {
|
||||
return Err(IdeError::invalid("ClickHouse Verify CA requires a CA certificate file"));
|
||||
}
|
||||
builder = builder.tls_danger_accept_invalid_hostnames(true);
|
||||
}
|
||||
let cert = source.ssl_cert_file.trim();
|
||||
let key = source.ssl_key_file.trim();
|
||||
if cert.is_empty() != key.is_empty() {
|
||||
return Err(IdeError::invalid("ClickHouse client TLS requires both certificate and key files"));
|
||||
}
|
||||
if !cert.is_empty() {
|
||||
let mut pem = read_pem(cert, "client certificate")?;
|
||||
pem.push(b'\n');
|
||||
pem.extend(read_pem(key, "client key")?);
|
||||
let identity = reqwest::Identity::from_pem(&pem)
|
||||
.map_err(|_| IdeError::invalid("invalid ClickHouse client certificate or key PEM"))?;
|
||||
builder = builder.identity(identity);
|
||||
}
|
||||
let proxy = ide_core::proxy_settings::proxy();
|
||||
let client = ide_http::client::with_proxy(builder, &proxy).build().map_err(http_error)?;
|
||||
let database = if source.options.default_schema.trim().is_empty() {
|
||||
source.database.trim()
|
||||
} else {
|
||||
source.options.default_schema.trim()
|
||||
};
|
||||
let pool = Self(Arc::new(State {
|
||||
client: RwLock::new(Some(client)),
|
||||
endpoint,
|
||||
user: if source.user.is_empty() { "default".into() } else { source.user.clone() },
|
||||
password: password.unwrap_or_default().to_owned(),
|
||||
database: if database.is_empty() { "default".into() } else { database.into() },
|
||||
settings: setup.params,
|
||||
read_only: source.read_only,
|
||||
session_id: uuid::Uuid::new_v4().to_string(),
|
||||
session: Mutex::new(false),
|
||||
closed: CancellationToken::new(),
|
||||
}));
|
||||
let cancel = CancellationToken::new();
|
||||
let initialize = async {
|
||||
pool.execute("SELECT 1", 1, &cancel).await?;
|
||||
for sql in setup.statements {
|
||||
pool.execute(&sql, 0, &cancel).await?;
|
||||
}
|
||||
Ok(pool.clone())
|
||||
};
|
||||
tokio::pin!(initialize);
|
||||
tokio::select! {
|
||||
result = &mut initialize => {
|
||||
if result.is_err() {
|
||||
pool.close();
|
||||
}
|
||||
result
|
||||
},
|
||||
() = tokio::time::sleep(timeout) => {
|
||||
cancel.cancel();
|
||||
let _ = initialize.await;
|
||||
pool.close();
|
||||
Err(IdeError::new(ErrorKind::TimedOut, "timed out connecting to ClickHouse HTTP"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn close(&self) {
|
||||
self.0.closed.cancel();
|
||||
self.0.client.write().take();
|
||||
}
|
||||
|
||||
pub fn is_closed(&self) -> bool {
|
||||
self.0.closed.is_cancelled()
|
||||
}
|
||||
|
||||
fn client(&self) -> IdeResult<Client> {
|
||||
self.0.client.read().clone().ok_or_else(|| IdeError::new(ErrorKind::Io, "ClickHouse connection is closed"))
|
||||
}
|
||||
|
||||
fn request(&self, client: &Client, query_id: &str, max_rows: usize, initialized: bool) -> RequestBuilder {
|
||||
let mut request = client
|
||||
.post(self.0.endpoint.clone())
|
||||
.basic_auth(&self.0.user, Some(&self.0.password))
|
||||
.header(reqwest::header::CONTENT_TYPE, "text/plain; charset=utf-8")
|
||||
.query(&self.0.settings)
|
||||
.query(&[
|
||||
("session_id", self.0.session_id.clone()),
|
||||
("session_timeout", "3600".into()),
|
||||
("query_id", query_id.to_owned()),
|
||||
("default_format", FORMAT.into()),
|
||||
("output_format_json_quote_denormals", "1".into()),
|
||||
("max_result_rows", max_rows.saturating_add(1).to_string()),
|
||||
("result_overflow_mode", "break".into()),
|
||||
]);
|
||||
if initialized {
|
||||
request = request.query(&[("session_check", "1")]);
|
||||
} else {
|
||||
request = request.query(&[("database", &self.0.database)]);
|
||||
}
|
||||
if self.0.read_only {
|
||||
request = request.query(&[("readonly", "1")]);
|
||||
}
|
||||
request
|
||||
}
|
||||
|
||||
pub async fn execute(&self, sql: &str, max_rows: usize, cancel: &CancellationToken) -> IdeResult<QueryResult> {
|
||||
if transaction_statement(sql) {
|
||||
return Err(transaction_error());
|
||||
}
|
||||
let mut session = tokio::select! {
|
||||
biased;
|
||||
() = cancel.cancelled() => return Err(IdeError::cancelled()),
|
||||
() = self.0.closed.cancelled() => return Err(IdeError::new(ErrorKind::Io, "ClickHouse connection is closed")),
|
||||
session = self.0.session.lock() => session,
|
||||
};
|
||||
let client = self.client()?;
|
||||
let query_id = uuid::Uuid::new_v4().to_string();
|
||||
let request = self.request(&client, &query_id, max_rows, *session).body(sql.to_owned());
|
||||
let started = Instant::now();
|
||||
let fetch = async {
|
||||
let mut response = request.send().await.map_err(http_error)?;
|
||||
let execution_ms = started.elapsed().as_secs_f64() * 1000.0;
|
||||
let status = response.status();
|
||||
let header = |name: &str| response.headers().get(name).and_then(|v| v.to_str().ok()).map(str::to_owned);
|
||||
let exception_code = header("X-ClickHouse-Exception-Code");
|
||||
let format = header("X-ClickHouse-Format");
|
||||
let affected = header("X-ClickHouse-Summary")
|
||||
.and_then(|v| serde_json::from_str::<serde_json::Value>(&v).ok())
|
||||
.and_then(|v| {
|
||||
v.get("written_rows")
|
||||
.and_then(|n| n.as_str().and_then(|n| n.parse::<u64>().ok()).or_else(|| n.as_u64()))
|
||||
});
|
||||
if !status.is_success() || exception_code.as_deref().is_some_and(|code| code != "0") {
|
||||
let body = response.bytes().await.map_err(http_error)?;
|
||||
return Err(self.server_error(status, exception_code.as_deref(), &body));
|
||||
}
|
||||
let (columns, rows, has_more) = if format.as_deref().is_some_and(|f| f != FORMAT) {
|
||||
let body = response.bytes().await.map_err(http_error)?;
|
||||
if !body.iter().all(u8::is_ascii_whitespace) {
|
||||
return Err(IdeError::invalid(
|
||||
"ClickHouse result grids need the default output format; remove the query's FORMAT clause",
|
||||
));
|
||||
}
|
||||
(Vec::new(), Vec::new(), false)
|
||||
} else {
|
||||
let mut reader = RowReader::new(max_rows);
|
||||
while let Some(chunk) = response.chunk().await.map_err(http_error)? {
|
||||
reader.push(&chunk)?;
|
||||
}
|
||||
reader.finish().map_err(|error| self.redact_error(error))?
|
||||
};
|
||||
let affected = if columns.is_empty() { affected.map(|n| n as f64) } else { None };
|
||||
Ok(QueryResult {
|
||||
statement: sql.to_owned(),
|
||||
columns,
|
||||
rows,
|
||||
affected,
|
||||
has_more,
|
||||
elapsed_ms: started.elapsed().as_secs_f64() * 1000.0,
|
||||
execution_ms,
|
||||
})
|
||||
};
|
||||
let result = tokio::select! {
|
||||
biased;
|
||||
() = cancel.cancelled() => None,
|
||||
() = self.0.closed.cancelled() => None,
|
||||
result = fetch => Some(result),
|
||||
};
|
||||
match result {
|
||||
Some(result) => {
|
||||
if result.is_ok() {
|
||||
*session = true;
|
||||
}
|
||||
result
|
||||
}
|
||||
None => {
|
||||
let killed = self.kill(&client, &query_id).await;
|
||||
match killed {
|
||||
Ok(()) => Err(IdeError::cancelled()),
|
||||
Err(error) => Err(IdeError::new(
|
||||
ErrorKind::Cancelled,
|
||||
format!("cancelled locally; ClickHouse server cancellation failed: {}", error.message),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn kill(&self, client: &Client, query_id: &str) -> IdeResult<()> {
|
||||
let response = client
|
||||
.post(self.0.endpoint.clone())
|
||||
.basic_auth(&self.0.user, Some(&self.0.password))
|
||||
.timeout(Duration::from_secs(3))
|
||||
.body(format!("KILL QUERY WHERE query_id = '{query_id}' SYNC"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(http_error)?;
|
||||
let status = response.status();
|
||||
let code = response
|
||||
.headers()
|
||||
.get("X-ClickHouse-Exception-Code")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_owned);
|
||||
let body = response.bytes().await.map_err(http_error)?;
|
||||
if status.is_success() && !code.as_deref().is_some_and(|code| code != "0") && !body.starts_with(b"Code:") {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(self.server_error(status, code.as_deref(), &body))
|
||||
}
|
||||
}
|
||||
|
||||
fn redact_error(&self, mut error: IdeError) -> IdeError {
|
||||
if !self.0.password.is_empty() {
|
||||
error.message = error.message.replace(&self.0.password, "[redacted]");
|
||||
}
|
||||
error
|
||||
}
|
||||
|
||||
fn server_error(&self, status: StatusCode, code: Option<&str>, body: &[u8]) -> IdeError {
|
||||
let message = String::from_utf8_lossy(body);
|
||||
let message: String = exception_text(&message).chars().take(16_384).collect();
|
||||
let auth = matches!(status, StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN)
|
||||
|| matches!(code, Some("193" | "194" | "195" | "516" | "497"));
|
||||
let detail = if message.is_empty() { status.canonical_reason().unwrap_or("request failed") } else { &message };
|
||||
let detail = if detail.contains("SESSION_NOT_FOUND") || detail.contains("Session not found") {
|
||||
"ClickHouse HTTP session expired; reconnect to restore session settings"
|
||||
} else {
|
||||
detail
|
||||
};
|
||||
self.redact_error(IdeError::new(
|
||||
if auth { ErrorKind::PermissionDenied } else { ErrorKind::InvalidInput },
|
||||
format!("ClickHouse HTTP {}: {detail}", status.as_u16()),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
type DecodedResult = (Vec<ResultColumn>, Vec<Vec<Option<String>>>, bool);
|
||||
|
||||
/// Decodes a streamed [`FORMAT`] body. Keeps the first `max_rows` rows; later rows only set
|
||||
/// `has_more` and are read past unparsed, so a grid limit bounds memory however large the
|
||||
/// block `result_overflow_mode=break` lets the server send. A line that is not a JSON array
|
||||
/// starts the exception ClickHouse writes when a query fails mid-stream.
|
||||
struct RowReader {
|
||||
max_rows: usize,
|
||||
pending: Vec<u8>,
|
||||
names: Option<Vec<String>>,
|
||||
types: Option<Vec<String>>,
|
||||
rows: Vec<Vec<Option<String>>>,
|
||||
more: bool,
|
||||
exception: Option<String>,
|
||||
}
|
||||
|
||||
impl RowReader {
|
||||
fn new(max_rows: usize) -> Self {
|
||||
Self { max_rows, pending: Vec::new(), names: None, types: None, rows: Vec::new(), more: false, exception: None }
|
||||
}
|
||||
|
||||
fn push(&mut self, chunk: &[u8]) -> IdeResult<()> {
|
||||
self.pending.extend_from_slice(chunk);
|
||||
let buffer = std::mem::take(&mut self.pending);
|
||||
let mut start = 0;
|
||||
while let Some(end) = buffer[start..].iter().position(|&b| b == b'\n') {
|
||||
self.line(&buffer[start..start + end])?;
|
||||
start += end + 1;
|
||||
}
|
||||
self.pending = buffer[start..].to_vec();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn line(&mut self, line: &[u8]) -> IdeResult<()> {
|
||||
if let Some(exception) = &mut self.exception {
|
||||
if exception.len() < 16_384 {
|
||||
exception.push_str(&String::from_utf8_lossy(line));
|
||||
exception.push('\n');
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let text = line.trim_ascii();
|
||||
if text.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if text[0] != b'[' {
|
||||
self.exception = Some(format!("{}\n", String::from_utf8_lossy(line)));
|
||||
return Ok(());
|
||||
}
|
||||
if self.names.is_some() && self.types.is_some() && self.rows.len() >= self.max_rows {
|
||||
self.more = true;
|
||||
return Ok(());
|
||||
}
|
||||
let Ok(values) = serde_json::from_slice::<Vec<Box<serde_json::value::RawValue>>>(text) else {
|
||||
self.exception = Some(format!("{}\n", String::from_utf8_lossy(line)));
|
||||
return Ok(());
|
||||
};
|
||||
let protocol = |message: String| IdeError::new(ErrorKind::Protocol, message);
|
||||
let header = |values: Vec<Box<serde_json::value::RawValue>>| {
|
||||
values
|
||||
.iter()
|
||||
.map(|v| serde_json::from_str::<String>(v.get()))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| protocol(format!("invalid ClickHouse result header: {e}")))
|
||||
};
|
||||
let Some(names) = &self.names else {
|
||||
self.names = Some(header(values)?);
|
||||
return Ok(());
|
||||
};
|
||||
if self.types.is_none() {
|
||||
self.types = Some(header(values)?);
|
||||
return Ok(());
|
||||
}
|
||||
if values.len() != names.len() {
|
||||
return Err(protocol("ClickHouse result row width does not match its metadata".into()));
|
||||
}
|
||||
let row = values
|
||||
.into_iter()
|
||||
.map(|value| {
|
||||
let text = value.get();
|
||||
if text == "null" {
|
||||
Ok(None)
|
||||
} else if text.starts_with('"') {
|
||||
serde_json::from_str::<String>(text).map(Some).map_err(|e| protocol(e.to_string()))
|
||||
} else {
|
||||
Ok(Some(text.to_owned()))
|
||||
}
|
||||
})
|
||||
.collect::<IdeResult<_>>()?;
|
||||
self.rows.push(row);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finish(mut self) -> IdeResult<DecodedResult> {
|
||||
let rest = std::mem::take(&mut self.pending);
|
||||
self.line(&rest)?;
|
||||
if let Some(exception) = self.exception {
|
||||
return Err(IdeError::invalid(format!("ClickHouse: {}", exception_text(&exception))));
|
||||
}
|
||||
let Some(names) = self.names else { return Ok((Vec::new(), Vec::new(), false)) };
|
||||
let types = self.types.unwrap_or_default();
|
||||
if types.len() != names.len() {
|
||||
return Err(IdeError::new(ErrorKind::Protocol, "ClickHouse result types do not match its columns"));
|
||||
}
|
||||
let columns = names.into_iter().zip(types).map(|(name, type_name)| ResultColumn { name, type_name }).collect();
|
||||
Ok((columns, self.rows, self.more))
|
||||
}
|
||||
}
|
||||
|
||||
/// The server's message in a failed response: rows already streamed and the
|
||||
/// `__exception__` block markers ClickHouse wraps a mid-stream error in are dropped.
|
||||
fn exception_text(body: &str) -> &str {
|
||||
let Some(at) = body.find("Code:") else { return body.trim() };
|
||||
let text = &body[at..];
|
||||
match text.find("\n__exception__") {
|
||||
// The line before the closing marker is `<length> <tag>`.
|
||||
Some(end) => text[..end].trim_end().rsplit_once('\n').map_or(text[..end].trim(), |(message, _)| message.trim()),
|
||||
None => text.trim(),
|
||||
}
|
||||
}
|
||||
|
||||
fn http_error(error: reqwest::Error) -> IdeError {
|
||||
use std::error::Error;
|
||||
let kind = if error.is_timeout() { ErrorKind::TimedOut } else { ErrorKind::Io };
|
||||
let error = error.without_url();
|
||||
let mut message = error.to_string();
|
||||
let mut source = error.source();
|
||||
while let Some(error) = source {
|
||||
let detail = error.to_string();
|
||||
if !message.contains(&detail) {
|
||||
message.push_str(": ");
|
||||
message.push_str(&detail);
|
||||
}
|
||||
source = error.source();
|
||||
}
|
||||
IdeError::new(kind, format!("ClickHouse HTTP: {message}"))
|
||||
}
|
||||
|
||||
fn read_pem(path: &str, label: &str) -> IdeResult<Vec<u8>> {
|
||||
std::fs::read(ide_ssh::expand(path))
|
||||
.map_err(|error| IdeError::new(ErrorKind::Io, format!("cannot read ClickHouse {label}: {error}")))
|
||||
}
|
||||
|
||||
fn reserved_setting(name: &str) -> bool {
|
||||
let name = name.to_ascii_lowercase();
|
||||
name.starts_with("output_format_json_")
|
||||
|| matches!(
|
||||
name.as_str(),
|
||||
"user"
|
||||
| "password"
|
||||
| "query"
|
||||
| "database"
|
||||
| "session_id"
|
||||
| "session_check"
|
||||
| "session_timeout"
|
||||
| "query_id"
|
||||
| "default_format"
|
||||
| "max_result_rows"
|
||||
| "result_overflow_mode"
|
||||
| "readonly"
|
||||
)
|
||||
}
|
||||
|
||||
fn transaction_statement(sql: &str) -> bool {
|
||||
let mut sql = sql.trim_start();
|
||||
loop {
|
||||
if let Some(rest) = sql.strip_prefix("--").or_else(|| sql.strip_prefix('#')) {
|
||||
sql = rest.split_once('\n').map_or("", |(_, rest)| rest).trim_start();
|
||||
} else if let Some(rest) = sql.strip_prefix("/*") {
|
||||
sql = rest.split_once("*/").map_or("", |(_, rest)| rest).trim_start();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let mut words = sql.split(|c: char| !c.is_ascii_alphanumeric() && c != '_').filter(|s| !s.is_empty());
|
||||
let first = words.next().unwrap_or_default();
|
||||
["BEGIN", "COMMIT", "ROLLBACK", "SAVEPOINT", "RELEASE"].iter().any(|s| first.eq_ignore_ascii_case(s))
|
||||
|| (first.eq_ignore_ascii_case("START") && words.next().is_some_and(|s| s.eq_ignore_ascii_case("TRANSACTION")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::conn::Pool;
|
||||
use crate::source::{DbDriver, DbOptions, DbProperty};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
const ONE: &str = "[\"1\"]\n[\"UInt8\"]\n[1]\n";
|
||||
|
||||
async fn read_request(stream: &mut tokio::net::TcpStream) -> String {
|
||||
let mut bytes = Vec::new();
|
||||
let mut buffer = [0; 4096];
|
||||
loop {
|
||||
let count = tokio::time::timeout(Duration::from_secs(5), stream.read(&mut buffer)).await.unwrap().unwrap();
|
||||
assert!(count > 0, "request ended early");
|
||||
bytes.extend_from_slice(&buffer[..count]);
|
||||
if let Some(end) = bytes.windows(4).position(|s| s == b"\r\n\r\n") {
|
||||
let header = String::from_utf8_lossy(&bytes[..end]);
|
||||
let length: usize = header
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
line.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse().unwrap())
|
||||
})
|
||||
.unwrap_or(0);
|
||||
if bytes.len() >= end + 4 + length {
|
||||
return String::from_utf8(bytes).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn reply(stream: &mut tokio::net::TcpStream, status: &str, headers: &str, body: &str) {
|
||||
let response =
|
||||
format!("HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n{headers}\r\n{body}", body.len());
|
||||
stream.write_all(response.as_bytes()).await.unwrap();
|
||||
}
|
||||
|
||||
async fn server(
|
||||
responses: Vec<(&'static str, &'static str, &'static str)>,
|
||||
) -> (u16, tokio::task::JoinHandle<Vec<String>>) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let task = tokio::spawn(async move {
|
||||
let mut requests = Vec::new();
|
||||
for (status, headers, body) in responses {
|
||||
let (mut stream, _) =
|
||||
tokio::time::timeout(Duration::from_secs(5), listener.accept()).await.unwrap().unwrap();
|
||||
requests.push(read_request(&mut stream).await);
|
||||
reply(&mut stream, status, headers, body).await;
|
||||
}
|
||||
requests
|
||||
});
|
||||
(port, task)
|
||||
}
|
||||
|
||||
fn source(port: u16) -> DataSource {
|
||||
DataSource { driver: DbDriver::Clickhouse, host: "127.0.0.1".into(), port: Some(port), ..Default::default() }
|
||||
}
|
||||
|
||||
fn decode(body: &[u8], max_rows: usize, chunk: usize) -> IdeResult<DecodedResult> {
|
||||
let mut reader = RowReader::new(max_rows);
|
||||
for part in body.chunks(chunk) {
|
||||
reader.push(part)?;
|
||||
}
|
||||
reader.finish()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_preserve_precision_nulls_and_strings_across_chunks() {
|
||||
let body = concat!(
|
||||
r#"["n", "d", "v", "v", "v"]"#,
|
||||
"\n",
|
||||
r#"["UInt64", "Decimal(38, 9)", "Nullable(String)", "Nullable(String)", "Nullable(String)"]"#,
|
||||
"\n",
|
||||
r#"[18446744073709551615, 12345678901234567890.123456789, null, "null", "ᴺᵁᴸᴸ"]"#,
|
||||
);
|
||||
for chunk in [1, 7, body.len()] {
|
||||
let (columns, rows, more) = decode(body.as_bytes(), 1, chunk).unwrap();
|
||||
assert_eq!(columns.len(), 5);
|
||||
assert_eq!(columns[1].type_name, "Decimal(38, 9)");
|
||||
assert_eq!(
|
||||
rows[0],
|
||||
vec![
|
||||
Some("18446744073709551615".into()),
|
||||
Some("12345678901234567890.123456789".into()),
|
||||
None,
|
||||
Some("null".into()),
|
||||
Some("ᴺᵁᴸᴸ".into()),
|
||||
]
|
||||
);
|
||||
assert!(!more);
|
||||
}
|
||||
let (columns, rows, more) = decode(b"[\"x\"]\n[\"String\"]\n", 0, 64).unwrap();
|
||||
assert_eq!(columns[0].name, "x");
|
||||
assert!(rows.is_empty() && !more);
|
||||
assert_eq!(decode(b"", 5, 64).unwrap().0.len(), 0);
|
||||
assert!(decode(b"[\"x\"]\n[\"UInt8\"]\n[1, 2]\n", 1, 64).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_past_the_limit_are_skipped_but_a_later_exception_still_fails() {
|
||||
let mut body = String::from("[\"n\"]\n[\"UInt64\"]\n");
|
||||
for n in 0..1000 {
|
||||
body.push_str(&format!("[{n}]\n"));
|
||||
}
|
||||
let (_, rows, more) = decode(body.as_bytes(), 2, 4096).unwrap();
|
||||
assert_eq!(rows, vec![vec![Some("0".into())], vec![Some("1".into())]]);
|
||||
assert!(more);
|
||||
body.push_str("\n__exception__\nabcdefgh\nCode: 395. DB::Exception: Value passed to 'throwIf' is non-zero\n");
|
||||
body.push_str("62 abcdefgh\n__exception__\n");
|
||||
let error = decode(body.as_bytes(), 2, 4096).unwrap_err();
|
||||
assert_eq!(error.message, "ClickHouse: Code: 395. DB::Exception: Value passed to 'throwIf' is non-zero");
|
||||
let multiline = "Code: 62. DB::Exception: Syntax error\nat line 2";
|
||||
assert_eq!(exception_text(multiline), multiline);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_requests_use_auth_settings_limits_and_a_shared_session() {
|
||||
let (port, requests) = server(vec![
|
||||
("200 OK", "X-ClickHouse-Format: JSONCompactEachRowWithNamesAndTypes\r\n", ONE),
|
||||
("200 OK", "", ""),
|
||||
(
|
||||
"200 OK",
|
||||
"X-ClickHouse-Format: JSONCompactEachRowWithNamesAndTypes\r\n",
|
||||
"[\"n\"]\n[\"UInt64\"]\n[\"1\"]\n[\"2\"]\n[\"3\"]\n",
|
||||
),
|
||||
])
|
||||
.await;
|
||||
let source = DataSource {
|
||||
user: "reader".into(),
|
||||
read_only: true,
|
||||
options: DbOptions { default_schema: "analytics".into(), time_zone: "UTC".into(), ..Default::default() },
|
||||
properties: vec![DbProperty { name: "max_threads".into(), value: "2".into() }],
|
||||
..source(port)
|
||||
};
|
||||
let pool = Pool::connect(&source, Some("mock-password"), None).await.unwrap();
|
||||
pool.execute("USE other", 0, &CancellationToken::new()).await.unwrap();
|
||||
let result = pool.execute("SELECT number FROM numbers(3)", 1, &CancellationToken::new()).await.unwrap();
|
||||
assert_eq!(result.rows, vec![vec![Some("1".into())]]);
|
||||
assert!(result.has_more && result.affected.is_none());
|
||||
let requests = requests.await.unwrap();
|
||||
let target = |request: &String| {
|
||||
Url::parse(&format!(
|
||||
"http://localhost{}",
|
||||
request.lines().next().unwrap().split_whitespace().nth(1).unwrap()
|
||||
))
|
||||
.unwrap()
|
||||
};
|
||||
let urls: Vec<_> = requests.iter().map(target).collect();
|
||||
let value = |url: &Url, key: &str| url.query_pairs().find(|(k, _)| k == key).map(|(_, v)| v.into_owned());
|
||||
assert_eq!(value(&urls[0], "database").as_deref(), Some("analytics"));
|
||||
assert_eq!(value(&urls[1], "database"), None);
|
||||
assert_eq!(value(&urls[1], "session_check").as_deref(), Some("1"));
|
||||
assert_eq!(value(&urls[0], "session_id"), value(&urls[2], "session_id"));
|
||||
assert_ne!(value(&urls[0], "query_id"), value(&urls[2], "query_id"));
|
||||
assert_eq!(value(&urls[2], "max_result_rows").as_deref(), Some("2"));
|
||||
for (request, url) in requests.iter().zip(&urls) {
|
||||
assert!(request.starts_with("POST /?"));
|
||||
assert!(request.contains("Basic cmVhZGVyOm1vY2stcGFzc3dvcmQ="));
|
||||
assert!(!url.as_str().contains("reader") && !url.as_str().contains("mock-password"));
|
||||
assert_eq!(value(url, "readonly").as_deref(), Some("1"));
|
||||
assert_eq!(value(url, "session_timezone").as_deref(), Some("UTC"));
|
||||
assert_eq!(value(url, "max_threads").as_deref(), Some("2"));
|
||||
}
|
||||
assert!(requests[0].ends_with("SELECT 1"));
|
||||
assert!(pool.transaction(&[]).await.is_err());
|
||||
let cancel = CancellationToken::new();
|
||||
cancel.cancel();
|
||||
assert_eq!(pool.execute("SELECT 1", 1, &cancel).await.unwrap_err().kind, ErrorKind::Cancelled);
|
||||
pool.close().await;
|
||||
assert!(pool.is_closed());
|
||||
assert!(pool.execute("SELECT 1", 1, &CancellationToken::new()).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sequential_queries_reuse_the_http_connection() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let task = tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
for _ in 0..2 {
|
||||
read_request(&mut stream).await;
|
||||
let response = format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{ONE}", ONE.len());
|
||||
stream.write_all(response.as_bytes()).await.unwrap();
|
||||
}
|
||||
});
|
||||
let pool = Pool::connect(&source(port), None, None).await.unwrap();
|
||||
assert_eq!(
|
||||
pool.execute("SELECT 1", 1, &CancellationToken::new()).await.unwrap().rows,
|
||||
vec![vec![Some("1".into())]]
|
||||
);
|
||||
task.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn server_errors_keep_details_and_hide_passwords() {
|
||||
let (port, requests) = server(vec![(
|
||||
"403 Forbidden",
|
||||
"X-ClickHouse-Exception-Code: 516\r\n",
|
||||
"Code: 516. DB::Exception: bad mock-password",
|
||||
)])
|
||||
.await;
|
||||
let error = match Pool::connect(&source(port), Some("mock-password"), None).await {
|
||||
Ok(_) => panic!("expected login failure"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert_eq!(error.kind, ErrorKind::PermissionDenied);
|
||||
assert!(error.message.contains("516") && error.message.contains("[redacted]"));
|
||||
assert!(!error.message.contains("mock-password"));
|
||||
requests.await.unwrap();
|
||||
let (port, requests) = server(vec![("200 OK", "", "Code: 241. DB::Exception: memory limit")]).await;
|
||||
let error = match Pool::connect(&source(port), None, None).await {
|
||||
Ok(_) => panic!("expected late query failure"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert!(error.message.contains("memory limit"));
|
||||
requests.await.unwrap();
|
||||
let (port, requests) = server(vec![(
|
||||
"500 Internal Server Error",
|
||||
"X-ClickHouse-Exception-Code: 395\r\n",
|
||||
"[\"1\"]\n[\"UInt8\"]\n[1]\n\n__exception__\ntag\nCode: 395. DB::Exception: thrown\n32 tag\n__exception__\n",
|
||||
)])
|
||||
.await;
|
||||
let error = match Pool::connect(&source(port), None, None).await {
|
||||
Ok(_) => panic!("expected mid-stream failure"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert_eq!(error.message, "ClickHouse HTTP 500: Code: 395. DB::Exception: thrown");
|
||||
requests.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancellation_sends_kill_outside_the_busy_session() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let (started, received) = tokio::sync::oneshot::channel();
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut health, _) = listener.accept().await.unwrap();
|
||||
read_request(&mut health).await;
|
||||
reply(&mut health, "200 OK", "", ONE).await;
|
||||
drop(health);
|
||||
let (mut query, _) = listener.accept().await.unwrap();
|
||||
let request = read_request(&mut query).await;
|
||||
started.send(()).unwrap();
|
||||
let (mut kill, _) = tokio::time::timeout(Duration::from_secs(5), listener.accept()).await.unwrap().unwrap();
|
||||
let kill_request = read_request(&mut kill).await;
|
||||
reply(&mut kill, "200 OK", "", "").await;
|
||||
(request, kill_request)
|
||||
});
|
||||
let pool = Pool::connect(&source(port), None, None).await.unwrap();
|
||||
let cancel = CancellationToken::new();
|
||||
let query = pool.execute("SELECT sleep(30)", 1, &cancel);
|
||||
let cancelled = async {
|
||||
received.await.unwrap();
|
||||
cancel.cancel();
|
||||
};
|
||||
let (result, ()) = tokio::join!(query, cancelled);
|
||||
assert_eq!(result.unwrap_err().kind, ErrorKind::Cancelled);
|
||||
let (query, kill) = server.await.unwrap();
|
||||
let target = query.lines().next().unwrap().split_whitespace().nth(1).unwrap();
|
||||
let url = Url::parse(&format!("http://localhost{target}")).unwrap();
|
||||
let query_id = url.query_pairs().find(|(k, _)| k == "query_id").unwrap().1.into_owned();
|
||||
assert!(kill.ends_with(&format!("KILL QUERY WHERE query_id = '{query_id}' SYNC")));
|
||||
assert!(!kill.contains("session_id="));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unsupported_settings_and_transactions_fail_before_connecting() {
|
||||
for statement in ["BEGIN", "-- comment\n COMMIT", "/* comment */ ROLLBACK", "START TRANSACTION", "SAVEPOINT s"]
|
||||
{
|
||||
assert!(transaction_statement(statement));
|
||||
}
|
||||
assert!(!transaction_statement("SELECT 'BEGIN'"));
|
||||
let sources = [
|
||||
DataSource { options: DbOptions { transaction: DbTxMode::Manual, ..Default::default() }, ..source(1) },
|
||||
DataSource {
|
||||
properties: vec![DbProperty { name: "statement_cache_capacity".into(), value: "4".into() }],
|
||||
..source(1)
|
||||
},
|
||||
DataSource { properties: vec![DbProperty { name: "password".into(), value: "x".into() }], ..source(1) },
|
||||
DataSource { ssl_ca_file: "unused.pem".into(), ..source(1) },
|
||||
DataSource { ssl: DbSslMode::VerifyCa, ..source(1) },
|
||||
DataSource { ssl: DbSslMode::Require, ssl_cert_file: "unused.pem".into(), ..source(1) },
|
||||
];
|
||||
for source in sources {
|
||||
let error = match Pool::connect(&source, None, None).await {
|
||||
Ok(_) => panic!("unsupported settings were accepted"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert_eq!(error.kind, ErrorKind::InvalidInput);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires CLICKHOUSE_TEST_HOST, CLICKHOUSE_TEST_USER, and CLICKHOUSE_TEST_PASSWORD"]
|
||||
async fn clickhouse_cloud_transport() {
|
||||
let source = DataSource {
|
||||
driver: DbDriver::Clickhouse,
|
||||
host: std::env::var("CLICKHOUSE_TEST_HOST").expect("CLICKHOUSE_TEST_HOST"),
|
||||
port: Some(std::env::var("CLICKHOUSE_TEST_PORT").ok().and_then(|v| v.parse().ok()).unwrap_or(8443)),
|
||||
user: std::env::var("CLICKHOUSE_TEST_USER").expect("CLICKHOUSE_TEST_USER"),
|
||||
ssl: DbSslMode::Require,
|
||||
options: DbOptions {
|
||||
time_zone: "UTC".into(),
|
||||
startup_script: "SET max_threads = 2".into(),
|
||||
..Default::default()
|
||||
},
|
||||
properties: vec![DbProperty { name: "max_block_size".into(), value: "50".into() }],
|
||||
..Default::default()
|
||||
};
|
||||
let password = std::env::var("CLICKHOUSE_TEST_PASSWORD").expect("CLICKHOUSE_TEST_PASSWORD");
|
||||
let pool = Pool::connect(&source, Some(&password), None).await.unwrap();
|
||||
let cancel = CancellationToken::new();
|
||||
let settings = pool
|
||||
.execute("SELECT getSetting('max_threads'), getSetting('max_block_size'), timezone()", 1, &cancel)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(settings.rows, vec![vec![Some("2".into()), Some("50".into()), Some("UTC".into())]]);
|
||||
let result = pool.execute("SELECT toUInt64('18446744073709551615') AS n, CAST(NULL AS Nullable(String)) AS missing, CAST('ᴺᵁᴸᴸ' AS Nullable(String)) AS text, 'a\\nb' AS lines", 1, &cancel).await.unwrap();
|
||||
assert_eq!(
|
||||
result.rows,
|
||||
vec![vec![Some("18446744073709551615".into()), None, Some("ᴺᵁᴸᴸ".into()), Some("a\nb".into())]]
|
||||
);
|
||||
assert_eq!(result.columns[1].type_name, "Nullable(String)");
|
||||
let limited = pool.execute("SELECT number FROM numbers(100)", 2, &cancel).await.unwrap();
|
||||
assert_eq!(limited.rows.len(), 2);
|
||||
assert!(limited.has_more);
|
||||
let marker = format!("transport_cancel_{}", uuid::Uuid::new_v4().simple());
|
||||
let sql = format!("SELECT sleepEachRow(0.05) FROM numbers(50) /* {marker} */");
|
||||
let stop = CancellationToken::new();
|
||||
let query = pool.execute(&sql, 1, &stop);
|
||||
let cancel_query = async {
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
stop.cancel();
|
||||
};
|
||||
let (cancelled, ()) = tokio::join!(query, cancel_query);
|
||||
let cancelled = cancelled.unwrap_err();
|
||||
assert_eq!(cancelled.kind, ErrorKind::Cancelled);
|
||||
assert_eq!(cancelled.message, "cancelled");
|
||||
let active = pool.execute(&format!("SELECT count() FROM system.processes WHERE position(query, '{marker}') > 0 AND query_id != currentQueryID()"), 1, &cancel).await.unwrap();
|
||||
assert_eq!(active.rows, vec![vec![Some("0".into())]]);
|
||||
let table = format!("transport_test_{}", uuid::Uuid::new_v4().simple());
|
||||
pool.execute(&format!("CREATE TEMPORARY TABLE {table} (n UInt8)"), 0, &cancel).await.unwrap();
|
||||
let continuity = async {
|
||||
pool.execute(&format!("INSERT INTO {table} VALUES (7)"), 0, &cancel).await?;
|
||||
pool.execute(&format!("SELECT n FROM {table}"), 1, &cancel).await
|
||||
}
|
||||
.await;
|
||||
let cleanup = pool.execute(&format!("DROP TEMPORARY TABLE {table}"), 0, &cancel).await;
|
||||
assert_eq!(continuity.unwrap().rows, vec![vec![Some("7".into())]]);
|
||||
cleanup.unwrap();
|
||||
pool.close().await;
|
||||
assert!(pool.is_closed());
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,7 @@ pub enum Pool {
|
||||
Postgres(PgPool),
|
||||
Mysql(MySqlPool),
|
||||
SqlServer(Bb8Pool<MssqlConnectionManager>),
|
||||
Clickhouse(crate::clickhouse::ClickhousePool),
|
||||
}
|
||||
|
||||
/// Runs `$body` with `$p` bound to the concrete pool of each driver.
|
||||
@@ -115,7 +116,7 @@ macro_rules! with_pool {
|
||||
Pool::Sqlite($p) => $body,
|
||||
Pool::Postgres($p) => $body,
|
||||
Pool::Mysql($p) => $body,
|
||||
Pool::SqlServer(_) => unreachable!(),
|
||||
Pool::SqlServer(_) | Pool::Clickhouse(_) => unreachable!(),
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -503,6 +504,9 @@ impl Setup {
|
||||
value.parse::<u64>().map_err(|_| IdeError::invalid(format!("{what} must be a whole number: {value}")))
|
||||
};
|
||||
match (source.driver, name.to_ascii_lowercase().as_str()) {
|
||||
(DbDriver::Clickhouse, "statement_cache_capacity") => {
|
||||
return Err(IdeError::invalid("ClickHouse HTTP does not support statement caching"));
|
||||
}
|
||||
(_, "statement_cache_capacity") => s.statement_cache = Some(number(&name)? as usize),
|
||||
(_, "connect_timeout") => s.connect_timeout = Some(Duration::from_secs(number(&name)?)),
|
||||
(DbDriver::Postgres, "application_name") => s.application_name = Some(value.to_owned()),
|
||||
@@ -512,6 +516,9 @@ impl Setup {
|
||||
_ => s.params.push((name, value.to_owned())),
|
||||
}
|
||||
}
|
||||
if source.driver == DbDriver::Clickhouse && !o.time_zone.trim().is_empty() {
|
||||
s.params.push(("session_timezone".into(), o.time_zone.trim().into()));
|
||||
}
|
||||
let script = o.startup_script.trim();
|
||||
if !script.is_empty() {
|
||||
s.statements.extend(crate::split::split(script, source.driver).into_iter().map(|st| st.text));
|
||||
@@ -527,6 +534,7 @@ impl Pool {
|
||||
Pool::Postgres(_) => DbDriver::Postgres,
|
||||
Pool::Mysql(_) => DbDriver::Mysql,
|
||||
Pool::SqlServer(_) => DbDriver::SqlServer,
|
||||
Pool::Clickhouse(_) => DbDriver::Clickhouse,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -534,6 +542,9 @@ impl Pool {
|
||||
pub async fn connect(source: &DataSource, password: Option<&str>, root: Option<&Path>) -> IdeResult<Self> {
|
||||
const MAX: u32 = 4;
|
||||
let setup = Setup::of(source)?;
|
||||
if source.driver == DbDriver::Clickhouse {
|
||||
return crate::clickhouse::ClickhousePool::connect(source, password, setup).await.map(Pool::Clickhouse);
|
||||
}
|
||||
let timeout = setup.connect_timeout.unwrap_or(Duration::from_secs(30));
|
||||
let init = Arc::new(setup.statements);
|
||||
// Runs the session statements (startup script…) on every new connection.
|
||||
@@ -695,12 +706,14 @@ impl Pool {
|
||||
conn.query("SELECT 1", &[]).await.map_err(mssql_err)?.into_results().await.map_err(mssql_err)?;
|
||||
Ok(Pool::SqlServer(pool))
|
||||
}
|
||||
DbDriver::Clickhouse => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn close(&self) {
|
||||
match self {
|
||||
Pool::SqlServer(_) => {}
|
||||
Pool::Clickhouse(p) => p.close(),
|
||||
_ => with_pool!(self, p => p.close().await),
|
||||
}
|
||||
}
|
||||
@@ -708,12 +721,16 @@ impl Pool {
|
||||
pub fn is_closed(&self) -> bool {
|
||||
match self {
|
||||
Pool::SqlServer(_) => false,
|
||||
Pool::Clickhouse(p) => p.is_closed(),
|
||||
_ => with_pool!(self, p => p.is_closed()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `sql` (no row limit beyond `max`), for introspection: cells are text (see [`Cells::Text`]).
|
||||
pub async fn rows(&self, sql: &str, max: usize) -> IdeResult<Vec<Vec<Option<String>>>> {
|
||||
if let Pool::Clickhouse(p) = self {
|
||||
return Ok(p.execute(sql, max, &CancellationToken::new()).await?.rows);
|
||||
}
|
||||
let fetched: IdeResult<Fetched> = match self {
|
||||
Pool::SqlServer(p) => {
|
||||
let mut conn = p.get_owned().await.map_err(mssql_pool_err)?;
|
||||
@@ -727,6 +744,9 @@ impl Pool {
|
||||
/// Runs one statement and keeps at most `max_rows` rows. Cancelling stops
|
||||
/// the statement on the server (PostgreSQL, MySQL) and drops the connection.
|
||||
pub async fn execute(&self, sql: &str, max_rows: usize, cancel: &CancellationToken) -> IdeResult<QueryResult> {
|
||||
if let Pool::Clickhouse(p) = self {
|
||||
return p.execute(sql, max_rows, cancel).await;
|
||||
}
|
||||
let mut conn = Held::acquire(self).await?;
|
||||
let r = conn.execute(self, sql, max_rows, cancel).await;
|
||||
if cancel.is_cancelled() {
|
||||
@@ -740,6 +760,7 @@ impl Pool {
|
||||
/// row when flagged (table editor submits).
|
||||
pub async fn transaction(&self, statements: &[(String, bool)]) -> IdeResult<u64> {
|
||||
match self {
|
||||
Pool::Clickhouse(_) => Err(crate::clickhouse::transaction_error()),
|
||||
Pool::SqlServer(p) => {
|
||||
let mut conn = p.get_owned().await.map_err(mssql_pool_err)?;
|
||||
let result = async {
|
||||
@@ -790,6 +811,7 @@ pub enum Held {
|
||||
Postgres(PoolConnection<Postgres>),
|
||||
Mysql(PoolConnection<MySql>),
|
||||
SqlServer(Box<bb8::PooledConnection<'static, MssqlConnectionManager>>),
|
||||
Clickhouse(crate::clickhouse::ClickhousePool),
|
||||
}
|
||||
|
||||
impl Held {
|
||||
@@ -799,6 +821,12 @@ impl Held {
|
||||
Pool::Postgres(p) => Held::Postgres(p.acquire().await.map_err(db_err)?),
|
||||
Pool::Mysql(p) => Held::Mysql(p.acquire().await.map_err(db_err)?),
|
||||
Pool::SqlServer(p) => Held::SqlServer(Box::new(p.get_owned().await.map_err(mssql_pool_err)?)),
|
||||
Pool::Clickhouse(p) => {
|
||||
if p.is_closed() {
|
||||
return Err(IdeError::new(ErrorKind::Io, "ClickHouse connection is closed"));
|
||||
}
|
||||
Held::Clickhouse(p.clone())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -806,13 +834,14 @@ impl Held {
|
||||
pub async fn command(&mut self, sql: &str) -> IdeResult<()> {
|
||||
match self {
|
||||
Held::SqlServer(c) => mssql_command(c, sql).await,
|
||||
Held::Clickhouse(_) => Err(crate::clickhouse::transaction_error()),
|
||||
other => {
|
||||
let sql = sqlx::AssertSqlSafe(sql.to_owned());
|
||||
match other {
|
||||
Held::Sqlite(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop),
|
||||
Held::Postgres(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop),
|
||||
Held::Mysql(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop),
|
||||
Held::SqlServer(_) => unreachable!(),
|
||||
Held::SqlServer(_) | Held::Clickhouse(_) => unreachable!(),
|
||||
}
|
||||
.map_err(db_err)
|
||||
}
|
||||
@@ -833,6 +862,7 @@ impl Held {
|
||||
Held::SqlServer(mut c) => {
|
||||
c.0.take();
|
||||
}
|
||||
Held::Clickhouse(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -845,6 +875,9 @@ impl Held {
|
||||
max_rows: usize,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<QueryResult> {
|
||||
if let Held::Clickhouse(c) = self {
|
||||
return c.execute(sql, max_rows, cancel).await;
|
||||
}
|
||||
let started = Instant::now();
|
||||
// `$id`: the query naming the server process, `$kill`: how another connection stops it.
|
||||
macro_rules! run {
|
||||
@@ -887,6 +920,7 @@ impl Held {
|
||||
() = cancel.cancelled() => return Err(IdeError::cancelled()),
|
||||
}
|
||||
}
|
||||
Held::Clickhouse(_) => unreachable!(),
|
||||
};
|
||||
let (columns, rows, affected, has_more, first) = fetched;
|
||||
let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0;
|
||||
@@ -907,7 +941,7 @@ pub fn begin_statement(driver: DbDriver) -> &'static str {
|
||||
match driver {
|
||||
DbDriver::Mysql => "START TRANSACTION",
|
||||
DbDriver::SqlServer => "BEGIN TRAN",
|
||||
DbDriver::Sqlite | DbDriver::Postgres => "BEGIN",
|
||||
DbDriver::Sqlite | DbDriver::Postgres | DbDriver::Clickhouse => "BEGIN",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! execution ([`conn`]), introspection, table data paging / editing ([`sql`])
|
||||
//! and statement splitting ([`split`]), for SQLite, PostgreSQL and MySQL/MariaDB.
|
||||
|
||||
mod clickhouse;
|
||||
pub mod conn;
|
||||
pub mod history;
|
||||
pub mod plan;
|
||||
@@ -547,12 +548,14 @@ impl DbManager {
|
||||
})?;
|
||||
let version = match source.driver {
|
||||
DbDriver::Sqlite => "SELECT 'SQLite ' || sqlite_version()",
|
||||
DbDriver::Postgres => "SELECT version()",
|
||||
DbDriver::Postgres | DbDriver::Clickhouse => "SELECT version()",
|
||||
DbDriver::Mysql => "SELECT CONCAT(@@version_comment, ' ', VERSION())",
|
||||
DbDriver::SqlServer => "SELECT @@VERSION",
|
||||
};
|
||||
let v = pool.rows(version, 1).await.ok().and_then(|r| r.into_iter().next()?.into_iter().next()?);
|
||||
let result = pool.rows(version, 1).await;
|
||||
pool.close().await;
|
||||
let rows = if source.driver == DbDriver::Clickhouse { result? } else { result.unwrap_or_default() };
|
||||
let v = rows.into_iter().next().and_then(|r| r.into_iter().next().flatten());
|
||||
Ok(v.unwrap_or_else(|| "Connected".into()))
|
||||
}
|
||||
|
||||
@@ -701,8 +704,11 @@ impl DbManager {
|
||||
_ => indexes.push(IndexInfo { name, unique: flag(&unique), columns: vec![col] }),
|
||||
}
|
||||
}
|
||||
let fk_rows =
|
||||
pool.rows(&sql::table_foreign_keys(driver, schema, table), MAX_META_ROWS).await.unwrap_or_default();
|
||||
let fk_rows = if driver == DbDriver::Clickhouse {
|
||||
Vec::new()
|
||||
} else {
|
||||
pool.rows(&sql::table_foreign_keys(driver, schema, table), MAX_META_ROWS).await.unwrap_or_default()
|
||||
};
|
||||
let mut foreign_keys = group_foreign_keys(fk_rows, &[]);
|
||||
// SQLite keys that reference the primary key implicitly: look it up.
|
||||
for k in foreign_keys.iter_mut().filter(|k| k.ref_columns.is_empty()) {
|
||||
@@ -715,13 +721,15 @@ impl DbManager {
|
||||
.collect();
|
||||
}
|
||||
foreign_keys.retain(|k| k.columns.len() == k.ref_columns.len());
|
||||
let triggers = pool
|
||||
.rows(&sql::triggers(driver, schema, table), MAX_META_ROWS)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
let trigger_rows = if driver == DbDriver::Clickhouse {
|
||||
Vec::new()
|
||||
} else {
|
||||
pool.rows(&sql::triggers(driver, schema, table), MAX_META_ROWS).await.unwrap_or_else(|e| {
|
||||
tracing::warn!("triggers: {}", e.message);
|
||||
Vec::new()
|
||||
})
|
||||
};
|
||||
let triggers = trigger_rows
|
||||
.into_iter()
|
||||
.filter_map(|r| {
|
||||
let mut it = r.into_iter();
|
||||
@@ -822,6 +830,11 @@ impl DbManager {
|
||||
let ddl = cells(pool.rows(&q, 1).await?, 1).into_iter().next();
|
||||
ddl.map(|d| format!("{d};\n")).ok_or_else(missing)
|
||||
}
|
||||
DbDriver::Clickhouse => {
|
||||
let q = format!("SHOW CREATE TABLE {}", sql::qualified(driver, schema, name));
|
||||
let ddl = cells(pool.rows(&q, 1).await?, 0).into_iter().next();
|
||||
ddl.map(|d| format!("{};\n", d.trim_end().trim_end_matches(';'))).ok_or_else(missing)
|
||||
}
|
||||
DbDriver::SqlServer => {
|
||||
let obj = format!("{}.{}", schema, name).replace('\'', "''");
|
||||
let ddl = cells(pool.rows(&format!("SELECT OBJECT_DEFINITION(OBJECT_ID('{}'))", obj), 1).await?, 0)
|
||||
@@ -902,10 +915,14 @@ impl DbManager {
|
||||
}
|
||||
}
|
||||
// Foreign keys are a nicety (JOIN completion): a server that refuses the catalog query still completes.
|
||||
let fk_rows = pool.rows(sql::foreign_keys(driver), MAX_META_ROWS).await.unwrap_or_else(|e| {
|
||||
tracing::warn!("foreign keys: {}", e.message);
|
||||
let fk_rows = if driver == DbDriver::Clickhouse {
|
||||
Vec::new()
|
||||
});
|
||||
} else {
|
||||
pool.rows(sql::foreign_keys(driver), MAX_META_ROWS).await.unwrap_or_else(|e| {
|
||||
tracing::warn!("foreign keys: {}", e.message);
|
||||
Vec::new()
|
||||
})
|
||||
};
|
||||
let foreign_keys = group_foreign_keys(fk_rows, &tables);
|
||||
let default_schema = match sql::current_schema(driver) {
|
||||
None => "main".to_owned(),
|
||||
@@ -980,6 +997,9 @@ impl DbManager {
|
||||
let Some(session) = session else {
|
||||
return pool.execute(statement, max_rows, cancel).await;
|
||||
};
|
||||
if pool.driver() == DbDriver::Clickhouse {
|
||||
return Err(IdeError::invalid("ClickHouse manual transactions are unsupported"));
|
||||
}
|
||||
let mut held = session.lock().await;
|
||||
if held.is_none() {
|
||||
let mut conn = Held::acquire(&pool).await?;
|
||||
@@ -998,8 +1018,11 @@ impl DbManager {
|
||||
|
||||
/// Switches `id` between auto-commit and manual commit. Leaving manual
|
||||
/// mode rolls back what was not committed.
|
||||
pub async fn set_manual_commit(&self, id: &str, manual: bool) {
|
||||
pub async fn set_manual_commit(&self, id: &str, manual: bool) -> IdeResult<()> {
|
||||
if manual {
|
||||
if self.source(id)?.driver == DbDriver::Clickhouse {
|
||||
return Err(IdeError::invalid("ClickHouse manual transactions are unsupported"));
|
||||
}
|
||||
self.sessions.lock().entry(id.to_owned()).or_default();
|
||||
} else {
|
||||
let session = self.sessions.lock().remove(id);
|
||||
@@ -1007,10 +1030,14 @@ impl DbManager {
|
||||
end_session(&s).await;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Commits (or rolls back) the open transaction of a manual-commit data source.
|
||||
pub async fn finish_transaction(&self, id: &str, commit: bool) -> IdeResult<()> {
|
||||
if self.source(id).is_ok_and(|s| s.driver == DbDriver::Clickhouse) {
|
||||
return Err(IdeError::invalid("ClickHouse manual transactions are unsupported"));
|
||||
}
|
||||
let session = self.sessions.lock().get(id).cloned();
|
||||
let Some(session) = session else { return Ok(()) };
|
||||
let mut held = session.lock().await;
|
||||
@@ -1052,6 +1079,9 @@ impl DbManager {
|
||||
|
||||
/// Applies the table editor's pending changes in one transaction.
|
||||
pub async fn apply_edits(&self, id: &str, schema: &str, table: &str, edits: &[DbRowEdit]) -> IdeResult<u64> {
|
||||
if self.source(id)?.driver == DbDriver::Clickhouse {
|
||||
return Err(IdeError::invalid("ClickHouse table editing is unsupported"));
|
||||
}
|
||||
let pool = self.pool(id).await?;
|
||||
let driver = pool.driver();
|
||||
let statements = edits
|
||||
@@ -1064,3 +1094,37 @@ impl DbManager {
|
||||
pool.transaction(&statements).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn clickhouse_rejects_manual_transactions_and_table_edits_without_connecting() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().to_path_buf();
|
||||
let ssh = Arc::new(SshManager::new(root.join("ssh-passwords.json")));
|
||||
let db = DbManager::new(root.clone(), root.join("passwords.json"), root.join("history.json"), ssh);
|
||||
db.save_sources(vec![DataSource {
|
||||
id: "clickhouse".into(),
|
||||
name: "clickhouse".into(),
|
||||
driver: DbDriver::Clickhouse,
|
||||
host: "127.0.0.1".into(),
|
||||
port: Some(1),
|
||||
..Default::default()
|
||||
}])
|
||||
.await
|
||||
.unwrap();
|
||||
let error = db.set_manual_commit("clickhouse", true).await.unwrap_err();
|
||||
assert!(error.message.contains("manual transactions are unsupported"));
|
||||
assert!(db.sessions.lock().is_empty());
|
||||
for commit in [false, true] {
|
||||
let error = db.finish_transaction("clickhouse", commit).await.unwrap_err();
|
||||
assert!(error.message.contains("manual transactions are unsupported"));
|
||||
}
|
||||
let error = db.apply_edits("clickhouse", "default", "events", &[]).await.unwrap_err();
|
||||
assert!(error.message.contains("table editing is unsupported"));
|
||||
assert!(!db.is_connected("clickhouse"));
|
||||
db.set_manual_commit("clickhouse", false).await.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ pub fn explain_statement(driver: DbDriver, statement: &str) -> String {
|
||||
match driver {
|
||||
DbDriver::Sqlite => format!("EXPLAIN QUERY PLAN {s}"),
|
||||
DbDriver::Postgres => format!("EXPLAIN (FORMAT JSON) {s}"),
|
||||
DbDriver::Clickhouse => format!("EXPLAIN json = 1 {s}"),
|
||||
DbDriver::Mysql => format!("EXPLAIN {s}"),
|
||||
DbDriver::SqlServer => format!("SET SHOWPLAN_TEXT ON; {s}"),
|
||||
}
|
||||
@@ -44,7 +45,7 @@ pub fn explain_statement(driver: DbDriver, statement: &str) -> String {
|
||||
pub fn parse(driver: DbDriver, result: &QueryResult) -> IdeResult<Vec<PlanNode>> {
|
||||
match driver {
|
||||
DbDriver::Sqlite => Ok(sqlite(&result.rows)),
|
||||
DbDriver::Postgres => {
|
||||
DbDriver::Postgres | DbDriver::Clickhouse => {
|
||||
let text: String =
|
||||
result.rows.iter().filter_map(|r| r.first().cloned().flatten()).collect::<Vec<_>>().join("\n");
|
||||
let json: Value = serde_json::from_str(&text).map_err(|e| IdeError::invalid(format!("plan: {e}")))?;
|
||||
@@ -199,6 +200,7 @@ mod tests {
|
||||
assert_eq!(explain_statement(DbDriver::Sqlite, "select 1;"), "EXPLAIN QUERY PLAN select 1");
|
||||
assert_eq!(explain_statement(DbDriver::Postgres, " select 1 "), "EXPLAIN (FORMAT JSON) select 1");
|
||||
assert_eq!(explain_statement(DbDriver::Mysql, "select 1"), "EXPLAIN select 1");
|
||||
assert_eq!(explain_statement(DbDriver::Clickhouse, " select 1;"), "EXPLAIN json = 1 select 1");
|
||||
assert_eq!(explain_statement(DbDriver::SqlServer, "select 1"), "SET SHOWPLAN_TEXT ON; select 1");
|
||||
}
|
||||
|
||||
@@ -254,6 +256,21 @@ mod tests {
|
||||
assert_eq!((plan[3].parent, plan[3].operation.as_str()), (Some(2), "Seq Scan"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_clickhouse_json_plans_and_rejects_invalid_json() {
|
||||
let json = r#"[{"Plan": {"Node Type": "Expression", "Node Id": "Expression_5",
|
||||
"Description": "Project names", "Plans": [
|
||||
{"Node Type": "ReadFromMergeTree", "Node Id": "ReadFromMergeTree_0",
|
||||
"Description": "default.events"}
|
||||
]}}]"#;
|
||||
let plan = parse(DbDriver::Clickhouse, &result(&["explain"], vec![vec![Some(json)]])).unwrap();
|
||||
assert_eq!(plan.len(), 2);
|
||||
assert_eq!((plan[0].parent, plan[0].operation.as_str()), (None, "Expression"));
|
||||
assert_eq!((plan[1].parent, plan[1].operation.as_str()), (Some(0), "ReadFromMergeTree"));
|
||||
assert!(plan[1].properties.iter().any(|p| p.name == "Description" && p.value == "default.events"));
|
||||
assert!(parse(DbDriver::Clickhouse, &result(&["explain"], vec![vec![Some("invalid")]])).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_mysql_rows() {
|
||||
let r = result(
|
||||
|
||||
@@ -18,6 +18,7 @@ pub enum DbDriver {
|
||||
Mysql,
|
||||
/// Microsoft SQL Server.
|
||||
SqlServer,
|
||||
Clickhouse,
|
||||
}
|
||||
|
||||
impl DbDriver {
|
||||
@@ -27,6 +28,7 @@ impl DbDriver {
|
||||
DbDriver::Postgres => Some(5432),
|
||||
DbDriver::Mysql => Some(3306),
|
||||
DbDriver::SqlServer => Some(1433),
|
||||
DbDriver::Clickhouse => Some(8123),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -253,6 +255,7 @@ pub fn scheme_driver(scheme: &str) -> Option<DbDriver> {
|
||||
"mysql" | "mariadb" | "tidb" | "singlestore" | "memsql" | "oceanbase" => Some(DbDriver::Mysql),
|
||||
"sqlserver" | "mssql" | "mssqlserver" => Some(DbDriver::SqlServer),
|
||||
"sqlite" | "sqlite3" => Some(DbDriver::Sqlite),
|
||||
"clickhouse" | "http" | "https" => Some(DbDriver::Clickhouse),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -260,7 +263,7 @@ pub fn scheme_driver(scheme: &str) -> Option<DbDriver> {
|
||||
/// Parses `scheme://[user[:password]@]host[:port][/database][?k=v&…]`, `jdbc:` forms
|
||||
/// included, and `sqlite:path`.
|
||||
pub fn parse_url(raw: &str) -> IdeResult<ParsedUrl> {
|
||||
let bad = || IdeError::invalid(format!("not a database URL: {raw}"));
|
||||
let bad = || IdeError::invalid("invalid database URL");
|
||||
let url = raw.trim();
|
||||
let url = url.strip_prefix("jdbc:").or_else(|| url.strip_prefix("JDBC:")).unwrap_or(url);
|
||||
let (scheme, rest) = url.split_once(':').ok_or_else(bad)?;
|
||||
@@ -367,6 +370,15 @@ impl DataSource {
|
||||
}
|
||||
properties.append(&mut s.properties);
|
||||
s.properties = properties;
|
||||
if s.driver == DbDriver::Clickhouse {
|
||||
let raw_url = self.url.trim().to_ascii_lowercase();
|
||||
let url = raw_url.strip_prefix("jdbc:").unwrap_or(&raw_url);
|
||||
if url.starts_with("https://") {
|
||||
s.ssl = DbSslMode::VerifyFull;
|
||||
} else if url.starts_with("http://") {
|
||||
s.ssl = DbSslMode::Disable;
|
||||
}
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
@@ -519,4 +531,31 @@ mod tests {
|
||||
assert!(DataSource { url: "mysql://h/db".into(), ..s.clone() }.resolved().is_err());
|
||||
assert_eq!(DataSource { url_only: false, ..s }.resolved().unwrap().host, "localhost");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clickhouse_urls_resolve_http_https_and_credentials() {
|
||||
assert_eq!(DbDriver::Clickhouse.default_port(), Some(8123));
|
||||
let source = DataSource {
|
||||
driver: DbDriver::Clickhouse,
|
||||
url_only: true,
|
||||
url: "https://reader:s%[email protected]:8443/analytics?max_execution_time=30".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let resolved = source.resolved().unwrap();
|
||||
assert_eq!(resolved.host, "db.example");
|
||||
assert_eq!(resolved.port, Some(8443));
|
||||
assert_eq!(resolved.database, "analytics");
|
||||
assert_eq!(resolved.user, "reader");
|
||||
assert_eq!(resolved.ssl, DbSslMode::VerifyFull);
|
||||
assert_eq!(source.url_password().as_deref(), Some("s@cret"));
|
||||
assert_eq!(resolved.properties[0].name, "max_execution_time");
|
||||
let http = DataSource { url: "http://localhost/default".into(), ..source.clone() }.resolved().unwrap();
|
||||
assert_eq!(http.ssl, DbSslMode::Disable);
|
||||
let jdbc = DataSource { url: "jdbc:clickhouse://localhost/analytics".into(), ..source.clone() };
|
||||
assert_eq!(jdbc.resolved().unwrap().database, "analytics");
|
||||
let secure = DataSource { url: "https://db.example/default?sslmode=disable".into(), ..source };
|
||||
assert_eq!(secure.resolved().unwrap().ssl, DbSslMode::VerifyFull);
|
||||
let error = parse_url("clickhouse://reader:[email protected]:invalid/default").unwrap_err();
|
||||
assert!(!error.message.contains("secret"));
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ enum State {
|
||||
/// Every statement of `text` that contains more than whitespace and comments.
|
||||
pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
|
||||
let mysql = driver == DbDriver::Mysql;
|
||||
let clickhouse = driver == DbDriver::Clickhouse;
|
||||
let chars: Vec<(usize, char)> = text.char_indices().collect();
|
||||
// UTF-16 offset of each char index (plus the end).
|
||||
let mut u16_at = Vec::with_capacity(chars.len() + 1);
|
||||
@@ -81,7 +82,7 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
|
||||
state = State::Double;
|
||||
has_code = true;
|
||||
}
|
||||
'`' if mysql => {
|
||||
'`' if mysql || clickhouse => {
|
||||
state = State::Backtick;
|
||||
has_code = true;
|
||||
}
|
||||
@@ -117,7 +118,7 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
|
||||
_ => {}
|
||||
},
|
||||
State::Single => {
|
||||
if mysql && c == '\\' {
|
||||
if (mysql || clickhouse) && c == '\\' {
|
||||
i += 1;
|
||||
} else if c == '\'' {
|
||||
if next == Some('\'') {
|
||||
@@ -128,7 +129,9 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
|
||||
}
|
||||
}
|
||||
State::Double => {
|
||||
if c == '"' {
|
||||
if clickhouse && c == '\\' {
|
||||
i += 1;
|
||||
} else if c == '"' {
|
||||
if next == Some('"') {
|
||||
i += 1;
|
||||
} else {
|
||||
@@ -137,7 +140,9 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
|
||||
}
|
||||
}
|
||||
State::Backtick => {
|
||||
if c == '`' {
|
||||
if clickhouse && (c == '\\' || (c == '`' && next == Some('`'))) {
|
||||
i += 1;
|
||||
} else if c == '`' {
|
||||
state = State::Code;
|
||||
}
|
||||
}
|
||||
@@ -224,6 +229,12 @@ mod tests {
|
||||
assert_eq!(texts(sql, DbDriver::Mysql), vec!["select `a;b`, 'it\\'s;' from t # x;y", "select 2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clickhouse_backticks_and_escaped_quotes_keep_statements_intact() {
|
||||
let query = r#"SELECT `a\`;b`, "c\";d", 'it\'s;safe'; SELECT 2"#;
|
||||
assert_eq!(texts(query, DbDriver::Clickhouse), vec![r#"SELECT `a\`;b`, "c\";d", 'it\'s;safe'"#, "SELECT 2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postgres_dollar_quotes() {
|
||||
let sql =
|
||||
|
||||
@@ -7,6 +7,7 @@ use specta::Type;
|
||||
|
||||
pub fn quote_ident(driver: DbDriver, name: &str) -> String {
|
||||
match driver {
|
||||
DbDriver::Clickhouse => format!("`{}`", name.replace('\\', "\\\\").replace('`', "\\`")),
|
||||
DbDriver::Mysql => format!("`{}`", name.replace('`', "``")),
|
||||
DbDriver::SqlServer => format!("[{}]", name.replace(']', "]]")),
|
||||
_ => format!("\"{}\"", name.replace('"', "\"\"")),
|
||||
@@ -15,7 +16,7 @@ pub fn quote_ident(driver: DbDriver, name: &str) -> String {
|
||||
|
||||
pub fn quote_literal(driver: DbDriver, value: &str) -> String {
|
||||
let mut s = value.replace('\'', "''");
|
||||
if driver == DbDriver::Mysql {
|
||||
if matches!(driver, DbDriver::Mysql | DbDriver::Clickhouse) {
|
||||
s = s.replace('\\', "\\\\");
|
||||
}
|
||||
format!("'{s}'")
|
||||
@@ -48,6 +49,10 @@ pub const INTERNAL_DATABASES: &[&str] = &["rdsadmin", "azure_maintenance", "azur
|
||||
pub fn schemas(driver: DbDriver, system: bool) -> &'static str {
|
||||
match driver {
|
||||
DbDriver::Sqlite => "SELECT name FROM pragma_database_list WHERE name <> 'temp' ORDER BY seq",
|
||||
DbDriver::Clickhouse if system => "SELECT name FROM system.databases ORDER BY name",
|
||||
DbDriver::Clickhouse => {
|
||||
"SELECT name FROM system.databases WHERE lower(name) NOT IN ('system', 'information_schema') ORDER BY name"
|
||||
}
|
||||
DbDriver::Postgres if system => {
|
||||
"SELECT schema_name FROM information_schema.schemata \
|
||||
WHERE schema_name NOT LIKE 'pg\\_toast%' AND schema_name NOT LIKE 'pg\\_temp%' ORDER BY 1"
|
||||
@@ -73,6 +78,12 @@ pub fn schemas(driver: DbDriver, system: bool) -> &'static str {
|
||||
/// Rows: `name, kind` where kind is `table`, `view` or `materialized view`.
|
||||
pub fn tables(driver: DbDriver, schema: &str) -> String {
|
||||
match driver {
|
||||
DbDriver::Clickhouse => format!(
|
||||
"SELECT name, CASE engine WHEN 'View' THEN 'view' WHEN 'MaterializedView' THEN 'materialized view' \
|
||||
WHEN 'WindowView' THEN 'view' ELSE 'table' END FROM system.tables \
|
||||
WHERE database = {} AND is_temporary = 0 ORDER BY name",
|
||||
quote_literal(driver, schema)
|
||||
),
|
||||
DbDriver::Sqlite => format!(
|
||||
"SELECT name, type FROM {}.sqlite_master WHERE type IN ('table', 'view') \
|
||||
AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\' ORDER BY name",
|
||||
@@ -101,6 +112,12 @@ pub fn tables(driver: DbDriver, schema: &str) -> String {
|
||||
pub fn columns(driver: DbDriver, schema: &str, table: &str) -> String {
|
||||
let (s, t) = (quote_literal(driver, schema), quote_literal(driver, table));
|
||||
match driver {
|
||||
DbDriver::Clickhouse => format!(
|
||||
"SELECT name, type, CASE WHEN startsWith(type, 'Nullable(') \
|
||||
OR startsWith(type, 'LowCardinality(Nullable(') THEN 'YES' ELSE 'NO' END, \
|
||||
if(default_kind = '', NULL, default_expression), is_in_primary_key \
|
||||
FROM system.columns WHERE database = {s} AND table = {t} ORDER BY position"
|
||||
),
|
||||
DbDriver::Sqlite => format!(
|
||||
"SELECT name, type, CASE WHEN \"notnull\" = 0 THEN 'YES' ELSE 'NO' END, dflt_value, \
|
||||
CASE WHEN pk > 0 THEN 1 ELSE 0 END FROM pragma_table_info({t}, {s}) ORDER BY cid"
|
||||
@@ -138,6 +155,12 @@ pub fn columns(driver: DbDriver, schema: &str, table: &str) -> String {
|
||||
pub fn indexes(driver: DbDriver, schema: &str, table: &str) -> String {
|
||||
let (s, t) = (quote_literal(driver, schema), quote_literal(driver, table));
|
||||
match driver {
|
||||
DbDriver::Clickhouse => format!(
|
||||
"SELECT * FROM (SELECT 'PRIMARY KEY' AS index_name, 0 AS is_unique, primary_key AS expression \
|
||||
FROM system.tables WHERE database = {s} AND name = {t} AND primary_key != '' \
|
||||
UNION ALL SELECT name, 0, expr FROM system.data_skipping_indices \
|
||||
WHERE database = {s} AND table = {t}) ORDER BY index_name"
|
||||
),
|
||||
DbDriver::Sqlite => format!(
|
||||
"SELECT il.name, il.\"unique\", ii.name FROM pragma_index_list({t}, {s}) il \
|
||||
JOIN pragma_index_info(il.name, {s}) ii ORDER BY il.name, ii.seqno"
|
||||
@@ -169,6 +192,15 @@ pub fn indexes(driver: DbDriver, schema: &str, table: &str) -> String {
|
||||
/// for SQL completion, grouped by table in column order.
|
||||
pub fn completion_columns(driver: DbDriver) -> &'static str {
|
||||
match driver {
|
||||
DbDriver::Clickhouse => {
|
||||
"SELECT c.database, c.table, CASE t.engine WHEN 'View' THEN 'view' \
|
||||
WHEN 'MaterializedView' THEN 'materialized view' WHEN 'WindowView' THEN 'view' ELSE 'table' END, \
|
||||
c.name, c.type, CASE WHEN startsWith(c.type, 'Nullable(') \
|
||||
OR startsWith(c.type, 'LowCardinality(Nullable(') THEN 'YES' ELSE 'NO' END, c.is_in_primary_key, 1 \
|
||||
FROM system.columns c JOIN system.tables t ON t.database = c.database AND t.name = c.table \
|
||||
WHERE lower(c.database) NOT IN ('system', 'information_schema') AND t.is_temporary = 0 \
|
||||
ORDER BY c.database, c.table, c.position LIMIT 50000"
|
||||
}
|
||||
DbDriver::Sqlite => {
|
||||
"SELECT 'main', m.name, m.type, p.name, p.type, CASE WHEN p.\"notnull\" = 0 THEN 'YES' ELSE 'NO' END, \
|
||||
CASE WHEN p.pk > 0 THEN 1 ELSE 0 END, \
|
||||
@@ -221,6 +253,7 @@ pub fn completion_columns(driver: DbDriver) -> &'static str {
|
||||
/// when the key references the primary key implicitly.
|
||||
pub fn foreign_keys(driver: DbDriver) -> &'static str {
|
||||
match driver {
|
||||
DbDriver::Clickhouse => "SELECT '', '', '', '', '', '', '' WHERE 0",
|
||||
DbDriver::Sqlite => {
|
||||
"SELECT 'main', m.name, f.id, f.\"from\", 'main', f.\"table\", f.\"to\" \
|
||||
FROM sqlite_master m JOIN pragma_foreign_key_list(m.name) f \
|
||||
@@ -258,6 +291,7 @@ pub fn foreign_keys(driver: DbDriver) -> &'static str {
|
||||
pub fn table_foreign_keys(driver: DbDriver, schema: &str, table: &str) -> String {
|
||||
let (s, t) = (quote_literal(driver, schema), quote_literal(driver, table));
|
||||
match driver {
|
||||
DbDriver::Clickhouse => foreign_keys(driver).to_owned(),
|
||||
DbDriver::Sqlite => format!(
|
||||
"SELECT {s}, {t}, f.id, f.\"from\", {s}, f.\"table\", f.\"to\" FROM pragma_foreign_key_list({t}, {s}) f ORDER BY f.id, f.seq"
|
||||
),
|
||||
@@ -276,6 +310,7 @@ pub fn table_foreign_keys(driver: DbDriver, schema: &str, table: &str) -> String
|
||||
/// The schema unqualified names resolve to (`None`: always `main`).
|
||||
pub fn current_schema(driver: DbDriver) -> Option<&'static str> {
|
||||
match driver {
|
||||
DbDriver::Clickhouse => Some("SELECT currentDatabase()"),
|
||||
DbDriver::Sqlite => None,
|
||||
DbDriver::Postgres => Some("SELECT current_schema()"),
|
||||
DbDriver::Mysql => Some("SELECT DATABASE()"),
|
||||
@@ -362,6 +397,9 @@ fn where_key(driver: DbDriver, key: &[CellValue]) -> String {
|
||||
/// The DML statement for `edit`. Values are written as quoted literals, which
|
||||
/// every supported database coerces to the column type.
|
||||
pub fn edit_statement(driver: DbDriver, schema: &str, table: &str, edit: &DbRowEdit) -> Result<String, String> {
|
||||
if driver == DbDriver::Clickhouse {
|
||||
return Err("ClickHouse table editing is unsupported: primary keys do not uniquely identify rows and edits require transactions".into());
|
||||
}
|
||||
let target = qualified(driver, schema, table);
|
||||
match edit {
|
||||
DbRowEdit::Update { key, values } => {
|
||||
@@ -450,6 +488,7 @@ pub fn postgres_ddl_indexes(oid: u32) -> String {
|
||||
pub fn triggers(driver: DbDriver, schema: &str, table: &str) -> String {
|
||||
let (s, t) = (quote_literal(driver, schema), quote_literal(driver, table));
|
||||
match driver {
|
||||
DbDriver::Clickhouse => "SELECT '', '', '' WHERE 0".to_owned(),
|
||||
DbDriver::Sqlite => format!(
|
||||
"SELECT name, sql FROM {}.sqlite_master WHERE type = 'trigger' AND tbl_name = {t} ORDER BY name",
|
||||
quote_ident(driver, schema)
|
||||
@@ -492,7 +531,7 @@ pub fn sqlite_trigger_event(sql: &str) -> (String, String) {
|
||||
pub fn routines(driver: DbDriver, schema: &str) -> Option<String> {
|
||||
let s = quote_literal(driver, schema);
|
||||
match driver {
|
||||
DbDriver::Sqlite => None,
|
||||
DbDriver::Sqlite | DbDriver::Clickhouse => None,
|
||||
// Functions of extensions are left out, as in IDEA.
|
||||
DbDriver::Postgres => Some(format!(
|
||||
"SELECT p.proname, CASE WHEN p.prokind = 'p' THEN 'procedure' ELSE 'function' END, \
|
||||
@@ -625,6 +664,71 @@ mod tests {
|
||||
assert_eq!(quote_literal(DbDriver::Mysql, "it's \\"), "'it''s \\\\'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clickhouse_escapes_backslashes_and_delimiters() {
|
||||
let driver = DbDriver::Clickhouse;
|
||||
assert_eq!(quote_literal(driver, r"path\O'Brien"), r"'path\\O''Brien'");
|
||||
assert_eq!(quote_literal(driver, r"\' OR 1 = 1 --"), r"'\\'' OR 1 = 1 --'");
|
||||
assert_eq!(quote_ident(driver, r"a\`b"), r"`a\\\`b`");
|
||||
assert_eq!(qualified(driver, "data base", "event`log"), r"`data base`.`event\`log`");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clickhouse_metadata_preserves_types_defaults_and_nonunique_keys() {
|
||||
let driver = DbDriver::Clickhouse;
|
||||
assert_eq!(schemas(driver, true), "SELECT name FROM system.databases ORDER BY name");
|
||||
assert!(schemas(driver, false).contains("lower(name) NOT IN ('system', 'information_schema')"));
|
||||
assert_eq!(current_schema(driver), Some("SELECT currentDatabase()"));
|
||||
let relations = tables(driver, "event's\\db");
|
||||
assert!(relations.contains("WHEN 'MaterializedView' THEN 'materialized view'"));
|
||||
assert!(relations.contains(r"WHERE database = 'event''s\\db' AND is_temporary = 0"));
|
||||
let fields = columns(driver, "default", "events");
|
||||
assert!(fields.starts_with("SELECT name, type, CASE WHEN startsWith(type, 'Nullable(')"));
|
||||
assert!(fields.contains("startsWith(type, 'LowCardinality(Nullable(')"));
|
||||
assert!(fields.contains("if(default_kind = '', NULL, default_expression), is_in_primary_key"));
|
||||
assert!(fields.ends_with("WHERE database = 'default' AND table = 'events' ORDER BY position"));
|
||||
let keys = indexes(driver, "default", "events");
|
||||
assert!(keys.contains("'PRIMARY KEY' AS index_name, 0 AS is_unique, primary_key AS expression"));
|
||||
assert!(keys.contains("SELECT name, 0, expr FROM system.data_skipping_indices"));
|
||||
let completion = completion_columns(driver);
|
||||
assert!(completion.contains("c.is_in_primary_key, 1"));
|
||||
assert!(completion.contains("ORDER BY c.database, c.table, c.position LIMIT 50000"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clickhouse_has_no_foreign_key_trigger_or_schema_routine_catalogs() {
|
||||
let driver = DbDriver::Clickhouse;
|
||||
assert_eq!(foreign_keys(driver), "SELECT '', '', '', '', '', '', '' WHERE 0");
|
||||
assert_eq!(table_foreign_keys(driver, "default", "events"), foreign_keys(driver));
|
||||
assert_eq!(triggers(driver, "default", "events"), "SELECT '', '', '' WHERE 0");
|
||||
assert!(routines(driver, "default").is_none());
|
||||
assert!(sequences(driver, "default").is_none());
|
||||
assert!(object_ddl(driver, "default", crate::DbObjectKind::Trigger, "t").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clickhouse_pages_quote_keys_and_fetch_one_extra_row() {
|
||||
let sort = [SortKey { column: "event`id".into(), descending: true }];
|
||||
assert_eq!(
|
||||
table_page(DbDriver::Clickhouse, "default", "events", Some("WHERE id > 3"), &sort, 50, 25),
|
||||
r"SELECT * FROM `default`.`events` WHERE id > 3 ORDER BY `event\`id` DESC LIMIT 26 OFFSET 50"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clickhouse_keys_never_enable_transactional_table_edits() {
|
||||
let key = vec![cv("id", Some("1"))];
|
||||
for edit in [
|
||||
DbRowEdit::Update { key: key.clone(), values: vec![cv("name", Some("new"))] },
|
||||
DbRowEdit::Delete { key },
|
||||
DbRowEdit::Insert { values: vec![cv("name", Some("new"))] },
|
||||
DbRowEdit::Insert { values: vec![] },
|
||||
] {
|
||||
let error = edit_statement(DbDriver::Clickhouse, "default", "events", &edit).unwrap_err();
|
||||
assert!(error.contains("primary keys do not uniquely identify rows"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pages() {
|
||||
let sort = [SortKey { column: "id".into(), descending: true }];
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use ide_db::{CellValue, DataSource, DbDriver, DbManager, DbRowEdit, DbSslMode, SortKey, sql};
|
||||
use std::sync::Arc;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn check(condition: bool, message: &str) -> IdeResult<()> {
|
||||
if condition { Ok(()) } else { Err(IdeError::invalid(message)) }
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires CLICKHOUSE_TEST_HOST, CLICKHOUSE_TEST_USER, and CLICKHOUSE_TEST_PASSWORD"]
|
||||
async fn clickhouse_cloud_manager_catalogs_and_session_table() -> IdeResult<()> {
|
||||
let tmp = tempfile::tempdir()?;
|
||||
let root = tmp.path().to_path_buf();
|
||||
let ssh = Arc::new(ide_ssh::SshManager::new(root.join("ssh-passwords.json")));
|
||||
let db = DbManager::new(root.clone(), root.join("passwords.json"), root.join("history.json"), ssh);
|
||||
let mut source = DataSource {
|
||||
id: "clickhouse".into(),
|
||||
name: "clickhouse".into(),
|
||||
driver: DbDriver::Clickhouse,
|
||||
host: std::env::var("CLICKHOUSE_TEST_HOST").expect("set CLICKHOUSE_TEST_HOST"),
|
||||
port: Some(
|
||||
std::env::var("CLICKHOUSE_TEST_PORT")
|
||||
.map(|value| value.parse().expect("numeric CLICKHOUSE_TEST_PORT"))
|
||||
.unwrap_or(8443),
|
||||
),
|
||||
user: std::env::var("CLICKHOUSE_TEST_USER").expect("set CLICKHOUSE_TEST_USER"),
|
||||
database: "default".into(),
|
||||
ssl: DbSslMode::VerifyFull,
|
||||
..Default::default()
|
||||
};
|
||||
source.introspection.show_system = true;
|
||||
let password = std::env::var("CLICKHOUSE_TEST_PASSWORD").expect("set CLICKHOUSE_TEST_PASSWORD");
|
||||
check(!db.test(&source, Some(password.clone()), None).await?.is_empty(), "missing server version")?;
|
||||
db.save_sources(vec![source]).await?;
|
||||
db.set_password("clickhouse", Some(password), false).await?;
|
||||
let cancel = CancellationToken::new();
|
||||
let suffix = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
|
||||
let name = format!("manager_test_{}_{suffix}", std::process::id());
|
||||
let table = sql::quote_ident(DbDriver::Clickhouse, &name);
|
||||
db.execute(
|
||||
"clickhouse",
|
||||
&format!(
|
||||
"CREATE TEMPORARY TABLE {table} (id UInt64, label Nullable(String) DEFAULT NULL, \
|
||||
category LowCardinality(Nullable(String)) DEFAULT NULL, items Array(Nullable(UInt8)), \
|
||||
label_size UInt64 ALIAS length(ifNull(label, ''))) ENGINE = Memory"
|
||||
),
|
||||
0,
|
||||
&cancel,
|
||||
)
|
||||
.await?;
|
||||
let checked: IdeResult<()> = async {
|
||||
db.execute(
|
||||
"clickhouse",
|
||||
&format!("INSERT INTO {table} (id, label, items) VALUES (1, 'first', [1, NULL]), (1, NULL, [])"),
|
||||
0,
|
||||
&cancel,
|
||||
)
|
||||
.await?;
|
||||
let schemas = db.schemas("clickhouse").await?;
|
||||
check(schemas.iter().any(|schema| schema == "default"), "default database missing")?;
|
||||
check(schemas.iter().any(|schema| schema == "system"), "system database missing")?;
|
||||
let tables = db.tables("clickhouse", "system").await?;
|
||||
check(tables.iter().any(|table| table.name == "one"), "system table listing missing one")?;
|
||||
let details = db.table_details("clickhouse", "", &name).await?;
|
||||
check(details.columns.len() == 5, "temporary table columns missing")?;
|
||||
for (name, data_type, nullable) in [
|
||||
("id", "UInt64", false),
|
||||
("label", "Nullable(String)", true),
|
||||
("category", "LowCardinality(Nullable(String))", true),
|
||||
("items", "Array(Nullable(UInt8))", false),
|
||||
] {
|
||||
check(
|
||||
details
|
||||
.columns
|
||||
.iter()
|
||||
.any(|column| column.name == name && column.data_type == data_type && column.nullable == nullable),
|
||||
"column type or nullability mismatch",
|
||||
)?;
|
||||
}
|
||||
check(
|
||||
details.columns.iter().any(|column| column.name == "label" && column.default.as_deref() == Some("NULL")),
|
||||
"explicit default expression missing",
|
||||
)?;
|
||||
check(details.indexes.iter().all(|index| !index.unique), "ClickHouse index reported unique")?;
|
||||
check(details.foreign_keys.is_empty() && details.triggers.is_empty(), "unsupported table objects returned")?;
|
||||
let sort = [SortKey { column: "id".into(), descending: false }];
|
||||
let first = db.table_page("clickhouse", "", &name, None, &sort, 0, 1, &cancel).await?;
|
||||
check(first.result.rows.len() == 1 && first.result.has_more, "first page limit failed")?;
|
||||
let second = db.table_page("clickhouse", "", &name, None, &sort, 1, 1, &cancel).await?;
|
||||
check(second.result.rows.len() == 1 && !second.result.has_more, "page offset failed")?;
|
||||
let ddl = db.ddl("clickhouse", "", &name).await?;
|
||||
check(ddl.contains(&name) && ddl.contains("Memory"), "temporary table DDL missing")?;
|
||||
let completion = db.completion("clickhouse").await?;
|
||||
check(completion.default_schema == "default", "completion default database mismatch")?;
|
||||
check(completion.foreign_keys.is_empty(), "completion returned unsupported foreign keys")?;
|
||||
if let Some(table) = completion.tables.first() {
|
||||
let details = db.table_details("clickhouse", &table.schema, &table.name).await?;
|
||||
check(
|
||||
table.columns.iter().all(|column| {
|
||||
details.columns.iter().any(|field| field.name == column.name && field.data_type == column.data_type)
|
||||
}),
|
||||
"completion columns differ from catalog",
|
||||
)?;
|
||||
}
|
||||
let plan = db.explain("clickhouse", &format!("SELECT id FROM {table}"), &cancel).await?;
|
||||
check(!plan.is_empty(), "query plan missing")?;
|
||||
let value = r"path\O'Brien";
|
||||
let result = db
|
||||
.execute("clickhouse", &format!("SELECT {}", sql::quote_literal(DbDriver::Clickhouse, value)), 1, &cancel)
|
||||
.await?;
|
||||
check(result.rows == vec![vec![Some(value.to_owned())]], "quoted literal changed value")?;
|
||||
let edits = [DbRowEdit::Update {
|
||||
key: vec![CellValue { column: "id".into(), value: Some("1".into()) }],
|
||||
values: vec![CellValue { column: "label".into(), value: Some("changed".into()) }],
|
||||
}];
|
||||
check(
|
||||
db.apply_edits("clickhouse", "", &name, &edits)
|
||||
.await
|
||||
.err()
|
||||
.is_some_and(|error| error.message.contains("table editing is unsupported")),
|
||||
"supplied row key authorized table editing",
|
||||
)?;
|
||||
check(db.set_manual_commit("clickhouse", true).await.is_err(), "manual transactions were accepted")?;
|
||||
for commit in [false, true] {
|
||||
check(db.finish_transaction("clickhouse", commit).await.is_err(), "transaction control was accepted")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
let cleanup = db.execute("clickhouse", &format!("DROP TEMPORARY TABLE {table}"), 0, &cancel).await;
|
||||
db.disconnect("clickhouse").await;
|
||||
checked?;
|
||||
cleanup?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -197,7 +197,7 @@ async fn count(db: &DbManager) -> String {
|
||||
async fn manual_commit_keeps_a_transaction_until_commit_or_rollback() -> IdeResult<()> {
|
||||
let (_tmp, db) = setup().await;
|
||||
let c = CancellationToken::new();
|
||||
db.set_manual_commit("s1", true).await;
|
||||
db.set_manual_commit("s1", true).await.unwrap();
|
||||
db.execute("s1", "INSERT INTO users (name) VALUES ('dee')", 1, &c).await?;
|
||||
// The session sees its own change.
|
||||
assert_eq!(count(&db).await, "4");
|
||||
@@ -208,10 +208,10 @@ async fn manual_commit_keeps_a_transaction_until_commit_or_rollback() -> IdeResu
|
||||
db.finish_transaction("s1", true).await?;
|
||||
// Leaving manual mode rolls back what is still open.
|
||||
db.execute("s1", "DELETE FROM users", 1, &c).await?;
|
||||
db.set_manual_commit("s1", false).await;
|
||||
db.set_manual_commit("s1", false).await.unwrap();
|
||||
assert_eq!(count(&db).await, "4");
|
||||
// Disconnecting with an open transaction does not hang.
|
||||
db.set_manual_commit("s1", true).await;
|
||||
db.set_manual_commit("s1", true).await.unwrap();
|
||||
db.execute("s1", "DELETE FROM users", 1, &c).await?;
|
||||
db.disconnect("s1").await;
|
||||
assert_eq!(count(&db).await, "4");
|
||||
|
||||
@@ -216,8 +216,7 @@ pub async fn connect(host: &Host, window: &str, id: &str) -> IdeResult<()> {
|
||||
/// manual mode rolls back what was not committed).
|
||||
pub async fn set_manual_commit(host: &Host, window: &str, id: &str, manual: bool) -> IdeResult<()> {
|
||||
let db = host.project(window)?.db.clone();
|
||||
db.set_manual_commit(id, manual).await;
|
||||
Ok(())
|
||||
db.set_manual_commit(id, manual).await
|
||||
}
|
||||
|
||||
/// Commits or rolls back the open transaction of a manual-commit data source.
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
|
||||
test("configures a ClickHouse HTTP source and keeps transaction controls unavailable", async ({
|
||||
workbench: page,
|
||||
}, info) => {
|
||||
await page.evaluate(async () => {
|
||||
const storePath = "/src/features/database/store.ts";
|
||||
const bindingsPath = "/src/bindings.ts";
|
||||
const { useDatabase } = await import(storePath);
|
||||
const { commands } = await import(bindingsPath);
|
||||
commands.dbTest = async (source: unknown) => {
|
||||
document.documentElement.dataset.testedSource = JSON.stringify(source);
|
||||
return { status: "ok", data: "ClickHouse" };
|
||||
};
|
||||
useDatabase.setState({ dialog: { kind: "source", source: null } });
|
||||
});
|
||||
const dialog = page.getByRole("dialog", { name: "New Data Source", exact: true });
|
||||
await dialog.getByRole("button", { name: "Data source type", exact: true }).click();
|
||||
await page.getByRole("option", { name: "ClickHouse", exact: true }).click();
|
||||
await expect(dialog.locator("#db-port")).toHaveValue("8123");
|
||||
await expect(dialog.locator("#db-user")).toHaveValue("default");
|
||||
await expect(dialog.locator("#db-database")).toHaveValue("default");
|
||||
await expect(dialog.locator("#db-url")).toHaveValue("http://localhost:8123/default");
|
||||
await dialog.locator("#db-url").fill("https://[email protected]:8443/analytics");
|
||||
await expect(dialog.locator("#db-host")).toHaveValue("db.example");
|
||||
await expect(dialog.locator("#db-port")).toHaveValue("8443");
|
||||
await expect(dialog.locator("#db-user")).toHaveValue("reader");
|
||||
await expect(dialog.locator("#db-database")).toHaveValue("analytics");
|
||||
await dialog.getByRole("button", { name: "Test Connection", exact: true }).click();
|
||||
await expect(dialog.getByRole("status")).toHaveText("Succeeded: ClickHouse");
|
||||
expect(await page.evaluate(() => JSON.parse(document.documentElement.dataset.testedSource!))).toMatchObject({
|
||||
driver: "clickhouse",
|
||||
host: "db.example",
|
||||
port: 8443,
|
||||
user: "reader",
|
||||
database: "analytics",
|
||||
ssl: "verifyFull",
|
||||
});
|
||||
await dialog.screenshot({ path: info.outputPath("clickhouse-source.png") });
|
||||
await dialog.getByRole("tab", { name: "Options", exact: true }).click();
|
||||
await expect(dialog.getByRole("button", { name: "Transaction control", exact: true })).toBeDisabled();
|
||||
});
|
||||
@@ -2405,7 +2405,7 @@ export type DbDriver = "sqlite" | "postgres" |
|
||||
/** MySQL and MariaDB. */
|
||||
"mysql" |
|
||||
/** Microsoft SQL Server. */
|
||||
"sqlServer";
|
||||
"sqlServer" | "clickhouse";
|
||||
|
||||
/** `schema.table (columns) REFERENCES ref_schema.ref_table (ref_columns)`. */
|
||||
export type DbForeignKey = {
|
||||
|
||||
@@ -269,6 +269,7 @@ const TYPES: Record<DbDriver, string[]> = {
|
||||
"bit",
|
||||
"varbinary(max)",
|
||||
],
|
||||
clickhouse: ["UInt64", "Int64", "Float64", "String", "Bool", "Date", "DateTime", "UUID", "Nullable(String)"],
|
||||
};
|
||||
|
||||
interface ModifyProps {
|
||||
@@ -328,7 +329,8 @@ function ModifyTableForm({
|
||||
const driver = driverOf(id);
|
||||
const [name, setName] = useState(table ?? "table_name");
|
||||
const [columns, setColumns] = useState<ColumnDraft[]>(() => {
|
||||
if (!initial) return [{ ...blankColumn(driver, 1), name: "id", primaryKey: true, notNull: true }];
|
||||
if (!initial)
|
||||
return [{ ...blankColumn(driver, 1), name: "id", primaryKey: driver !== "clickhouse", notNull: true }];
|
||||
const drafts = initial.map(draftOf);
|
||||
if (addColumn) drafts.push(blankColumn(driver, drafts.length + 1));
|
||||
return drafts;
|
||||
@@ -448,14 +450,18 @@ function ModifyTableForm({
|
||||
disabled={current.dropped || locked}
|
||||
onChange={(e) => patch({ dataType: e.target.value })}
|
||||
/>
|
||||
<span />
|
||||
<Checkbox
|
||||
checked={current.notNull}
|
||||
disabled={current.dropped || locked}
|
||||
onChange={(notNull) => patch({ notNull })}
|
||||
label="Not null"
|
||||
/>
|
||||
{!table && (
|
||||
{driver !== "clickhouse" && (
|
||||
<>
|
||||
<span />
|
||||
<Checkbox
|
||||
checked={current.notNull}
|
||||
disabled={current.dropped || locked}
|
||||
onChange={(notNull) => patch({ notNull })}
|
||||
label="Not null"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{!table && driver !== "clickhouse" && (
|
||||
<>
|
||||
<span />
|
||||
<Checkbox
|
||||
|
||||
@@ -73,6 +73,10 @@ const SSH_AUTH: Array<SelectOption<SshAuth>> = [
|
||||
|
||||
/** Driver property names the Advanced tab suggests, and what the backend does with them. */
|
||||
const PROPERTIES: Record<string, { names: string[]; hint: string }> = {
|
||||
clickhouse: {
|
||||
names: ["max_execution_time", "max_threads", "connect_timeout"],
|
||||
hint: "ClickHouse settings sent with each HTTP request. connect_timeout is in seconds.",
|
||||
},
|
||||
postgres: {
|
||||
names: [
|
||||
"statement_timeout",
|
||||
@@ -376,6 +380,7 @@ export function SourceDialog({
|
||||
const vendor = vendorOf(s);
|
||||
const sqlite = s.driver === "sqlite";
|
||||
const postgres = s.driver === "postgres";
|
||||
const clickhouse = s.driver === "clickhouse";
|
||||
const urlOnly = !sqlite && !!s.urlOnly;
|
||||
const parsedUrl = urlOnly ? parseSourceUrl(s.url ?? "") : null;
|
||||
const urlValid = !!parsedUrl && parsedUrl.source.driver === s.driver;
|
||||
@@ -398,7 +403,20 @@ export function SourceDialog({
|
||||
setS((x) => {
|
||||
const old = vendorOf(x);
|
||||
const port = x.port == null || x.port === old.port ? v.port : x.port;
|
||||
return { ...x, driver: v.driver, vendor: v.id === v.driver ? "" : v.id, port, ssl: v.ssl ?? x.ssl };
|
||||
return {
|
||||
...x,
|
||||
driver: v.driver,
|
||||
vendor: v.id === v.driver ? "" : v.id,
|
||||
port,
|
||||
ssl: v.ssl ?? (v.driver === "clickhouse" ? "disable" : x.ssl),
|
||||
...(v.driver === "clickhouse"
|
||||
? {
|
||||
user: x.user || "default",
|
||||
database: x.database || "default",
|
||||
options: { ...blankOptions(), ...x.options, transaction: "auto" as const },
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
@@ -614,8 +632,9 @@ export function SourceDialog({
|
||||
<Select
|
||||
aria-label="Transaction control"
|
||||
className={styles.narrowSelect}
|
||||
value={options.transaction ?? "auto"}
|
||||
options={TX}
|
||||
value={clickhouse ? "auto" : (options.transaction ?? "auto")}
|
||||
options={clickhouse ? TX.filter((option) => option.value === "auto") : TX}
|
||||
disabled={clickhouse}
|
||||
onChange={(transaction) => setOptions({ transaction })}
|
||||
/>
|
||||
{postgres && (
|
||||
@@ -640,7 +659,9 @@ export function SourceDialog({
|
||||
id="db-time-zone"
|
||||
value={options.timeZone ?? ""}
|
||||
placeholder={
|
||||
postgres ? "Server default, e.g. UTC or Europe/Berlin" : "UTC (+00:00), e.g. Europe/Berlin or +08:00"
|
||||
postgres || clickhouse
|
||||
? "Server default, e.g. UTC or Europe/Berlin"
|
||||
: "UTC (+00:00), e.g. Europe/Berlin or +08:00"
|
||||
}
|
||||
onChange={(e) => setOptions({ timeZone: e.target.value })}
|
||||
/>
|
||||
@@ -679,9 +700,11 @@ export function SourceDialog({
|
||||
placeholder={
|
||||
postgres
|
||||
? "SET statement_timeout = '30s';"
|
||||
: sqlite
|
||||
? "PRAGMA foreign_keys = ON;"
|
||||
: "SET SESSION sql_mode = 'ANSI';"
|
||||
: clickhouse
|
||||
? "SET max_execution_time = 30;"
|
||||
: sqlite
|
||||
? "PRAGMA foreign_keys = ON;"
|
||||
: "SET SESSION sql_mode = 'ANSI';"
|
||||
}
|
||||
onChange={(e) => setOptions({ startupScript: e.target.value })}
|
||||
/>
|
||||
@@ -834,8 +857,17 @@ export function SourceDialog({
|
||||
aria-label="SSL mode"
|
||||
className={styles.narrowSelect}
|
||||
value={s.ssl ?? "prefer"}
|
||||
options={SSL}
|
||||
onChange={(ssl) => set({ ssl })}
|
||||
options={clickhouse ? SSL.filter((mode) => mode.value !== "prefer") : SSL}
|
||||
onChange={(ssl) => {
|
||||
const secure = ssl !== "disable" && ssl !== "prefer";
|
||||
const oldSecure = s.ssl !== "disable" && s.ssl !== "prefer";
|
||||
set({
|
||||
ssl,
|
||||
...(clickhouse && (s.port == null || s.port === (oldSecure ? 8443 : 8123))
|
||||
? { port: secure ? 8443 : 8123 }
|
||||
: {}),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<FileField
|
||||
id="db-ssl-ca"
|
||||
|
||||
@@ -89,7 +89,8 @@ export function TableEditor({ input }: { input: EditorInput }) {
|
||||
const names = columns.map((c) => c.name);
|
||||
const pk = page?.details.columns.filter((c) => c.primaryKey).map((c) => c.name) ?? [];
|
||||
const fkColumns = new Set(page?.details.foreignKeys.flatMap((k) => k.columns) ?? []);
|
||||
const editable = pk.length > 0 && !sourceById(ref.id)?.readOnly;
|
||||
const source = sourceById(ref.id);
|
||||
const editable = pk.length > 0 && !source?.readOnly && source?.driver !== "clickhouse";
|
||||
const loaded = page?.result.rows.length ?? 0;
|
||||
const rowCount = loaded + edits.inserts.length;
|
||||
const gridColumns = useMemo(
|
||||
@@ -149,7 +150,7 @@ export function TableEditor({ input }: { input: EditorInput }) {
|
||||
});
|
||||
|
||||
const submit = async () => {
|
||||
if (!page) return;
|
||||
if (!page || !editable) return;
|
||||
try {
|
||||
const n = await call(
|
||||
commands.dbApplyEdits(ref.id, ref.schema, ref.table, buildRowEdits(names, page.result.rows, pk, edits)),
|
||||
@@ -300,7 +301,11 @@ export function TableEditor({ input }: { input: EditorInput }) {
|
||||
</div>
|
||||
{page && !editable && (
|
||||
<div className={styles.notice}>
|
||||
{pk.length ? tx("Read-only data source.") : tx("Read-only: the table has no primary key to identify rows.")}
|
||||
{source?.driver === "clickhouse"
|
||||
? tx("Use the query console to change ClickHouse data.")
|
||||
: pk.length
|
||||
? tx("Read-only data source.")
|
||||
: tx("Read-only: the table has no primary key to identify rows.")}
|
||||
</div>
|
||||
)}
|
||||
{error ? (
|
||||
|
||||
@@ -230,6 +230,7 @@ function ConsoleBar({ path, view }: { path: string; view: EditorView }) {
|
||||
const pending = useDatabase((s) => !!bound && s.pendingTx.has(bound));
|
||||
const databases = useDatabase((s) => s.databases);
|
||||
const boundId = bound ? splitTarget(bound).id : null;
|
||||
const supportsTransactions = sourceById(boundId)?.driver !== "clickhouse";
|
||||
useEffect(() => {
|
||||
if (boundId && listsDatabases(sourceById(boundId)) && !useDatabase.getState().databases[boundId])
|
||||
void loadDatabases(boundId);
|
||||
@@ -291,18 +292,22 @@ function ConsoleBar({ path, view }: { path: string; view: EditorView }) {
|
||||
options={ROW_LIMITS.map((v) => ({ value: v, label: tx`${Number(v).toLocaleString("en")} rows` }))}
|
||||
onChange={(v) => useDatabase.setState({ maxRows: Number(v) })}
|
||||
/>
|
||||
<span className={styles.toolbarSeparator} />
|
||||
<Select
|
||||
aria-label="Transaction mode"
|
||||
value={manual ? "manual" : "auto"}
|
||||
options={[
|
||||
{ value: "auto", label: "Tx: Auto" },
|
||||
{ value: "manual", label: "Tx: Manual" },
|
||||
]}
|
||||
onChange={(v) => bound && void setManualCommit(bound, v === "manual")}
|
||||
disabled={!bound}
|
||||
/>
|
||||
{manual && (
|
||||
{supportsTransactions && (
|
||||
<>
|
||||
<span className={styles.toolbarSeparator} />
|
||||
<Select
|
||||
aria-label="Transaction mode"
|
||||
value={manual ? "manual" : "auto"}
|
||||
options={[
|
||||
{ value: "auto", label: "Tx: Auto" },
|
||||
{ value: "manual", label: "Tx: Manual" },
|
||||
]}
|
||||
onChange={(v) => bound && void setManualCommit(bound, v === "manual")}
|
||||
disabled={!bound}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{supportsTransactions && manual && (
|
||||
<>
|
||||
<IconButton
|
||||
icon="commit"
|
||||
|
||||
@@ -102,6 +102,12 @@ describe("export formats", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves backslashes in ClickHouse SQL exports", () => {
|
||||
expect(exportData("sql", { ...data, driver: "clickhouse", rows: [["1", "C:\\new\\file's"]] })).toBe(
|
||||
`INSERT INTO "order items" (id, name) VALUES (1, 'C:\\\\new\\\\file''s');`,
|
||||
);
|
||||
});
|
||||
|
||||
it("writes a Markdown table, numbers right-aligned", () => {
|
||||
expect(exportData("markdown", { ...data, rows: [["1", "a|b"]] })).toBe(
|
||||
"| id | name |\n| --: | --- |\n| 1 | a\\|b |",
|
||||
@@ -184,7 +190,9 @@ describe("data source types", () => {
|
||||
});
|
||||
expect(vendorPreset(vendorById("postgres")!)).toEqual({ driver: "postgres", vendor: "", port: 5432 });
|
||||
expect(vendorPreset(vendorById("supabase")!).ssl).toBe("require");
|
||||
expect(VENDORS.every((v) => ["postgres", "mysql", "sqlite", "sqlServer"].includes(v.driver))).toBe(true);
|
||||
expect(VENDORS.every((v) => ["postgres", "mysql", "sqlite", "sqlServer", "clickhouse"].includes(v.driver))).toBe(
|
||||
true,
|
||||
);
|
||||
const labels = VENDORS.map((v) => v.label);
|
||||
expect(labels).toEqual([...labels].sort((a, b) => a.localeCompare(b)));
|
||||
});
|
||||
@@ -227,6 +235,36 @@ describe("data source types", () => {
|
||||
expect(parseSourceUrl("redshift://r.example:5439/dev")?.source.vendor).toBe("redshift");
|
||||
expect(parseSourceUrl("postgresql://h:70000/db")).toBeNull();
|
||||
});
|
||||
|
||||
it("adds ClickHouse with native HTTP defaults and HTTPS cloud URLs", () => {
|
||||
expect(vendorPreset(vendorById("clickhouse")!)).toEqual({
|
||||
driver: "clickhouse",
|
||||
vendor: "",
|
||||
port: 8123,
|
||||
user: "default",
|
||||
database: "default",
|
||||
ssl: "disable",
|
||||
});
|
||||
expect(sourceUrl({ driver: "clickhouse", host: "localhost", database: "default" })).toBe(
|
||||
"http://localhost:8123/default",
|
||||
);
|
||||
expect(sourceUrl({ driver: "clickhouse", host: "db.example", ssl: "verifyFull", database: "analytics" })).toBe(
|
||||
"https://db.example:8443/analytics",
|
||||
);
|
||||
const parsed = parseSourceUrl("https://reader:s%[email protected]:8443/analytics?max_execution_time=30");
|
||||
expect(parsed?.source).toMatchObject({
|
||||
driver: "clickhouse",
|
||||
host: "db.example",
|
||||
port: 8443,
|
||||
database: "analytics",
|
||||
user: "reader",
|
||||
ssl: "verifyFull",
|
||||
});
|
||||
expect(parsed?.password).toBe("s@cret");
|
||||
expect(parseSourceUrl("http://localhost/default")?.source.ssl).toBe("disable");
|
||||
expect(parseSourceUrl("jdbc:clickhouse://localhost/default")?.source.driver).toBe("clickhouse");
|
||||
expect(parseSourceUrl("https://db.example/default?sslmode=disable")?.source.ssl).toBe("verifyFull");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Schemas tab", () => {
|
||||
|
||||
@@ -79,7 +79,11 @@ export function exportData(
|
||||
}
|
||||
case "sql": {
|
||||
const literal = (v: Cell, i: number) =>
|
||||
v === null ? "NULL" : numeric[i] && Number.isFinite(Number(v)) ? v : `'${v.replace(/'/g, "''")}'`;
|
||||
v === null
|
||||
? "NULL"
|
||||
: numeric[i] && Number.isFinite(Number(v))
|
||||
? v
|
||||
: `'${(d.driver === "clickhouse" ? v.replace(/\\/g, "\\\\") : v).replace(/'/g, "''")}'`;
|
||||
const head = `INSERT INTO ${quoteIdent(d.table, d.driver)} (${names.map((n) => quoteIdent(n, d.driver)).join(", ")}) VALUES`;
|
||||
return d.rows.map((r) => `${head} (${r.map(literal).join(", ")});`).join("\n");
|
||||
}
|
||||
|
||||
@@ -390,6 +390,10 @@ export function parseSourceUrl(raw: string): { source: Partial<DataSource>; pass
|
||||
}
|
||||
}
|
||||
source.user = user;
|
||||
if (vendor.driver === "clickhouse") {
|
||||
if (m[1]!.toLowerCase() === "https") source.ssl = "verifyFull";
|
||||
else if (m[1]!.toLowerCase() === "http") source.ssl = "disable";
|
||||
}
|
||||
if (properties.length) source.properties = properties;
|
||||
return { source, password };
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest";
|
||||
import type { DbColumnInfo } from "@/platform/ipc";
|
||||
import {
|
||||
alterTableSql,
|
||||
canDrop,
|
||||
canModifyTable,
|
||||
createTableSql,
|
||||
draftOf,
|
||||
dropSql,
|
||||
@@ -10,6 +12,7 @@ import {
|
||||
renameSql,
|
||||
scriptSql,
|
||||
truncateSql,
|
||||
quoteIdent,
|
||||
type ColumnDraft,
|
||||
} from "./objects";
|
||||
|
||||
@@ -29,6 +32,51 @@ const users = [
|
||||
];
|
||||
|
||||
describe("database object SQL", () => {
|
||||
it("uses ClickHouse object syntax and escapes identifiers", () => {
|
||||
expect(quoteIdent("clickhouse", 'a\\b"c')).toBe('"a\\\\b\\"c"');
|
||||
expect(ident("clickhouse", "price$usd")).toBe('"price$usd"');
|
||||
expect(renameSql("clickhouse", { kind: "table", schema: "default", name: "users" }, "people")).toBe(
|
||||
'rename table "default".users to "default".people;',
|
||||
);
|
||||
expect(
|
||||
dropSql("clickhouse", { kind: "schema", schema: "", name: "analytics" }, { ifExists: true, cascade: true }),
|
||||
).toBe("drop database if exists analytics;");
|
||||
expect(
|
||||
dropSql(
|
||||
"clickhouse",
|
||||
{ kind: "materializedView", schema: "default", name: "totals" },
|
||||
{ ifExists: true, cascade: true },
|
||||
),
|
||||
).toBe('drop table if exists "default".totals;');
|
||||
expect(truncateSql("clickhouse", "default", "users", { restartIdentity: true, cascade: true })).toBe(
|
||||
'truncate table "default".users;',
|
||||
);
|
||||
expect(canDrop("clickhouse", { kind: "index", schema: "default", name: "ix" })).toBe(false);
|
||||
expect(canModifyTable("clickhouse")).toBe(false);
|
||||
expect(alterTableSql("clickhouse", "default", "users", "people", users.map(draftOf))).toEqual([]);
|
||||
});
|
||||
|
||||
it("creates ClickHouse MergeTree tables without a relational primary key", () => {
|
||||
const drafts = [col("id", "UInt64", { primaryKey: true, nullable: false }), col("name", "Nullable(String)")].map(
|
||||
draftOf,
|
||||
);
|
||||
expect(createTableSql("clickhouse", "default", "people", drafts)).toBe(
|
||||
'create table "default".people\n(\n id UInt64,\n name Nullable(String)\n)\nengine = MergeTree\norder by tuple();\n',
|
||||
);
|
||||
});
|
||||
|
||||
it("writes guarded ClickHouse mutation templates without unique key assumptions", () => {
|
||||
expect(scriptSql("clickhouse", "update", "default", "users", users)).toBe(
|
||||
'alter table "default".users\nupdate email = ?,\n name = ?\nwhere 1 = 0;\n',
|
||||
);
|
||||
expect(scriptSql("clickhouse", "delete", "default", "users", users)).toBe(
|
||||
'alter table "default".users\ndelete\nwhere 1 = 0;\n',
|
||||
);
|
||||
expect(() => scriptSql("clickhouse", "update", "default", "users", users.slice(0, 1))).toThrow(
|
||||
"ClickHouse sorting key columns cannot be updated.",
|
||||
);
|
||||
});
|
||||
|
||||
it("quotes only when needed", () => {
|
||||
expect(ident("postgres", "users")).toBe("users");
|
||||
expect(ident("postgres", "Users")).toBe('"Users"');
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import type { DbColumnInfo, DbDriver } from "@/platform/ipc";
|
||||
|
||||
export function quoteIdent(driver: DbDriver, name: string): string {
|
||||
if (driver === "clickhouse") return `"${name.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
||||
if (driver === "mysql") return `\`${name.replace(/`/g, "``")}\``;
|
||||
if (driver === "sqlServer") return `[${name.replace(/]/g, "]]")}]`;
|
||||
return `"${name.replace(/"/g, '""')}"`;
|
||||
@@ -13,7 +14,12 @@ export function quoteIdent(driver: DbDriver, name: string): string {
|
||||
|
||||
/** Quotes only when needed: lower-case (PostgreSQL) / plain words stay bare, like IDEA's generated SQL. */
|
||||
export function ident(driver: DbDriver, name: string): string {
|
||||
const plain = driver === "postgres" ? /^[a-z_][a-z0-9_$]*$/ : /^[A-Za-z_][A-Za-z0-9_$]*$/;
|
||||
const plain =
|
||||
driver === "postgres"
|
||||
? /^[a-z_][a-z0-9_$]*$/
|
||||
: driver === "clickhouse"
|
||||
? /^[A-Za-z_][A-Za-z0-9_]*$/
|
||||
: /^[A-Za-z_][A-Za-z0-9_$]*$/;
|
||||
return plain.test(name) && !RESERVED.has(name.toUpperCase()) ? name : quoteIdent(driver, name);
|
||||
}
|
||||
|
||||
@@ -83,7 +89,8 @@ export function renameSql(driver: DbDriver, o: DbObjectRef, to: string): string
|
||||
if (driver === "postgres") return `alter view ${q} rename to ${ident(driver, to)};`;
|
||||
return `rename table ${q} to ${qualifiedName(driver, o.schema, to)};`;
|
||||
default:
|
||||
if (driver === "mysql") return `rename table ${q} to ${qualifiedName(driver, o.schema, to)};`;
|
||||
if (driver === "mysql" || driver === "clickhouse")
|
||||
return `rename table ${q} to ${qualifiedName(driver, o.schema, to)};`;
|
||||
return `alter table ${q} rename to ${ident(driver, to)};`;
|
||||
}
|
||||
}
|
||||
@@ -94,7 +101,12 @@ export interface DropOptions {
|
||||
cascade: boolean;
|
||||
}
|
||||
|
||||
export const canDrop = (driver: DbDriver, o: DbObjectRef) => !(o.kind === "schema" && driver === "sqlite");
|
||||
export const canDrop = (driver: DbDriver, o: DbObjectRef) =>
|
||||
driver === "clickhouse"
|
||||
? ["table", "view", "materializedView", "column", "schema"].includes(o.kind)
|
||||
: !(o.kind === "schema" && driver === "sqlite");
|
||||
|
||||
export const canModifyTable = (driver: DbDriver) => driver !== "clickhouse";
|
||||
|
||||
/** IDEA's Confirm Drop preview. */
|
||||
export function dropSql(driver: DbDriver, o: DbObjectRef, opts: DropOptions): string {
|
||||
@@ -108,8 +120,9 @@ export function dropSql(driver: DbDriver, o: DbObjectRef, opts: DropOptions): st
|
||||
return `drop index ${ident(driver, o.name)} on ${qualifiedName(driver, o.schema, o.table ?? "")};`;
|
||||
return `drop index ${ifExists}${qualifiedName(driver, o.schema, o.name)}${cascade};`;
|
||||
case "schema":
|
||||
return `drop ${driver === "mysql" ? "database" : "schema"} ${ifExists}${ident(driver, o.name)}${cascade};`;
|
||||
return `drop ${driver === "mysql" || driver === "clickhouse" ? "database" : "schema"} ${ifExists}${ident(driver, o.name)}${cascade};`;
|
||||
case "materializedView":
|
||||
if (driver === "clickhouse") return `drop table ${ifExists}${qualifiedName(driver, o.schema, o.name)};`;
|
||||
return `drop materialized view ${ifExists}${qualifiedName(driver, o.schema, o.name)}${cascade};`;
|
||||
case "function":
|
||||
case "procedure": {
|
||||
@@ -134,7 +147,7 @@ export function truncateSql(
|
||||
): string {
|
||||
const q = qualifiedName(driver, schema, table);
|
||||
if (driver === "sqlite") return `delete from ${q};`;
|
||||
if (driver === "mysql" || driver === "sqlServer") return `truncate table ${q};`;
|
||||
if (driver === "mysql" || driver === "sqlServer" || driver === "clickhouse") return `truncate table ${q};`;
|
||||
return `truncate table ${q}${opts.restartIdentity ? " restart identity" : ""}${opts.cascade ? " cascade" : ""};`;
|
||||
}
|
||||
|
||||
@@ -169,9 +182,14 @@ export function scriptSql(
|
||||
return `insert into ${q} (${names.join(", ")})\nvalues (${names.map(() => "?").join(", ")});\n`;
|
||||
case "update": {
|
||||
const set = columns.filter((c) => !c.primaryKey).map((c) => `${ident(driver, c.name)} = ?`);
|
||||
if (driver === "clickhouse") {
|
||||
if (!set.length) throw new Error("ClickHouse sorting key columns cannot be updated.");
|
||||
return `alter table ${q}\nupdate ${set.join(",\n ")}\nwhere 1 = 0;\n`;
|
||||
}
|
||||
return `update ${q}\nset ${(set.length ? set : names.map((n) => `${n} = ?`)).join(",\n ")}\nwhere ${where};\n`;
|
||||
}
|
||||
case "delete":
|
||||
if (driver === "clickhouse") return `alter table ${q}\ndelete\nwhere 1 = 0;\n`;
|
||||
return `delete\nfrom ${q}\nwhere ${where};\n`;
|
||||
}
|
||||
}
|
||||
@@ -205,9 +223,9 @@ export function draftOf(c: DbColumnInfo, key: number): ColumnDraft {
|
||||
}
|
||||
|
||||
function columnDefinition(driver: DbDriver, c: ColumnDraft): string {
|
||||
let s = `${ident(driver, c.name)} ${c.dataType.trim() || (driver === "sqlite" ? "TEXT" : "text")}`;
|
||||
let s = `${ident(driver, c.name)} ${c.dataType.trim() || (driver === "clickhouse" ? "String" : driver === "sqlite" ? "TEXT" : "text")}`;
|
||||
if (c.default.trim()) s += ` default ${c.default.trim()}`;
|
||||
if (c.notNull) s += " not null";
|
||||
if (c.notNull && driver !== "clickhouse") s += " not null";
|
||||
return s;
|
||||
}
|
||||
|
||||
@@ -221,8 +239,8 @@ export function createTableSql(
|
||||
const live = columns.filter((c) => !c.dropped && c.name.trim());
|
||||
const keys = live.filter((c) => c.primaryKey).map((c) => ident(driver, c.name));
|
||||
const items = live.map((c) => ` ${columnDefinition(driver, c)}`);
|
||||
if (keys.length) items.push(` primary key (${keys.join(", ")})`);
|
||||
return `create table ${qualifiedName(driver, schema, name)}\n(\n${items.join(",\n")}\n);\n`;
|
||||
if (keys.length && driver !== "clickhouse") items.push(` primary key (${keys.join(", ")})`);
|
||||
return `create table ${qualifiedName(driver, schema, name)}\n(\n${items.join(",\n")}\n)${driver === "clickhouse" ? "\nengine = MergeTree\norder by tuple()" : ""};\n`;
|
||||
}
|
||||
|
||||
/** Whether `alterTableSql` can express changing column `c` (SQLite only renames, adds and drops). */
|
||||
@@ -242,6 +260,7 @@ export function alterTableSql(
|
||||
name: string,
|
||||
columns: readonly ColumnDraft[],
|
||||
): string[] {
|
||||
if (!canModifyTable(driver)) return [];
|
||||
const out: string[] = [];
|
||||
const q = () => qualifiedName(driver, schema, table);
|
||||
for (const c of columns) {
|
||||
|
||||
@@ -174,7 +174,7 @@ export function analyze(text: string, pos: number, driver: DbDriver, lexed: Lexe
|
||||
blocks.forEach((g, depth) => {
|
||||
// Within an enclosing block, the "caret" is the paren of the block nested in it.
|
||||
const [ra, rb] = blockRange(st, parens, g, depth === 0 ? nb : blocks[depth - 1]!);
|
||||
for (const r of parseRefs(st, parens, g, ra, rb, ctes, depth)) {
|
||||
for (const r of parseRefs(st, parens, g, ra, rb, ctes, depth, driver)) {
|
||||
refs.push(r.ref);
|
||||
if (depth === 0) refEnds.set(r.end, r.ref);
|
||||
}
|
||||
@@ -362,6 +362,7 @@ function parseRefs(
|
||||
b: number,
|
||||
ctes: TableRef[],
|
||||
depth: number,
|
||||
driver: DbDriver,
|
||||
): Array<{ ref: TableRef; end: number }> {
|
||||
const out: Array<{ ref: TableRef; end: number }> = [];
|
||||
const level = parens.level(g, a, b);
|
||||
@@ -409,7 +410,10 @@ function parseRefs(
|
||||
const name = identName(last);
|
||||
const schema = parts.length > 1 ? identName(parts.at(-2)!) : null;
|
||||
const al = alias(st, k, b);
|
||||
const cte = schema === null ? ctes.find((c) => c.name.toLowerCase() === name.toLowerCase()) : undefined;
|
||||
const cte =
|
||||
schema === null
|
||||
? ctes.find((c) => (driver === "clickhouse" ? c.name === name : c.name.toLowerCase() === name.toLowerCase()))
|
||||
: undefined;
|
||||
out.push({
|
||||
ref: {
|
||||
schema,
|
||||
|
||||
@@ -7,7 +7,15 @@
|
||||
*/
|
||||
import type { DbCompletion, DbCompletionColumn, DbCompletionTable, DbDriver } from "@/platform/ipc";
|
||||
import type { Analysis, Clause, TableRef } from "./analyze";
|
||||
import { DATA_TYPES, functions, isReserved, joinKeywords, predicateKeywords, statementStarters } from "./dialect";
|
||||
import {
|
||||
DATA_TYPES,
|
||||
functions,
|
||||
isReserved,
|
||||
joinKeywords,
|
||||
nameMatcher,
|
||||
predicateKeywords,
|
||||
statementStarters,
|
||||
} from "./dialect";
|
||||
|
||||
export type CandidateKind =
|
||||
| "table"
|
||||
@@ -55,13 +63,16 @@ export function shouldAutoCompleteSql(analysis: Analysis): boolean {
|
||||
|
||||
const MAX_ALL_COLUMNS = 3000;
|
||||
|
||||
const eq = (a: string | null | undefined, b: string | null | undefined) =>
|
||||
!!a && !!b && a.toLowerCase() === b.toLowerCase();
|
||||
|
||||
/** `name`, quoted when the dialect needs it (case, characters, reserved words). */
|
||||
export function quoteIdent(name: string, driver: DbDriver): string {
|
||||
const plain = driver === "postgres" ? /^[a-z_][a-z0-9_$]*$/ : /^[A-Za-z_][A-Za-z0-9_$]*$/;
|
||||
const plain =
|
||||
driver === "postgres"
|
||||
? /^[a-z_][a-z0-9_$]*$/
|
||||
: driver === "clickhouse"
|
||||
? /^[A-Za-z_][A-Za-z0-9_]*$/
|
||||
: /^[A-Za-z_][A-Za-z0-9_$]*$/;
|
||||
if (plain.test(name) && !isReserved(name)) return name;
|
||||
if (driver === "clickhouse") return `"${name.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
||||
const q = driver === "mysql" ? "`" : '"';
|
||||
return q + name.split(q).join(q + q) + q;
|
||||
}
|
||||
@@ -94,6 +105,7 @@ export interface CompletionInput {
|
||||
}
|
||||
|
||||
export function sqlCandidates({ model, analysis: a, driver, sourceName }: CompletionInput): SqlCandidate[] {
|
||||
const eq = nameMatcher(driver);
|
||||
const ctx = a.context;
|
||||
if (ctx.kind === "none") return [];
|
||||
const typedLower = /[a-z]/.test(a.prefix.text) && !/[A-Z]/.test(a.prefix.text);
|
||||
@@ -224,7 +236,7 @@ export function sqlCandidates({ model, analysis: a, driver, sourceName }: Comple
|
||||
else {
|
||||
if (ctx.ref.viaJoin) {
|
||||
out.push(
|
||||
...conditionCandidates(a, model, ctx.ref, kw).map((c) => ({
|
||||
...conditionCandidates(a, model, ctx.ref, kw, driver).map((c) => ({
|
||||
...c,
|
||||
label: `${kw("ON")} ${c.label}`,
|
||||
insert: `${kw("ON")} ${c.insert ?? c.label}`,
|
||||
@@ -238,7 +250,7 @@ export function sqlCandidates({ model, analysis: a, driver, sourceName }: Comple
|
||||
}
|
||||
|
||||
case "joinCondition":
|
||||
out.push(...conditionCandidates(a, model, ctx.ref, kw));
|
||||
out.push(...conditionCandidates(a, model, ctx.ref, kw, driver));
|
||||
columnContext("on");
|
||||
return out;
|
||||
|
||||
@@ -322,7 +334,7 @@ export function sqlCandidates({ model, analysis: a, driver, sourceName }: Comple
|
||||
if (!visible.some((r) => eq(r.name, c.name))) out.push({ label: c.name, kind: "table", tail: "(CTE)", boost: 0 });
|
||||
const fnBoost = clause === "select" || clause === "having" ? 15 : 8;
|
||||
for (const f of functions(driver)) {
|
||||
const name = lower ? f.name : f.name.toUpperCase();
|
||||
const name = lower || driver === "clickhouse" ? f.name : f.name.toUpperCase();
|
||||
out.push({
|
||||
label: name,
|
||||
kind: "function",
|
||||
@@ -372,7 +384,7 @@ function clauseKeywords(clause: Clause, driver: DbDriver, a: Analysis): string[]
|
||||
case "returning":
|
||||
return ["AS"];
|
||||
default:
|
||||
return [...pred, ...DATA_TYPES[driver].map((t) => t.toUpperCase())];
|
||||
return [...pred, ...DATA_TYPES[driver].map((t) => (driver === "clickhouse" ? t : t.toUpperCase()))];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,6 +402,7 @@ function joinCandidates(
|
||||
kw: Kw,
|
||||
): SqlCandidate[] {
|
||||
if (!model) return [];
|
||||
const eq = nameMatcher(driver);
|
||||
const out: SqlCandidate[] = [];
|
||||
const q = (n: string) => quoteIdent(n, driver);
|
||||
const seen = new Set<string>();
|
||||
@@ -446,8 +459,15 @@ function joinCandidates(
|
||||
}
|
||||
|
||||
/** `JOIN t x ON |` → conditions relating `ref` to the other tables in scope. */
|
||||
function conditionCandidates(a: Analysis, model: DbCompletion | null, ref: TableRef, kw: Kw): SqlCandidate[] {
|
||||
function conditionCandidates(
|
||||
a: Analysis,
|
||||
model: DbCompletion | null,
|
||||
ref: TableRef,
|
||||
kw: Kw,
|
||||
driver: DbDriver,
|
||||
): SqlCandidate[] {
|
||||
if (!model || ref.kind !== "table") return [];
|
||||
const eq = nameMatcher(driver);
|
||||
const out: SqlCandidate[] = [];
|
||||
const find = (r: TableRef) =>
|
||||
model.tables.find((t) => eq(t.name, r.name) && (r.schema ? eq(t.schema, r.schema) : true));
|
||||
|
||||
@@ -24,6 +24,9 @@ export function isReserved(word: string): boolean {
|
||||
return RESERVED.has(word.toUpperCase());
|
||||
}
|
||||
|
||||
export const nameMatcher = (driver: DbDriver) => (a: string | null | undefined, b: string | null | undefined) =>
|
||||
!!a && !!b && (driver === "clickhouse" ? a === b : a.toLowerCase() === b.toLowerCase());
|
||||
|
||||
/** Clause keywords: a word in this set ends a table alias or expression. */
|
||||
export const CLAUSE_WORDS = new Set(
|
||||
(
|
||||
@@ -34,6 +37,30 @@ export const CLAUSE_WORDS = new Set(
|
||||
);
|
||||
|
||||
export function statementStarters(driver: DbDriver): string[] {
|
||||
if (driver === "clickhouse")
|
||||
return [
|
||||
"SELECT",
|
||||
"INSERT INTO",
|
||||
"WITH",
|
||||
"CREATE TABLE",
|
||||
"CREATE VIEW",
|
||||
"CREATE MATERIALIZED VIEW",
|
||||
"CREATE DATABASE",
|
||||
"ALTER TABLE",
|
||||
"DROP TABLE",
|
||||
"DROP VIEW",
|
||||
"DROP DATABASE",
|
||||
"RENAME TABLE",
|
||||
"TRUNCATE TABLE",
|
||||
"EXPLAIN",
|
||||
"SHOW DATABASES",
|
||||
"SHOW TABLES",
|
||||
"SHOW CREATE TABLE",
|
||||
"DESCRIBE TABLE",
|
||||
"USE",
|
||||
"SET",
|
||||
"OPTIMIZE TABLE",
|
||||
];
|
||||
const common = [
|
||||
"SELECT",
|
||||
"INSERT INTO",
|
||||
@@ -177,6 +204,33 @@ export const DATA_TYPES: Record<DbDriver, string[]> = {
|
||||
"VARBINARY",
|
||||
"XML",
|
||||
],
|
||||
clickhouse: [
|
||||
"UInt64",
|
||||
"UInt32",
|
||||
"UInt16",
|
||||
"UInt8",
|
||||
"Int64",
|
||||
"Int32",
|
||||
"Float64",
|
||||
"Float32",
|
||||
"Decimal",
|
||||
"String",
|
||||
"FixedString",
|
||||
"Bool",
|
||||
"Date",
|
||||
"Date32",
|
||||
"DateTime",
|
||||
"DateTime64",
|
||||
"UUID",
|
||||
"IPv4",
|
||||
"IPv6",
|
||||
"Array",
|
||||
"Tuple",
|
||||
"Map",
|
||||
"Nullable",
|
||||
"LowCardinality",
|
||||
"JSON",
|
||||
],
|
||||
};
|
||||
|
||||
const f = (name: string, params: string, returns?: string, aggregate?: boolean): SqlFunction => ({
|
||||
@@ -297,6 +351,38 @@ const FUNCTIONS: Record<DbDriver, SqlFunction[]> = {
|
||||
f("row_number", "", "bigint"),
|
||||
f("rank", "", "bigint"),
|
||||
],
|
||||
clickhouse: [
|
||||
f("count", "*", "UInt64", true),
|
||||
f("sum", "expr", undefined, true),
|
||||
f("avg", "expr", "Float64", true),
|
||||
f("min", "expr", undefined, true),
|
||||
f("max", "expr", undefined, true),
|
||||
f("uniq", "expr", "UInt64", true),
|
||||
f("uniqExact", "expr", "UInt64", true),
|
||||
f("groupArray", "expr", "Array", true),
|
||||
f("argMax", "arg, value", undefined, true),
|
||||
f("coalesce", "value, ..."),
|
||||
f("ifNull", "a, b"),
|
||||
f("nullIf", "a, b"),
|
||||
f("lower", "text", "String"),
|
||||
f("upper", "text", "String"),
|
||||
f("length", "value", "UInt64"),
|
||||
f("concat", "value, ...", "String"),
|
||||
f("substring", "text, start, length", "String"),
|
||||
f("replaceAll", "text, pattern, replacement", "String"),
|
||||
f("abs", "number"),
|
||||
f("round", "number, digits"),
|
||||
f("toString", "expr", "String"),
|
||||
f("toUInt64", "expr", "UInt64"),
|
||||
f("toDate", "expr", "Date"),
|
||||
f("toDateTime", "expr", "DateTime"),
|
||||
f("toStartOfDay", "datetime", "DateTime"),
|
||||
f("now", "", "DateTime"),
|
||||
f("today", "", "Date"),
|
||||
f("arrayJoin", "array"),
|
||||
f("tuple", "value, ...", "Tuple"),
|
||||
f("cast", "expr AS type"),
|
||||
],
|
||||
};
|
||||
|
||||
export function functions(driver: DbDriver): SqlFunction[] {
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface Token {
|
||||
to: number;
|
||||
/** Upper-cased `text` of words, for keyword checks. */
|
||||
upper: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export interface Lexed {
|
||||
@@ -30,13 +31,23 @@ const TWO_CHAR = new Set(["::", "<=", ">=", "<>", "!=", "||", "->", "=>", ":="])
|
||||
|
||||
export function lex(text: string, driver: DbDriver = "sqlite"): Lexed {
|
||||
const mysql = driver === "mysql";
|
||||
const clickhouse = driver === "clickhouse";
|
||||
const tokens: Token[] = [];
|
||||
const comments: Lexed["comments"] = [];
|
||||
const n = text.length;
|
||||
let i = 0;
|
||||
const push = (kind: TokenKind, from: number, to: number) => {
|
||||
const t = text.slice(from, to);
|
||||
tokens.push({ kind, text: t, from, to, upper: kind === "word" ? t.toUpperCase() : t });
|
||||
tokens.push({
|
||||
kind,
|
||||
text: t,
|
||||
from,
|
||||
to,
|
||||
upper: kind === "word" ? t.toUpperCase() : t,
|
||||
...(kind === "quoted" && clickhouse
|
||||
? { value: t.slice(1, t.endsWith(t[0]!) ? -1 : undefined).replace(/\\([\\'"`])/g, "$1") }
|
||||
: {}),
|
||||
});
|
||||
};
|
||||
/** End of a quoted run starting at `i` (closing char doubled = escaped). */
|
||||
const quoted = (start: number, close: string, backslash: boolean): number => {
|
||||
@@ -67,15 +78,15 @@ export function lex(text: string, driver: DbDriver = "sqlite"): Lexed {
|
||||
comments.push({ from: i, to, open: end < 0 });
|
||||
i = to;
|
||||
} else if (c === "'") {
|
||||
const to = quoted(i, "'", mysql);
|
||||
const to = quoted(i, "'", mysql || clickhouse);
|
||||
push("string", i, to);
|
||||
i = to;
|
||||
} else if (c === '"') {
|
||||
const to = quoted(i, '"', false);
|
||||
const to = quoted(i, '"', clickhouse);
|
||||
push(mysql ? "string" : "quoted", i, to);
|
||||
i = to;
|
||||
} else if (c === "`" && mysql) {
|
||||
const to = quoted(i, "`", false);
|
||||
} else if (c === "`" && (mysql || clickhouse)) {
|
||||
const to = quoted(i, "`", clickhouse);
|
||||
push("quoted", i, to);
|
||||
i = to;
|
||||
} else if (c === "$" && driver === "postgres") {
|
||||
@@ -146,6 +157,7 @@ function closed(t: Token): boolean {
|
||||
/** The identifier a word / quoted token names (quotes removed, doubled quotes collapsed). */
|
||||
export function identName(t: Token): string {
|
||||
if (t.kind !== "quoted") return t.text;
|
||||
if (t.value !== undefined) return t.value;
|
||||
const q = t.text[0]!;
|
||||
const body = t.text.endsWith(q) && t.text.length > 1 ? t.text.slice(1, -1) : t.text.slice(1);
|
||||
return body.split(q + q).join(q);
|
||||
|
||||
@@ -21,7 +21,18 @@ export function findParams(text: string, driver: DbDriver, numbered = 0): SqlPar
|
||||
const toks = lex(text, driver).tokens;
|
||||
const out: SqlParam[] = [];
|
||||
let positional = numbered;
|
||||
let typedEnd = -1;
|
||||
toks.forEach((t, i) => {
|
||||
if (t.from < typedEnd) return;
|
||||
if (driver === "clickhouse" && t.kind === "punct" && t.text === "{") {
|
||||
const end = toks.slice(i + 1).find((candidate) => candidate.kind === "punct" && candidate.text === "}");
|
||||
const name = end ? text.slice(t.from, end.to) : "";
|
||||
if (end && /^\{[A-Za-z_][A-Za-z0-9_]*\s*:\s*[^{}]+\}$/.test(name)) {
|
||||
out.push({ name, from: t.from, to: end.to });
|
||||
typedEnd = end.to;
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (t.kind === "param") {
|
||||
// MySQL `@var` / `@@var` are session variables.
|
||||
if (driver === "mysql" && t.text.startsWith("@")) return;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
import type { DbCompletion, DbCompletionColumn, DbCompletionTable, DbDriver } from "@/platform/ipc";
|
||||
import { analyze, type TableRef } from "./analyze";
|
||||
import { CLAUSE_WORDS, DATA_TYPES, functions, isReserved } from "./dialect";
|
||||
import { CLAUSE_WORDS, DATA_TYPES, functions, isReserved, nameMatcher } from "./dialect";
|
||||
import { identName, lex, type Lexed, type Token } from "./lexer";
|
||||
|
||||
export type SqlSymbol =
|
||||
@@ -49,14 +49,13 @@ const NOT_COLUMNS = new Set(
|
||||
).split(" "),
|
||||
);
|
||||
|
||||
const eq = (a: string | null | undefined, b: string | null | undefined) =>
|
||||
!!a && !!b && a.toLowerCase() === b.toLowerCase();
|
||||
|
||||
export function findTable(
|
||||
model: DbCompletion | null,
|
||||
schema: string | null,
|
||||
name: string,
|
||||
driver: DbDriver = "sqlite",
|
||||
): DbCompletionTable | undefined {
|
||||
const eq = nameMatcher(driver);
|
||||
const tables = model?.tables ?? [];
|
||||
if (schema) return tables.find((t) => eq(t.schema, schema) && eq(t.name, name));
|
||||
return (
|
||||
@@ -126,6 +125,7 @@ export class SqlResolver {
|
||||
|
||||
/** Resolves token `i`; `null` when it is no name (keywords, function names…). */
|
||||
resolve(i: number): Resolved | null {
|
||||
const eq = nameMatcher(this.driver);
|
||||
const toks = this.lexed.tokens;
|
||||
const t = toks[i]!;
|
||||
if (!isName(t) || (t.kind === "word" && CLAUSE_WORDS.has(t.upper))) return null;
|
||||
@@ -166,7 +166,7 @@ export class SqlResolver {
|
||||
const schema = qualifier?.at(-1) ?? null;
|
||||
const cte = schema ? undefined : a.ctes.find((c) => eq(c.name, name));
|
||||
if (cte) return { ...span, symbol: { kind: "cte", name: cte.name, def: { from: cte.from, to: cte.to } } };
|
||||
const table = findTable(this.model, schema, name);
|
||||
const table = findTable(this.model, schema, name, this.driver);
|
||||
if (table) return { ...span, symbol: { kind: "table", table } };
|
||||
if (!loaded || (a.verb !== "DROP" && a.verb !== "ALTER" && !checked)) return { ...span, symbol: null };
|
||||
if (schema && !this.model!.tables.some((x) => eq(x.schema, schema))) return { ...span, symbol: null };
|
||||
@@ -228,7 +228,7 @@ export class SqlResolver {
|
||||
clause !== "other" &&
|
||||
clause !== "values" &&
|
||||
clause !== "limit" &&
|
||||
!this.outputAliases(i).has(name.toLowerCase());
|
||||
!this.outputAliases(i).has(this.driver === "clickhouse" ? name : name.toLowerCase());
|
||||
return judged
|
||||
? { ...span, symbol: null, problem: `Unable to resolve column '${name}'` }
|
||||
: { ...span, symbol: null };
|
||||
@@ -247,15 +247,17 @@ export class SqlResolver {
|
||||
}
|
||||
|
||||
private schema(name: string, span: Span): Resolved {
|
||||
const eq = nameMatcher(this.driver);
|
||||
return { ...span, symbol: this.model?.tables.some((x) => eq(x.schema, name)) ? { kind: "schema", name } : null };
|
||||
}
|
||||
|
||||
private findRef(refs: readonly TableRef[], schema: string | null, name: string): TableRef | undefined {
|
||||
const eq = nameMatcher(this.driver);
|
||||
return refs.find((r) => (r.alias ? eq(r.alias, name) : eq(r.name, name) && (!schema || eq(r.schema, schema))));
|
||||
}
|
||||
|
||||
private refSymbol(ref: TableRef): SqlSymbol {
|
||||
const table = ref.kind === "table" ? findTable(this.model, ref.schema, ref.name) : undefined;
|
||||
const table = ref.kind === "table" ? findTable(this.model, ref.schema, ref.name, this.driver) : undefined;
|
||||
if (ref.alias && ref.aliasAt) return { kind: "alias", name: ref.alias, def: ref.aliasAt, table };
|
||||
if (table) return { kind: "table", table };
|
||||
return { kind: "cte", name: ref.name, def: { from: ref.from, to: ref.to } };
|
||||
@@ -264,12 +266,12 @@ export class SqlResolver {
|
||||
/** Columns of a FROM item; `null` when unknown (not in the model, `select *` subqueries). */
|
||||
private columnsOf(ref: TableRef): Array<{ name: string; column?: DbCompletionColumn }> | null {
|
||||
if (ref.columns) return ref.columns.length ? ref.columns.map((name) => ({ name })) : null;
|
||||
const table = findTable(this.model, ref.schema, ref.name);
|
||||
const table = findTable(this.model, ref.schema, ref.name, this.driver);
|
||||
return table ? table.columns.map((c) => ({ name: c.name, column: c })) : null;
|
||||
}
|
||||
|
||||
private columnSymbol(ref: TableRef, col: { name: string; column?: DbCompletionColumn }): SqlSymbol {
|
||||
const table = ref.kind === "table" ? findTable(this.model, ref.schema, ref.name) : undefined;
|
||||
const table = ref.kind === "table" ? findTable(this.model, ref.schema, ref.name, this.driver) : undefined;
|
||||
return { kind: "column", name: col.name, column: col.column, table, owner: ref.alias ?? ref.name };
|
||||
}
|
||||
|
||||
@@ -287,7 +289,7 @@ export class SqlResolver {
|
||||
prev.kind === "number" ||
|
||||
prev.kind === "string"
|
||||
)
|
||||
out.add(identName(t).toLowerCase());
|
||||
out.add(this.driver === "clickhouse" ? identName(t) : identName(t).toLowerCase());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { DbCompletion } from "@/platform/ipc";
|
||||
import type { DbCompletion, DbDriver } from "@/platform/ipc";
|
||||
import { analyze, templateContexts, valuesSlot } from "./analyze";
|
||||
import { aliasFor, quoteIdent, sqlCandidates, type SqlCandidate } from "./complete";
|
||||
import { inLiteral, lex } from "./lexer";
|
||||
import { identName, inLiteral, lex } from "./lexer";
|
||||
import { DATA_TYPES, statementStarters } from "./dialect";
|
||||
import { bindParams, findParams } from "./params";
|
||||
import { SqlResolver } from "./resolve";
|
||||
|
||||
@@ -65,17 +66,13 @@ const model: DbCompletion = {
|
||||
};
|
||||
|
||||
/** Analysis at the `|` of `sql`. */
|
||||
function at(sql: string, driver: "sqlite" | "postgres" | "mysql" = "sqlite") {
|
||||
function at(sql: string, driver: DbDriver = "sqlite") {
|
||||
const pos = sql.indexOf("|");
|
||||
const text = sql.slice(0, pos) + sql.slice(pos + 1);
|
||||
return analyze(text, pos, driver);
|
||||
}
|
||||
|
||||
function complete(
|
||||
sql: string,
|
||||
driver: "sqlite" | "postgres" | "mysql" = "sqlite",
|
||||
m: DbCompletion = model,
|
||||
): SqlCandidate[] {
|
||||
function complete(sql: string, driver: DbDriver = "sqlite", m: DbCompletion = model): SqlCandidate[] {
|
||||
return sqlCandidates({ model: m, analysis: at(sql, driver), driver, sourceName: "shop" }).sort(
|
||||
(a, b) => b.boost - a.boost,
|
||||
);
|
||||
@@ -83,6 +80,14 @@ function complete(
|
||||
const labels = (c: SqlCandidate[], kind?: string) => c.filter((x) => !kind || x.kind === kind).map((x) => x.label);
|
||||
|
||||
describe("lexer", () => {
|
||||
it("reads ClickHouse backticks and escaped quotes without exposing literal parameters", () => {
|
||||
const sql = String.raw`select "a\\b\"c", ` + "`my column`" + String.raw`, 'it\'s ?; text', ?`;
|
||||
const tokens = lex(sql, "clickhouse").tokens;
|
||||
expect(identName(tokens[1]!)).toBe('a\\b"c');
|
||||
expect(tokens.filter((t) => t.kind === "quoted").map(identName)).toEqual(['a\\b"c', "my column"]);
|
||||
expect(findParams(sql, "clickhouse").map((p) => p.name)).toEqual(["?1"]);
|
||||
});
|
||||
|
||||
it("skips comments and knows literals", () => {
|
||||
const l = lex("select 'a;b', \"Col\" -- c\nfrom t /* x */ where a::int = $1", "postgres");
|
||||
expect(l.tokens.map((t) => t.text)).toEqual([
|
||||
@@ -164,6 +169,42 @@ describe("analysis", () => {
|
||||
});
|
||||
|
||||
describe("completion candidates", () => {
|
||||
it("keeps ClickHouse schema, table and column names distinct by case", () => {
|
||||
const catalog: DbCompletion = {
|
||||
defaultSchema: "analytics",
|
||||
foreignKeys: [],
|
||||
tables: [
|
||||
{ schema: "analytics", name: "Events", kind: "table", columns: [col("UpperOnly")] },
|
||||
{ schema: "analytics", name: "events", kind: "table", columns: [col("Value"), col("value")] },
|
||||
{ schema: "Analytics", name: "events", kind: "table", columns: [col("OtherSchema")] },
|
||||
],
|
||||
};
|
||||
expect(labels(complete("select e.| from events e", "clickhouse", catalog))).toEqual(["Value", "value"]);
|
||||
expect(labels(complete("select e.| from Events e", "clickhouse", catalog))).toEqual(["UpperOnly"]);
|
||||
expect(labels(complete("select e.| from Analytics.events e", "clickhouse", catalog))).toEqual(["OtherSchema"]);
|
||||
const problems = (sql: string) => new SqlResolver(sql, "clickhouse", catalog).problems().map((p) => p.message);
|
||||
expect(problems("select Value, value from events")).toEqual([]);
|
||||
expect(problems("select VALUE from events")).toEqual(["Unable to resolve column 'VALUE'"]);
|
||||
expect(problems("select UpperOnly from events")).toEqual(["Unable to resolve column 'UpperOnly'"]);
|
||||
expect(problems("select OtherSchema from Analytics.events")).toEqual([]);
|
||||
expect(problems("select OtherSchema from analytics.events")).toEqual(["Unable to resolve column 'OtherSchema'"]);
|
||||
expect(
|
||||
at("with Events as (select 1 as UpperOnly) select | from events e", "clickhouse").refs.find(
|
||||
(ref) => ref.name === "events",
|
||||
)?.kind,
|
||||
).toBe("table");
|
||||
});
|
||||
|
||||
it("offers ClickHouse SQL and keeps case-sensitive function and type names", () => {
|
||||
expect(statementStarters("clickhouse")).toEqual(expect.arrayContaining(["SHOW DATABASES", "ALTER TABLE"]));
|
||||
expect(statementStarters("clickhouse")).not.toEqual(expect.arrayContaining(["BEGIN", "SET search_path TO"]));
|
||||
expect(DATA_TYPES.clickhouse).toContain("UInt64");
|
||||
expect(complete("SELECT | FROM users", "clickhouse").find((c) => c.label === "toDateTime")).toMatchObject({
|
||||
snippet: "toDateTime(${})",
|
||||
});
|
||||
expect(quoteIdent('a\\b"c', "clickhouse")).toBe('"a\\\\b\\"c"');
|
||||
});
|
||||
|
||||
it("offers statement starters in the user's case", () => {
|
||||
expect(labels(complete("|"))).toContain("SELECT");
|
||||
expect(labels(complete("select 1; ins|"))).toContain("insert into");
|
||||
@@ -388,6 +429,15 @@ describe("resolve", () => {
|
||||
});
|
||||
|
||||
describe("params", () => {
|
||||
it("binds whole ClickHouse typed parameters without treating their types as names", () => {
|
||||
const sql = "select {value:UInt64}, {label: Nullable(String)}, '{skip:String}'";
|
||||
const params = findParams(sql, "clickhouse");
|
||||
expect(params.map((p) => p.name)).toEqual(["{value:UInt64}", "{label: Nullable(String)}"]);
|
||||
expect(bindParams(sql, params, { "{value:UInt64}": "12", "{label: Nullable(String)}": "'test'" })).toBe(
|
||||
"select 12, 'test', '{skip:String}'",
|
||||
);
|
||||
});
|
||||
|
||||
it("finds parameters per dialect and binds values", () => {
|
||||
const names = (sql: string, driver: "sqlite" | "postgres" | "mysql" = "sqlite", n = 0) =>
|
||||
findParams(sql, driver, n).map((p) => p.name);
|
||||
|
||||
@@ -353,7 +353,11 @@ const lastUsed = new Map<string, number>();
|
||||
|
||||
/** Options › Transaction control: a manual data source's consoles start in manual commit. */
|
||||
function applyConnectDefaults(id: string) {
|
||||
if (sourceById(id)?.options?.transaction === "manual" && !useDatabase.getState().manualCommit.has(id))
|
||||
if (
|
||||
sourceById(id)?.driver !== "clickhouse" &&
|
||||
sourceById(id)?.options?.transaction === "manual" &&
|
||||
!useDatabase.getState().manualCommit.has(id)
|
||||
)
|
||||
void setManualCommit(id, true).catch(() => {});
|
||||
}
|
||||
|
||||
@@ -944,6 +948,7 @@ function setPending(id: string, pending: boolean) {
|
||||
|
||||
/** Commits or rolls back the open transaction of a manual-commit data source. */
|
||||
export async function finishTransaction(id: string, commit: boolean): Promise<boolean> {
|
||||
if (sourceById(id)?.driver === "clickhouse") return false;
|
||||
try {
|
||||
await call(commands.dbFinishTransaction(id, commit));
|
||||
log("statement", commit ? "commit" : "rollback", `${promptOf(id)}>`);
|
||||
@@ -975,6 +980,7 @@ async function askEndTransaction(source: DataSource): Promise<boolean | null> {
|
||||
* whether to commit or roll them back.
|
||||
*/
|
||||
export async function setManualCommit(id: string, manual: boolean): Promise<void> {
|
||||
if (sourceById(id)?.driver === "clickhouse" && manual) return;
|
||||
const st = useDatabase.getState();
|
||||
if (manual === st.manualCommit.has(id)) return;
|
||||
if (!manual && st.pendingTx.has(id)) {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DbDriver, DbTablePage } from "@/platform/ipc";
|
||||
import { TableEditor } from "./TableEditor";
|
||||
import { tableInput } from "./tableInput";
|
||||
|
||||
const backend = vi.hoisted(() => ({
|
||||
driver: "clickhouse" as DbDriver,
|
||||
page: vi.fn(),
|
||||
applyEdits: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/platform/ipc")>();
|
||||
return { ...actual, commands: { ...actual.commands, dbTablePage: backend.page, dbApplyEdits: backend.applyEdits } };
|
||||
});
|
||||
|
||||
vi.mock("./store", () => ({
|
||||
sourceById: () => ({ driver: backend.driver, readOnly: false }),
|
||||
withConnection: (_id: string, run: () => unknown) => run(),
|
||||
openDialog: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-virtual", () => ({
|
||||
useVirtualizer: ({ count }: { count: number }) => ({
|
||||
getTotalSize: () => count * 24,
|
||||
getVirtualItems: () => Array.from({ length: count }, (_, index) => ({ index, start: index * 24, key: index })),
|
||||
scrollToIndex: () => {},
|
||||
}),
|
||||
}));
|
||||
|
||||
const page: DbTablePage = {
|
||||
result: {
|
||||
statement: "select * from users",
|
||||
columns: [
|
||||
{ name: "id", typeName: "UInt64" },
|
||||
{ name: "name", typeName: "String" },
|
||||
],
|
||||
rows: [["1", "Ann"]],
|
||||
affected: null,
|
||||
hasMore: false,
|
||||
elapsedMs: 1,
|
||||
executionMs: 1,
|
||||
},
|
||||
details: {
|
||||
columns: [
|
||||
{ name: "id", dataType: "UInt64", primaryKey: true, nullable: false, default: null },
|
||||
{ name: "name", dataType: "String", primaryKey: false, nullable: false, default: null },
|
||||
],
|
||||
indexes: [],
|
||||
foreignKeys: [],
|
||||
triggers: [],
|
||||
},
|
||||
offset: 0,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
backend.driver = "clickhouse";
|
||||
backend.page.mockResolvedValue({ status: "ok", data: page });
|
||||
backend.applyEdits.mockClear();
|
||||
});
|
||||
|
||||
describe("table editor capabilities", () => {
|
||||
it("keeps ClickHouse rows read-only even when metadata marks a primary key", async () => {
|
||||
await act(async () => {
|
||||
render(<TableEditor input={tableInput("source", "default", "users")} />);
|
||||
});
|
||||
const cell = await screen.findByRole("gridcell", { name: "Ann" });
|
||||
fireEvent.doubleClick(cell);
|
||||
expect(screen.queryByRole("textbox", { name: "Edit name" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Add Row" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Submit" })).toBeNull();
|
||||
expect(screen.getByText("Use the query console to change ClickHouse data.")).toBeTruthy();
|
||||
fireEvent.mouseDown(cell, { button: 0 });
|
||||
fireEvent.paste(screen.getByRole("grid"), { clipboardData: { getData: () => "Changed" } });
|
||||
expect(screen.getByRole("gridcell", { name: "Ann" })).toBeTruthy();
|
||||
expect(backend.applyEdits).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps keyed PostgreSQL rows editable", async () => {
|
||||
backend.driver = "postgres";
|
||||
await act(async () => {
|
||||
render(<TableEditor input={tableInput("source", "public", "users")} />);
|
||||
});
|
||||
fireEvent.doubleClick(await screen.findByRole("gridcell", { name: "Ann" }));
|
||||
expect(screen.getByRole("textbox", { name: "Edit name" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Add Row" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Submit" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import { registerActions, type Action } from "@/platform/actions";
|
||||
import { editorService, notifications, showServiceView, toolWindowService } from "@/workbench/contributions";
|
||||
import { splitTarget } from "./model";
|
||||
import { nodeTarget, objectOf, schemaObjectOf, tableFolder, tableOf, type DbNode } from "./nodes";
|
||||
import { canDrop, canRename, reference, scriptSql, type ScriptKind } from "./objects";
|
||||
import { canDrop, canModifyTable, canRename, reference, scriptSql, type ScriptKind } from "./objects";
|
||||
import {
|
||||
DB_TOOL_WINDOW,
|
||||
OUTPUT_TAB,
|
||||
@@ -134,7 +134,15 @@ export async function insertScript(n: DbNode, kind: ScriptKind) {
|
||||
if (useDatabase.getState().details[key]?.status !== "ok") await loadDetails(t.id, t.schema, t.table);
|
||||
const d = useDatabase.getState().details[key];
|
||||
const columns = d?.status === "ok" ? d.value.columns : [];
|
||||
await openConsoleWith(t.id, scriptSql(driverOf(t.id), kind, t.schema, t.table, columns));
|
||||
try {
|
||||
await openConsoleWith(t.id, scriptSql(driverOf(t.id), kind, t.schema, t.table, columns));
|
||||
} catch (e) {
|
||||
notifications().notify({
|
||||
level: "error",
|
||||
title: "Cannot generate the SQL script",
|
||||
body: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** SQL Scripts › Run SQL Script…: picks a `.sql` file and runs all its statements. */
|
||||
@@ -319,7 +327,7 @@ export function registerDatabaseTreeActions(): () => void {
|
||||
run: ({ arg }) => {
|
||||
const n = nodeFrom(arg);
|
||||
const t = n && tableOf(n);
|
||||
if (!t || t.view) return;
|
||||
if (!t || t.view || !canModifyTable(driverOf(t.id))) return;
|
||||
openDialog({
|
||||
kind: "modifyTable",
|
||||
id: t.id,
|
||||
@@ -351,7 +359,7 @@ export function registerDatabaseTreeActions(): () => void {
|
||||
run: ({ arg }) => {
|
||||
const n = nodeFrom(arg);
|
||||
const t = n && tableOf(n);
|
||||
if (t && !t.view)
|
||||
if (t && !t.view && canModifyTable(driverOf(t.id)))
|
||||
openDialog({ kind: "modifyTable", id: t.id, schema: t.schema, table: t.table, addColumn: true });
|
||||
},
|
||||
}),
|
||||
@@ -362,7 +370,7 @@ export function registerDatabaseTreeActions(): () => void {
|
||||
keywords: ["csv", "tsv", "load data"],
|
||||
run: ({ arg }) => {
|
||||
const n = nodeFrom(arg);
|
||||
if (n?.kind === "table" && n.table.kind === "table")
|
||||
if (n?.kind === "table" && n.table.kind === "table" && driverOf(n.id) !== "clickhouse")
|
||||
openDialog({ kind: "import", id: n.id, schema: n.schema, table: n.table.name });
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -4,7 +4,7 @@ import { notifications } from "@/workbench/contributions";
|
||||
import { fileSourceName, parseSourceUrl } from "./model";
|
||||
import { VENDORS, vendorPreset } from "./vendors";
|
||||
import { nodeTarget, objectOf, tableOf, type DbNode } from "./nodes";
|
||||
import { SCRIPT_LABELS, canDrop, canRename, type ScriptKind } from "./objects";
|
||||
import { SCRIPT_LABELS, canDrop, canModifyTable, canRename, type ScriptKind } from "./objects";
|
||||
import { connectedTargets, openDialog, openSourceDialog, sourceById, useDatabase } from "./store";
|
||||
|
||||
const act = (
|
||||
@@ -150,9 +150,17 @@ export function nodeMenu(n: DbNode | null): MenuItem[] {
|
||||
{
|
||||
label: "New",
|
||||
icon: "plus",
|
||||
submenu: [newConsole, sep, act("db.newColumn", n, { disabled: view }), act("db.newTable", n)],
|
||||
submenu: [
|
||||
newConsole,
|
||||
sep,
|
||||
act("db.newColumn", n, { disabled: view || !canModifyTable(driver) }),
|
||||
act("db.newTable", n),
|
||||
],
|
||||
},
|
||||
act("db.modifyTable", n, { label: column ? "Modify Column…" : "Modify Table…", disabled: view }),
|
||||
act("db.modifyTable", n, {
|
||||
label: column ? "Modify Column…" : "Modify Table…",
|
||||
disabled: view || !canModifyTable(driver),
|
||||
}),
|
||||
{ label: "Copy/Paste", submenu: [copyRef] },
|
||||
act("db.editData", n),
|
||||
refresh,
|
||||
@@ -165,7 +173,7 @@ export function nodeMenu(n: DbNode | null): MenuItem[] {
|
||||
sqlScripts(n, true),
|
||||
...(n.kind === "table" && !view
|
||||
? [
|
||||
{ label: "Import/Export", submenu: [act("db.importData", n)] },
|
||||
{ label: "Import/Export", submenu: [act("db.importData", n, { disabled: driver === "clickhouse" })] },
|
||||
{ label: "Object Actions", submenu: [act("db.truncate", n)] },
|
||||
]
|
||||
: []),
|
||||
@@ -203,7 +211,7 @@ export function nodeMenu(n: DbNode | null): MenuItem[] {
|
||||
case "key":
|
||||
return [
|
||||
...(n.kind === "group" && n.group === "columns"
|
||||
? [{ label: "New", icon: "plus", submenu: [act("db.newColumn", n)] }]
|
||||
? [{ label: "New", icon: "plus", submenu: [act("db.newColumn", n, { disabled: !canModifyTable(driver) })] }]
|
||||
: []),
|
||||
refresh,
|
||||
sep,
|
||||
|
||||
@@ -28,6 +28,14 @@ export const VENDORS: readonly DbVendor[] = [
|
||||
scheme: "redshift",
|
||||
ssl: "require",
|
||||
},
|
||||
{
|
||||
id: "clickhouse",
|
||||
label: "ClickHouse",
|
||||
driver: "clickhouse",
|
||||
port: 8123,
|
||||
icon: "db-clickhouse",
|
||||
scheme: "clickhouse",
|
||||
},
|
||||
{
|
||||
id: "cockroachdb",
|
||||
label: "CockroachDB",
|
||||
@@ -101,11 +109,19 @@ export const vendorById = (id: string): DbVendor | undefined => BY_ID.get(id);
|
||||
export function vendorPreset(v: DbVendor): Partial<DataSource> {
|
||||
const preset: Partial<DataSource> = { driver: v.driver, vendor: v.id === v.driver ? "" : v.id, port: v.port };
|
||||
if (v.ssl) preset.ssl = v.ssl;
|
||||
if (v.driver === "clickhouse") {
|
||||
preset.user = "default";
|
||||
preset.database = "default";
|
||||
preset.ssl = "disable";
|
||||
}
|
||||
return preset;
|
||||
}
|
||||
|
||||
/** URL schemes (besides the JDBC ones) that name a type. */
|
||||
const SCHEMES: Record<string, string> = {
|
||||
clickhouse: "clickhouse",
|
||||
http: "clickhouse",
|
||||
https: "clickhouse",
|
||||
postgres: "postgres",
|
||||
postgresql: "postgres",
|
||||
pgsql: "postgres",
|
||||
@@ -136,5 +152,9 @@ export function sourceUrl(s: DataSource): string {
|
||||
const host = bracketed(s.host?.trim() || "localhost");
|
||||
const port = s.port ?? v.port;
|
||||
const db = s.database ? `/${encodeURIComponent(s.database)}` : "";
|
||||
if (v.driver === "clickhouse") {
|
||||
const secure = s.ssl != null && s.ssl !== "disable" && s.ssl !== "prefer";
|
||||
return `${secure ? "https" : "http"}://${host}:${s.port ?? (secure ? 8443 : 8123)}${db}`;
|
||||
}
|
||||
return `jdbc:${v.scheme}://${host}${port ? `:${port}` : ""}${db}`;
|
||||
}
|
||||
@@ -343,6 +343,7 @@
|
||||
"Cannot export data": "无法导出数据",
|
||||
"Cannot find usages of '{name}'": "无法查找 '{name}' 的用法",
|
||||
"Cannot generate the DDL": "无法生成 DDL",
|
||||
"Cannot generate the SQL script": "无法生成 SQL 脚本",
|
||||
"Cannot get the DDL of {name}": "无法获取 {name} 的 DDL",
|
||||
"Cannot open file": "无法打开文件",
|
||||
"Cannot open library class": "无法打开库类",
|
||||
@@ -4603,6 +4604,7 @@
|
||||
"The Database tree shows only the checked names and patterns (* and ?); the connected database always shows.": "数据库树只显示选中的名称和模式(* 和 ?);已连接的数据库始终显示。",
|
||||
"Save the data source to pick from the server's names; patterns (* and ?) work any time.": "保存数据源后可从服务器上的名称中选取;模式(* 和 ?)随时可用。",
|
||||
"Read-only data source.": "只读数据源。",
|
||||
"Use the query console to change ClickHouse data.": "请使用查询控制台修改 ClickHouse 数据。",
|
||||
"Read-only: the table has no primary key to identify rows.": "只读:该表没有可用于标识行的主键。",
|
||||
"The session has finished.": "会话已结束。",
|
||||
"Variables are available while suspended.": "变量仅在挂起时可用。",
|
||||
@@ -4941,6 +4943,7 @@
|
||||
"Add New Interpreter…": "添加新的解释器…",
|
||||
"Interpreter Settings…": "解释器设置…",
|
||||
"Connection settings for SQL Server.": "SQL Server 的连接设置。",
|
||||
"ClickHouse settings sent with each HTTP request. connect_timeout is in seconds.": "每个 HTTP 请求附带的 ClickHouse 设置。connect_timeout 的单位为秒。",
|
||||
"Microsoft SQL Server": "微软 SQL Server",
|
||||
"Add to .gitignore": "添加到 .gitignore",
|
||||
"Add to .git/info/exclude": "添加到 .git/info/exclude",
|
||||
|
||||
@@ -118,6 +118,7 @@ const UNTRANSLATED = new Set<string>([
|
||||
"basedpyright",
|
||||
"Cargo Check",
|
||||
"Clippy",
|
||||
"ClickHouse",
|
||||
"CockroachDB",
|
||||
"CodeLLDB",
|
||||
"CodeLLDB (Rust, C, C++)",
|
||||
|
||||
@@ -303,6 +303,7 @@ const ICONS = {
|
||||
"db-disconnect": "openidea/database/disconnect",
|
||||
"db-sqlite": "openidea/database/sqlite",
|
||||
"db-postgres": "openidea/database/postgres",
|
||||
"db-clickhouse": "openidea/database/clickhouse",
|
||||
"db-mysql": "openidea/database/mysql",
|
||||
"db-sqlserver": "openidea/database/sqlserver",
|
||||
"db-mariadb": "openidea/database/mariadb",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><rect width="512" height="512" rx="8" fill="#161616"/><g fill="#FCFF74"><rect x="83" y="83" width="38" height="346" rx="3"/><rect x="160" y="83" width="38" height="346" rx="3"/><rect x="237" y="83" width="38" height="346" rx="3"/><rect x="314" y="83" width="38" height="346" rx="3"/><rect x="391" y="218" width="38" height="76" rx="3"/></g></svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 434 B |
Reference in new issue
Заблокувати користувача