mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 16:08:05 +00:00
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 <59262001+kkMihai@users.noreply.github.com>
137 lines
6.8 KiB
Rust
137 lines
6.8 KiB
Rust
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(())
|
|
}
|