дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-12 20:56:00 +02:00
Порівняти коміти
14
Коміти
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
7619931c7e | ||
|
|
4f9dc0a56c | ||
|
|
67afcc9805 | ||
|
|
632eb9cbf7 | ||
|
|
88af3cb1eb | ||
|
|
59eb6446d4 | ||
|
|
51c14d3b03 | ||
|
|
c727c5b842 | ||
|
|
b296ac1ab7 | ||
|
|
8cce7ce390 | ||
|
|
1e41fa86ff | ||
|
|
65e0feb98f | ||
|
|
76faa39bc5 | ||
|
|
7f9bf3727f |
No files matched your search
Створено
@@ -4294,18 +4294,22 @@ dependencies = [
|
||||
name = "ide-db"
|
||||
version = "0.1.43"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bb8",
|
||||
"blake3",
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
"flate2",
|
||||
"futures",
|
||||
"ide-core",
|
||||
"ide-http",
|
||||
"ide-ssh",
|
||||
"notify",
|
||||
"open",
|
||||
"oracledb",
|
||||
"parking_lot",
|
||||
"reqwest",
|
||||
"rsa",
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4319,6 +4323,7 @@ dependencies = [
|
||||
"toml_edit 0.25.17+spec-1.1.0",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -412,24 +412,47 @@ pub(crate) fn accept(
|
||||
reason: "the checkout has changes of its own to it; accept it by hand".to_owned(),
|
||||
};
|
||||
for change in changes {
|
||||
// A case-only rename on a case-insensitive disk: both names reach the same file, so
|
||||
// rename it in place before bringing it to the task's version.
|
||||
if let Some(old) = renamed_from.get(&change.rel_path)
|
||||
&& old != &change.rel_path
|
||||
&& old.to_lowercase() == change.rel_path.to_lowercase()
|
||||
&& local_root.join(old).exists()
|
||||
&& same_file(&local_root.join(old), &local_root.join(&change.rel_path))
|
||||
{
|
||||
std::fs::rename(local_root.join(old), local_root.join(&change.rel_path))?;
|
||||
// A case-only rename (`a.txt` → `A.txt`). Where the disk resolves both
|
||||
// spellings to one file it is renamed in place and `apply` then refreshes
|
||||
// the content. On a case-sensitive disk the two names are different files:
|
||||
// the old one's unchanged-ness (it is the file the checkout had) decides —
|
||||
// then the task's version lands at the new name and the old one goes.
|
||||
let old = renamed_from.get(&change.rel_path);
|
||||
let case_only =
|
||||
old.is_some_and(|o| o != &change.rel_path && o.to_lowercase() == change.rel_path.to_lowercase());
|
||||
if case_only {
|
||||
let old = old.expect("case_only implies a rename source");
|
||||
let (old_path, new_path) = (local_root.join(old), local_root.join(&change.rel_path));
|
||||
if same_file(&old_path, &new_path) {
|
||||
std::fs::rename(&old_path, &new_path)?;
|
||||
} else {
|
||||
if !apply(old)? {
|
||||
skipped.push(skip(old));
|
||||
continue;
|
||||
}
|
||||
// `apply(old)` removed the old file (the task's tree has it only at
|
||||
// the new name); now write that version under the new name. `apply`
|
||||
// can't do it — it reads the new name's own base/absence — so the
|
||||
// write is open-coded from the same task bytes `apply` would use.
|
||||
if let Ok(bytes) = std::fs::read(repo.root().join(&change.rel_path)) {
|
||||
if let Some(parent) = new_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(&new_path, bytes)?;
|
||||
}
|
||||
accepted.push(change.rel_path.clone());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if !apply(&change.rel_path)? {
|
||||
skipped.push(skip(&change.rel_path));
|
||||
continue;
|
||||
}
|
||||
accepted.push(change.rel_path.clone());
|
||||
// A rename also takes the file away from its old path (unless only its case changed).
|
||||
if let Some(old) = renamed_from.get(&change.rel_path)
|
||||
&& old.to_lowercase() != change.rel_path.to_lowercase()
|
||||
// A rename also takes the file away from its old path (a case-only rename
|
||||
// is handled above).
|
||||
if !case_only
|
||||
&& let Some(old) = old
|
||||
&& !apply(old)?
|
||||
{
|
||||
skipped.push(skip(old));
|
||||
@@ -644,7 +667,18 @@ mod tests {
|
||||
|
||||
fn git(dir: &Path, args: &[&str]) {
|
||||
let ok = std::process::Command::new("git")
|
||||
.args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"])
|
||||
.args([
|
||||
"-c",
|
||||
"user.name=t",
|
||||
"-c",
|
||||
"user.email=t@t",
|
||||
"-c",
|
||||
"commit.gpgsign=false",
|
||||
// Hermetic line endings: a developer or CI runner with
|
||||
// `core.autocrlf=true` would otherwise check the fixtures out as CRLF.
|
||||
"-c",
|
||||
"core.autocrlf=false",
|
||||
])
|
||||
.args(args)
|
||||
.current_dir(dir)
|
||||
.status()
|
||||
@@ -810,6 +844,9 @@ mod tests {
|
||||
let repo = tmp.path().join("repo");
|
||||
std::fs::create_dir(&repo).unwrap();
|
||||
git(&repo, &["init", "-q", "-b", "main"]);
|
||||
// Hermetic line endings: a machine with `core.autocrlf=true` would check
|
||||
// the fixtures and the worktree out as CRLF.
|
||||
git(&repo, &["config", "core.autocrlf", "false"]);
|
||||
std::fs::write(repo.join("a.txt"), "one\ntwo\n").unwrap();
|
||||
std::fs::write(repo.join("gone.txt"), "bye\n").unwrap();
|
||||
git(&repo, &["add", "."]);
|
||||
|
||||
@@ -194,6 +194,19 @@ where
|
||||
Ok(serde_json::from_value(serde_json::Value::deserialize(d)?).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// A theme id, with the built-in themes that were removed read as the Islands theme of their kind.
|
||||
fn theme_id<'de, D>(d: D) -> Result<String, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let id = String::deserialize(d)?;
|
||||
Ok(match id.as_str() {
|
||||
"one-light" => "light".into(),
|
||||
"one-dark" => "dark".into(),
|
||||
_ => id,
|
||||
})
|
||||
}
|
||||
|
||||
/// [`AgentSettings::defaults`], keeping only the text values of each agent's table.
|
||||
fn agent_defaults<'de, D>(d: D) -> Result<BTreeMap<String, BTreeMap<String, String>>, D::Error>
|
||||
where
|
||||
@@ -557,13 +570,19 @@ pub enum NavigationBarPosition {
|
||||
pub struct AppearanceSettings {
|
||||
/// A built-in theme id (`light`, `dark`, `dark-hc`, …), `system` (legacy
|
||||
/// "Sync with OS"), or a plugin's theme: `plugin:<plugin-id>/<theme-id>`.
|
||||
#[serde(deserialize_with = "theme_id")]
|
||||
#[specta(type = String)]
|
||||
pub theme: String,
|
||||
pub accent: String,
|
||||
pub ui_font_size: f32,
|
||||
pub locale: String,
|
||||
/// Follow the OS light/dark mode with the preferred themes below.
|
||||
pub sync_with_os: bool,
|
||||
#[serde(deserialize_with = "theme_id")]
|
||||
#[specta(type = String)]
|
||||
pub preferred_light_theme: String,
|
||||
#[serde(deserialize_with = "theme_id")]
|
||||
#[specta(type = String)]
|
||||
pub preferred_dark_theme: String,
|
||||
/// A plugin's file icon theme (`<plugin-id>/<theme-id>`); empty = the built-in icons.
|
||||
pub file_icon_theme: String,
|
||||
@@ -1424,6 +1443,19 @@ impl Settings {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn removed_one_themes_read_as_islands_themes() {
|
||||
let settings: Settings = serde_json::from_str(
|
||||
r#"{"appearance":{"theme":"one-dark","preferredLightTheme":"one-light","preferredDarkTheme":"one-dark"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let a = settings.appearance;
|
||||
assert_eq!((a.theme.as_str(), a.preferred_light_theme.as_str()), ("dark", "light"));
|
||||
assert_eq!(a.preferred_dark_theme, "dark");
|
||||
let kept: Settings = serde_json::from_str(r#"{"appearance":{"theme":"solarized-dark"}}"#).unwrap();
|
||||
assert_eq!(kept.appearance.theme, "solarized-dark");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_close_settings_default_off_and_round_trip() {
|
||||
for old in [r#"{}"#, r#"{"general":{"confirmExit":false}}"#] {
|
||||
|
||||
@@ -30,6 +30,11 @@ ide-http.workspace = true
|
||||
reqwest = { workspace = true, features = ["query"] }
|
||||
rustls.workspace = true
|
||||
uuid.workspace = true
|
||||
base64.workspace = true
|
||||
flate2 = "1"
|
||||
rsa = { version = "0.10.0-rc.18", features = ["sha2", "pkcs5"] }
|
||||
zeroize = "1"
|
||||
open = "5"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
//! The introspected catalog of a data source, kept on disk: the schemas, tables and views of
|
||||
//! its databases, so completion knows them from the start of the next run without reading the
|
||||
//! server again. Names only — no data, no credentials. One file per project and data source in
|
||||
//! the app data directory.
|
||||
|
||||
use crate::TableInfo;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// A schema of a database and its tables and views.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(rename = "DbCatalogSchema")]
|
||||
pub struct CatalogSchema {
|
||||
pub name: String,
|
||||
pub tables: Vec<TableInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(rename = "DbCatalogDatabase")]
|
||||
pub struct CatalogDatabase {
|
||||
pub name: String,
|
||||
/// When this database was read, in milliseconds since the Unix epoch.
|
||||
pub read_at: f64,
|
||||
pub schemas: Vec<CatalogSchema>,
|
||||
}
|
||||
|
||||
/// What introspection has read of a data source.
|
||||
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
#[serde(rename = "DbSourceCatalog")]
|
||||
pub struct SourceCatalog {
|
||||
pub databases: Vec<CatalogDatabase>,
|
||||
}
|
||||
|
||||
pub(crate) struct CatalogStore {
|
||||
dir: PathBuf,
|
||||
project: String,
|
||||
/// One writer at a time: an update reads the file, changes a database and writes it back.
|
||||
writing: tokio::sync::Mutex<()>,
|
||||
}
|
||||
|
||||
fn now_ms() -> f64 {
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0.0, |d| d.as_millis() as f64)
|
||||
}
|
||||
|
||||
impl CatalogStore {
|
||||
pub(crate) fn new(dir: PathBuf, root: &Path) -> Self {
|
||||
Self { dir, project: root.to_string_lossy().into_owned(), writing: tokio::sync::Mutex::new(()) }
|
||||
}
|
||||
|
||||
fn file(&self, id: &str) -> PathBuf {
|
||||
let name = blake3::hash(format!("{}\0{id}", self.project).as_bytes()).to_hex();
|
||||
self.dir.join(format!("{}.json", &name.as_str()[..32]))
|
||||
}
|
||||
|
||||
fn read(file: &Path) -> SourceCatalog {
|
||||
std::fs::read(file).ok().and_then(|bytes| serde_json::from_slice(&bytes).ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn write(file: &Path, catalog: &SourceCatalog) -> IdeResult<()> {
|
||||
if let Some(dir) = file.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
// Written beside the file and moved over it, so a crash leaves the old catalog whole.
|
||||
let partial = file.with_extension("json.part");
|
||||
let bytes = serde_json::to_vec(catalog).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
std::fs::write(&partial, bytes)?;
|
||||
std::fs::rename(&partial, file)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn blocking<T: Send + 'static>(task: impl FnOnce() -> T + Send + 'static) -> IdeResult<T> {
|
||||
tokio::task::spawn_blocking(task).await.map_err(|e| IdeError::internal(e.to_string()))
|
||||
}
|
||||
|
||||
/// The saved catalog of data source `id` (`None`: never introspected).
|
||||
pub(crate) async fn load(&self, id: &str) -> IdeResult<Option<SourceCatalog>> {
|
||||
let file = self.file(id);
|
||||
Self::blocking(move || file.exists().then(|| Self::read(&file))).await
|
||||
}
|
||||
|
||||
/// Saves what was just read of `database`, replacing what was known of it.
|
||||
pub(crate) async fn put(&self, id: &str, database: &str, schemas: Vec<CatalogSchema>) -> IdeResult<()> {
|
||||
let _writing = self.writing.lock().await;
|
||||
let (file, name) = (self.file(id), database.to_owned());
|
||||
Self::blocking(move || {
|
||||
let mut catalog = Self::read(&file);
|
||||
catalog.databases.retain(|d| d.name != name);
|
||||
catalog.databases.push(CatalogDatabase { name, read_at: now_ms(), schemas });
|
||||
catalog.databases.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Self::write(&file, &catalog)
|
||||
})
|
||||
.await?
|
||||
}
|
||||
|
||||
/// Forgets the databases not in `keep` (dropped on the server, or no longer shown).
|
||||
pub(crate) async fn retain(&self, id: &str, keep: Vec<String>) -> IdeResult<()> {
|
||||
let _writing = self.writing.lock().await;
|
||||
let file = self.file(id);
|
||||
Self::blocking(move || {
|
||||
if !file.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut catalog = Self::read(&file);
|
||||
let before = catalog.databases.len();
|
||||
catalog.databases.retain(|d| keep.contains(&d.name));
|
||||
if catalog.databases.len() == before { Ok(()) } else { Self::write(&file, &catalog) }
|
||||
})
|
||||
.await?
|
||||
}
|
||||
|
||||
/// Deletes the saved catalog of data source `id` (the data source was removed).
|
||||
pub(crate) fn forget(&self, id: &str) {
|
||||
let _ = std::fs::remove_file(self.file(id));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::DbTableKind;
|
||||
|
||||
fn schema(name: &str, tables: &[&str]) -> CatalogSchema {
|
||||
CatalogSchema {
|
||||
name: name.into(),
|
||||
tables: tables.iter().map(|t| TableInfo { name: (*t).into(), kind: DbTableKind::Table }).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_catalog_survives_a_restart_per_project_and_data_source() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = CatalogStore::new(dir.path().join("db-catalogs"), Path::new("/projects/shop"));
|
||||
assert_eq!(store.load("sf").await.unwrap(), None);
|
||||
store.put("sf", "RAW", vec![schema("PUBLIC", &["EVENTS"])]).await.unwrap();
|
||||
store
|
||||
.put("sf", "ANALYTICS", vec![schema("PUBLIC", &["ORDERS", "USERS"]), schema("STAGING", &[])])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// A new store over the same directory: what the next run of the app sees.
|
||||
let reopened = CatalogStore::new(dir.path().join("db-catalogs"), Path::new("/projects/shop"));
|
||||
let saved = reopened.load("sf").await.unwrap().unwrap();
|
||||
assert_eq!(saved.databases.iter().map(|d| d.name.as_str()).collect::<Vec<_>>(), ["ANALYTICS", "RAW"]);
|
||||
assert_eq!(saved.databases[0].schemas, vec![schema("PUBLIC", &["ORDERS", "USERS"]), schema("STAGING", &[])]);
|
||||
assert!(saved.databases.iter().all(|d| d.read_at > 0.0));
|
||||
|
||||
// Another data source and another project keep their own.
|
||||
assert_eq!(reopened.load("other").await.unwrap(), None);
|
||||
let elsewhere = CatalogStore::new(dir.path().join("db-catalogs"), Path::new("/projects/blog"));
|
||||
assert_eq!(elsewhere.load("sf").await.unwrap(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rereading_replaces_a_database_and_dropped_ones_are_forgotten() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = CatalogStore::new(dir.path().to_path_buf(), Path::new("/p"));
|
||||
store.put("sf", "RAW", vec![schema("PUBLIC", &["OLD"])]).await.unwrap();
|
||||
store.put("sf", "GONE", vec![schema("PUBLIC", &["T"])]).await.unwrap();
|
||||
store.put("sf", "RAW", vec![schema("PUBLIC", &["NEW"])]).await.unwrap();
|
||||
store.retain("sf", vec!["RAW".into()]).await.unwrap();
|
||||
let saved = store.load("sf").await.unwrap().unwrap();
|
||||
assert_eq!(saved.databases.len(), 1);
|
||||
assert_eq!(saved.databases[0].schemas, vec![schema("PUBLIC", &["NEW"])]);
|
||||
// Nothing half-written is left beside the file.
|
||||
let files: Vec<_> = std::fs::read_dir(dir.path()).unwrap().map(|f| f.unwrap().file_name()).collect();
|
||||
assert_eq!(files.len(), 1);
|
||||
|
||||
store.forget("sf");
|
||||
assert_eq!(store.load("sf").await.unwrap(), None);
|
||||
store.retain("sf", vec![]).await.unwrap();
|
||||
assert_eq!(store.load("sf").await.unwrap(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_damaged_file_reads_as_an_empty_catalog() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = CatalogStore::new(dir.path().to_path_buf(), Path::new("/p"));
|
||||
std::fs::write(store.file("sf"), b"{ not json").unwrap();
|
||||
assert_eq!(store.load("sf").await.unwrap(), Some(SourceCatalog::default()));
|
||||
store.put("sf", "RAW", vec![schema("PUBLIC", &["T"])]).await.unwrap();
|
||||
assert_eq!(store.load("sf").await.unwrap().unwrap().databases.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -238,6 +238,7 @@ pub enum Pool {
|
||||
SqlServer(Bb8Pool<MssqlConnectionManager>),
|
||||
Clickhouse(crate::clickhouse::ClickhousePool),
|
||||
Oracle(crate::oracle::OraclePool),
|
||||
Snowflake(crate::snowflake::SnowflakePool),
|
||||
}
|
||||
|
||||
pub(crate) fn db_err(e: sqlx::Error) -> IdeError {
|
||||
@@ -703,6 +704,9 @@ impl Setup {
|
||||
if source.driver == DbDriver::Clickhouse && !o.time_zone.trim().is_empty() {
|
||||
s.params.push(("session_timezone".into(), o.time_zone.trim().into()));
|
||||
}
|
||||
if source.driver == DbDriver::Snowflake && !o.time_zone.trim().is_empty() {
|
||||
s.params.push(("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));
|
||||
@@ -720,6 +724,7 @@ impl Pool {
|
||||
Pool::SqlServer(_) => DbDriver::SqlServer,
|
||||
Pool::Clickhouse(_) => DbDriver::Clickhouse,
|
||||
Pool::Oracle(_) => DbDriver::Oracle,
|
||||
Pool::Snowflake(_) => DbDriver::Snowflake,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -741,6 +746,9 @@ impl Pool {
|
||||
if source.driver == DbDriver::Oracle {
|
||||
return crate::oracle::OraclePool::connect(source, password, &setup).await.map(Pool::Oracle);
|
||||
}
|
||||
if source.driver == DbDriver::Snowflake {
|
||||
return crate::snowflake::SnowflakePool::connect(source, password, setup).await.map(Pool::Snowflake);
|
||||
}
|
||||
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.
|
||||
@@ -899,7 +907,7 @@ impl Pool {
|
||||
Ok(Pool::SqlServer(pool))
|
||||
}
|
||||
DbDriver::Clickhouse => unreachable!(),
|
||||
DbDriver::Oracle => unreachable!(),
|
||||
DbDriver::Oracle | DbDriver::Snowflake => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -907,6 +915,7 @@ impl Pool {
|
||||
match self {
|
||||
Pool::SqlServer(_) | Pool::Oracle(_) => {}
|
||||
Pool::Clickhouse(p) => p.close(),
|
||||
Pool::Snowflake(p) => p.close().await,
|
||||
Pool::Sqlite(p) => p.close().await,
|
||||
Pool::Postgres(p) => p.closed.store(true, Ordering::Relaxed),
|
||||
Pool::Mysql(p) => p.closed.store(true, Ordering::Relaxed),
|
||||
@@ -918,6 +927,7 @@ impl Pool {
|
||||
Pool::SqlServer(_) => false,
|
||||
Pool::Oracle(p) => p.is_closed(),
|
||||
Pool::Clickhouse(p) => p.is_closed(),
|
||||
Pool::Snowflake(p) => p.is_closed(),
|
||||
Pool::Sqlite(p) => p.is_closed(),
|
||||
Pool::Postgres(p) => p.closed.load(Ordering::Relaxed),
|
||||
Pool::Mysql(p) => p.closed.load(Ordering::Relaxed),
|
||||
@@ -929,6 +939,9 @@ impl Pool {
|
||||
if let Pool::Clickhouse(p) = self {
|
||||
return Ok(p.execute(sql, max, &CancellationToken::new()).await?.rows);
|
||||
}
|
||||
if let Pool::Snowflake(p) = self {
|
||||
return Ok(p.metadata().execute(sql, max, &CancellationToken::new()).await?.rows);
|
||||
}
|
||||
let mut conn = Held::acquire(self).await?;
|
||||
conn.apply_context(None).await?;
|
||||
let fetched = conn.fetch_rows(sql, max).await;
|
||||
@@ -953,6 +966,9 @@ impl Pool {
|
||||
if let Pool::Clickhouse(p) = self {
|
||||
return p.execute_from(sql, skip, max_rows, cancel).await;
|
||||
}
|
||||
if let Pool::Snowflake(p) = self {
|
||||
return p.execute_from(sql, skip, max_rows, cancel).await;
|
||||
}
|
||||
let mut conn = Held::acquire(self).await?;
|
||||
let r = match conn.apply_context(None).await {
|
||||
Ok(()) => conn.execute_from(self, sql, skip, max_rows, cancel).await,
|
||||
@@ -970,6 +986,7 @@ impl Pool {
|
||||
pub async fn transaction(&self, statements: &[(String, bool)]) -> IdeResult<u64> {
|
||||
match self {
|
||||
Pool::Clickhouse(_) => Err(crate::clickhouse::transaction_error()),
|
||||
Pool::Snowflake(p) => p.transaction(statements).await,
|
||||
Pool::SqlServer(_) => {
|
||||
let mut held = Held::acquire(self).await?;
|
||||
held.apply_context(None).await?;
|
||||
@@ -1051,6 +1068,8 @@ pub enum Held {
|
||||
SqlServer(Box<bb8::PooledConnection<'static, MssqlConnectionManager>>),
|
||||
Clickhouse(crate::clickhouse::ClickhousePool),
|
||||
Oracle(Box<crate::oracle::OracleConnection>),
|
||||
/// The shared session, and the schema this holder's statements run in.
|
||||
Snowflake(crate::snowflake::SnowflakePool, Option<String>),
|
||||
}
|
||||
|
||||
impl Held {
|
||||
@@ -1067,6 +1086,12 @@ impl Held {
|
||||
Held::Clickhouse(p.clone())
|
||||
}
|
||||
Pool::Oracle(p) => Held::Oracle(Box::new(p.get().await?)),
|
||||
Pool::Snowflake(p) => {
|
||||
if p.is_closed() {
|
||||
return Err(IdeError::new(ErrorKind::Io, "Snowflake connection is closed"));
|
||||
}
|
||||
Held::Snowflake(p.clone(), None)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1111,6 +1136,11 @@ impl Held {
|
||||
}
|
||||
|
||||
pub async fn apply_context(&mut self, schema: Option<&str>) -> IdeResult<()> {
|
||||
if let Held::Snowflake(_, wanted) = self {
|
||||
// Applied with each statement, while it holds the shared session.
|
||||
*wanted = schema.filter(|s| !s.is_empty()).map(str::to_owned);
|
||||
return Ok(());
|
||||
}
|
||||
let driver = match self {
|
||||
Held::Postgres(_) => DbDriver::Postgres,
|
||||
Held::Mysql(_) => DbDriver::Mysql,
|
||||
@@ -1166,7 +1196,7 @@ impl Held {
|
||||
Held::Mysql(c) => fetch!(&mut ****c, sql, max, Cells::Text),
|
||||
Held::SqlServer(c) => mssql_fetch(c, sql, 0, max).await,
|
||||
Held::Oracle(c) => crate::oracle::run(c, sql, 0, max, Cells::Text, &CancellationToken::new()).await,
|
||||
Held::Clickhouse(_) => unreachable!(),
|
||||
Held::Clickhouse(_) | Held::Snowflake(..) => unreachable!(),
|
||||
};
|
||||
if result.is_ok() {
|
||||
self.after_statement(sql);
|
||||
@@ -1188,6 +1218,12 @@ impl Held {
|
||||
match self {
|
||||
Held::SqlServer(c) => mssql_command(c, sql).await,
|
||||
Held::Clickhouse(_) => Err(crate::clickhouse::transaction_error()),
|
||||
Held::Snowflake(c, schema) => {
|
||||
if sql.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
c.execute_in(schema.as_deref(), sql, 0, 0, &CancellationToken::new()).await.map(drop)
|
||||
}
|
||||
Held::Oracle(c) => {
|
||||
let sql = sql.trim();
|
||||
if sql.is_empty() {
|
||||
@@ -1201,7 +1237,9 @@ impl Held {
|
||||
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(_) | Held::Clickhouse(_) | Held::Oracle(_) => unreachable!(),
|
||||
Held::SqlServer(_) | Held::Clickhouse(_) | Held::Oracle(_) | Held::Snowflake(..) => {
|
||||
unreachable!()
|
||||
}
|
||||
}
|
||||
.map_err(db_err)
|
||||
}
|
||||
@@ -1226,7 +1264,7 @@ impl Held {
|
||||
Held::SqlServer(mut c) => {
|
||||
c.0.take();
|
||||
}
|
||||
Held::Clickhouse(_) => {}
|
||||
Held::Clickhouse(_) | Held::Snowflake(..) => {}
|
||||
Held::Oracle(mut c) => c.discard_inner(),
|
||||
}
|
||||
}
|
||||
@@ -1256,6 +1294,9 @@ impl Held {
|
||||
if let Held::Clickhouse(c) = self {
|
||||
return c.execute_from(sql, skip, max_rows, cancel).await;
|
||||
}
|
||||
if let Held::Snowflake(c, schema) = self {
|
||||
return c.execute_in(schema.as_deref(), sql, skip, max_rows, cancel).await;
|
||||
}
|
||||
self.before_statement(sql);
|
||||
let started = Instant::now();
|
||||
// `$id`: the query naming the server process, `$kill`: how another connection stops it.
|
||||
@@ -1300,7 +1341,7 @@ impl Held {
|
||||
}
|
||||
}
|
||||
Held::Oracle(c) => crate::oracle::run(c, sql, skip, max_rows, Cells::Display, cancel).await?,
|
||||
Held::Clickhouse(_) => unreachable!(),
|
||||
Held::Clickhouse(_) | Held::Snowflake(..) => unreachable!(),
|
||||
};
|
||||
self.after_statement(sql);
|
||||
let (columns, rows, affected, has_more, first) = fetched;
|
||||
@@ -1323,7 +1364,7 @@ pub fn begin_statement(driver: DbDriver) -> &'static str {
|
||||
DbDriver::Mysql => "START TRANSACTION",
|
||||
DbDriver::SqlServer => "BEGIN TRAN",
|
||||
DbDriver::Oracle => "", // opens implicitly with autocommit off
|
||||
DbDriver::Sqlite | DbDriver::Postgres | DbDriver::Clickhouse => "BEGIN",
|
||||
DbDriver::Sqlite | DbDriver::Postgres | DbDriver::Clickhouse | DbDriver::Snowflake => "BEGIN",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+207
-14
@@ -2,6 +2,7 @@
|
||||
//! execution ([`conn`]), introspection, table data paging / editing ([`sql`])
|
||||
//! and statement splitting ([`split`]), for SQLite, PostgreSQL and MySQL/MariaDB.
|
||||
|
||||
pub mod catalog;
|
||||
mod clickhouse;
|
||||
pub mod conn;
|
||||
pub mod history;
|
||||
@@ -9,17 +10,20 @@ mod oracle;
|
||||
pub mod plan;
|
||||
pub mod plugin;
|
||||
pub mod secrets;
|
||||
mod snowflake;
|
||||
pub mod source;
|
||||
pub mod split;
|
||||
pub mod sql;
|
||||
mod watch;
|
||||
pub use watch::SourcesWatch;
|
||||
|
||||
pub use catalog::{CatalogDatabase, CatalogSchema, SourceCatalog};
|
||||
pub use conn::{Held, Pool, QueryResult, ResultColumn};
|
||||
pub use history::HistoryEntry;
|
||||
pub use plan::{PlanNode, PlanProperty};
|
||||
pub use source::{
|
||||
DataSource, DbColor, DbDriver, DbIntrospection, DbOptions, DbProperty, DbSsh, DbSshAuth, DbSslMode, DbTxMode,
|
||||
DataSource, DbColor, DbDriver, DbIntrospection, DbOptions, DbProperty, DbSnowflake, DbSnowflakeAuth, DbSsh,
|
||||
DbSshAuth, DbSslMode, DbTxMode,
|
||||
};
|
||||
pub use sql::{CellValue, DbRowEdit, SortKey};
|
||||
|
||||
@@ -111,6 +115,21 @@ pub struct SchemaObjects {
|
||||
pub routines: Vec<RoutineInfo>,
|
||||
pub packages: Vec<PackageInfo>,
|
||||
pub sequences: Vec<SequenceInfo>,
|
||||
/// Objects of kinds only some databases have (Snowflake stages, streams, tasks…).
|
||||
#[serde(default)]
|
||||
pub others: Vec<SchemaObject>,
|
||||
}
|
||||
|
||||
/// A schema object the tree lists by name under a folder of its kind.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(rename = "DbSchemaObject")]
|
||||
pub struct SchemaObject {
|
||||
/// `stage`, `stream`, `task`, `pipe`, `fileFormat`.
|
||||
pub kind: String,
|
||||
pub name: String,
|
||||
/// What the list says about it (a stage's type, a stream's table, a task's schedule).
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
@@ -206,6 +225,18 @@ pub struct TablePage {
|
||||
pub offset: f64,
|
||||
}
|
||||
|
||||
/// What a Snowflake session computes with and acts as, and what it may switch to.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(rename = "DbSessionContext")]
|
||||
pub struct SessionContext {
|
||||
/// Empty: no warehouse is in use.
|
||||
pub warehouse: String,
|
||||
pub role: String,
|
||||
pub warehouses: Vec<String>,
|
||||
pub roles: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(rename = "DbDatabases")]
|
||||
@@ -230,6 +261,8 @@ pub struct DbManager {
|
||||
/// Directory of the global data sources and their saved passwords ([`DbManager::with_global_sources`]).
|
||||
global: Option<(PathBuf, SecretStore)>,
|
||||
history: Arc<HistoryStore>,
|
||||
/// What introspection read of each data source, kept between runs.
|
||||
catalogs: catalog::CatalogStore,
|
||||
/// Records executed statements in the history off the async threads, in order.
|
||||
recorder: std::sync::mpsc::Sender<(String, String, f64)>,
|
||||
/// Passwords entered this session (not saved, or not yet), by credential key.
|
||||
@@ -341,6 +374,9 @@ impl DbManager {
|
||||
/// `passwords` / `history` are the app-wide files of saved passwords
|
||||
/// ([`SecretStore`]) and query history ([`HistoryStore`]).
|
||||
pub fn new(root: PathBuf, passwords: PathBuf, history: PathBuf, ssh: Arc<SshManager>) -> Self {
|
||||
// Beside the query history, in the app data directory.
|
||||
let catalogs = history.parent().unwrap_or(&root).join("db-catalogs");
|
||||
let catalogs = catalog::CatalogStore::new(catalogs, &root);
|
||||
let history = Arc::new(HistoryStore::new(history, &root));
|
||||
let (recorder, statements) = std::sync::mpsc::channel::<(String, String, f64)>();
|
||||
{
|
||||
@@ -360,6 +396,7 @@ impl DbManager {
|
||||
saved_passwords: SecretStore::new(passwords, &root),
|
||||
global: None,
|
||||
history,
|
||||
catalogs,
|
||||
recorder,
|
||||
root,
|
||||
pools: Mutex::default(),
|
||||
@@ -423,6 +460,10 @@ impl DbManager {
|
||||
.ok_or_else(|| IdeError::not_found(format!("no data source {id}")))?
|
||||
.resolved()?;
|
||||
if let Some(db) = database {
|
||||
// The configured schema belongs to the configured database.
|
||||
if source.driver == DbDriver::Snowflake && !source.database.eq_ignore_ascii_case(db) {
|
||||
source.options.default_schema.clear();
|
||||
}
|
||||
source.database = db.to_owned();
|
||||
}
|
||||
Ok(source)
|
||||
@@ -457,9 +498,10 @@ impl DbManager {
|
||||
match sources.iter().find(|s| s.id == o.id) {
|
||||
Some(n) if *n == o => {}
|
||||
gone_or_changed => {
|
||||
let keys = [o.id.clone(), secrets::ssh_key(&o.id)];
|
||||
let keys = [o.id.clone(), secrets::ssh_key(&o.id), Self::sso_credential(&o.id)];
|
||||
match gone_or_changed {
|
||||
None => {
|
||||
self.catalogs.forget(&o.id);
|
||||
for k in &keys {
|
||||
self.session_passwords.lock().remove(k);
|
||||
self.store_of(&o).delete(k);
|
||||
@@ -613,7 +655,9 @@ impl DbManager {
|
||||
ssh_secret: Option<String>,
|
||||
shared: bool,
|
||||
) -> IdeResult<(DataSource, Option<Arc<Tunnel>>)> {
|
||||
let Some(ssh) = source.ssh.as_ref().filter(|s| s.enabled && source.driver != DbDriver::Sqlite) else {
|
||||
// Snowflake is HTTPS to a named host: a tunnel's local end cannot pass its certificate check.
|
||||
let tunneled = !matches!(source.driver, DbDriver::Sqlite | DbDriver::Snowflake);
|
||||
let Some(ssh) = source.ssh.as_ref().filter(|s| s.enabled && tunneled) else {
|
||||
return Ok((source.clone(), None));
|
||||
};
|
||||
let local = |t: &Tunnel| DataSource { host: "127.0.0.1".into(), port: Some(t.port()), ..source.clone() };
|
||||
@@ -667,7 +711,10 @@ impl DbManager {
|
||||
None => self.password(source).await,
|
||||
};
|
||||
let (target, tunnel) = self.endpoint(source, ssh_secret, false).await?;
|
||||
let pool = Pool::connect(&target, password.as_deref(), Some(&self.root)).await.map_err(|e| {
|
||||
self.restore_sso(source).await;
|
||||
let connected = Pool::connect(&target, password.as_deref(), Some(&self.root)).await;
|
||||
self.keep_sso(source).await;
|
||||
let pool = connected.map_err(|e| {
|
||||
through_tunnel(
|
||||
e,
|
||||
source,
|
||||
@@ -680,10 +727,16 @@ impl DbManager {
|
||||
DbDriver::Mysql => "SELECT CONCAT(@@version_comment, ' ', VERSION())",
|
||||
DbDriver::SqlServer => "SELECT @@VERSION",
|
||||
DbDriver::Oracle => "SELECT banner FROM v$version WHERE banner LIKE 'Oracle%' AND ROWNUM = 1",
|
||||
DbDriver::Snowflake => "SELECT 'Snowflake ' || CURRENT_VERSION()",
|
||||
};
|
||||
let result = match &pool {
|
||||
// On the session the login opened: metadata has its own, which would sign in again.
|
||||
Pool::Snowflake(p) => p.execute(version, 1, &CancellationToken::new()).await.map(|r| r.rows),
|
||||
_ => pool.rows(version, 1).await,
|
||||
};
|
||||
let result = pool.rows(version, 1).await;
|
||||
pool.close().await;
|
||||
let rows = if source.driver == DbDriver::Clickhouse { result? } else { result.unwrap_or_default() };
|
||||
let strict = matches!(source.driver, DbDriver::Clickhouse | DbDriver::Snowflake);
|
||||
let rows = if strict { 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()))
|
||||
}
|
||||
@@ -710,7 +763,10 @@ impl DbManager {
|
||||
let source = self.source(id)?;
|
||||
let password = self.password(&source).await;
|
||||
let (target, tunnel) = self.endpoint(&source, None, true).await?;
|
||||
let pool = match Pool::connect(&target, password.as_deref(), Some(&self.root)).await {
|
||||
self.restore_sso(&source).await;
|
||||
let connected = Pool::connect(&target, password.as_deref(), Some(&self.root)).await;
|
||||
self.keep_sso(&source).await;
|
||||
let pool = match connected {
|
||||
Ok(p) => Arc::new(p),
|
||||
Err(e) => {
|
||||
self.drop_unused_tunnel(source_id);
|
||||
@@ -723,9 +779,10 @@ impl DbManager {
|
||||
};
|
||||
// Another caller may have connected meanwhile; keep theirs.
|
||||
let kept = self.pools.lock().entry(id.to_owned()).or_insert_with(|| pool.clone()).clone();
|
||||
if Arc::ptr_eq(&kept, &pool)
|
||||
&& let Some(secs) = source.options.keep_alive_secs.filter(|s| *s > 0)
|
||||
{
|
||||
if !Arc::ptr_eq(&kept, &pool) {
|
||||
// Ours lost the race: close it rather than leave its session open on the server.
|
||||
pool.close().await;
|
||||
} else if let Some(secs) = source.options.keep_alive_secs.filter(|s| *s > 0) {
|
||||
keep_alive(&pool, Duration::from_secs(secs.into()));
|
||||
}
|
||||
Ok(kept)
|
||||
@@ -775,6 +832,23 @@ impl DbManager {
|
||||
let names = first(pool.rows(sql::sqlserver_databases(), MAX_META_ROWS).await?).collect();
|
||||
return Ok(Databases { current, names });
|
||||
}
|
||||
if pool.driver() == DbDriver::Snowflake {
|
||||
let current = pool
|
||||
.rows("SELECT CURRENT_DATABASE()", 1)
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
.and_then(|r| r.into_iter().next().flatten())
|
||||
.unwrap_or_default();
|
||||
// `SHOW` needs no running warehouse and lists what the role may see. Columns: created_on, name…
|
||||
let names = pool
|
||||
.rows("SHOW TERSE DATABASES", MAX_META_ROWS)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter_map(|r| r.into_iter().nth(1).flatten())
|
||||
.collect();
|
||||
return Ok(Databases { current, names });
|
||||
}
|
||||
if pool.driver() != DbDriver::Postgres {
|
||||
return Err(IdeError::invalid("this data source does not list databases"));
|
||||
}
|
||||
@@ -789,17 +863,95 @@ impl DbManager {
|
||||
Ok(Databases { current, names })
|
||||
}
|
||||
|
||||
/// The schemas of `database` of data source `id` with their tables and views (Snowflake):
|
||||
/// what completion needs to follow `database.schema.table` without the tree being opened.
|
||||
/// The result is also saved ([`DbManager::saved_catalog`]) for the next run.
|
||||
pub async fn database_catalog(&self, id: &str, database: &str) -> IdeResult<Vec<CatalogSchema>> {
|
||||
let id = split_target(id).0;
|
||||
let schemas = match &*self.pool(id).await? {
|
||||
Pool::Snowflake(p) => snowflake::meta::catalog(p, database).await?,
|
||||
_ => return Err(IdeError::invalid("this data source does not list other databases")),
|
||||
};
|
||||
if let Err(e) = self.catalogs.put(id, database, schemas.clone()).await {
|
||||
// Completion still has it for this run.
|
||||
tracing::warn!("could not save the catalog of {database}: {}", e.message);
|
||||
}
|
||||
Ok(schemas)
|
||||
}
|
||||
|
||||
/// What earlier introspection of data source `id` saved (`None`: never introspected).
|
||||
/// Read from disk: the data source is not connected to.
|
||||
pub async fn saved_catalog(&self, id: &str) -> IdeResult<Option<SourceCatalog>> {
|
||||
self.catalogs.load(split_target(id).0).await
|
||||
}
|
||||
|
||||
/// Drops the saved databases of data source `id` that are not in `databases` any more.
|
||||
pub async fn retain_catalog(&self, id: &str, databases: Vec<String>) -> IdeResult<()> {
|
||||
self.catalogs.retain(split_target(id).0, databases).await
|
||||
}
|
||||
|
||||
/// Credential key of the single sign-on ID token of data source `id`.
|
||||
fn sso_credential(id: &str) -> String {
|
||||
format!("{id}#sso")
|
||||
}
|
||||
|
||||
/// Hands the saved single sign-on token of `source` to the driver, so it signs in without the browser.
|
||||
async fn restore_sso(&self, source: &DataSource) {
|
||||
let Some(key) = snowflake::sso_key(source) else { return };
|
||||
if snowflake::sso_token(&key).is_none()
|
||||
&& let Some(saved) = self.store_of(source).get(&Self::sso_credential(&source.id))
|
||||
{
|
||||
snowflake::sso_seed(&key, &saved);
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps the token the last sign-in of `source` left in the credential store (or forgets a refused one).
|
||||
async fn keep_sso(&self, source: &DataSource) {
|
||||
let Some(key) = snowflake::sso_key(source) else { return };
|
||||
if source.id.is_empty() {
|
||||
return;
|
||||
}
|
||||
let credential = Self::sso_credential(&source.id);
|
||||
let store = self.store_of(source);
|
||||
let (now, saved) = (snowflake::sso_token(&key), store.get(&credential));
|
||||
if now == saved {
|
||||
return;
|
||||
}
|
||||
match now {
|
||||
Some(token) => {
|
||||
if let Err(e) = store.set(&credential, &token) {
|
||||
tracing::warn!("could not save the single sign-on token: {}", e.message);
|
||||
}
|
||||
}
|
||||
None => store.delete(&credential),
|
||||
}
|
||||
}
|
||||
|
||||
/// The warehouse and role of the session of data source `id` (Snowflake), and its choices.
|
||||
pub async fn session_context(&self, id: &str) -> IdeResult<SessionContext> {
|
||||
match &*self.pool(id).await? {
|
||||
Pool::Snowflake(p) => snowflake::meta::session(p).await,
|
||||
_ => Err(IdeError::invalid("this data source has no warehouse or role")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every schema (the Schemas tab picks the ones the tree shows); system schemas
|
||||
/// only with [`DbIntrospection::show_system`].
|
||||
pub async fn schemas(&self, id: &str) -> IdeResult<Vec<String>> {
|
||||
let system = self.source(id)?.introspection.show_system;
|
||||
let pool = self.pool(id).await?;
|
||||
if let Pool::Snowflake(p) = &*pool {
|
||||
return snowflake::meta::schemas(p, system).await;
|
||||
}
|
||||
let rows = pool.rows(sql::schemas(pool.driver(), system), MAX_META_ROWS).await?;
|
||||
Ok(rows.into_iter().filter_map(|r| r.into_iter().next().flatten()).collect())
|
||||
}
|
||||
|
||||
pub async fn tables(&self, id: &str, schema: &str) -> IdeResult<Vec<TableInfo>> {
|
||||
let pool = self.pool(id).await?;
|
||||
if let Pool::Snowflake(p) = &*pool {
|
||||
return snowflake::meta::tables(p, schema).await;
|
||||
}
|
||||
let rows = pool.rows(&sql::tables(pool.driver(), schema), MAX_META_ROWS).await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
@@ -813,6 +965,9 @@ impl DbManager {
|
||||
|
||||
pub async fn table_details(&self, id: &str, schema: &str, table: &str) -> IdeResult<TableDetails> {
|
||||
let pool = self.pool(id).await?;
|
||||
if let Pool::Snowflake(p) = &*pool {
|
||||
return snowflake::meta::table_details(p, schema, table).await;
|
||||
}
|
||||
let driver = pool.driver();
|
||||
let columns = pool
|
||||
.rows(&sql::columns(driver, schema, table), MAX_META_ROWS)
|
||||
@@ -840,7 +995,7 @@ impl DbManager {
|
||||
_ => indexes.push(IndexInfo { name, unique: flag(&unique), columns: vec![col] }),
|
||||
}
|
||||
}
|
||||
let fk_rows = if driver == DbDriver::Clickhouse {
|
||||
let fk_rows = if matches!(driver, DbDriver::Clickhouse | DbDriver::Snowflake) {
|
||||
Vec::new()
|
||||
} else {
|
||||
pool.rows(&sql::table_foreign_keys(driver, schema, table), MAX_META_ROWS).await.unwrap_or_default()
|
||||
@@ -857,7 +1012,7 @@ impl DbManager {
|
||||
.collect();
|
||||
}
|
||||
foreign_keys.retain(|k| k.columns.len() == k.ref_columns.len());
|
||||
let trigger_rows = if driver == DbDriver::Clickhouse {
|
||||
let trigger_rows = if matches!(driver, DbDriver::Clickhouse | DbDriver::Snowflake) {
|
||||
Vec::new()
|
||||
} else {
|
||||
pool.rows(&sql::triggers(driver, schema, table), MAX_META_ROWS).await.unwrap_or_else(|e| {
|
||||
@@ -884,6 +1039,9 @@ impl DbManager {
|
||||
/// The routines and sequences of `schema`.
|
||||
pub async fn schema_objects(&self, id: &str, schema: &str) -> IdeResult<SchemaObjects> {
|
||||
let pool = self.pool(id).await?;
|
||||
if let Pool::Snowflake(p) = &*pool {
|
||||
return snowflake::meta::schema_objects(p, schema).await;
|
||||
}
|
||||
let driver = pool.driver();
|
||||
let mut out = SchemaObjects::default();
|
||||
if let Some(q) = sql::routines(driver, schema) {
|
||||
@@ -933,6 +1091,14 @@ impl DbManager {
|
||||
/// The definition of a routine (every overload), package (spec and body), trigger or sequence.
|
||||
pub async fn object_ddl(&self, id: &str, schema: &str, kind: DbObjectKind, name: &str) -> IdeResult<String> {
|
||||
let pool = self.pool(id).await?;
|
||||
if let Pool::Snowflake(p) = &*pool {
|
||||
let defs = snowflake::meta::object_ddl(p, schema, kind, name).await?;
|
||||
if defs.is_empty() {
|
||||
return Err(IdeError::not_found(format!("{schema}.{name}")));
|
||||
}
|
||||
let defs: Vec<String> = defs.iter().map(|d| format!("{};\n", d.trim_end().trim_end_matches(';'))).collect();
|
||||
return Ok(defs.join("\n"));
|
||||
}
|
||||
if kind == DbObjectKind::Package {
|
||||
let mut defs: Vec<String> = Vec::new();
|
||||
for body in [false, true] {
|
||||
@@ -1033,6 +1199,26 @@ impl DbManager {
|
||||
.next();
|
||||
ddl.map(|d| format!("{};\n", d.trim_end().trim_end_matches(';'))).ok_or_else(missing)
|
||||
}
|
||||
DbDriver::Snowflake => {
|
||||
let path = match &*pool {
|
||||
Pool::Snowflake(p) => p.database_ident().map(|db| format!("{db}.")).unwrap_or_default(),
|
||||
_ => String::new(),
|
||||
};
|
||||
let target = sql::quote_literal(driver, &format!("{path}{}", sql::qualified(driver, schema, name)));
|
||||
let mut ddl = None;
|
||||
// Views answer to their own object type on some accounts.
|
||||
for kind in ["TABLE", "VIEW"] {
|
||||
match pool.rows(&format!("SELECT GET_DDL('{kind}', {target})"), 1).await {
|
||||
Ok(rows) => {
|
||||
ddl = cells(rows, 0).into_iter().next();
|
||||
break;
|
||||
}
|
||||
Err(_) if kind == "TABLE" => {}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
ddl.map(|d| format!("{};\n", d.trim_end().trim_end_matches(';'))).ok_or_else(missing)
|
||||
}
|
||||
DbDriver::Postgres => {
|
||||
let rel = pool.rows(&sql::postgres_relation(schema, name), 1).await?.into_iter().next();
|
||||
let (oid, kind) = match rel.as_deref() {
|
||||
@@ -1081,7 +1267,14 @@ impl DbManager {
|
||||
let pool = self.pool(id).await?;
|
||||
let driver = pool.driver();
|
||||
let mut tables: Vec<CompletionTable> = Vec::new();
|
||||
for r in pool.rows(sql::completion_columns(driver), MAX_META_ROWS).await? {
|
||||
let rows = match &*pool {
|
||||
Pool::Snowflake(p) => {
|
||||
tables = snowflake::meta::completion(p).await?;
|
||||
Vec::new()
|
||||
}
|
||||
_ => pool.rows(sql::completion_columns(driver), MAX_META_ROWS).await?,
|
||||
};
|
||||
for r in rows {
|
||||
let mut it = r.into_iter().map(Option::unwrap_or_default);
|
||||
let mut next = || it.next().unwrap_or_default();
|
||||
let (schema, name, kind) = (next(), next(), next());
|
||||
@@ -1106,7 +1299,7 @@ impl DbManager {
|
||||
}
|
||||
}
|
||||
// Foreign keys are a nicety (JOIN completion): a server that refuses the catalog query still completes.
|
||||
let fk_rows = if driver == DbDriver::Clickhouse {
|
||||
let fk_rows = if matches!(driver, DbDriver::Clickhouse | DbDriver::Snowflake) {
|
||||
Vec::new()
|
||||
} else {
|
||||
pool.rows(sql::foreign_keys(driver), MAX_META_ROWS).await.unwrap_or_else(|e| {
|
||||
|
||||
@@ -39,6 +39,7 @@ pub fn explain_statement(driver: DbDriver, statement: &str) -> String {
|
||||
DbDriver::Mysql => format!("EXPLAIN {s}"),
|
||||
DbDriver::SqlServer => format!("SET SHOWPLAN_TEXT ON; {s}"),
|
||||
DbDriver::Oracle => format!("EXPLAIN PLAN FOR {s}"),
|
||||
DbDriver::Snowflake => format!("EXPLAIN USING TABULAR {s}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +78,10 @@ pub fn parse(driver: DbDriver, result: &QueryResult) -> IdeResult<Vec<PlanNode>>
|
||||
Ok(mysql(&names, &result.rows))
|
||||
}
|
||||
DbDriver::SqlServer => Ok(Vec::new()),
|
||||
DbDriver::Snowflake => {
|
||||
let names: Vec<&str> = result.columns.iter().map(|c| c.name.as_str()).collect();
|
||||
Ok(snowflake(&names, &result.rows))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,6 +202,42 @@ fn oracle(rows: &[Vec<Option<String>>]) -> Vec<PlanNode> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Snowflake's `EXPLAIN USING TABULAR`: one row per operation with its parents' ids; the
|
||||
/// first row totals the partitions and bytes the statement would scan.
|
||||
fn snowflake(names: &[&str], rows: &[Vec<Option<String>>]) -> Vec<PlanNode> {
|
||||
let column = |name: &str| names.iter().position(|n| n.eq_ignore_ascii_case(name));
|
||||
let cell = |r: &[Option<String>], name: &str| column(name).and_then(|i| r.get(i).cloned().flatten());
|
||||
let mut ids: Vec<String> = Vec::new();
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
let operation = cell(r, "operation").unwrap_or_default();
|
||||
let id = cell(r, "id").unwrap_or_default();
|
||||
// `[0]`, `[1, 2]`: the first is the operation this one feeds.
|
||||
let parent = cell(r, "parentOperators")
|
||||
.and_then(|p| p.trim_matches(['[', ']']).split(',').next().map(|p| p.trim().to_owned()))
|
||||
.and_then(|p| ids.iter().position(|x| *x == p))
|
||||
.map(|i| i as u32);
|
||||
let mut params = cell(r, "objects").unwrap_or_default();
|
||||
if let Some(alias) = cell(r, "alias").filter(|a| !a.is_empty()) {
|
||||
params = format!("{params} {alias}").trim().to_owned();
|
||||
}
|
||||
let mut properties = Vec::new();
|
||||
for (label, name) in [
|
||||
("Expressions", "expressions"),
|
||||
("Partitions scanned", "partitionsAssigned"),
|
||||
("Partitions total", "partitionsTotal"),
|
||||
("Bytes scanned", "bytesAssigned"),
|
||||
] {
|
||||
if let Some(value) = cell(r, name).filter(|v| !v.is_empty()) {
|
||||
properties.push(prop(label, value));
|
||||
}
|
||||
}
|
||||
out.push(PlanNode { parent, operation, params, properties });
|
||||
ids.push(id);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// MySQL's tabular `EXPLAIN`: one row per table access, flat.
|
||||
fn mysql(names: &[&str], rows: &[Vec<Option<String>>]) -> Vec<PlanNode> {
|
||||
let col = |r: &[Option<String>], n: &str| {
|
||||
@@ -377,4 +418,61 @@ mod tests {
|
||||
assert_eq!((plan[1].operation.as_str(), plan[1].params.as_str()), ("Index Lookup", "table: c; key: PRIMARY;"));
|
||||
assert!(plan[0].properties.iter().any(|p| p.name == "rows" && p.value == "20"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_snowflake_tabular_plans() {
|
||||
let names = [
|
||||
"step",
|
||||
"id",
|
||||
"parentOperators",
|
||||
"operation",
|
||||
"objects",
|
||||
"alias",
|
||||
"expressions",
|
||||
"partitionsTotal",
|
||||
"partitionsAssigned",
|
||||
"bytesAssigned",
|
||||
];
|
||||
let row = |cells: [Option<&'static str>; 10]| cells.to_vec();
|
||||
let r = result(
|
||||
&names,
|
||||
vec![
|
||||
row([None, None, None, Some("GlobalStats"), None, None, None, Some("12"), Some("3"), Some("4096")]),
|
||||
row([Some("1"), Some("0"), None, Some("Result"), None, None, Some("O.ID"), None, None, None]),
|
||||
row([
|
||||
Some("1"),
|
||||
Some("1"),
|
||||
Some("[0]"),
|
||||
Some("Filter"),
|
||||
None,
|
||||
None,
|
||||
Some("O.ID > 3"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
]),
|
||||
row([
|
||||
Some("1"),
|
||||
Some("2"),
|
||||
Some("[1]"),
|
||||
Some("TableScan"),
|
||||
Some("DB.PUBLIC.ORDERS"),
|
||||
Some("O"),
|
||||
Some("ID"),
|
||||
Some("12"),
|
||||
Some("3"),
|
||||
Some("4096"),
|
||||
]),
|
||||
],
|
||||
);
|
||||
let plan = parse(DbDriver::Snowflake, &r).unwrap();
|
||||
assert_eq!(
|
||||
plan.iter().map(|n| n.operation.as_str()).collect::<Vec<_>>(),
|
||||
["GlobalStats", "Result", "Filter", "TableScan"]
|
||||
);
|
||||
assert_eq!(plan.iter().map(|n| n.parent).collect::<Vec<_>>(), [None, None, Some(1), Some(2)]);
|
||||
assert_eq!(plan[3].params, "DB.PUBLIC.ORDERS O");
|
||||
assert!(plan[3].properties.contains(&PlanProperty { name: "Partitions scanned".into(), value: "3".into() }));
|
||||
assert!(plan[0].properties.contains(&PlanProperty { name: "Bytes scanned".into(), value: "4096".into() }));
|
||||
}
|
||||
}
|
||||
@@ -171,7 +171,7 @@ impl DbManager {
|
||||
}
|
||||
/// Completes cleanup after removal from the file.
|
||||
pub async fn forget_plugin_source(&self, id: &str) {
|
||||
for key in [id.to_owned(), crate::secrets::ssh_key(id)] {
|
||||
for key in [id.to_owned(), crate::secrets::ssh_key(id), format!("{id}#sso")] {
|
||||
self.session_passwords.lock().remove(&key);
|
||||
self.saved_passwords.delete(&key);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
//! Where a Snowflake account is reached, and the account name its login and JWT carry.
|
||||
|
||||
use crate::source::{DataSource, DbSslMode};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use reqwest::Url;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct Endpoint {
|
||||
pub base: Url,
|
||||
/// Upper-case account name: the first label of the account identifier.
|
||||
pub account: String,
|
||||
}
|
||||
|
||||
/// Reads the data source's host: an account identifier (`myorg-myaccount`,
|
||||
/// `xy12345.eu-central-1`), a host name (`myorg-myaccount.snowflakecomputing.com`) or the
|
||||
/// account URL.
|
||||
pub(crate) fn endpoint(source: &DataSource) -> IdeResult<Endpoint> {
|
||||
let raw = source.host.trim();
|
||||
let raw = raw.strip_prefix("https://").or_else(|| raw.strip_prefix("http://")).unwrap_or(raw);
|
||||
let raw = raw.split(['/', '?', '#']).next().unwrap_or_default().trim_end_matches('.');
|
||||
let (host, port) = match raw.rsplit_once(':') {
|
||||
Some((host, port)) if port.chars().all(|c| c.is_ascii_digit()) && !port.is_empty() => {
|
||||
(host, port.parse::<u16>().ok())
|
||||
}
|
||||
_ => (raw, None),
|
||||
};
|
||||
let valid = |c: char| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.');
|
||||
if host.is_empty() || host == "localhost" && source.ssl != DbSslMode::Disable || !host.chars().all(valid) {
|
||||
return Err(IdeError::invalid(
|
||||
"enter the Snowflake account identifier, for example myorg-myaccount or xy12345.eu-central-1",
|
||||
));
|
||||
}
|
||||
let account = host.split('.').next().unwrap_or_default().to_ascii_uppercase();
|
||||
let literal = host.parse::<std::net::IpAddr>().is_ok() || host == "localhost";
|
||||
let full = if literal || host.to_ascii_lowercase().contains(".snowflakecomputing.") {
|
||||
host.to_owned()
|
||||
} else {
|
||||
// Host names cannot hold underscores; Snowflake serves such accounts under the dashed name.
|
||||
format!("{}.snowflakecomputing.com", host.replace('_', "-"))
|
||||
};
|
||||
let scheme = if source.ssl == DbSslMode::Disable { "http" } else { "https" };
|
||||
let port = port.or(source.port).filter(|p| *p != 443 || scheme == "http");
|
||||
let authority = match port {
|
||||
Some(port) => format!("{full}:{port}"),
|
||||
None => full,
|
||||
};
|
||||
let base = Url::parse(&format!("{scheme}://{authority}/"))
|
||||
.map_err(|_| IdeError::invalid("invalid Snowflake account identifier"))?;
|
||||
Ok(Endpoint { base, account })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::source::DbDriver;
|
||||
|
||||
fn of(host: &str) -> Endpoint {
|
||||
endpoint(&DataSource { driver: DbDriver::Snowflake, host: host.into(), ..Default::default() }).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_identifiers_become_hosts() {
|
||||
let e = of("myorg-myaccount");
|
||||
assert_eq!(e.base.as_str(), "https://myorg-myaccount.snowflakecomputing.com/");
|
||||
assert_eq!(e.account, "MYORG-MYACCOUNT");
|
||||
let e = of("xy12345.eu-central-1");
|
||||
assert_eq!(e.base.as_str(), "https://xy12345.eu-central-1.snowflakecomputing.com/");
|
||||
assert_eq!(e.account, "XY12345");
|
||||
let e = of("xy12345.us-east-2.aws");
|
||||
assert_eq!(e.base.as_str(), "https://xy12345.us-east-2.aws.snowflakecomputing.com/");
|
||||
assert_eq!(e.account, "XY12345");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn underscores_stay_in_the_account_name_but_not_in_the_host() {
|
||||
let e = of("my_org-my_account");
|
||||
assert_eq!(e.base.as_str(), "https://my-org-my-account.snowflakecomputing.com/");
|
||||
assert_eq!(e.account, "MY_ORG-MY_ACCOUNT");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosts_and_urls_are_accepted() {
|
||||
for host in [
|
||||
"myorg-myaccount.snowflakecomputing.com",
|
||||
"https://myorg-myaccount.snowflakecomputing.com",
|
||||
"https://myorg-myaccount.snowflakecomputing.com/console/login?x=1",
|
||||
"MyOrg-MyAccount.snowflakecomputing.com:443",
|
||||
] {
|
||||
let e = of(host);
|
||||
assert_eq!(e.base.host_str().unwrap().to_ascii_lowercase(), "myorg-myaccount.snowflakecomputing.com");
|
||||
assert_eq!(e.base.port(), None);
|
||||
assert_eq!(e.account, "MYORG-MYACCOUNT");
|
||||
}
|
||||
let e = of("xy12345.eu-west-1.privatelink.snowflakecomputing.com");
|
||||
assert_eq!(e.base.as_str(), "https://xy12345.eu-west-1.privatelink.snowflakecomputing.com/");
|
||||
assert_eq!(e.account, "XY12345");
|
||||
assert_eq!(of("acct.snowflakecomputing.cn").base.as_str(), "https://acct.snowflakecomputing.cn/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_and_malformed_identifiers() {
|
||||
for host in ["", " ", "localhost", "my account", "user@host", "/path"] {
|
||||
let source = DataSource { driver: DbDriver::Snowflake, host: host.into(), ..Default::default() };
|
||||
let error = endpoint(&source).unwrap_err();
|
||||
assert!(error.message.contains("account identifier"), "{host}: {}", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_http_is_only_for_an_explicit_ssl_off() {
|
||||
let source = DataSource {
|
||||
driver: DbDriver::Snowflake,
|
||||
host: "127.0.0.1".into(),
|
||||
port: Some(8080),
|
||||
ssl: DbSslMode::Disable,
|
||||
..Default::default()
|
||||
};
|
||||
let e = endpoint(&source).unwrap();
|
||||
assert_eq!(e.base.as_str(), "http://127.0.0.1:8080/");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
//! Single sign-on in the web browser (Snowflake's `externalbrowser` authenticator): Snowflake
|
||||
//! names the identity provider's page, the browser signs in there and is sent back to a
|
||||
//! listener on this computer with a one-time token, which the login request then presents
|
||||
//! together with the proof key Snowflake issued for this attempt.
|
||||
|
||||
use super::client::{Api, Request};
|
||||
use super::{APP, error, uuid};
|
||||
use ide_core::{IdeError, IdeResult, error::ErrorKind};
|
||||
use parking_lot::Mutex;
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::LazyLock;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
const AUTHENTICATOR: &str = "session/authenticator-request";
|
||||
/// How long the user has to finish signing in.
|
||||
const WAIT: Duration = Duration::from_secs(120);
|
||||
const DONE_PAGE: &str = "<!doctype html><html><head><meta charset=\"utf-8\"><title>OpenIDEA</title></head>\
|
||||
<body style=\"font-family: sans-serif; margin: 3em\"><h3>Signed in to Snowflake</h3>\
|
||||
<p>You can close this tab and return to OpenIDEA.</p></body></html>";
|
||||
|
||||
/// One browser sign-in at a time: connections opened together share its outcome through [`cached`].
|
||||
pub(crate) static FLOW: LazyLock<tokio::sync::Mutex<()>> = LazyLock::new(|| tokio::sync::Mutex::new(()));
|
||||
|
||||
/// ID tokens Snowflake issued after a browser sign-in, by account address and user. They let
|
||||
/// further sessions of this run open without the browser. Kept in memory only.
|
||||
static ID_TOKENS: LazyLock<Mutex<HashMap<String, String>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
pub(crate) fn cached(key: &str) -> Option<String> {
|
||||
ID_TOKENS.lock().get(key).cloned()
|
||||
}
|
||||
|
||||
pub(crate) fn remember(key: &str, token: &str) {
|
||||
ID_TOKENS.lock().insert(key.to_owned(), token.to_owned());
|
||||
}
|
||||
|
||||
pub(crate) fn forget(key: &str) {
|
||||
ID_TOKENS.lock().remove(key);
|
||||
}
|
||||
|
||||
/// What opens the sign-in page; tests put a stand-in for the browser here.
|
||||
#[cfg(test)]
|
||||
type Opener = dyn Fn(&str, u16) -> IdeResult<()> + Send + Sync;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) static TEST_OPENER: LazyLock<Mutex<Option<Box<Opener>>>> = LazyLock::new(|| Mutex::new(None));
|
||||
|
||||
fn open(url: &str, port: u16) -> IdeResult<()> {
|
||||
#[cfg(test)]
|
||||
if let Some(opener) = TEST_OPENER.lock().as_ref() {
|
||||
return opener(url, port);
|
||||
}
|
||||
let _ = port;
|
||||
open::that_detached(url)
|
||||
.map_err(|e| IdeError::new(ErrorKind::Io, format!("Snowflake: cannot open the web browser: {e}")))
|
||||
}
|
||||
|
||||
/// The one-time token and proof key of a completed browser sign-in.
|
||||
pub(crate) async fn authenticate(
|
||||
api: &Api,
|
||||
account: &str,
|
||||
user: &str,
|
||||
timeout: Duration,
|
||||
) -> IdeResult<(String, String)> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.map_err(|e| IdeError::new(ErrorKind::Io, format!("Snowflake: cannot listen for the browser sign-in: {e}")))?;
|
||||
let port = listener.local_addr().map_err(|e| IdeError::new(ErrorKind::Io, e.to_string()))?.port();
|
||||
let body = json!({ "data": {
|
||||
"ACCOUNT_NAME": account,
|
||||
"LOGIN_NAME": user,
|
||||
"AUTHENTICATOR": "EXTERNALBROWSER",
|
||||
"BROWSER_MODE_REDIRECT_PORT": port.to_string(),
|
||||
"CLIENT_APP_ID": APP,
|
||||
"CLIENT_APP_VERSION": env!("CARGO_PKG_VERSION"),
|
||||
} });
|
||||
let envelope = api.send(Request::post(AUTHENTICATOR, body).query("requestId", uuid()).timeout(timeout)).await?;
|
||||
if !envelope.success {
|
||||
return Err(api.scrub.clean(error::server(envelope.code.as_deref(), envelope.message.as_deref())));
|
||||
}
|
||||
let text = |name: &str| envelope.data.get(name).and_then(Value::as_str).map(str::to_owned);
|
||||
let (Some(url), Some(proof_key)) = (text("ssoUrl"), text("proofKey")) else {
|
||||
return Err(IdeError::new(
|
||||
ErrorKind::Protocol,
|
||||
"Snowflake did not name a sign-in page; check that the account allows browser sign-in",
|
||||
));
|
||||
};
|
||||
if !(url.starts_with("https://") || url.starts_with("http://")) {
|
||||
return Err(IdeError::new(ErrorKind::Protocol, "Snowflake named a sign-in page that is not a web address"));
|
||||
}
|
||||
api.scrub.add(&proof_key);
|
||||
open(&url, port)?;
|
||||
let token = tokio::time::timeout(WAIT, callback(&listener)).await.map_err(|_| {
|
||||
IdeError::new(ErrorKind::TimedOut, "Snowflake: the browser sign-in was not finished within 2 minutes")
|
||||
})??;
|
||||
api.scrub.add(&token);
|
||||
Ok((token, proof_key))
|
||||
}
|
||||
|
||||
/// Waits for the browser to come back with the token (other requests, like the tab's icon, are refused).
|
||||
async fn callback(listener: &TcpListener) -> IdeResult<String> {
|
||||
loop {
|
||||
let (mut stream, _) = listener
|
||||
.accept()
|
||||
.await
|
||||
.map_err(|e| IdeError::new(ErrorKind::Io, format!("Snowflake: browser sign-in failed: {e}")))?;
|
||||
let Some(request) = read(&mut stream).await else { continue };
|
||||
let line = request.head.lines().next().unwrap_or_default();
|
||||
let mut parts = line.split_whitespace();
|
||||
let (method, target) = (parts.next().unwrap_or_default(), parts.next().unwrap_or_default());
|
||||
let origin = request.header("origin").unwrap_or("*").to_owned();
|
||||
let cors = format!(
|
||||
"Access-Control-Allow-Origin: {origin}\r\nVary: Origin\r\nAccess-Control-Allow-Methods: POST, GET\r\n\
|
||||
Access-Control-Allow-Headers: {}\r\n",
|
||||
request.header("access-control-request-headers").unwrap_or("content-type")
|
||||
);
|
||||
if method == "OPTIONS" {
|
||||
respond(&mut stream, "200 OK", &cors, "").await;
|
||||
continue;
|
||||
}
|
||||
let token = match method {
|
||||
"GET" => target.split_once('?').and_then(|(_, query)| parameter(query, "token")),
|
||||
"POST" => serde_json::from_str::<Value>(&request.body)
|
||||
.ok()
|
||||
.and_then(|v| v.get("token").and_then(Value::as_str).map(str::to_owned))
|
||||
.or_else(|| parameter(&request.body, "token")),
|
||||
_ => None,
|
||||
};
|
||||
match token.filter(|t| !t.is_empty()) {
|
||||
Some(token) => {
|
||||
respond(&mut stream, "200 OK", &format!("{cors}Content-Type: text/html; charset=utf-8\r\n"), DONE_PAGE)
|
||||
.await;
|
||||
return Ok(token);
|
||||
}
|
||||
None => respond(&mut stream, "404 Not Found", "", "").await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Received {
|
||||
head: String,
|
||||
body: String,
|
||||
}
|
||||
|
||||
impl Received {
|
||||
fn header(&self, name: &str) -> Option<&str> {
|
||||
self.head.lines().skip(1).find_map(|line| {
|
||||
let (key, value) = line.split_once(':')?;
|
||||
key.eq_ignore_ascii_case(name).then(|| value.trim())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn read(stream: &mut TcpStream) -> Option<Received> {
|
||||
const LIMIT: usize = 256 * 1024;
|
||||
let mut bytes = Vec::new();
|
||||
let mut buffer = [0; 8192];
|
||||
loop {
|
||||
let count = tokio::time::timeout(Duration::from_secs(10), stream.read(&mut buffer)).await.ok()?.ok()?;
|
||||
if count == 0 || bytes.len() > LIMIT {
|
||||
return None;
|
||||
}
|
||||
bytes.extend_from_slice(&buffer[..count]);
|
||||
if let Some(end) = bytes.windows(4).position(|w| w == b"\r\n\r\n") {
|
||||
let head = String::from_utf8_lossy(&bytes[..end]).into_owned();
|
||||
let length: usize = head
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
line.to_ascii_lowercase().strip_prefix("content-length:").and_then(|v| v.trim().parse().ok())
|
||||
})
|
||||
.unwrap_or(0);
|
||||
if length > LIMIT {
|
||||
return None;
|
||||
}
|
||||
if bytes.len() >= end + 4 + length {
|
||||
return Some(Received { head, body: String::from_utf8_lossy(&bytes[end + 4..]).into_owned() });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn respond(stream: &mut TcpStream, status: &str, headers: &str, body: &str) {
|
||||
let response =
|
||||
format!("HTTP/1.1 {status}\r\n{headers}Content-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
|
||||
let _ = stream.write_all(response.as_bytes()).await;
|
||||
let _ = stream.shutdown().await;
|
||||
}
|
||||
|
||||
/// The value of `name` in a URL query or form body.
|
||||
fn parameter(query: &str, name: &str) -> Option<String> {
|
||||
query.split('&').find_map(|pair| {
|
||||
let (key, value) = pair.split_once('=')?;
|
||||
(key == name).then(|| decode(value))
|
||||
})
|
||||
}
|
||||
|
||||
fn decode(text: &str) -> String {
|
||||
let bytes = text.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let hex = |i: usize| bytes.get(i).and_then(|&c| (c as char).to_digit(16));
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'%'
|
||||
&& let (Some(high), Some(low)) = (hex(i + 1), hex(i + 2))
|
||||
{
|
||||
out.push((high * 16 + low) as u8);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
out.push(if bytes[i] == b'+' { b' ' } else { bytes[i] });
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reads_the_token_from_a_query_or_form() {
|
||||
assert_eq!(parameter("token=abc%2Bdef%3D%3D&x=1", "token").as_deref(), Some("abc+def=="));
|
||||
assert_eq!(parameter("x=1&token=t", "token").as_deref(), Some("t"));
|
||||
assert_eq!(parameter("tokens=1", "token"), None);
|
||||
assert_eq!(parameter("", "token"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
//! The HTTP side of the Snowflake session protocol: one JSON envelope per response.
|
||||
//! Every error leaves through [`Scrubber::clean`], so no credential reaches a message or a log.
|
||||
|
||||
use ide_core::{IdeError, IdeResult, error::ErrorKind};
|
||||
use parking_lot::RwLock;
|
||||
use reqwest::{Client, Method, StatusCode, Url};
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Replaces known secrets (passphrase, password, tokens) in text that leaves the driver.
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct Scrubber(Arc<RwLock<Vec<String>>>);
|
||||
|
||||
impl Scrubber {
|
||||
pub(crate) fn add(&self, secret: &str) {
|
||||
// Short values would blank out ordinary words.
|
||||
if secret.len() >= 4 {
|
||||
let mut secrets = self.0.write();
|
||||
if !secrets.iter().any(|s| s == secret) {
|
||||
secrets.push(secret.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn clean(&self, mut error: IdeError) -> IdeError {
|
||||
for secret in self.0.read().iter() {
|
||||
if error.message.contains(secret.as_str()) {
|
||||
error.message = error.message.replace(secret.as_str(), "[redacted]");
|
||||
}
|
||||
}
|
||||
error
|
||||
}
|
||||
}
|
||||
|
||||
/// A response body: `data` on success, `code` and `message` on failure.
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub(crate) struct Envelope {
|
||||
pub success: bool,
|
||||
#[serde(deserialize_with = "code")]
|
||||
pub code: Option<String>,
|
||||
pub message: Option<String>,
|
||||
pub data: Value,
|
||||
}
|
||||
|
||||
/// Codes arrive as strings, numbers or null.
|
||||
fn code<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<Option<String>, D::Error> {
|
||||
Ok(match Value::deserialize(deserializer)? {
|
||||
Value::String(s) if !s.is_empty() => Some(s),
|
||||
Value::Number(n) => Some(n.to_string()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
impl Envelope {
|
||||
pub(crate) fn has_code(&self, codes: &[&str]) -> bool {
|
||||
self.code.as_deref().is_some_and(|code| codes.contains(&code))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Api {
|
||||
http: Client,
|
||||
base: Url,
|
||||
pub scrub: Scrubber,
|
||||
}
|
||||
|
||||
pub(crate) struct Request<'a> {
|
||||
pub method: Method,
|
||||
pub path: &'a str,
|
||||
pub query: Vec<(&'a str, String)>,
|
||||
/// The session or master token.
|
||||
pub token: Option<&'a str>,
|
||||
pub body: Option<Value>,
|
||||
pub timeout: Option<Duration>,
|
||||
}
|
||||
|
||||
impl<'a> Request<'a> {
|
||||
pub(crate) fn post(path: &'a str, body: Value) -> Self {
|
||||
Self { method: Method::POST, path, query: Vec::new(), token: None, body: Some(body), timeout: None }
|
||||
}
|
||||
|
||||
pub(crate) fn get(path: &'a str) -> Self {
|
||||
Self { method: Method::GET, path, query: Vec::new(), token: None, body: None, timeout: None }
|
||||
}
|
||||
|
||||
pub(crate) fn token(mut self, token: &'a str) -> Self {
|
||||
self.token = Some(token);
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn query(mut self, name: &'a str, value: impl Into<String>) -> Self {
|
||||
self.query.push((name, value.into()));
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn timeout(mut self, timeout: Duration) -> Self {
|
||||
self.timeout = Some(timeout);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Api {
|
||||
pub(crate) fn new(http: Client, base: Url) -> Self {
|
||||
Self { http, base, scrub: Scrubber::default() }
|
||||
}
|
||||
|
||||
/// The account's address, for keys that tell accounts apart.
|
||||
pub(crate) fn address(&self) -> String {
|
||||
format!("{}:{}", self.host(), self.base.port_or_known_default().unwrap_or_default())
|
||||
}
|
||||
|
||||
fn host(&self) -> &str {
|
||||
self.base.host_str().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Sends `request` and reads the envelope, whatever it reports.
|
||||
pub(crate) async fn send(&self, request: Request<'_>) -> IdeResult<Envelope> {
|
||||
let url = self.base.join(request.path).map_err(|_| IdeError::internal("invalid Snowflake request path"))?;
|
||||
let mut builder = self
|
||||
.http
|
||||
.request(request.method, url)
|
||||
.query(&request.query)
|
||||
.header(reqwest::header::ACCEPT, "application/snowflake");
|
||||
if let Some(token) = request.token {
|
||||
builder = builder.header(reqwest::header::AUTHORIZATION, format!("Snowflake Token=\"{token}\""));
|
||||
}
|
||||
if let Some(body) = &request.body {
|
||||
builder = builder.header(reqwest::header::CONTENT_TYPE, "application/json").body(body.to_string());
|
||||
}
|
||||
if let Some(timeout) = request.timeout {
|
||||
builder = builder.timeout(timeout);
|
||||
}
|
||||
let response = builder.send().await.map_err(|e| self.transport(e))?;
|
||||
let status = response.status();
|
||||
let body = response.bytes().await.map_err(|e| self.transport(e))?;
|
||||
match serde_json::from_slice::<Envelope>(&body) {
|
||||
Ok(envelope) if status.is_success() || envelope.code.is_some() || envelope.message.is_some() => {
|
||||
Ok(envelope)
|
||||
}
|
||||
_ => Err(self.scrub.clean(self.status_error(status))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Downloads a result chunk from cloud storage (no Snowflake credentials: the URL is presigned).
|
||||
pub(crate) async fn chunk(&self, url: &str, headers: &[(String, String)]) -> IdeResult<Vec<u8>> {
|
||||
let mut builder = self.http.get(url);
|
||||
for (name, value) in headers {
|
||||
builder = builder.header(name.as_str(), value.as_str());
|
||||
}
|
||||
let response = builder.send().await.map_err(|e| self.transport(e))?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return Err(IdeError::new(
|
||||
ErrorKind::Io,
|
||||
format!("Snowflake: cannot download a result chunk (HTTP {})", status.as_u16()),
|
||||
));
|
||||
}
|
||||
Ok(response.bytes().await.map_err(|e| self.transport(e))?.to_vec())
|
||||
}
|
||||
|
||||
fn status_error(&self, status: StatusCode) -> IdeError {
|
||||
let host = self.host();
|
||||
match status {
|
||||
StatusCode::FORBIDDEN | StatusCode::NOT_FOUND => IdeError::invalid(format!(
|
||||
"Snowflake has no account at {host} (HTTP {}): check the account identifier",
|
||||
status.as_u16()
|
||||
)),
|
||||
s if s.is_server_error() || s == StatusCode::TOO_MANY_REQUESTS => IdeError::new(
|
||||
ErrorKind::Io,
|
||||
format!("Snowflake is temporarily unavailable (HTTP {}); try again", s.as_u16()),
|
||||
),
|
||||
s => IdeError::new(ErrorKind::Protocol, format!("Snowflake: unexpected response (HTTP {})", s.as_u16())),
|
||||
}
|
||||
}
|
||||
|
||||
fn transport(&self, error: reqwest::Error) -> IdeError {
|
||||
use std::error::Error;
|
||||
let kind = if error.is_timeout() { ErrorKind::TimedOut } else { ErrorKind::Io };
|
||||
let connect = error.is_connect();
|
||||
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();
|
||||
}
|
||||
let hint = if connect { "; check the account identifier and the network connection" } else { "" };
|
||||
self.scrub.clean(IdeError::new(kind, format!("Snowflake ({}): {message}{hint}", self.host())))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn envelopes_read_any_code_shape() {
|
||||
let e: Envelope =
|
||||
serde_json::from_str(r#"{"success":false,"code":"390100","message":"no","data":null}"#).unwrap();
|
||||
assert_eq!(e.code.as_deref(), Some("390100"));
|
||||
assert!(e.has_code(&["390100"]));
|
||||
let e: Envelope = serde_json::from_str(r#"{"success":false,"code":390100}"#).unwrap();
|
||||
assert_eq!(e.code.as_deref(), Some("390100"));
|
||||
let e: Envelope = serde_json::from_str(r#"{"success":true,"code":null,"data":{"a":1}}"#).unwrap();
|
||||
assert!(e.success && e.code.is_none());
|
||||
assert_eq!(e.data["a"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrubbing_removes_every_known_secret() {
|
||||
let scrub = Scrubber::default();
|
||||
scrub.add("s3cret-passphrase");
|
||||
scrub.add("eyJ.token.value");
|
||||
scrub.add("ab");
|
||||
let e = scrub.clean(IdeError::invalid("failed with s3cret-passphrase and eyJ.token.value, ab stays"));
|
||||
assert_eq!(e.message, "failed with [redacted] and [redacted], ab stays");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
//! Every SQL command of Snowflake's reference
|
||||
//! (<https://docs.snowflake.com/en/sql-reference/sql-all>) through the parts of the client a
|
||||
//! statement passes: the script splitter, the read-only guard and the wire. The samples are in
|
||||
//! `tests/fixtures/snowflake/commands.json`, one per documented command; the SQL assistance of
|
||||
//! the UI reads the same file (`src/features/database/sql/snowflakeCommands.test.ts`).
|
||||
//!
|
||||
//! The samples exercise the client, not Snowflake's grammar: a server is needed to prove a
|
||||
//! statement valid, which the ignored live test does for the commands that only read.
|
||||
|
||||
use super::tests::{CLOSED_OK, LOGIN_OK, bind, ok, result, serve, source};
|
||||
use super::{first_word, is_write};
|
||||
use crate::conn::Pool;
|
||||
use crate::source::DbDriver;
|
||||
use crate::split::split;
|
||||
use ide_core::CancellationToken;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct Command {
|
||||
pub command: String,
|
||||
pub sql: String,
|
||||
/// Runs on a read-only data source: it reads, or changes the session only.
|
||||
pub reads: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Fixture {
|
||||
commands: Vec<Command>,
|
||||
}
|
||||
|
||||
pub(crate) fn commands() -> Vec<Command> {
|
||||
let fixture: Fixture =
|
||||
serde_json::from_str(include_str!("../../tests/fixtures/snowflake/commands.json")).expect("the fixture parses");
|
||||
fixture.commands
|
||||
}
|
||||
|
||||
/// `PUT` and `GET` move files on the client's side.
|
||||
fn transfers(c: &Command) -> bool {
|
||||
matches!(first_word(&c.sql).as_str(), "PUT" | "GET")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_fixture_covers_the_reference() {
|
||||
let commands = commands();
|
||||
assert!(commands.len() >= 786, "{} commands", commands.len());
|
||||
let mut names: Vec<&str> = commands.iter().map(|c| c.command.as_str()).collect();
|
||||
names.sort_unstable();
|
||||
names.dedup();
|
||||
assert_eq!(names.len(), commands.len(), "a command is listed twice");
|
||||
for family in ["ALTER", "CREATE", "DESCRIBE", "DROP", "SHOW", "UNDROP", "USE", "GRANT", "REVOKE", "EXECUTE"] {
|
||||
assert!(names.iter().any(|n| n.starts_with(family)), "no {family} command");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_command_is_one_statement() {
|
||||
let commands = commands();
|
||||
for c in &commands {
|
||||
let alone = split(&c.sql, DbDriver::Snowflake);
|
||||
assert_eq!(alone.len(), 1, "{}: split into {} statements", c.command, alone.len());
|
||||
assert_eq!(alone[0].text, c.sql.trim(), "{}", c.command);
|
||||
// Terminated, commented and followed by another statement.
|
||||
let script = format!("-- {}; a comment\n{};\n// next; one\nselect 1;", c.command, c.sql);
|
||||
let parts: Vec<String> = split(&script, DbDriver::Snowflake).into_iter().map(|s| s.text).collect();
|
||||
assert_eq!(parts.len(), 2, "{}: {parts:?}", c.command);
|
||||
assert!(parts[0].ends_with(c.sql.trim()), "{}: {:?}", c.command, parts[0]);
|
||||
assert_eq!(parts[1].trim_start_matches("// next; one\n"), "select 1", "{}", c.command);
|
||||
}
|
||||
// The whole reference as one script.
|
||||
let script = commands.iter().map(|c| format!("{};\n", c.sql)).collect::<String>();
|
||||
let parts = split(&script, DbDriver::Snowflake);
|
||||
assert_eq!(parts.len(), commands.len());
|
||||
for (part, c) in parts.iter().zip(&commands) {
|
||||
assert_eq!(part.text, c.sql.trim(), "{}", c.command);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_command_is_known_to_read_or_to_write() {
|
||||
for c in commands() {
|
||||
assert_eq!(!is_write(&c.sql), c.reads, "{}: {}", c.command, c.sql);
|
||||
assert!(!first_word(&c.sql).is_empty(), "{}", c.command);
|
||||
// Comments before a statement do not change what it is.
|
||||
let commented = format!("/* note; */ -- one\n// two\n{}", c.sql);
|
||||
assert_eq!(!is_write(&commented), c.reads, "{} after comments", c.command);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn every_command_reaches_the_server_as_written_and_its_answer_is_read() {
|
||||
let commands = commands();
|
||||
let (listener, port) = bind().await;
|
||||
let status = json!({ "success": true, "data": { "rowtype": [{ "name": "status", "type": "text" }],
|
||||
"rowset": [["Statement executed successfully."]], "queryResultFormat": "json", "statementTypeId": 0x5000 } })
|
||||
.to_string();
|
||||
let rows = result(
|
||||
json!([{ "name": "name", "type": "text" }, { "name": "n", "type": "fixed", "scale": 0 }]),
|
||||
json!([["a", "1"], ["b", null]]),
|
||||
);
|
||||
let changed =
|
||||
json!({ "success": true, "data": { "rowtype": [{ "name": "number of rows inserted", "type": "fixed" }],
|
||||
"rowset": [["3"]], "queryResultFormat": "json", "statementTypeId": 0x3100, "total": 1 } })
|
||||
.to_string();
|
||||
// Login (which names the schema), then one answer per command.
|
||||
let mut replies = vec![ok(LOGIN_OK)];
|
||||
let mut entered = true;
|
||||
for c in commands.iter().filter(|c| !transfers(c)) {
|
||||
if !entered && first_word(&c.sql) != "USE" {
|
||||
replies.push(ok(status.clone()));
|
||||
}
|
||||
// These leave the session wherever they went: its schema is entered again afterwards.
|
||||
entered = !matches!(first_word(&c.sql).as_str(), "USE" | "CALL" | "EXECUTE");
|
||||
let dml = matches!(first_word(&c.sql).as_str(), "INSERT" | "UPDATE" | "DELETE" | "MERGE");
|
||||
replies.push(ok(if c.reads && first_word(&c.sql) != "USE" {
|
||||
rows.clone()
|
||||
} else if dml {
|
||||
changed.clone()
|
||||
} else {
|
||||
status.clone()
|
||||
}));
|
||||
}
|
||||
replies.push(ok(CLOSED_OK));
|
||||
let server = serve(listener, replies);
|
||||
let pool = Pool::connect(&source(port), None, None).await.unwrap();
|
||||
let cancel = CancellationToken::new();
|
||||
for c in &commands {
|
||||
if transfers(c) {
|
||||
// Refused before anything is sent: the files would not move.
|
||||
let refused = pool.execute(&c.sql, 100, &cancel).await.unwrap_err();
|
||||
assert!(refused.message.contains("Snowflake CLI"), "{}: {}", c.command, refused.message);
|
||||
continue;
|
||||
}
|
||||
let answer = pool.execute(&c.sql, 100, &cancel).await;
|
||||
let answer = answer.unwrap_or_else(|e| panic!("{}: {}", c.command, e.message));
|
||||
let word = first_word(&c.sql);
|
||||
if c.reads && word != "USE" {
|
||||
assert_eq!(
|
||||
answer.rows,
|
||||
[[Some("a".to_owned()), Some("1".to_owned())], [Some("b".to_owned()), None]],
|
||||
"{}",
|
||||
c.command
|
||||
);
|
||||
assert_eq!(answer.affected, None, "{}", c.command);
|
||||
} else if matches!(word.as_str(), "INSERT" | "UPDATE" | "DELETE" | "MERGE") {
|
||||
assert_eq!(answer.affected, Some(3.0), "{}", c.command);
|
||||
} else {
|
||||
assert!(answer.columns.is_empty() && answer.rows.is_empty(), "{}: a command shows no grid", c.command);
|
||||
}
|
||||
}
|
||||
pool.close().await;
|
||||
let seen = server.await.unwrap();
|
||||
let sent = seen[1..seen.len() - 1]
|
||||
.iter()
|
||||
.map(|r| r.json()["sqlText"].as_str().unwrap_or_default().to_owned())
|
||||
.collect::<Vec<_>>();
|
||||
let sent: Vec<&str> = sent.iter().map(String::as_str).collect();
|
||||
let context = "USE SCHEMA \"ANALYTICS\".\"PUBLIC\"";
|
||||
let written: Vec<&str> = sent.iter().copied().filter(|sql| *sql != context).collect();
|
||||
assert_eq!(written, commands.iter().filter(|c| !transfers(c)).map(|c| c.sql.as_str()).collect::<Vec<_>>());
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Snowflake error codes as errors the UI can act on: `PermissionDenied` asks for the
|
||||
//! data source's secret again, everything else shows its message.
|
||||
|
||||
use ide_core::{IdeError, error::ErrorKind};
|
||||
|
||||
/// The session token expired; the master token renews it.
|
||||
pub(crate) const SESSION_EXPIRED: &str = "390112";
|
||||
/// The session is gone (master token expired, session closed by the server): log in again.
|
||||
pub(crate) const SESSION_GONE: &[&str] = &["390111", "390114", "390104"];
|
||||
/// The statement still runs; its result is polled.
|
||||
pub(crate) const IN_PROGRESS: &[&str] = &["333333", "333334"];
|
||||
|
||||
const KEY_HINT: &str = "Check that the public key of this private key is assigned to the user \
|
||||
(ALTER USER … SET RSA_PUBLIC_KEY), and that the account identifier and user name are right.";
|
||||
|
||||
/// What to do about a failed login or statement, by Snowflake error code.
|
||||
fn hint(code: &str, message: &str) -> Option<&'static str> {
|
||||
Some(match code {
|
||||
"390144" => KEY_HINT,
|
||||
"394300" => "Snowflake found no such user: check the user name and the account identifier.",
|
||||
"394302" => "The login token arrived too late: check this computer's clock.",
|
||||
"394303" => "The login token had expired: check this computer's clock.",
|
||||
"394304" => {
|
||||
"The public key assigned to the user does not match this private key: compare the fingerprint below \
|
||||
with RSA_PUBLIC_KEY_FP of DESC USER."
|
||||
}
|
||||
"394307" => "The account in the login token does not match: check the account identifier.",
|
||||
"390189" => "The role is not granted to this user: change the role of the data source.",
|
||||
"390201" => "Check the database, schema and warehouse of the data source, and that the role may use them.",
|
||||
"000606" => "Set a warehouse in the data source, or run USE WAREHOUSE <name>.",
|
||||
"390422" | "390420" => "A network policy of the account blocks this address.",
|
||||
_ if message.contains("is not allowed to access Snowflake") => {
|
||||
"A network policy of the account blocks this address."
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the login was refused for reasons a different private key or account setting
|
||||
/// fixes, so the key's fingerprint helps.
|
||||
pub(crate) fn key_rejected(code: &str) -> bool {
|
||||
code == "390144" || code.starts_with("3943")
|
||||
}
|
||||
|
||||
fn kind(code: &str) -> ErrorKind {
|
||||
match code {
|
||||
// Wrong password or token; expired or revoked tokens.
|
||||
"390100" | "390303" | "390318" | "394400" | "394401" | "394402" => ErrorKind::PermissionDenied,
|
||||
"000604" | "000605" => ErrorKind::Cancelled,
|
||||
"000630" => ErrorKind::TimedOut,
|
||||
_ => ErrorKind::InvalidInput,
|
||||
}
|
||||
}
|
||||
|
||||
/// The error of a response that reports failure.
|
||||
pub(crate) fn server(code: Option<&str>, message: Option<&str>) -> IdeError {
|
||||
let code = code.unwrap_or_default();
|
||||
let message = message.map(str::trim).filter(|m| !m.is_empty()).unwrap_or("the request failed");
|
||||
let mut text =
|
||||
if code.is_empty() { format!("Snowflake: {message}") } else { format!("Snowflake [{code}]: {message}") };
|
||||
if let Some(hint) = hint(code, message) {
|
||||
text.push('\n');
|
||||
text.push_str(hint);
|
||||
}
|
||||
IdeError::new(kind(code), text)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wrong_credentials_ask_again_and_other_failures_explain() {
|
||||
let e = server(Some("390100"), Some("Incorrect username or password was specified."));
|
||||
assert_eq!(e.kind, ErrorKind::PermissionDenied);
|
||||
assert_eq!(e.message, "Snowflake [390100]: Incorrect username or password was specified.");
|
||||
let e = server(Some("390144"), Some("JWT token is invalid. [abc]"));
|
||||
assert_eq!(e.kind, ErrorKind::InvalidInput);
|
||||
assert!(e.message.starts_with("Snowflake [390144]: JWT token is invalid. [abc]\nCheck that the public key"));
|
||||
assert!(key_rejected("390144") && key_rejected("394304") && !key_rejected("390100"));
|
||||
let e = server(Some("000606"), Some("No active warehouse selected in the current session."));
|
||||
assert!(e.message.ends_with("run USE WAREHOUSE <name>."), "{}", e.message);
|
||||
let e = server(Some("002003"), Some("SQL compilation error:\nObject 'T' does not exist or not authorized."));
|
||||
assert_eq!(e.kind, ErrorKind::InvalidInput);
|
||||
assert_eq!(
|
||||
e.message,
|
||||
"Snowflake [002003]: SQL compilation error:\nObject 'T' does not exist or not authorized."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_policy_and_bare_failures() {
|
||||
let e = server(Some("390432"), Some("IP 203.0.113.9 is not allowed to access Snowflake."));
|
||||
assert!(e.message.ends_with("A network policy of the account blocks this address."));
|
||||
assert_eq!(server(None, None).message, "Snowflake: the request failed");
|
||||
assert_eq!(server(Some("000604"), Some("SQL execution canceled")).kind, ErrorKind::Cancelled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
//! Key-pair authentication: reads the user's RSA private key file and signs the login JWT.
|
||||
//! Parsing, decryption and signing are RustCrypto's (`rsa`, `pkcs8`, `pkcs5`).
|
||||
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
|
||||
use ide_core::{IdeError, IdeResult, error::ErrorKind};
|
||||
use rsa::RsaPrivateKey;
|
||||
use rsa::pkcs1::DecodeRsaPrivateKey;
|
||||
use rsa::pkcs1v15::SigningKey;
|
||||
use rsa::pkcs8::{EncodePublicKey, EncryptedPrivateKeyInfoRef, PrivateKeyInfoRef, SecretDocument};
|
||||
use rsa::sha2::{Digest, Sha256};
|
||||
use rsa::signature::{SignatureEncoding, Signer};
|
||||
use rsa::traits::PublicKeyParts;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
/// Snowflake rejects a JWT that lives longer than an hour.
|
||||
const LIFETIME_SECS: u64 = 59 * 60;
|
||||
const MIN_BITS: usize = 2048;
|
||||
const CONVERT: &str = "openssl pkcs8 -topk8 -v2 aes-256-cbc -in <key file> -out rsa_key.p8";
|
||||
|
||||
pub(crate) struct KeyPair {
|
||||
key: RsaPrivateKey,
|
||||
}
|
||||
|
||||
fn invalid(message: impl Into<String>) -> IdeError {
|
||||
IdeError::invalid(message)
|
||||
}
|
||||
|
||||
/// The UI answers `PermissionDenied` by asking for the secret, here the key's passphrase.
|
||||
fn passphrase_error(message: &str) -> IdeError {
|
||||
IdeError::new(ErrorKind::PermissionDenied, message)
|
||||
}
|
||||
|
||||
/// Reads the private key file at `path` (`~/` is the home directory).
|
||||
pub(crate) fn load(path: &str, passphrase: Option<&str>) -> IdeResult<KeyPair> {
|
||||
let path = path.trim();
|
||||
if path.is_empty() {
|
||||
return Err(invalid("select the private key file of the Snowflake user"));
|
||||
}
|
||||
let file = ide_ssh::expand(path);
|
||||
let bytes = Zeroizing::new(
|
||||
std::fs::read(&file)
|
||||
.map_err(|e| invalid(format!("cannot read the private key file {}: {e}", file.display())))?,
|
||||
);
|
||||
let Ok(text) = std::str::from_utf8(&bytes) else {
|
||||
return Err(invalid(format!(
|
||||
"{} is not a PEM file; convert a DER key with: openssl pkcs8 -inform DER -outform PEM -in <key file> -out rsa_key.p8",
|
||||
file.display()
|
||||
)));
|
||||
};
|
||||
parse(text, passphrase)
|
||||
}
|
||||
|
||||
pub(crate) fn parse(pem: &str, passphrase: Option<&str>) -> IdeResult<KeyPair> {
|
||||
let pem = pem.trim();
|
||||
if pem.contains("Proc-Type: 4,ENCRYPTED") {
|
||||
return Err(invalid(format!(
|
||||
"the private key uses the legacy encrypted PEM format; convert it to PKCS#8 with: {CONVERT}"
|
||||
)));
|
||||
}
|
||||
if pem.contains("BEGIN OPENSSH PRIVATE KEY") {
|
||||
return Err(invalid(
|
||||
"the private key is in OpenSSH format; convert it to PKCS#8 with: ssh-keygen -p -m PKCS8 -f <key file>",
|
||||
));
|
||||
}
|
||||
let (label, document) = SecretDocument::from_pem(pem).map_err(|_| {
|
||||
if pem.contains("PRIVATE KEY-----") {
|
||||
invalid("the private key file is damaged")
|
||||
} else {
|
||||
invalid("the private key file holds no PEM private key (expected -----BEGIN PRIVATE KEY-----)")
|
||||
}
|
||||
})?;
|
||||
let key = match label {
|
||||
"PRIVATE KEY" => from_pkcs8(document.as_bytes(), "the private key file is damaged")?,
|
||||
"ENCRYPTED PRIVATE KEY" => {
|
||||
let Some(passphrase) = passphrase.filter(|p| !p.is_empty()) else {
|
||||
return Err(passphrase_error("the private key is encrypted; enter its passphrase"));
|
||||
};
|
||||
let encrypted = EncryptedPrivateKeyInfoRef::try_from(document.as_bytes())
|
||||
.map_err(|_| invalid("the encrypted private key file is damaged"))?;
|
||||
let decrypted = encrypted.decrypt(passphrase).map_err(|error| match error {
|
||||
rsa::pkcs8::Error::EncryptedPrivateKey(rsa::pkcs8::pkcs5::Error::DecryptFailed) => {
|
||||
passphrase_error("incorrect passphrase for the private key")
|
||||
}
|
||||
rsa::pkcs8::Error::EncryptedPrivateKey(_) => invalid(format!(
|
||||
"the private key is encrypted with an unsupported algorithm; re-encrypt it with: {CONVERT}"
|
||||
)),
|
||||
_ => invalid("the encrypted private key file is damaged"),
|
||||
})?;
|
||||
// A wrong passphrase can leave valid padding around bytes that are no key.
|
||||
from_pkcs8(decrypted.as_bytes(), "incorrect passphrase for the private key").map_err(|error| {
|
||||
if error.message.starts_with("incorrect passphrase") { passphrase_error(&error.message) } else { error }
|
||||
})?
|
||||
}
|
||||
"RSA PRIVATE KEY" => RsaPrivateKey::from_pkcs1_der(document.as_bytes())
|
||||
.map_err(|_| invalid("the private key file is damaged"))?,
|
||||
"PUBLIC KEY" | "RSA PUBLIC KEY" => {
|
||||
return Err(invalid("this file holds the public key; select the private key file"));
|
||||
}
|
||||
"EC PRIVATE KEY" => return Err(not_rsa()),
|
||||
other => return Err(invalid(format!("unsupported private key format: {other}"))),
|
||||
};
|
||||
let bits = key.size() * 8;
|
||||
if bits < MIN_BITS {
|
||||
return Err(invalid(format!("the RSA key has {bits} bits; Snowflake requires at least {MIN_BITS}")));
|
||||
}
|
||||
Ok(KeyPair { key })
|
||||
}
|
||||
|
||||
fn not_rsa() -> IdeError {
|
||||
invalid("the private key is not an RSA key; Snowflake key-pair authentication needs an RSA key")
|
||||
}
|
||||
|
||||
fn from_pkcs8(der: &[u8], malformed: &str) -> IdeResult<RsaPrivateKey> {
|
||||
let info = PrivateKeyInfoRef::try_from(der).map_err(|_| invalid(malformed))?;
|
||||
if info.algorithm.oid != rsa::pkcs1::ALGORITHM_OID {
|
||||
return Err(not_rsa());
|
||||
}
|
||||
RsaPrivateKey::try_from(info).map_err(|_| invalid(malformed))
|
||||
}
|
||||
|
||||
impl KeyPair {
|
||||
/// `SHA256:<base64>` of the public key, as `DESC USER` shows it in `RSA_PUBLIC_KEY_FP`.
|
||||
pub(crate) fn fingerprint(&self) -> IdeResult<String> {
|
||||
let der = self
|
||||
.key
|
||||
.to_public_key()
|
||||
.to_public_key_der()
|
||||
.map_err(|e| IdeError::internal(format!("cannot encode the public key: {e}")))?;
|
||||
Ok(format!("SHA256:{}", STANDARD.encode(Sha256::digest(der.as_bytes()))))
|
||||
}
|
||||
|
||||
/// The login token for `user` of `account` (both as Snowflake stores them: upper case).
|
||||
pub(crate) fn jwt(&self, account: &str, user: &str, now_secs: u64) -> IdeResult<String> {
|
||||
let subject = format!("{}.{}", account.to_uppercase(), user.to_uppercase());
|
||||
let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"RS256","typ":"JWT"}"#);
|
||||
let claims = serde_json::json!({
|
||||
"iss": format!("{subject}.{}", self.fingerprint()?),
|
||||
"sub": subject,
|
||||
"iat": now_secs,
|
||||
"exp": now_secs + LIFETIME_SECS,
|
||||
});
|
||||
let message = format!("{header}.{}", URL_SAFE_NO_PAD.encode(claims.to_string()));
|
||||
let signature = SigningKey::<Sha256>::new(self.key.clone())
|
||||
.try_sign(message.as_bytes())
|
||||
.map_err(|e| IdeError::internal(format!("cannot sign the login token: {e}")))?;
|
||||
Ok(format!("{message}.{}", URL_SAFE_NO_PAD.encode(signature.to_bytes())))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rsa::pkcs1v15::{Signature, VerifyingKey};
|
||||
use rsa::signature::Verifier;
|
||||
|
||||
const PLAIN: &str = include_str!("../../tests/fixtures/snowflake/rsa_key.p8");
|
||||
const ENCRYPTED: &str = include_str!("../../tests/fixtures/snowflake/rsa_key_encrypted.p8");
|
||||
const SCRYPT: &str = include_str!("../../tests/fixtures/snowflake/rsa_key_scrypt.p8");
|
||||
const PKCS1: &str = include_str!("../../tests/fixtures/snowflake/rsa_key_pkcs1.pem");
|
||||
const LEGACY: &str = include_str!("../../tests/fixtures/snowflake/rsa_key_legacy_encrypted.pem");
|
||||
const SHORT: &str = include_str!("../../tests/fixtures/snowflake/rsa_key_1024.p8");
|
||||
const EC: &str = include_str!("../../tests/fixtures/snowflake/ec_key.p8");
|
||||
const PUBLIC: &str = include_str!("../../tests/fixtures/snowflake/rsa_key.pub");
|
||||
/// Computed with OpenSSL (see the fixtures' README).
|
||||
const FINGERPRINT: &str = "SHA256:ajjL6Bf6l1FIIyTcJli4i2AWPYDVJGQnSZr+9q9ClgI=";
|
||||
const PASSPHRASE: &str = "test-passphrase";
|
||||
|
||||
fn error(pem: &str, passphrase: Option<&str>) -> IdeError {
|
||||
match parse(pem, passphrase) {
|
||||
Ok(_) => panic!("the key was accepted"),
|
||||
Err(error) => error,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_supported_format_yields_the_same_key() {
|
||||
for (pem, passphrase) in [
|
||||
(PLAIN, None),
|
||||
(PLAIN, Some("ignored")),
|
||||
(ENCRYPTED, Some(PASSPHRASE)),
|
||||
(SCRYPT, Some(PASSPHRASE)),
|
||||
(PKCS1, None),
|
||||
] {
|
||||
assert_eq!(parse(pem, passphrase).unwrap().fingerprint().unwrap(), FINGERPRINT);
|
||||
}
|
||||
assert_eq!(parse(&format!("\n\n{PLAIN}\n\n"), None).unwrap().fingerprint().unwrap(), FINGERPRINT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_encrypted_key_asks_for_its_passphrase() {
|
||||
for passphrase in [None, Some("")] {
|
||||
let e = error(ENCRYPTED, passphrase);
|
||||
assert_eq!(e.kind, ErrorKind::PermissionDenied);
|
||||
assert!(e.message.contains("enter its passphrase"), "{}", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wrong_passphrase_is_named_and_asks_again() {
|
||||
for pem in [ENCRYPTED, SCRYPT] {
|
||||
for wrong in ["wrong", "test-passphrase ", "Test-Passphrase"] {
|
||||
let e = error(pem, Some(wrong));
|
||||
assert_eq!(e.kind, ErrorKind::PermissionDenied, "{}", e.message);
|
||||
assert_eq!(e.message, "incorrect passphrase for the private key");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unusable_files_say_what_is_wrong() {
|
||||
let cases = [
|
||||
(LEGACY, "legacy encrypted PEM"),
|
||||
(SHORT, "1024 bits"),
|
||||
(EC, "not an RSA key"),
|
||||
(PUBLIC, "public key"),
|
||||
("", "no PEM private key"),
|
||||
("not a key", "no PEM private key"),
|
||||
("-----BEGIN OPENSSH PRIVATE KEY-----\nAAAA\n-----END OPENSSH PRIVATE KEY-----", "OpenSSH format"),
|
||||
("-----BEGIN PRIVATE KEY-----\nAAAA\n-----END PRIVATE KEY-----", "damaged"),
|
||||
];
|
||||
for (pem, expected) in cases {
|
||||
let e = error(pem, Some(PASSPHRASE));
|
||||
assert_eq!(e.kind, ErrorKind::InvalidInput, "{expected}: {}", e.message);
|
||||
assert!(e.message.contains(expected), "{expected}: {}", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_and_unreadable_files_are_reported_with_their_path() {
|
||||
let e = load("", None).err().unwrap();
|
||||
assert!(e.message.contains("select the private key file"));
|
||||
let e = load("/nonexistent/openidea/rsa_key.p8", None).err().unwrap();
|
||||
assert_eq!(e.kind, ErrorKind::InvalidInput);
|
||||
assert!(e.message.contains("/nonexistent/openidea/rsa_key.p8"), "{}", e.message);
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let der = dir.path().join("key.der");
|
||||
std::fs::write(&der, [0x30, 0x82, 0xff, 0xfe, 0x00]).unwrap();
|
||||
let e = load(der.to_str().unwrap(), None).err().unwrap();
|
||||
assert!(e.message.contains("not a PEM file"), "{}", e.message);
|
||||
let good = dir.path().join("rsa_key.p8");
|
||||
std::fs::write(&good, ENCRYPTED).unwrap();
|
||||
assert_eq!(load(good.to_str().unwrap(), Some(PASSPHRASE)).unwrap().fingerprint().unwrap(), FINGERPRINT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_token_carries_snowflake_claims_and_a_valid_rs256_signature() {
|
||||
let pair = parse(PLAIN, None).unwrap();
|
||||
let token = pair.jwt("myorg-myaccount", "[email protected]", 1_700_000_000).unwrap();
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
assert_eq!(parts.len(), 3);
|
||||
let decode = |part: &str| URL_SAFE_NO_PAD.decode(part).unwrap();
|
||||
let header: serde_json::Value = serde_json::from_slice(&decode(parts[0])).unwrap();
|
||||
assert_eq!(header, serde_json::json!({"alg": "RS256", "typ": "JWT"}));
|
||||
let claims: serde_json::Value = serde_json::from_slice(&decode(parts[1])).unwrap();
|
||||
assert_eq!(claims["sub"], "[email protected]");
|
||||
assert_eq!(claims["iss"], format!("[email protected].{FINGERPRINT}"));
|
||||
assert_eq!(claims["iat"], 1_700_000_000u64);
|
||||
assert_eq!(claims["exp"], 1_700_000_000u64 + 59 * 60);
|
||||
let verifier = VerifyingKey::<Sha256>::new(pair.key.to_public_key());
|
||||
let signature = Signature::try_from(decode(parts[2]).as_slice()).unwrap();
|
||||
verifier.verify(format!("{}.{}", parts[0], parts[1]).as_bytes(), &signature).unwrap();
|
||||
assert!(verifier.verify(format!("{}.{}x", parts[0], parts[1]).as_bytes(), &signature).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
//! The Database tree's metadata, read with `SHOW` and `DESCRIBE`: they need no running
|
||||
//! warehouse, list only what the session's role may see, and are asked one level at a time.
|
||||
|
||||
use super::SnowflakePool;
|
||||
use crate::sql::{quote_ident, quote_literal};
|
||||
use crate::{
|
||||
ColumnInfo, CompletionColumn, CompletionTable, DbDriver, DbObjectKind, DbTableKind, ForeignKey, RoutineInfo,
|
||||
SchemaObject, SchemaObjects, SequenceInfo, TableDetails, TableInfo,
|
||||
};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// `SHOW … LIMIT` takes at most this many rows; longer lists continue `FROM` the last name.
|
||||
const PAGE: usize = 10_000;
|
||||
const MAX_ROWS: usize = 100_000;
|
||||
const DRIVER: DbDriver = DbDriver::Snowflake;
|
||||
|
||||
/// A `SHOW` / `DESCRIBE` result, read by column name (their column order varies by version).
|
||||
struct Shown {
|
||||
columns: Vec<String>,
|
||||
rows: Vec<Vec<Option<String>>>,
|
||||
}
|
||||
|
||||
impl Shown {
|
||||
fn index(&self, name: &str) -> Option<usize> {
|
||||
self.columns.iter().position(|c| c.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
fn values<'a>(&'a self, name: &str) -> impl Iterator<Item = Option<&'a str>> + 'a {
|
||||
let index = self.index(name);
|
||||
self.rows.iter().map(move |row| index.and_then(|i| row.get(i)).and_then(|v| v.as_deref()))
|
||||
}
|
||||
|
||||
fn records(&self) -> impl Iterator<Item = Record<'_>> {
|
||||
self.rows.iter().map(|row| Record { shown: self, row })
|
||||
}
|
||||
}
|
||||
|
||||
struct Record<'a> {
|
||||
shown: &'a Shown,
|
||||
row: &'a [Option<String>],
|
||||
}
|
||||
|
||||
impl<'a> Record<'a> {
|
||||
fn get(&self, name: &str) -> Option<&'a str> {
|
||||
self.shown.index(name).and_then(|i| self.row.get(i)).and_then(|v| v.as_deref())
|
||||
}
|
||||
|
||||
fn text(&self, name: &str) -> String {
|
||||
self.get(name).unwrap_or_default().to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
async fn show(pool: &SnowflakePool, sql: &str) -> IdeResult<Shown> {
|
||||
let result = pool.metadata().execute(sql, MAX_ROWS, &CancellationToken::new()).await?;
|
||||
Ok(Shown { columns: result.columns.into_iter().map(|c| c.name).collect(), rows: result.rows })
|
||||
}
|
||||
|
||||
/// A `SHOW` that lists by name, page after page.
|
||||
async fn show_all(pool: &SnowflakePool, sql: &str) -> IdeResult<Shown> {
|
||||
let mut all = show(pool, &format!("{sql} LIMIT {PAGE}")).await?;
|
||||
let mut last_page = all.rows.len();
|
||||
while last_page >= PAGE && all.rows.len() < MAX_ROWS {
|
||||
let Some(last) = all.values("name").last().flatten().map(str::to_owned) else { break };
|
||||
let next = show(pool, &format!("{sql} LIMIT {PAGE} FROM {}", quote_literal(DRIVER, &last))).await?;
|
||||
last_page = next.rows.len();
|
||||
all.rows.extend(next.rows);
|
||||
}
|
||||
Ok(all)
|
||||
}
|
||||
|
||||
/// ` IN DATABASE "DB"`, or nothing for the session's current database.
|
||||
fn in_database(pool: &SnowflakePool) -> String {
|
||||
pool.database_ident().map(|db| format!(" IN DATABASE {db}")).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// `"DB"."SCHEMA"` (the schema as the tree names it: exactly).
|
||||
fn schema_path(pool: &SnowflakePool, schema: &str) -> String {
|
||||
match pool.database_ident() {
|
||||
Some(db) => format!("{db}.{}", quote_ident(DRIVER, schema)),
|
||||
None => quote_ident(DRIVER, schema),
|
||||
}
|
||||
}
|
||||
|
||||
fn object_path(pool: &SnowflakePool, schema: &str, name: &str) -> String {
|
||||
format!("{}.{}", schema_path(pool, schema), quote_ident(DRIVER, name))
|
||||
}
|
||||
|
||||
pub(crate) async fn schemas(pool: &SnowflakePool, system: bool) -> IdeResult<Vec<String>> {
|
||||
let shown = show_all(pool, &format!("SHOW TERSE SCHEMAS{}", in_database(pool))).await?;
|
||||
Ok(shown
|
||||
.values("name")
|
||||
.flatten()
|
||||
.filter(|name| system || *name != "INFORMATION_SCHEMA")
|
||||
.map(str::to_owned)
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn table_kind(kind: &str) -> DbTableKind {
|
||||
let kind = kind.to_ascii_uppercase().replace('_', " ");
|
||||
if kind == "MATERIALIZED VIEW" {
|
||||
DbTableKind::MaterializedView
|
||||
} else if kind.ends_with("VIEW") {
|
||||
DbTableKind::View
|
||||
} else {
|
||||
DbTableKind::Table
|
||||
}
|
||||
}
|
||||
|
||||
/// Tables of every kind (dynamic, external, hybrid…) and views of `schema`.
|
||||
pub(crate) async fn tables(pool: &SnowflakePool, schema: &str) -> IdeResult<Vec<TableInfo>> {
|
||||
let shown = show_all(pool, &format!("SHOW TERSE OBJECTS IN SCHEMA {}", schema_path(pool, schema))).await?;
|
||||
Ok(shown
|
||||
.records()
|
||||
.filter_map(|r| {
|
||||
Some(TableInfo { name: r.get("name")?.to_owned(), kind: table_kind(r.get("kind").unwrap_or("TABLE")) })
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub(crate) async fn table_details(pool: &SnowflakePool, schema: &str, table: &str) -> IdeResult<TableDetails> {
|
||||
let path = object_path(pool, schema, table);
|
||||
let described = show(pool, &format!("DESCRIBE TABLE {path}")).await?;
|
||||
let yes = |v: Option<&str>| v.is_some_and(|v| v.eq_ignore_ascii_case("Y"));
|
||||
let columns = described
|
||||
.records()
|
||||
.filter(|r| r.get("kind").is_none_or(|kind| kind.eq_ignore_ascii_case("COLUMN")))
|
||||
.filter_map(|r| {
|
||||
Some(ColumnInfo {
|
||||
name: r.get("name")?.to_owned(),
|
||||
data_type: r.text("type"),
|
||||
nullable: yes(r.get("null?")),
|
||||
default: r.get("default").filter(|d| !d.is_empty()).map(str::to_owned),
|
||||
primary_key: yes(r.get("primary key")),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// Views have no keys; a role without the privilege still sees the columns.
|
||||
let foreign_keys = match show(pool, &format!("SHOW IMPORTED KEYS IN TABLE {path}")).await {
|
||||
Ok(keys) => foreign_keys(&keys),
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
Ok(TableDetails { columns, indexes: Vec::new(), foreign_keys, triggers: Vec::new() })
|
||||
}
|
||||
|
||||
fn foreign_keys(shown: &Shown) -> Vec<ForeignKey> {
|
||||
let mut rows: Vec<Record<'_>> = shown.records().collect();
|
||||
let sequence = |r: &Record<'_>| r.get("key_sequence").and_then(|n| n.parse::<u32>().ok()).unwrap_or(0);
|
||||
rows.sort_by_key(|a| (a.text("fk_name"), sequence(a)));
|
||||
let mut keys: Vec<(String, ForeignKey)> = Vec::new();
|
||||
for r in rows {
|
||||
let name = r.text("fk_name");
|
||||
if !keys.last().is_some_and(|(last, _)| *last == name) {
|
||||
keys.push((
|
||||
name,
|
||||
ForeignKey {
|
||||
schema: r.text("fk_schema_name"),
|
||||
table: r.text("fk_table_name"),
|
||||
columns: Vec::new(),
|
||||
ref_schema: r.text("pk_schema_name"),
|
||||
ref_table: r.text("pk_table_name"),
|
||||
ref_columns: Vec::new(),
|
||||
},
|
||||
));
|
||||
}
|
||||
let key = &mut keys.last_mut().expect("pushed above").1;
|
||||
key.columns.push(r.text("fk_column_name"));
|
||||
key.ref_columns.push(r.text("pk_column_name"));
|
||||
}
|
||||
keys.into_iter().map(|(_, key)| key).collect()
|
||||
}
|
||||
|
||||
/// Rows of `SHOW TERSE OBJECTS` for database `db` (quoted; `None`: the session's), each with
|
||||
/// its `schema_name`. One listing for the database when that answers in full; schema by schema
|
||||
/// (as the tree lists them) when it is refused or is cut off at a page, since a page
|
||||
/// continues by object name and names repeat across schemas.
|
||||
async fn database_objects(pool: &SnowflakePool, db: Option<&str>, schemas: &[String]) -> IdeResult<Shown> {
|
||||
let scope = db.map(|db| format!(" IN DATABASE {db}")).unwrap_or_default();
|
||||
match show(pool, &format!("SHOW TERSE OBJECTS{scope} LIMIT {PAGE}")).await {
|
||||
Ok(whole) if whole.rows.len() < PAGE && whole.index("schema_name").is_some() => return Ok(whole),
|
||||
Ok(_) => {}
|
||||
Err(e) => tracing::warn!("listing a whole database failed, listing its schemas instead: {}", e.message),
|
||||
}
|
||||
let mut all = Shown { columns: vec!["name".into(), "kind".into(), "schema_name".into()], rows: Vec::new() };
|
||||
let (mut listed, mut refused) = (0, None);
|
||||
for schema in schemas {
|
||||
let path = match db {
|
||||
Some(db) => format!("{db}.{}", quote_ident(DRIVER, schema)),
|
||||
None => quote_ident(DRIVER, schema),
|
||||
};
|
||||
// A schema the role may not list: the others still complete.
|
||||
let shown = match show_all(pool, &format!("SHOW TERSE OBJECTS IN SCHEMA {path}")).await {
|
||||
Ok(shown) => shown,
|
||||
Err(e) => {
|
||||
refused.get_or_insert(e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
listed += 1;
|
||||
let (Some(name), Some(kind)) = (shown.index("name"), shown.index("kind")) else { continue };
|
||||
all.rows.extend(shown.rows.into_iter().map(|mut row| {
|
||||
let kind = row.get_mut(kind).and_then(Option::take);
|
||||
let name = row.get_mut(name).and_then(Option::take);
|
||||
vec![name, kind, Some(schema.clone())]
|
||||
}));
|
||||
if all.rows.len() >= MAX_ROWS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Nothing could be listed at all: that is a failure, not an empty database.
|
||||
match refused {
|
||||
Some(e) if listed == 0 => Err(e),
|
||||
_ => Ok(all),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every schema of `database` with its tables and views, for completion across databases.
|
||||
pub(crate) async fn catalog(pool: &SnowflakePool, database: &str) -> IdeResult<Vec<crate::CatalogSchema>> {
|
||||
let db = quote_ident(DRIVER, database);
|
||||
let schemas = show_all(pool, &format!("SHOW TERSE SCHEMAS IN DATABASE {db}")).await?;
|
||||
let names: Vec<String> =
|
||||
schemas.values("name").flatten().filter(|name| *name != "INFORMATION_SCHEMA").map(str::to_owned).collect();
|
||||
let mut out: Vec<crate::CatalogSchema> =
|
||||
names.iter().map(|name| crate::CatalogSchema { name: name.clone(), tables: Vec::new() }).collect();
|
||||
let at: HashMap<String, usize> = out.iter().enumerate().map(|(i, s)| (s.name.clone(), i)).collect();
|
||||
let objects = database_objects(pool, Some(&db), &names).await?;
|
||||
for r in objects.records() {
|
||||
let (Some(&index), Some(name)) = (at.get(r.get("schema_name").unwrap_or_default()), r.get("name")) else {
|
||||
continue;
|
||||
};
|
||||
out[index].tables.push(TableInfo { name: name.to_owned(), kind: table_kind(r.get("kind").unwrap_or("TABLE")) });
|
||||
}
|
||||
for schema in &mut out {
|
||||
schema.tables.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
schema.tables.dedup_by(|a, b| a.name == b.name);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The session's warehouse and role, and the ones it may switch to.
|
||||
pub(crate) async fn session(pool: &SnowflakePool) -> IdeResult<crate::SessionContext> {
|
||||
// The consoles' session: `USE WAREHOUSE` / `USE ROLE` there is what the pickers show.
|
||||
let sql = "SELECT CURRENT_WAREHOUSE(), CURRENT_ROLE(), CURRENT_AVAILABLE_ROLES()";
|
||||
let current = pool.execute(sql, 1, &CancellationToken::new()).await?.rows;
|
||||
let cell = |i: usize| current.first().and_then(|r| r.get(i).cloned().flatten()).unwrap_or_default();
|
||||
let mut roles: Vec<String> = serde_json::from_str(&cell(2)).unwrap_or_default();
|
||||
roles.sort();
|
||||
let warehouses = match show(pool, "SHOW WAREHOUSES").await {
|
||||
Ok(shown) => shown.values("name").flatten().map(str::to_owned).collect(),
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
Ok(crate::SessionContext { warehouse: cell(0), role: cell(1), warehouses, roles })
|
||||
}
|
||||
|
||||
/// `NAME(NUMBER, VARCHAR) RETURN NUMBER` as its argument types and result type.
|
||||
fn signature(arguments: &str) -> (String, Option<String>) {
|
||||
let inner = arguments.split_once('(').map_or(arguments, |(_, rest)| rest);
|
||||
// The result type may hold parentheses of its own (`TABLE (…)`): split at the marker.
|
||||
let (types, returns) = match inner.split_once(") RETURN ") {
|
||||
Some((types, returns)) => (types, Some(returns.trim().to_owned())),
|
||||
None => (inner.strip_suffix(')').unwrap_or(inner), None),
|
||||
};
|
||||
(types.trim().to_owned(), returns.filter(|r| !r.is_empty()))
|
||||
}
|
||||
|
||||
async fn routines(
|
||||
pool: &SnowflakePool,
|
||||
schema: &str,
|
||||
kind: DbObjectKind,
|
||||
like: Option<&str>,
|
||||
) -> IdeResult<Vec<RoutineInfo>> {
|
||||
let what = if kind == DbObjectKind::Procedure { "PROCEDURES" } else { "USER FUNCTIONS" };
|
||||
let like = like.map(|name| format!(" LIKE {}", quote_literal(DRIVER, &escape_like(name)))).unwrap_or_default();
|
||||
let shown = show(pool, &format!("SHOW {what}{like} IN SCHEMA {}", schema_path(pool, schema))).await?;
|
||||
Ok(shown
|
||||
.records()
|
||||
.filter(|r| !r.get("is_builtin").is_some_and(|b| b.eq_ignore_ascii_case("Y")))
|
||||
.filter_map(|r| {
|
||||
let (arguments, returns) = signature(r.get("arguments").unwrap_or_default());
|
||||
Some(RoutineInfo {
|
||||
name: r.get("name")?.to_owned(),
|
||||
kind,
|
||||
arguments,
|
||||
returns: returns.filter(|_| kind == DbObjectKind::Function),
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// `LIKE` patterns match `_` and `%` loosely; escape them to match the name itself.
|
||||
fn escape_like(name: &str) -> String {
|
||||
name.replace('\\', "\\\\").replace('_', "\\_").replace('%', "\\%")
|
||||
}
|
||||
|
||||
/// Kinds of the tree's other folders, the `SHOW` that lists them, and the column that says most about one.
|
||||
const OTHERS: &[(&str, &str, &str)] = &[
|
||||
("stage", "STAGES", "type"),
|
||||
("stream", "STREAMS", "table_name"),
|
||||
("task", "TASKS", "schedule"),
|
||||
("pipe", "PIPES", "definition"),
|
||||
("fileFormat", "FILE FORMATS", "type"),
|
||||
];
|
||||
|
||||
pub(crate) async fn schema_objects(pool: &SnowflakePool, schema: &str) -> IdeResult<SchemaObjects> {
|
||||
let mut out = SchemaObjects::default();
|
||||
// A role may see a schema's tables and none of these: each list stands alone.
|
||||
for kind in [DbObjectKind::Function, DbObjectKind::Procedure] {
|
||||
out.routines.extend(routines(pool, schema, kind, None).await.unwrap_or_default());
|
||||
}
|
||||
out.routines.sort_by(|a, b| (&a.name, &a.arguments).cmp(&(&b.name, &b.arguments)));
|
||||
if let Ok(shown) = show(pool, &format!("SHOW SEQUENCES IN SCHEMA {}", schema_path(pool, schema))).await {
|
||||
out.sequences = shown
|
||||
.values("name")
|
||||
.flatten()
|
||||
.map(|name| SequenceInfo { name: name.to_owned(), data_type: "NUMBER".into() })
|
||||
.collect();
|
||||
}
|
||||
for (kind, what, detail) in OTHERS {
|
||||
let Ok(shown) = show(pool, &format!("SHOW {what} IN SCHEMA {}", schema_path(pool, schema))).await else {
|
||||
continue;
|
||||
};
|
||||
out.others.extend(shown.records().filter_map(|r| {
|
||||
let detail: String = r.text(detail).split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
Some(SchemaObject {
|
||||
kind: (*kind).to_owned(),
|
||||
name: r.get("name")?.to_owned(),
|
||||
detail: detail.chars().take(120).collect(),
|
||||
})
|
||||
}));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The definitions of a routine's overloads, or of a sequence.
|
||||
pub(crate) async fn object_ddl(
|
||||
pool: &SnowflakePool,
|
||||
schema: &str,
|
||||
kind: DbObjectKind,
|
||||
name: &str,
|
||||
) -> IdeResult<Vec<String>> {
|
||||
let path = object_path(pool, schema, name);
|
||||
let mut out = Vec::new();
|
||||
match kind {
|
||||
DbObjectKind::Sequence => out.extend(ddl(pool, "SEQUENCE", path).await?),
|
||||
DbObjectKind::Function | DbObjectKind::Procedure => {
|
||||
let object = if kind == DbObjectKind::Procedure { "PROCEDURE" } else { "FUNCTION" };
|
||||
for routine in routines(pool, schema, kind, Some(name)).await?.into_iter().filter(|r| r.name == name) {
|
||||
// Overloads are told apart by their argument types (names and defaults dropped).
|
||||
let types: Vec<&str> = routine
|
||||
.arguments
|
||||
.split(',')
|
||||
.map(|a| a.trim().trim_start_matches("DEFAULT ").trim())
|
||||
.filter(|a| !a.is_empty())
|
||||
.collect();
|
||||
out.extend(ddl(pool, object, format!("{path}({})", types.join(", "))).await?);
|
||||
}
|
||||
}
|
||||
DbObjectKind::Trigger | DbObjectKind::Package => {
|
||||
return Err(IdeError::invalid("Snowflake has no triggers or packages"));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn ddl(pool: &SnowflakePool, object: &str, target: String) -> IdeResult<Option<String>> {
|
||||
let sql = format!("SELECT GET_DDL('{object}', {})", quote_literal(DRIVER, &target));
|
||||
let rows = pool.metadata().execute(&sql, 1, &CancellationToken::new()).await?.rows;
|
||||
Ok(rows.into_iter().next().and_then(|r| r.into_iter().next().flatten()))
|
||||
}
|
||||
|
||||
/// A column type of `SHOW COLUMNS` (`{"type":"FIXED","precision":38,"scale":0,…}`) the way SQL spells it.
|
||||
fn column_type(json: &str) -> String {
|
||||
let Ok(value) = serde_json::from_str::<serde_json::Value>(json) else { return json.to_owned() };
|
||||
let kind = value.get("type").and_then(|t| t.as_str()).unwrap_or_default().to_ascii_uppercase();
|
||||
let number = |name: &str| value.get(name).and_then(|n| n.as_i64());
|
||||
match kind.as_str() {
|
||||
"FIXED" => match (number("precision"), number("scale")) {
|
||||
(Some(p), Some(s)) => format!("NUMBER({p},{s})"),
|
||||
_ => "NUMBER".into(),
|
||||
},
|
||||
"REAL" => "FLOAT".into(),
|
||||
"TEXT" => match number("length") {
|
||||
Some(n) if n > 0 && n < 16_777_216 => format!("VARCHAR({n})"),
|
||||
_ => "VARCHAR".into(),
|
||||
},
|
||||
"TIME" | "TIMESTAMP_NTZ" | "TIMESTAMP_LTZ" | "TIMESTAMP_TZ" => match number("scale") {
|
||||
Some(s) => format!("{kind}({s})"),
|
||||
None => kind,
|
||||
},
|
||||
_ => kind,
|
||||
}
|
||||
}
|
||||
|
||||
/// Above this many tables, a database's columns are too many to read at once.
|
||||
const SMALL_DATABASE: usize = 1_000;
|
||||
|
||||
/// Every table of the target's database for SQL completion, with the columns of all of them
|
||||
/// when the database is small, else of the session's schema only. A table listed without
|
||||
/// columns is one whose columns were not read.
|
||||
pub(crate) async fn completion(pool: &SnowflakePool) -> IdeResult<Vec<CompletionTable>> {
|
||||
let names = schemas(pool, false).await?;
|
||||
let objects = database_objects(pool, pool.database_ident().as_deref(), &names).await?;
|
||||
let mut tables: Vec<CompletionTable> = objects
|
||||
.records()
|
||||
.filter(|r| r.get("schema_name") != Some("INFORMATION_SCHEMA"))
|
||||
.filter_map(|r| {
|
||||
Some(CompletionTable {
|
||||
schema: r.text("schema_name"),
|
||||
name: r.get("name")?.to_owned(),
|
||||
kind: table_kind(r.get("kind").unwrap_or("TABLE")),
|
||||
columns: Vec::new(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
tables.sort_by(|a, b| (&a.schema, &a.name).cmp(&(&b.schema, &b.name)));
|
||||
tables.dedup_by(|a, b| a.schema == b.schema && a.name == b.name);
|
||||
let scope = if tables.len() <= SMALL_DATABASE {
|
||||
Some(in_database(pool))
|
||||
} else {
|
||||
let current = show(pool, "SELECT CURRENT_SCHEMA()").await?;
|
||||
let schema = current.rows.first().and_then(|r| r.first().cloned().flatten()).filter(|s| !s.is_empty());
|
||||
schema.map(|schema| format!(" IN SCHEMA {}", schema_path(pool, &schema)))
|
||||
};
|
||||
let Some(scope) = scope else { return Ok(tables) };
|
||||
// Columns are a help, not a need: without them the tables still complete.
|
||||
let Ok(shown) = show(pool, &format!("SHOW COLUMNS{scope}")).await else { return Ok(tables) };
|
||||
let at: HashMap<(String, String), usize> =
|
||||
tables.iter().enumerate().map(|(i, t)| ((t.schema.clone(), t.name.clone()), i)).collect();
|
||||
for r in shown.records() {
|
||||
let Some(&index) = at.get(&(r.text("schema_name"), r.text("table_name"))) else { continue };
|
||||
tables[index].columns.push(CompletionColumn {
|
||||
name: r.text("column_name"),
|
||||
data_type: column_type(r.get("data_type").unwrap_or_default()),
|
||||
nullable: !r
|
||||
.get("null?")
|
||||
.is_some_and(|v| v.eq_ignore_ascii_case("NOT_NULL") || v.eq_ignore_ascii_case("N")),
|
||||
primary_key: false,
|
||||
has_default: r.get("default").is_some_and(|d| !d.is_empty())
|
||||
|| r.get("autoincrement").is_some_and(|a| !a.is_empty()),
|
||||
});
|
||||
}
|
||||
Ok(tables)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn signatures_split_into_arguments_and_result() {
|
||||
assert_eq!(signature("ADD_ONE(NUMBER) RETURN NUMBER"), ("NUMBER".into(), Some("NUMBER".into())));
|
||||
assert_eq!(
|
||||
signature("PARSE(VARCHAR, NUMBER(38,0)) RETURN TABLE (A NUMBER)"),
|
||||
("VARCHAR, NUMBER(38,0)".into(), Some("TABLE (A NUMBER)".into()))
|
||||
);
|
||||
assert_eq!(signature("NOW_UTC() RETURN TIMESTAMP_NTZ"), (String::new(), Some("TIMESTAMP_NTZ".into())));
|
||||
assert_eq!(signature(""), (String::new(), None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_types_read_like_sql() {
|
||||
assert_eq!(column_type(r#"{"type":"FIXED","precision":38,"scale":0,"nullable":true}"#), "NUMBER(38,0)");
|
||||
assert_eq!(column_type(r#"{"type":"TEXT","length":100,"byteLength":400,"nullable":false}"#), "VARCHAR(100)");
|
||||
assert_eq!(column_type(r#"{"type":"TEXT","length":16777216}"#), "VARCHAR");
|
||||
assert_eq!(column_type(r#"{"type":"TIMESTAMP_NTZ","precision":0,"scale":9}"#), "TIMESTAMP_NTZ(9)");
|
||||
assert_eq!(column_type(r#"{"type":"VARIANT","nullable":true}"#), "VARIANT");
|
||||
assert_eq!(column_type(r#"{"type":"REAL"}"#), "FLOAT");
|
||||
assert_eq!(column_type("not json"), "not json");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_kinds_become_tree_kinds() {
|
||||
for kind in ["TABLE", "DYNAMIC TABLE", "EXTERNAL TABLE", "EVENT TABLE", "HYBRID TABLE"] {
|
||||
assert_eq!(table_kind(kind), DbTableKind::Table, "{kind}");
|
||||
}
|
||||
assert_eq!(table_kind("VIEW"), DbTableKind::View);
|
||||
assert_eq!(table_kind("MATERIALIZED_VIEW"), DbTableKind::MaterializedView);
|
||||
assert_eq!(table_kind("MATERIALIZED VIEW"), DbTableKind::MaterializedView);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_keys_group_by_constraint_in_column_order() {
|
||||
let columns = [
|
||||
"pk_schema_name",
|
||||
"pk_table_name",
|
||||
"pk_column_name",
|
||||
"fk_schema_name",
|
||||
"fk_table_name",
|
||||
"fk_column_name",
|
||||
"key_sequence",
|
||||
"fk_name",
|
||||
];
|
||||
let row = |values: [&str; 8]| values.iter().map(|v| Some((*v).to_owned())).collect::<Vec<_>>();
|
||||
let shown = Shown {
|
||||
columns: columns.iter().map(|c| (*c).to_owned()).collect(),
|
||||
rows: vec![
|
||||
row(["PUBLIC", "REGIONS", "CODE", "PUBLIC", "ORDERS", "REGION_CODE", "2", "FK_REGION"]),
|
||||
row(["PUBLIC", "CUSTOMERS", "ID", "PUBLIC", "ORDERS", "CUSTOMER_ID", "1", "FK_CUSTOMER"]),
|
||||
row(["PUBLIC", "REGIONS", "COUNTRY", "PUBLIC", "ORDERS", "COUNTRY", "1", "FK_REGION"]),
|
||||
],
|
||||
};
|
||||
let keys = foreign_keys(&shown);
|
||||
assert_eq!(keys.len(), 2);
|
||||
assert_eq!(
|
||||
(keys[0].ref_table.as_str(), keys[0].columns.as_slice()),
|
||||
("CUSTOMERS", &["CUSTOMER_ID".to_owned()][..])
|
||||
);
|
||||
assert_eq!(keys[1].columns, ["COUNTRY", "REGION_CODE"]);
|
||||
assert_eq!(keys[1].ref_columns, ["COUNTRY", "CODE"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,929 @@
|
||||
//! Snowflake over HTTPS: the session protocol of Snowflake's own drivers (login, statements,
|
||||
//! cancellation, token renewal) with results in JSON. One session per pool; statements run
|
||||
//! one at a time on it, so session state (`USE`, `ALTER SESSION`, transactions) holds.
|
||||
|
||||
mod account;
|
||||
mod browser;
|
||||
mod client;
|
||||
mod error;
|
||||
mod keypair;
|
||||
pub(crate) mod meta;
|
||||
mod rows;
|
||||
|
||||
use crate::conn::{QueryResult, Setup};
|
||||
use crate::source::{DataSource, DbSnowflakeAuth};
|
||||
use client::{Api, Envelope, Request};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult, error::ErrorKind};
|
||||
use parking_lot::RwLock;
|
||||
use rows::RowType;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, LazyLock, Weak};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
const APP: &str = "OpenIDEA";
|
||||
const LOGIN: &str = "session/v1/login-request";
|
||||
const QUERY: &str = "queries/v1/query-request";
|
||||
const ABORT: &str = "queries/v1/abort-request";
|
||||
const RENEW: &str = "session/token-request";
|
||||
const SESSION: &str = "session";
|
||||
/// DML statement types (`INSERT`, `UPDATE`, `DELETE`, `MERGE`…): their result is a row of counts.
|
||||
const DML: std::ops::Range<i64> = 0x3000..0x4000;
|
||||
const SELECT: i64 = 0x1000;
|
||||
|
||||
/// A connection target (a database of a data source) on the Snowflake session its data source's
|
||||
/// targets share: one login serves every database, and each statement runs under its target's
|
||||
/// database and schema.
|
||||
#[derive(Clone)]
|
||||
pub struct SnowflakePool {
|
||||
shared: Arc<State>,
|
||||
/// The target's database and the data source's schema (empty: the user's defaults).
|
||||
database: String,
|
||||
schema: String,
|
||||
read_only: Arc<AtomicBool>,
|
||||
closed: CancellationToken,
|
||||
/// Which of the data source's sessions this handle runs on.
|
||||
lane: usize,
|
||||
}
|
||||
|
||||
static CONNECTING: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
|
||||
|
||||
/// The key the single sign-on ID token of `source` is cached under (`None`: another method).
|
||||
pub(crate) fn sso_key(source: &DataSource) -> Option<String> {
|
||||
if source.snowflake().auth != DbSnowflakeAuth::Browser {
|
||||
return None;
|
||||
}
|
||||
let endpoint = account::endpoint(source).ok()?;
|
||||
let address = format!(
|
||||
"{}:{}",
|
||||
endpoint.base.host_str().unwrap_or_default(),
|
||||
endpoint.base.port_or_known_default().unwrap_or_default()
|
||||
);
|
||||
Some(format!("{address}|{}", source.user.trim().to_uppercase()))
|
||||
}
|
||||
|
||||
/// The ID token a browser sign-in left, which signs in again without the browser while Snowflake accepts it.
|
||||
pub(crate) fn sso_token(key: &str) -> Option<String> {
|
||||
browser::cached(key)
|
||||
}
|
||||
|
||||
/// Offers a saved ID token for the next sign-in (one already in use is kept).
|
||||
pub(crate) fn sso_seed(key: &str, token: &str) {
|
||||
if browser::cached(key).is_none() {
|
||||
browser::remember(key, token);
|
||||
}
|
||||
}
|
||||
|
||||
/// Live sessions by their connection settings, so targets opened with the same settings share one.
|
||||
static SESSIONS: LazyLock<parking_lot::Mutex<HashMap<String, Weak<State>>>> =
|
||||
LazyLock::new(|| parking_lot::Mutex::new(HashMap::new()));
|
||||
|
||||
struct State {
|
||||
api: Api,
|
||||
login: Login,
|
||||
/// The consoles' session and the tree's ([`CONSOLE`], [`METADATA`]): a long statement in a
|
||||
/// console does not hold up the tree. The second one logs in when it is first used.
|
||||
lanes: [Lane; 2],
|
||||
/// Open targets on this session; the last one to close ends it.
|
||||
users: AtomicUsize,
|
||||
key: String,
|
||||
closed: CancellationToken,
|
||||
}
|
||||
|
||||
/// One Snowflake session.
|
||||
#[derive(Default)]
|
||||
struct Lane {
|
||||
tokens: RwLock<Option<Tokens>>,
|
||||
/// Statements share the session, so they take turns.
|
||||
turn: Mutex<()>,
|
||||
sequence: AtomicU64,
|
||||
/// The `USE` statement the session is known to be under (`None`: a statement may have
|
||||
/// moved it; empty: as the login left it, without a database or schema).
|
||||
context: parking_lot::Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
const CONSOLE: usize = 0;
|
||||
const METADATA: usize = 1;
|
||||
|
||||
struct Login {
|
||||
account: String,
|
||||
user: String,
|
||||
credentials: Credentials,
|
||||
database: String,
|
||||
schema: String,
|
||||
warehouse: String,
|
||||
role: String,
|
||||
parameters: Vec<(String, String)>,
|
||||
timeout: Duration,
|
||||
}
|
||||
|
||||
enum Credentials {
|
||||
KeyPair { file: String, passphrase: Option<String> },
|
||||
Password(String),
|
||||
Token(String),
|
||||
OAuth(String),
|
||||
Browser,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Tokens {
|
||||
session: String,
|
||||
master: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
struct ResultData {
|
||||
rowtype: Vec<RowType>,
|
||||
rowset: Vec<Vec<Value>>,
|
||||
rowset_base64: Option<String>,
|
||||
chunks: Vec<Chunk>,
|
||||
chunk_headers: BTreeMap<String, String>,
|
||||
qrmk: Option<String>,
|
||||
query_result_format: Option<String>,
|
||||
statement_type_id: Option<i64>,
|
||||
get_result_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
struct Chunk {
|
||||
url: String,
|
||||
row_count: usize,
|
||||
}
|
||||
|
||||
fn uuid() -> String {
|
||||
uuid::Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs())
|
||||
}
|
||||
|
||||
fn closed() -> IdeError {
|
||||
IdeError::new(ErrorKind::Io, "Snowflake connection is closed")
|
||||
}
|
||||
|
||||
/// A session parameter as JSON: Snowflake types booleans and numbers.
|
||||
fn parameter(value: &str) -> Value {
|
||||
if value.eq_ignore_ascii_case("true") {
|
||||
Value::Bool(true)
|
||||
} else if value.eq_ignore_ascii_case("false") {
|
||||
Value::Bool(false)
|
||||
} else if let Ok(n) = value.parse::<i64>() {
|
||||
json!(n)
|
||||
} else {
|
||||
Value::String(value.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings of the Advanced tab the driver sets itself.
|
||||
fn reserved(name: &str) -> bool {
|
||||
matches!(
|
||||
name.to_ascii_uppercase().as_str(),
|
||||
"ROWS_PER_RESULTSET" | "MULTI_STATEMENT_COUNT" | "CLIENT_STORE_TEMPORARY_CREDENTIAL"
|
||||
)
|
||||
}
|
||||
|
||||
/// A name typed in the dialog, as SQL: typed in one case it is an unquoted name, which folds
|
||||
/// to upper case; anything else is taken as the exact name.
|
||||
fn typed(name: &str) -> String {
|
||||
let plain = name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
|
||||
&& !name.starts_with(|c: char| c.is_ascii_digit());
|
||||
let one_case = !name.chars().any(|c| c.is_ascii_lowercase()) || !name.chars().any(|c| c.is_ascii_uppercase());
|
||||
let name = if plain && one_case { name.to_ascii_uppercase() } else { name.to_owned() };
|
||||
crate::sql::quote_ident(crate::DbDriver::Snowflake, &name)
|
||||
}
|
||||
|
||||
fn first_word(sql: &str) -> String {
|
||||
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;
|
||||
}
|
||||
}
|
||||
sql.split(|c: char| !c.is_ascii_alphanumeric() && c != '_').next().unwrap_or_default().to_ascii_uppercase()
|
||||
}
|
||||
|
||||
/// Whether `sql` may change data or objects (a read-only data source refuses it).
|
||||
/// Snowflake answers a `USE` it will not perform with one message for every cause; this adds
|
||||
/// the causes.
|
||||
fn explain_use(sql: &str, mut error: IdeError) -> IdeError {
|
||||
if first_word(sql) == "USE" && error.message.contains("002043") {
|
||||
error.message.push_str(
|
||||
"\nThe role, warehouse, database or schema does not exist, or is not granted: a role must be \
|
||||
granted to your user (SHOW GRANTS TO USER <name>), the others to the current role.",
|
||||
);
|
||||
}
|
||||
error
|
||||
}
|
||||
|
||||
/// Whether `sql` may change the account. Statements that read, and those that change only the
|
||||
/// session (`USE`, `SET`, `UNSET`), run on a read-only data source.
|
||||
fn is_write(sql: &str) -> bool {
|
||||
match first_word(sql).as_str() {
|
||||
"SELECT" | "SHOW" | "DESCRIBE" | "DESC" | "EXPLAIN" | "LIST" | "LS" | "USE" | "SET" | "UNSET" | "VALUES"
|
||||
| "TABLE" | "" => false,
|
||||
// `WITH name AS PROCEDURE (…) … CALL name(…)` runs a procedure; other `WITH`s select.
|
||||
"WITH" => {
|
||||
sql.to_ascii_uppercase().split_whitespace().collect::<Vec<_>>().windows(2).any(|w| w == ["AS", "PROCEDURE"])
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn gunzip(bytes: Vec<u8>) -> IdeResult<Vec<u8>> {
|
||||
if !bytes.starts_with(&[0x1f, 0x8b]) {
|
||||
return Ok(bytes);
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
std::io::Read::read_to_end(&mut flate2::read::GzDecoder::new(bytes.as_slice()), &mut out)
|
||||
.map_err(|e| IdeError::new(ErrorKind::Protocol, format!("Snowflake: cannot read a result chunk: {e}")))?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
impl SnowflakePool {
|
||||
pub(crate) async fn connect(source: &DataSource, secret: Option<&str>, setup: Setup) -> IdeResult<Self> {
|
||||
let settings = source.snowflake();
|
||||
let endpoint = account::endpoint(source)?;
|
||||
let user = source.user.trim();
|
||||
if user.is_empty() {
|
||||
return Err(IdeError::invalid("enter the Snowflake user name"));
|
||||
}
|
||||
for (name, _) in &setup.params {
|
||||
if reserved(name) {
|
||||
return Err(IdeError::invalid(format!("the Snowflake driver manages the {name} parameter")));
|
||||
}
|
||||
}
|
||||
let secret = secret.filter(|s| !s.is_empty());
|
||||
let ask = |what: &str| IdeError::new(ErrorKind::PermissionDenied, format!("enter the {what}"));
|
||||
let credentials = match settings.auth {
|
||||
DbSnowflakeAuth::KeyPair => {
|
||||
Credentials::KeyPair { file: settings.private_key_file.clone(), passphrase: secret.map(str::to_owned) }
|
||||
}
|
||||
DbSnowflakeAuth::Password => Credentials::Password(secret.ok_or_else(|| ask("password"))?.to_owned()),
|
||||
DbSnowflakeAuth::Token => {
|
||||
Credentials::Token(secret.ok_or_else(|| ask("programmatic access token"))?.to_owned())
|
||||
}
|
||||
DbSnowflakeAuth::OAuth => Credentials::OAuth(secret.ok_or_else(|| ask("OAuth access token"))?.to_owned()),
|
||||
DbSnowflakeAuth::Browser => Credentials::Browser,
|
||||
};
|
||||
let timeout = setup.connect_timeout.unwrap_or(Duration::from_secs(30));
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
let builder = reqwest::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 proxy = ide_core::proxy_settings::proxy();
|
||||
let http = ide_http::client::with_proxy(builder, &proxy)
|
||||
.build()
|
||||
.map_err(|e| IdeError::new(ErrorKind::Io, format!("Snowflake: cannot start the HTTP client: {e}")))?;
|
||||
let api = Api::new(http, endpoint.base);
|
||||
if let Some(secret) = secret {
|
||||
api.scrub.add(secret);
|
||||
}
|
||||
let database = source.database.trim().to_owned();
|
||||
let schema = source.options.default_schema.trim().to_owned();
|
||||
let target = |shared: Arc<State>| Self {
|
||||
shared,
|
||||
database: database.clone(),
|
||||
schema: schema.clone(),
|
||||
read_only: Arc::new(AtomicBool::new(source.read_only)),
|
||||
closed: CancellationToken::new(),
|
||||
lane: CONSOLE,
|
||||
};
|
||||
// Everything that makes a session what it is, the secret included (hashed: the key is only compared).
|
||||
let key = blake3::hash(
|
||||
format!(
|
||||
"{}\0{user}\0{:?}\0{}\0{}\0{}\0{}\0{:?}\0{:?}",
|
||||
api.address(),
|
||||
settings.auth,
|
||||
secret.unwrap_or_default(),
|
||||
settings.private_key_file,
|
||||
settings.warehouse.trim(),
|
||||
settings.role.trim(),
|
||||
setup.params,
|
||||
setup.statements
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.to_hex()
|
||||
.to_string();
|
||||
// One connect at a time: targets opened together (the tree, consoles, completion) find the
|
||||
// session the first of them signs in to, instead of each signing in (and each opening the
|
||||
// browser with single sign-on).
|
||||
let _connecting = CONNECTING.lock().await;
|
||||
let live = SESSIONS.lock().get(&key).and_then(Weak::upgrade).filter(|s| !s.closed.is_cancelled());
|
||||
if let Some(shared) = live {
|
||||
shared.users.fetch_add(1, Ordering::SeqCst);
|
||||
return Ok(target(shared));
|
||||
}
|
||||
let pool = target(Arc::new(State {
|
||||
api,
|
||||
login: Login {
|
||||
account: endpoint.account,
|
||||
user: user.to_owned(),
|
||||
credentials,
|
||||
database: database.clone(),
|
||||
schema: schema.clone(),
|
||||
warehouse: settings.warehouse.trim().to_owned(),
|
||||
role: settings.role.trim().to_owned(),
|
||||
parameters: setup.params,
|
||||
timeout,
|
||||
},
|
||||
lanes: Default::default(),
|
||||
users: AtomicUsize::new(1),
|
||||
key: key.clone(),
|
||||
closed: CancellationToken::new(),
|
||||
}));
|
||||
pool.login().await?;
|
||||
// The login asked for this target's database and schema.
|
||||
*pool.lane().context.lock() = Some(pool.context(None).unwrap_or_default());
|
||||
let cancel = CancellationToken::new();
|
||||
// The startup script may write whatever the read-only setting says.
|
||||
pool.read_only.store(false, Ordering::Relaxed);
|
||||
for sql in setup.statements {
|
||||
if let Err(error) = pool.execute(&sql, 0, &cancel).await {
|
||||
pool.close().await;
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
pool.read_only.store(source.read_only, Ordering::Relaxed);
|
||||
SESSIONS.lock().insert(key, Arc::downgrade(&pool.shared));
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
/// The target's database as SQL (`None`: the session's current database).
|
||||
pub(crate) fn database_ident(&self) -> Option<String> {
|
||||
(!self.database.is_empty()).then(|| typed(&self.database))
|
||||
}
|
||||
|
||||
/// The `USE` statement for this target with `schema` active (`None`: the data source's schema).
|
||||
fn context(&self, schema: Option<&str>) -> Option<String> {
|
||||
let quote = |name: &str| crate::sql::quote_ident(crate::DbDriver::Snowflake, name);
|
||||
let schema = match schema.filter(|s| !s.is_empty()) {
|
||||
// Picked from the list of schemas: the exact name.
|
||||
Some(picked) => Some(quote(picked)),
|
||||
None if self.schema.is_empty() => None,
|
||||
None => Some(typed(&self.schema)),
|
||||
};
|
||||
match (self.database.is_empty(), schema) {
|
||||
(false, Some(schema)) => Some(format!("USE SCHEMA {}.{schema}", typed(&self.database))),
|
||||
(false, None) => Some(format!("USE DATABASE {}", typed(&self.database))),
|
||||
(true, Some(schema)) => Some(format!("USE SCHEMA {schema}")),
|
||||
(true, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ends the session on the server (best effort) and refuses further statements.
|
||||
pub async fn close(&self) {
|
||||
if self.closed.is_cancelled() {
|
||||
return;
|
||||
}
|
||||
self.closed.cancel();
|
||||
if self.shared.users.fetch_sub(1, Ordering::SeqCst) != 1 {
|
||||
return;
|
||||
}
|
||||
self.shared.closed.cancel();
|
||||
{
|
||||
let mut sessions = SESSIONS.lock();
|
||||
if sessions.get(&self.shared.key).is_some_and(|s| std::ptr::eq(s.as_ptr(), Arc::as_ptr(&self.shared))) {
|
||||
sessions.remove(&self.shared.key);
|
||||
}
|
||||
}
|
||||
for lane in &self.shared.lanes {
|
||||
let tokens = lane.tokens.write().take();
|
||||
if let Some(tokens) = tokens {
|
||||
let request = Request::post(SESSION, json!({}))
|
||||
.query("delete", "true")
|
||||
.query("requestId", uuid())
|
||||
.token(&tokens.session)
|
||||
.timeout(Duration::from_secs(5));
|
||||
let _ = self.shared.api.send(request).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn lane(&self) -> &Lane {
|
||||
&self.shared.lanes[self.lane]
|
||||
}
|
||||
|
||||
/// This target on the session the tree reads metadata with.
|
||||
pub(crate) fn metadata(&self) -> Self {
|
||||
// A second browser sign-in would interrupt the user: without an ID token to sign in
|
||||
// with, metadata takes turns with the consoles.
|
||||
let second = !matches!(self.shared.login.credentials, Credentials::Browser)
|
||||
|| browser::cached(&self.browser_key()).is_some();
|
||||
Self { lane: if second { METADATA } else { CONSOLE }, ..self.clone() }
|
||||
}
|
||||
|
||||
fn browser_key(&self) -> String {
|
||||
format!("{}|{}", self.shared.api.address(), self.shared.login.user.to_uppercase())
|
||||
}
|
||||
|
||||
pub fn is_closed(&self) -> bool {
|
||||
self.closed.is_cancelled() || self.shared.closed.is_cancelled()
|
||||
}
|
||||
|
||||
/// The fields of the login request that prove who the user is.
|
||||
async fn proof(&self) -> IdeResult<(Vec<(&'static str, Value)>, Option<String>)> {
|
||||
let login = &self.shared.login;
|
||||
Ok(match &login.credentials {
|
||||
Credentials::KeyPair { file, passphrase } => {
|
||||
let (file, passphrase) = (file.clone(), passphrase.clone());
|
||||
let (account, user) = (login.account.clone(), login.user.clone());
|
||||
// Decrypting the key derives its encryption key, which takes a while by design.
|
||||
let (token, fingerprint) = tokio::task::spawn_blocking(move || {
|
||||
let pair = keypair::load(&file, passphrase.as_deref())?;
|
||||
Ok::<_, IdeError>((pair.jwt(&account, &user, now_secs())?, pair.fingerprint()?))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))??;
|
||||
self.shared.api.scrub.add(&token);
|
||||
(vec![("AUTHENTICATOR", json!("SNOWFLAKE_JWT")), ("TOKEN", json!(token))], Some(fingerprint))
|
||||
}
|
||||
Credentials::Password(password) => (vec![("PASSWORD", json!(password))], None),
|
||||
Credentials::Token(token) => {
|
||||
(vec![("AUTHENTICATOR", json!("PROGRAMMATIC_ACCESS_TOKEN")), ("TOKEN", json!(token))], None)
|
||||
}
|
||||
Credentials::OAuth(token) => (vec![("AUTHENTICATOR", json!("OAUTH")), ("TOKEN", json!(token))], None),
|
||||
Credentials::Browser => unreachable!("browser sign-in has its own login"),
|
||||
})
|
||||
}
|
||||
|
||||
async fn login(&self) -> IdeResult<Tokens> {
|
||||
if matches!(self.shared.login.credentials, Credentials::Browser) {
|
||||
return self.browser_login().await;
|
||||
}
|
||||
let (proof, fingerprint) = self.proof().await?;
|
||||
self.sign_in(proof, fingerprint, None).await
|
||||
}
|
||||
|
||||
/// Signs in through the web browser, or without it while an ID token of an earlier
|
||||
/// sign-in of this run is still accepted.
|
||||
async fn browser_login(&self) -> IdeResult<Tokens> {
|
||||
let login = &self.shared.login;
|
||||
let key = self.browser_key();
|
||||
let _flow = browser::FLOW.lock().await;
|
||||
if let Some(id_token) = browser::cached(&key) {
|
||||
self.shared.api.scrub.add(&id_token);
|
||||
let proof = vec![("AUTHENTICATOR", json!("ID_TOKEN")), ("TOKEN", json!(id_token))];
|
||||
match self.sign_in(proof, None, Some(&key)).await {
|
||||
Ok(tokens) => return Ok(tokens),
|
||||
// The network is down: the browser would not get further either.
|
||||
Err(error) if matches!(error.kind, ErrorKind::Io | ErrorKind::TimedOut) => return Err(error),
|
||||
Err(_) => browser::forget(&key),
|
||||
}
|
||||
}
|
||||
let (token, proof_key) =
|
||||
browser::authenticate(&self.shared.api, &login.account, &login.user, login.timeout).await?;
|
||||
let proof =
|
||||
vec![("AUTHENTICATOR", json!("EXTERNALBROWSER")), ("TOKEN", json!(token)), ("PROOF_KEY", json!(proof_key))];
|
||||
self.sign_in(proof, None, Some(&key)).await
|
||||
}
|
||||
|
||||
/// The login request. `id_token_key`: ask for an ID token and keep it under this key.
|
||||
async fn sign_in(
|
||||
&self,
|
||||
proof: Vec<(&'static str, Value)>,
|
||||
fingerprint: Option<String>,
|
||||
id_token_key: Option<&str>,
|
||||
) -> IdeResult<Tokens> {
|
||||
let login = &self.shared.login;
|
||||
let mut parameters: serde_json::Map<String, Value> =
|
||||
login.parameters.iter().map(|(name, value)| (name.to_ascii_uppercase(), parameter(value))).collect();
|
||||
if id_token_key.is_some() {
|
||||
parameters.insert("CLIENT_STORE_TEMPORARY_CREDENTIAL".into(), Value::Bool(true));
|
||||
}
|
||||
let mut data = json!({
|
||||
"CLIENT_APP_ID": APP,
|
||||
"CLIENT_APP_VERSION": env!("CARGO_PKG_VERSION"),
|
||||
"ACCOUNT_NAME": login.account,
|
||||
"LOGIN_NAME": login.user,
|
||||
"CLIENT_ENVIRONMENT": { "APPLICATION": APP, "OS": std::env::consts::OS },
|
||||
"SESSION_PARAMETERS": parameters,
|
||||
});
|
||||
for (name, value) in proof {
|
||||
data[name] = value;
|
||||
}
|
||||
let mut request = Request::post(LOGIN, json!({ "data": data }))
|
||||
.query("requestId", uuid())
|
||||
.query("request_guid", uuid())
|
||||
.timeout(login.timeout);
|
||||
for (name, value) in [
|
||||
("databaseName", &login.database),
|
||||
("schemaName", &login.schema),
|
||||
("warehouse", &login.warehouse),
|
||||
("roleName", &login.role),
|
||||
] {
|
||||
if !value.is_empty() {
|
||||
request = request.query(name, value.as_str());
|
||||
}
|
||||
}
|
||||
let envelope = self.shared.api.send(request).await?;
|
||||
if !envelope.success {
|
||||
let code = envelope.code.as_deref().unwrap_or_default();
|
||||
let mut failure = error::server(envelope.code.as_deref(), envelope.message.as_deref());
|
||||
if let Some(fingerprint) = fingerprint.filter(|_| error::key_rejected(code)) {
|
||||
failure.message.push_str(&format!("\nFingerprint of this private key: {fingerprint}"));
|
||||
}
|
||||
return Err(self.shared.api.scrub.clean(failure));
|
||||
}
|
||||
let text = |name: &str| envelope.data.get(name).and_then(Value::as_str).map(str::to_owned);
|
||||
let (Some(session), Some(master)) = (text("token"), text("masterToken")) else {
|
||||
return Err(IdeError::new(ErrorKind::Protocol, "Snowflake: the login response has no session token"));
|
||||
};
|
||||
self.shared.api.scrub.add(&session);
|
||||
self.shared.api.scrub.add(&master);
|
||||
if let (Some(key), Some(id_token)) = (id_token_key, text("idToken")) {
|
||||
self.shared.api.scrub.add(&id_token);
|
||||
browser::remember(key, &id_token);
|
||||
}
|
||||
let tokens = Tokens { session, master };
|
||||
if self.is_closed() {
|
||||
return Err(closed());
|
||||
}
|
||||
// A new session: nothing is known about where it is until a statement says.
|
||||
*self.lane().context.lock() = None;
|
||||
*self.lane().tokens.write() = Some(tokens.clone());
|
||||
Ok(tokens)
|
||||
}
|
||||
|
||||
/// A fresh session token from the master token; a new login when that is refused.
|
||||
async fn renew(&self, stale: &Tokens) -> IdeResult<Tokens> {
|
||||
let request = Request::post(RENEW, json!({ "oldSessionToken": stale.session, "requestType": "RENEW" }))
|
||||
.query("requestId", uuid())
|
||||
.token(&stale.master)
|
||||
.timeout(self.shared.login.timeout);
|
||||
if let Ok(envelope) = self.shared.api.send(request).await
|
||||
&& envelope.success
|
||||
{
|
||||
let text = |name: &str| envelope.data.get(name).and_then(Value::as_str).map(str::to_owned);
|
||||
if let Some(session) = text("sessionToken") {
|
||||
let master = text("masterToken").unwrap_or_else(|| stale.master.clone());
|
||||
self.shared.api.scrub.add(&session);
|
||||
self.shared.api.scrub.add(&master);
|
||||
let tokens = Tokens { session, master };
|
||||
*self.lane().tokens.write() = Some(tokens.clone());
|
||||
return Ok(tokens);
|
||||
}
|
||||
}
|
||||
self.login().await
|
||||
}
|
||||
|
||||
async fn tokens(&self) -> IdeResult<Tokens> {
|
||||
let current = self.lane().tokens.read().clone();
|
||||
match current {
|
||||
Some(tokens) => Ok(tokens),
|
||||
None if self.is_closed() => Err(closed()),
|
||||
None => self.login().await,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn execute(&self, sql: &str, max_rows: usize, cancel: &CancellationToken) -> IdeResult<QueryResult> {
|
||||
self.execute_from(sql, 0, max_rows, cancel).await
|
||||
}
|
||||
|
||||
/// [`execute`](Self::execute), leaving out the first `skip` rows (a later page): whole
|
||||
/// result chunks before the page are not downloaded.
|
||||
pub async fn execute_from(
|
||||
&self,
|
||||
sql: &str,
|
||||
skip: usize,
|
||||
max_rows: usize,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<QueryResult> {
|
||||
self.execute_in(None, sql, skip, max_rows, cancel).await
|
||||
}
|
||||
|
||||
/// [`execute_from`](Self::execute_from) with unqualified names resolving in `schema`
|
||||
/// (a console's active schema; `None`: the data source's). The switch and the statement
|
||||
/// hold the session together, so consoles with different schemas do not cross.
|
||||
pub async fn execute_in(
|
||||
&self,
|
||||
schema: Option<&str>,
|
||||
sql: &str,
|
||||
skip: usize,
|
||||
max_rows: usize,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<QueryResult> {
|
||||
if self.read_only.load(Ordering::Relaxed) && is_write(sql) {
|
||||
return Err(IdeError::invalid(format!(
|
||||
"read-only data source: {} statements are not allowed",
|
||||
first_word(sql)
|
||||
)));
|
||||
}
|
||||
if matches!(first_word(sql).as_str(), "PUT" | "GET") {
|
||||
// The server only plans these; the client moves the files, which this driver does not do.
|
||||
return Err(IdeError::invalid(format!(
|
||||
"{} copies files between this computer and a stage, which OpenIDEA does not do yet: \
|
||||
run it with the Snowflake CLI (snow sql) or SnowSQL",
|
||||
first_word(sql)
|
||||
)));
|
||||
}
|
||||
let _turn = tokio::select! {
|
||||
biased;
|
||||
() = cancel.cancelled() => return Err(IdeError::cancelled()),
|
||||
() = self.closed.cancelled() => return Err(closed()),
|
||||
turn = self.lane().turn.lock() => turn,
|
||||
};
|
||||
// A `USE` needs no schema to run in, and may be what makes the schema reachable.
|
||||
if first_word(sql) != "USE" {
|
||||
self.enter(schema, cancel).await?;
|
||||
}
|
||||
// These can move the session to another database or schema.
|
||||
let moves = matches!(first_word(sql).as_str(), "USE" | "CALL" | "EXECUTE");
|
||||
let result = self.statement(sql, skip, max_rows, cancel).await;
|
||||
if moves {
|
||||
*self.lane().context.lock() = None;
|
||||
}
|
||||
result.map_err(|error| explain_use(sql, error))
|
||||
}
|
||||
|
||||
/// Puts the session under this target's database and `schema` when it is not there already.
|
||||
async fn enter(&self, schema: Option<&str>, cancel: &CancellationToken) -> IdeResult<()> {
|
||||
if let Some(wanted) = self.context(schema)
|
||||
&& self.lane().context.lock().as_deref() != Some(wanted.as_str())
|
||||
{
|
||||
*self.lane().context.lock() = None;
|
||||
match self.statement(&wanted, 0, 0, cancel).await {
|
||||
Ok(_) => {}
|
||||
// The session's role may not use this database or schema (after `USE ROLE`, or a
|
||||
// schema that is gone). The statement still runs, in the session's own context:
|
||||
// it may be the `USE` that leads back, or name its objects in full. Asked again
|
||||
// once a statement moves the session.
|
||||
Err(refused) if refused.kind == ErrorKind::InvalidInput => {
|
||||
tracing::warn!("Snowflake: the session did not enter its database and schema");
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
*self.lane().context.lock() = Some(wanted);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Runs `statements` in one transaction; each must change one row when flagged
|
||||
/// (table editor submits). Nothing else runs on the session meanwhile.
|
||||
pub(crate) async fn transaction(&self, statements: &[(String, bool)]) -> IdeResult<u64> {
|
||||
if self.read_only.load(Ordering::Relaxed) {
|
||||
return Err(IdeError::invalid("read-only data source: changes are not allowed"));
|
||||
}
|
||||
let _turn = self.lane().turn.lock().await;
|
||||
let cancel = CancellationToken::new();
|
||||
self.enter(None, &cancel).await?;
|
||||
self.statement("BEGIN", 0, 0, &cancel).await?;
|
||||
let changes = async {
|
||||
let mut total = 0;
|
||||
for (sql, expect_one) in statements {
|
||||
let changed = self.statement(sql, 0, 0, &cancel).await?.affected.unwrap_or(0.0) as u64;
|
||||
if *expect_one && changed != 1 {
|
||||
return Err(IdeError::invalid(format!(
|
||||
"{sql}: expected to change 1 row, changed {changed}; nothing was saved"
|
||||
)));
|
||||
}
|
||||
total += changed;
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
.await;
|
||||
match changes {
|
||||
Ok(total) => {
|
||||
self.statement("COMMIT", 0, 0, &cancel).await?;
|
||||
Ok(total)
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = self.statement("ROLLBACK", 0, 0, &cancel).await;
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One statement on the session; the caller holds the session's turn.
|
||||
async fn statement(
|
||||
&self,
|
||||
sql: &str,
|
||||
skip: usize,
|
||||
max_rows: usize,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<QueryResult> {
|
||||
let started = Instant::now();
|
||||
let mut tokens = self.tokens().await?;
|
||||
let mut renewed = false;
|
||||
loop {
|
||||
let request_id = uuid();
|
||||
let run = self.run(sql, skip, max_rows, &tokens, &request_id, started);
|
||||
let outcome = tokio::select! {
|
||||
biased;
|
||||
() = cancel.cancelled() => None,
|
||||
() = self.closed.cancelled() => None,
|
||||
outcome = run => Some(outcome),
|
||||
};
|
||||
let Some(outcome) = outcome else {
|
||||
if self.is_closed() {
|
||||
return Err(closed());
|
||||
}
|
||||
return Err(match self.abort(sql, &request_id, &tokens).await {
|
||||
Ok(()) => IdeError::cancelled(),
|
||||
Err(error) => IdeError::new(
|
||||
ErrorKind::Cancelled,
|
||||
format!("cancelled locally; Snowflake did not confirm the cancellation: {}", error.message),
|
||||
),
|
||||
});
|
||||
};
|
||||
match outcome {
|
||||
Ok(Outcome::Done(result)) => return Ok(*result),
|
||||
Ok(Outcome::Expired { gone }) if !renewed => {
|
||||
renewed = true;
|
||||
tokens = if gone { self.login().await? } else { self.renew(&tokens).await? };
|
||||
}
|
||||
Ok(Outcome::Expired { .. }) => {
|
||||
return Err(IdeError::new(ErrorKind::Io, "Snowflake: the session expired; reconnect"));
|
||||
}
|
||||
Err(error) => return Err(self.shared.api.scrub.clean(error)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(
|
||||
&self,
|
||||
sql: &str,
|
||||
skip: usize,
|
||||
max_rows: usize,
|
||||
tokens: &Tokens,
|
||||
request_id: &str,
|
||||
started: Instant,
|
||||
) -> IdeResult<Outcome> {
|
||||
let wanted = skip.saturating_add(max_rows).saturating_add(1);
|
||||
let body = json!({
|
||||
"sqlText": sql,
|
||||
"asyncExec": false,
|
||||
"sequenceId": self.lane().sequence.fetch_add(1, Ordering::Relaxed) + 1,
|
||||
"querySubmissionTime": SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_millis() as u64),
|
||||
// The grid's page, plus one row that tells whether more exist.
|
||||
"parameters": { "ROWS_PER_RESULTSET": wanted },
|
||||
});
|
||||
let request = Request::post(QUERY, body)
|
||||
.query("requestId", request_id)
|
||||
.query("request_guid", uuid())
|
||||
.token(&tokens.session);
|
||||
let mut envelope = self.shared.api.send(request).await?;
|
||||
while envelope.has_code(error::IN_PROGRESS) {
|
||||
let Some(url) = envelope.data.get("getResultUrl").and_then(Value::as_str).map(str::to_owned) else {
|
||||
return Err(IdeError::new(ErrorKind::Protocol, "Snowflake: a running statement has no result address"));
|
||||
};
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
envelope = self.shared.api.send(Request::get(url.trim_start_matches('/')).token(&tokens.session)).await?;
|
||||
}
|
||||
if let Some(expired) = expiry(&envelope) {
|
||||
return Ok(expired);
|
||||
}
|
||||
if !envelope.success {
|
||||
return Err(error::server(envelope.code.as_deref(), envelope.message.as_deref()));
|
||||
}
|
||||
let execution_ms = started.elapsed().as_secs_f64() * 1000.0;
|
||||
let data: ResultData = serde_json::from_value(envelope.data)
|
||||
.map_err(|e| IdeError::new(ErrorKind::Protocol, format!("Snowflake: unreadable result: {e}")))?;
|
||||
let arrow = data.query_result_format.as_deref().is_some_and(|f| !f.eq_ignore_ascii_case("json"));
|
||||
if arrow || (data.rowset_base64.as_deref().is_some_and(|r| !r.is_empty()) && data.rowset.is_empty()) {
|
||||
return Err(IdeError::new(
|
||||
ErrorKind::Protocol,
|
||||
"Snowflake sent the result in a format other than JSON, which this driver does not read",
|
||||
));
|
||||
}
|
||||
let (rows, has_more) = self.rows(&data, skip, max_rows).await?;
|
||||
let kind = data.statement_type_id.unwrap_or(SELECT);
|
||||
let status_only = kind != SELECT && data.rowtype.len() == 1 && data.rowtype[0].name == "status";
|
||||
let (columns, rows, affected, has_more) = if DML.contains(&kind) {
|
||||
let changed: f64 = data
|
||||
.rowset
|
||||
.first()
|
||||
.map(|row| row.iter().filter_map(|v| v.as_str().and_then(|s| s.parse::<f64>().ok())).sum())
|
||||
.unwrap_or(0.0);
|
||||
(Vec::new(), Vec::new(), Some(changed), false)
|
||||
} else if status_only {
|
||||
(Vec::new(), Vec::new(), Some(0.0), false)
|
||||
} else {
|
||||
(data.rowtype.iter().map(RowType::column).collect(), rows, None, has_more)
|
||||
};
|
||||
Ok(Outcome::Done(Box::new(QueryResult {
|
||||
statement: sql.to_owned(),
|
||||
columns,
|
||||
rows,
|
||||
affected,
|
||||
has_more,
|
||||
elapsed_ms: started.elapsed().as_secs_f64() * 1000.0,
|
||||
execution_ms,
|
||||
})))
|
||||
}
|
||||
|
||||
/// The page of rows `skip..skip + max_rows`, and whether rows follow it.
|
||||
async fn rows(
|
||||
&self,
|
||||
data: &ResultData,
|
||||
mut skip: usize,
|
||||
max_rows: usize,
|
||||
) -> IdeResult<(Vec<Vec<Option<String>>>, bool)> {
|
||||
let mut rows = Vec::new();
|
||||
let mut more = false;
|
||||
let mut take = |batch: Vec<Vec<Value>>, skip: &mut usize| -> IdeResult<bool> {
|
||||
for row in batch {
|
||||
if *skip > 0 {
|
||||
*skip -= 1;
|
||||
continue;
|
||||
}
|
||||
if rows.len() >= max_rows {
|
||||
return Ok(true);
|
||||
}
|
||||
if row.len() != data.rowtype.len() {
|
||||
return Err(IdeError::new(
|
||||
ErrorKind::Protocol,
|
||||
"Snowflake: a result row does not match the result's columns",
|
||||
));
|
||||
}
|
||||
rows.push(
|
||||
row.into_iter().zip(&data.rowtype).map(|(value, column)| rows::cell(column, value)).collect(),
|
||||
);
|
||||
}
|
||||
Ok(false)
|
||||
};
|
||||
if take(data.rowset.clone(), &mut skip)? {
|
||||
more = true;
|
||||
}
|
||||
let headers: Vec<(String, String)> = if !data.chunk_headers.is_empty() {
|
||||
data.chunk_headers.iter().map(|(name, value)| (name.clone(), value.clone())).collect()
|
||||
} else if let Some(key) = &data.qrmk {
|
||||
vec![
|
||||
("x-amz-server-side-encryption-customer-algorithm".into(), "AES256".into()),
|
||||
("x-amz-server-side-encryption-customer-key".into(), key.clone()),
|
||||
]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
for chunk in &data.chunks {
|
||||
if more {
|
||||
break;
|
||||
}
|
||||
if chunk.row_count > 0 && skip >= chunk.row_count {
|
||||
skip -= chunk.row_count;
|
||||
continue;
|
||||
}
|
||||
let body = gunzip(self.shared.api.chunk(&chunk.url, &headers).await?)?;
|
||||
// A chunk is its rows separated by commas, without the enclosing brackets.
|
||||
let mut text = Vec::with_capacity(body.len() + 2);
|
||||
text.push(b'[');
|
||||
text.extend_from_slice(&body);
|
||||
text.push(b']');
|
||||
let batch: Vec<Vec<Value>> = serde_json::from_slice(&text)
|
||||
.map_err(|e| IdeError::new(ErrorKind::Protocol, format!("Snowflake: unreadable result chunk: {e}")))?;
|
||||
if take(batch, &mut skip)? {
|
||||
more = true;
|
||||
}
|
||||
}
|
||||
Ok((rows, more))
|
||||
}
|
||||
|
||||
async fn abort(&self, sql: &str, request_id: &str, tokens: &Tokens) -> IdeResult<()> {
|
||||
let request = Request::post(ABORT, json!({ "sqlText": sql, "requestId": request_id }))
|
||||
.query("requestId", uuid())
|
||||
.token(&tokens.session)
|
||||
.timeout(Duration::from_secs(5));
|
||||
let envelope = self.shared.api.send(request).await?;
|
||||
if envelope.success {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(self.shared.api.scrub.clean(error::server(envelope.code.as_deref(), envelope.message.as_deref())))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Outcome {
|
||||
Done(Box<QueryResult>),
|
||||
/// The session token no longer works (`gone`: nor does the master token).
|
||||
Expired {
|
||||
gone: bool,
|
||||
},
|
||||
}
|
||||
|
||||
fn expiry(envelope: &Envelope) -> Option<Outcome> {
|
||||
if envelope.has_code(&[error::SESSION_EXPIRED]) {
|
||||
Some(Outcome::Expired { gone: false })
|
||||
} else if envelope.has_code(error::SESSION_GONE) {
|
||||
Some(Outcome::Expired { gone: true })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod commands;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,210 @@
|
||||
//! Result decoding: Snowflake's JSON result format sends every cell as text in an internal
|
||||
//! form (dates as day numbers, timestamps as epoch seconds); these become the grid's text.
|
||||
|
||||
use crate::conn::ResultColumn;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
/// A column of the result's `rowtype`.
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub(crate) struct RowType {
|
||||
pub name: String,
|
||||
#[serde(rename = "type")]
|
||||
pub kind: String,
|
||||
pub precision: Option<i64>,
|
||||
pub scale: Option<i64>,
|
||||
pub length: Option<i64>,
|
||||
}
|
||||
|
||||
/// `VARCHAR` without a length: Snowflake reports its maximum.
|
||||
const MAX_TEXT: i64 = 16_777_216;
|
||||
|
||||
impl RowType {
|
||||
pub(crate) fn column(&self) -> ResultColumn {
|
||||
let kind = self.kind.to_ascii_lowercase();
|
||||
let scaled = |name: &str| match self.scale {
|
||||
Some(scale) => format!("{name}({scale})"),
|
||||
None => name.to_owned(),
|
||||
};
|
||||
let type_name = match kind.as_str() {
|
||||
"fixed" => match (self.precision, self.scale) {
|
||||
(Some(p), Some(s)) => format!("NUMBER({p},{s})"),
|
||||
_ => "NUMBER".into(),
|
||||
},
|
||||
"real" => "FLOAT".into(),
|
||||
"text" => match self.length {
|
||||
Some(n) if n > 0 && n < MAX_TEXT => format!("VARCHAR({n})"),
|
||||
_ => "VARCHAR".into(),
|
||||
},
|
||||
"time" => scaled("TIME"),
|
||||
"timestamp_ntz" | "timestamp_ltz" | "timestamp_tz" => scaled(&kind.to_ascii_uppercase()),
|
||||
other => other.to_ascii_uppercase(),
|
||||
};
|
||||
ResultColumn { name: self.name.clone(), type_name }
|
||||
}
|
||||
}
|
||||
|
||||
/// The grid text of one cell (`None`: SQL NULL).
|
||||
pub(crate) fn cell(column: &RowType, value: Value) -> Option<String> {
|
||||
let text = match value {
|
||||
Value::Null => return None,
|
||||
Value::String(text) => text,
|
||||
other => other.to_string(),
|
||||
};
|
||||
let scale = column.scale.unwrap_or(0).clamp(0, 9) as usize;
|
||||
let shown = match column.kind.to_ascii_lowercase().as_str() {
|
||||
"boolean" => Some(if matches!(text.as_str(), "1" | "true" | "TRUE") { "true" } else { "false" }.to_owned()),
|
||||
"date" => text.parse::<i64>().ok().map(date),
|
||||
"time" => nanos(&text).map(|n| time(n.rem_euclid(DAY), scale)),
|
||||
"timestamp_ntz" => nanos(&text).map(|n| timestamp(n, scale)),
|
||||
"timestamp_ltz" => nanos(&text).map(|n| format!("{} Z", timestamp(n, scale))),
|
||||
"timestamp_tz" => timestamp_tz(&text, scale),
|
||||
"binary" => Some(format!("0x{text}")),
|
||||
_ => None,
|
||||
};
|
||||
Some(shown.unwrap_or(text))
|
||||
}
|
||||
|
||||
const SECOND: i128 = 1_000_000_000;
|
||||
const DAY: i128 = 86_400 * SECOND;
|
||||
|
||||
/// `seconds[.fraction]` as nanoseconds.
|
||||
fn nanos(text: &str) -> Option<i128> {
|
||||
let text = text.trim();
|
||||
let (negative, digits) = match text.strip_prefix('-') {
|
||||
Some(rest) => (true, rest),
|
||||
None => (false, text),
|
||||
};
|
||||
let (whole, fraction) = digits.split_once('.').unwrap_or((digits, ""));
|
||||
if whole.is_empty() || !whole.bytes().all(|b| b.is_ascii_digit()) || !fraction.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
let mut fraction = fraction.to_owned();
|
||||
fraction.truncate(9);
|
||||
while fraction.len() < 9 {
|
||||
fraction.push('0');
|
||||
}
|
||||
let value = whole.parse::<i128>().ok()? * SECOND + fraction.parse::<i128>().ok()?;
|
||||
Some(if negative { -value } else { value })
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 as `YYYY-MM-DD` (proleptic Gregorian calendar).
|
||||
fn date(days: i64) -> String {
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let day = doy - (153 * mp + 2) / 5 + 1;
|
||||
let month = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let year = yoe + era * 400 + i64::from(month <= 2);
|
||||
if year < 0 { format!("-{:04}-{month:02}-{day:02}", -year) } else { format!("{year:04}-{month:02}-{day:02}") }
|
||||
}
|
||||
|
||||
fn time(nanos_of_day: i128, scale: usize) -> String {
|
||||
let seconds = nanos_of_day / SECOND;
|
||||
let mut text = format!("{:02}:{:02}:{:02}", seconds / 3600, seconds / 60 % 60, seconds % 60);
|
||||
if scale > 0 {
|
||||
let fraction = format!("{:09}", nanos_of_day % SECOND);
|
||||
text.push('.');
|
||||
text.push_str(&fraction[..scale]);
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
fn timestamp(nanos: i128, scale: usize) -> String {
|
||||
format!("{} {}", date(nanos.div_euclid(DAY) as i64), time(nanos.rem_euclid(DAY), scale))
|
||||
}
|
||||
|
||||
/// `seconds[.fraction] offset`, the offset in minutes plus 1440.
|
||||
fn timestamp_tz(text: &str, scale: usize) -> Option<String> {
|
||||
let (instant, offset) = text.trim().split_once(' ')?;
|
||||
let offset = offset.trim().parse::<i128>().ok()? - 1440;
|
||||
let local = nanos(instant)? + offset * 60 * SECOND;
|
||||
let sign = if offset < 0 { '-' } else { '+' };
|
||||
Some(format!("{} {sign}{:02}:{:02}", timestamp(local, scale), offset.abs() / 60, offset.abs() % 60))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn column(kind: &str, scale: Option<i64>) -> RowType {
|
||||
RowType { name: "C".into(), kind: kind.into(), scale, ..Default::default() }
|
||||
}
|
||||
|
||||
fn shown(kind: &str, scale: Option<i64>, value: &str) -> String {
|
||||
cell(&column(kind, scale), Value::String(value.into())).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_types_the_way_snowflake_spells_them() {
|
||||
let named = |kind: &str, precision, scale, length| {
|
||||
RowType { name: "C".into(), kind: kind.into(), precision, scale, length }.column().type_name
|
||||
};
|
||||
assert_eq!(named("fixed", Some(38), Some(0), None), "NUMBER(38,0)");
|
||||
assert_eq!(named("fixed", Some(10), Some(2), None), "NUMBER(10,2)");
|
||||
assert_eq!(named("real", None, None, None), "FLOAT");
|
||||
assert_eq!(named("text", None, None, Some(100)), "VARCHAR(100)");
|
||||
assert_eq!(named("text", None, None, Some(16_777_216)), "VARCHAR");
|
||||
assert_eq!(named("text", None, None, Some(134_217_728)), "VARCHAR");
|
||||
assert_eq!(named("timestamp_ntz", None, Some(9), None), "TIMESTAMP_NTZ(9)");
|
||||
assert_eq!(named("timestamp_tz", None, Some(3), None), "TIMESTAMP_TZ(3)");
|
||||
assert_eq!(named("time", None, Some(0), None), "TIME(0)");
|
||||
for kind in ["boolean", "date", "variant", "object", "array", "binary", "geography", "vector"] {
|
||||
assert_eq!(named(kind, None, None, None), kind.to_ascii_uppercase());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_and_plain_values_pass_through() {
|
||||
assert_eq!(cell(&column("fixed", Some(0)), Value::Null), None);
|
||||
assert_eq!(shown("fixed", Some(2), "12345678901234567890.12"), "12345678901234567890.12");
|
||||
assert_eq!(shown("real", None, "1.5e-7"), "1.5e-7");
|
||||
assert_eq!(shown("text", None, "a\nb"), "a\nb");
|
||||
assert_eq!(shown("variant", None, "{\n \"a\": 1\n}"), "{\n \"a\": 1\n}");
|
||||
assert_eq!(cell(&column("fixed", Some(0)), serde_json::json!(7)), Some("7".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn booleans_and_binary() {
|
||||
assert_eq!(shown("boolean", None, "1"), "true");
|
||||
assert_eq!(shown("boolean", None, "true"), "true");
|
||||
assert_eq!(shown("boolean", None, "0"), "false");
|
||||
assert_eq!(shown("binary", None, "DEADBEEF"), "0xDEADBEEF");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dates_count_days_from_the_epoch() {
|
||||
assert_eq!(shown("date", None, "0"), "1970-01-01");
|
||||
assert_eq!(shown("date", None, "19675"), "2023-11-14");
|
||||
assert_eq!(shown("date", None, "-1"), "1969-12-31");
|
||||
assert_eq!(shown("date", None, "11016"), "2000-02-29");
|
||||
assert_eq!(shown("date", None, "-719162"), "0001-01-01");
|
||||
assert_eq!(shown("date", None, "2932896"), "9999-12-31");
|
||||
assert_eq!(shown("date", None, "not a number"), "not a number");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn times_and_timestamps_keep_their_scale() {
|
||||
assert_eq!(shown("time", Some(0), "3723"), "01:02:03");
|
||||
assert_eq!(shown("time", Some(3), "3723.5"), "01:02:03.500");
|
||||
assert_eq!(shown("time", Some(9), "86399.999999999"), "23:59:59.999999999");
|
||||
assert_eq!(shown("timestamp_ntz", Some(0), "1700000000"), "2023-11-14 22:13:20");
|
||||
assert_eq!(shown("timestamp_ntz", Some(3), "1700000000.123000000"), "2023-11-14 22:13:20.123");
|
||||
assert_eq!(shown("timestamp_ntz", Some(9), "1700000000.123456789"), "2023-11-14 22:13:20.123456789");
|
||||
assert_eq!(shown("timestamp_ntz", Some(3), "-0.500"), "1969-12-31 23:59:59.500");
|
||||
assert_eq!(shown("timestamp_ntz", Some(0), "-86401"), "1969-12-30 23:59:59");
|
||||
assert_eq!(shown("timestamp_ltz", Some(0), "1700000000"), "2023-11-14 22:13:20 Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoned_timestamps_show_local_time_and_offset() {
|
||||
assert_eq!(shown("timestamp_tz", Some(3), "1700000000.123 1440"), "2023-11-14 22:13:20.123 +00:00");
|
||||
assert_eq!(shown("timestamp_tz", Some(0), "1700000000 1560"), "2023-11-15 00:13:20 +02:00");
|
||||
assert_eq!(shown("timestamp_tz", Some(0), "1700000000 1110"), "2023-11-14 16:43:20 -05:30");
|
||||
assert_eq!(shown("timestamp_tz", Some(0), "garbage"), "garbage");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,921 @@
|
||||
//! The driver against an in-process HTTP server that plays Snowflake's part.
|
||||
|
||||
use super::*;
|
||||
use crate::conn::Pool;
|
||||
use crate::source::{DbDriver, DbOptions, DbProperty, DbSnowflake, DbSslMode};
|
||||
use base64::Engine;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
const KEY: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/snowflake/rsa_key.p8");
|
||||
const ENCRYPTED_KEY: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/snowflake/rsa_key_encrypted.p8");
|
||||
const FINGERPRINT: &str = "SHA256:ajjL6Bf6l1FIIyTcJli4i2AWPYDVJGQnSZr+9q9ClgI=";
|
||||
pub(super) const LOGIN_OK: &str =
|
||||
r#"{"success":true,"code":null,"message":null,"data":{"token":"session-token-1","masterToken":"master-token-1"}}"#;
|
||||
pub(super) const CLOSED_OK: &str = r#"{"success":true,"data":null}"#;
|
||||
|
||||
pub(super) struct Reply {
|
||||
status: &'static str,
|
||||
body: Vec<u8>,
|
||||
/// Answer only after this long (a statement that is still running).
|
||||
delay: Duration,
|
||||
}
|
||||
|
||||
pub(super) fn ok(body: impl Into<String>) -> Reply {
|
||||
Reply { status: "200 OK", body: body.into().into_bytes(), delay: Duration::ZERO }
|
||||
}
|
||||
|
||||
/// A recorded request: its request line and headers, and its body.
|
||||
pub(super) struct Seen {
|
||||
head: String,
|
||||
body: String,
|
||||
}
|
||||
|
||||
impl Seen {
|
||||
pub(super) fn json(&self) -> Value {
|
||||
serde_json::from_str(&self.body).unwrap()
|
||||
}
|
||||
|
||||
fn line(&self) -> &str {
|
||||
self.head.lines().next().unwrap()
|
||||
}
|
||||
|
||||
fn header(&self, name: &str) -> Option<&str> {
|
||||
self.head.lines().skip(1).find_map(|line| {
|
||||
let (key, value) = line.split_once(':')?;
|
||||
key.eq_ignore_ascii_case(name).then(|| value.trim())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_request(stream: &mut TcpStream) -> Seen {
|
||||
let mut bytes = Vec::new();
|
||||
let mut buffer = [0; 8192];
|
||||
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 head = String::from_utf8_lossy(&bytes[..end]).into_owned();
|
||||
let length: usize = head
|
||||
.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 Seen { head, body: String::from_utf8_lossy(&bytes[end + 4..]).into_owned() };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn bind() -> (TcpListener, u16) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
(listener, port)
|
||||
}
|
||||
|
||||
/// Answers one request per reply, in order, and returns what it received.
|
||||
pub(super) fn serve(listener: TcpListener, replies: Vec<Reply>) -> tokio::task::JoinHandle<Vec<Seen>> {
|
||||
tokio::spawn(async move {
|
||||
let mut seen = Vec::new();
|
||||
let mut held = Vec::new();
|
||||
for reply in replies {
|
||||
let (mut stream, _) =
|
||||
tokio::time::timeout(Duration::from_secs(5), listener.accept()).await.unwrap().unwrap();
|
||||
seen.push(read_request(&mut stream).await);
|
||||
let respond = async move {
|
||||
tokio::time::sleep(reply.delay).await;
|
||||
let head = format!(
|
||||
"HTTP/1.1 {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
reply.status,
|
||||
reply.body.len()
|
||||
);
|
||||
let _ = stream.write_all(head.as_bytes()).await;
|
||||
let _ = stream.write_all(&reply.body).await;
|
||||
};
|
||||
if reply.delay.is_zero() {
|
||||
respond.await;
|
||||
} else {
|
||||
held.push(tokio::spawn(respond));
|
||||
}
|
||||
}
|
||||
seen
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn source(port: u16) -> DataSource {
|
||||
DataSource {
|
||||
id: "sf".into(),
|
||||
name: "sf".into(),
|
||||
driver: DbDriver::Snowflake,
|
||||
host: "127.0.0.1".into(),
|
||||
port: Some(port),
|
||||
user: "jane".into(),
|
||||
database: "ANALYTICS".into(),
|
||||
ssl: DbSslMode::Disable,
|
||||
options: DbOptions { default_schema: "PUBLIC".into(), ..Default::default() },
|
||||
snowflake: Some(DbSnowflake {
|
||||
warehouse: "COMPUTE_WH".into(),
|
||||
role: "ANALYST".into(),
|
||||
private_key_file: KEY.into(),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn result(rowtype: Value, rowset: Value) -> String {
|
||||
json!({ "success": true, "code": null, "data": { "rowtype": rowtype, "rowset": rowset, "queryResultFormat": "json",
|
||||
"statementTypeId": 4096, "total": 0, "chunks": [] } })
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn one() -> String {
|
||||
result(json!([{ "name": "N", "type": "fixed", "precision": 1, "scale": 0 }]), json!([["1"]]))
|
||||
}
|
||||
|
||||
async fn failure(source: &DataSource, secret: Option<&str>) -> IdeError {
|
||||
match Pool::connect(source, secret, None).await {
|
||||
Ok(_) => panic!("the connection was accepted"),
|
||||
Err(error) => error,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn key_pair_login_then_a_statement_on_the_session() {
|
||||
let (listener, port) = bind().await;
|
||||
let server = serve(listener, vec![ok(LOGIN_OK), ok(one()), ok(CLOSED_OK)]);
|
||||
let mut source = source(port);
|
||||
source.options.time_zone = "Europe/Kyiv".into();
|
||||
source.properties = vec![
|
||||
DbProperty { name: "query_tag".into(), value: "ide".into() },
|
||||
DbProperty { name: "statement_timeout_in_seconds".into(), value: "60".into() },
|
||||
DbProperty { name: "client_session_keep_alive".into(), value: "true".into() },
|
||||
];
|
||||
let pool = Pool::connect(&source, None, None).await.unwrap();
|
||||
let cancel = CancellationToken::new();
|
||||
let rows = pool.execute("select 1", 10, &cancel).await.unwrap();
|
||||
assert_eq!(rows.rows, vec![vec![Some("1".to_owned())]]);
|
||||
assert_eq!(rows.columns[0].name, "N");
|
||||
assert_eq!(rows.columns[0].type_name, "NUMBER(1,0)");
|
||||
assert_eq!(rows.affected, None);
|
||||
assert!(!rows.has_more);
|
||||
pool.close().await;
|
||||
assert!(pool.is_closed());
|
||||
let seen = server.await.unwrap();
|
||||
|
||||
let login = &seen[0];
|
||||
assert!(login.line().starts_with("POST /session/v1/login-request?"), "{}", login.line());
|
||||
for parameter in
|
||||
["databaseName=ANALYTICS", "schemaName=PUBLIC", "warehouse=COMPUTE_WH", "roleName=ANALYST", "requestId="]
|
||||
{
|
||||
assert!(login.line().contains(parameter), "{parameter}: {}", login.line());
|
||||
}
|
||||
assert_eq!(login.header("accept"), Some("application/snowflake"));
|
||||
assert_eq!(login.header("authorization"), None);
|
||||
let data = &login.json()["data"];
|
||||
assert_eq!(data["AUTHENTICATOR"], "SNOWFLAKE_JWT");
|
||||
assert_eq!(data["LOGIN_NAME"], "jane");
|
||||
assert_eq!(data["ACCOUNT_NAME"], "127");
|
||||
assert_eq!(data["CLIENT_APP_ID"], "OpenIDEA");
|
||||
assert!(data.get("PASSWORD").is_none());
|
||||
assert_eq!(
|
||||
data["SESSION_PARAMETERS"],
|
||||
json!({ "QUERY_TAG": "ide", "STATEMENT_TIMEOUT_IN_SECONDS": 60, "CLIENT_SESSION_KEEP_ALIVE": true, "TIMEZONE": "Europe/Kyiv" })
|
||||
);
|
||||
let token = data["TOKEN"].as_str().unwrap();
|
||||
let claims: Value = serde_json::from_slice(
|
||||
&base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(token.split('.').nth(1).unwrap()).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(claims["sub"], "127.JANE");
|
||||
assert_eq!(claims["iss"], format!("127.JANE.{FINGERPRINT}"));
|
||||
|
||||
let query = &seen[1];
|
||||
assert!(query.line().starts_with("POST /queries/v1/query-request?requestId="), "{}", query.line());
|
||||
assert_eq!(query.header("authorization"), Some("Snowflake Token=\"session-token-1\""));
|
||||
let body = query.json();
|
||||
assert_eq!(body["sqlText"], "select 1");
|
||||
assert_eq!(body["asyncExec"], false);
|
||||
assert_eq!(body["parameters"]["ROWS_PER_RESULTSET"], 11);
|
||||
assert_eq!(body["sequenceId"], 1);
|
||||
|
||||
let close = &seen[2];
|
||||
assert!(close.line().starts_with("POST /session?delete=true"), "{}", close.line());
|
||||
assert_eq!(close.header("authorization"), Some("Snowflake Token=\"session-token-1\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_rejected_key_explains_itself_and_shows_the_fingerprint() {
|
||||
let (listener, port) = bind().await;
|
||||
let refused = r#"{"success":false,"code":"390144","message":"JWT token is invalid. [0ce9eb56-821d-4ca9-a774-04ae89a0cf5a]","data":{"authnMethod":"SNOWFLAKE_JWT"}}"#;
|
||||
let server = serve(listener, vec![ok(refused)]);
|
||||
let error = failure(&source(port), None).await;
|
||||
assert_eq!(error.kind, ErrorKind::InvalidInput);
|
||||
assert!(error.message.starts_with("Snowflake [390144]: JWT token is invalid."), "{}", error.message);
|
||||
assert!(error.message.contains("ALTER USER"), "{}", error.message);
|
||||
assert!(error.message.ends_with(&format!("Fingerprint of this private key: {FINGERPRINT}")), "{}", error.message);
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn key_problems_are_found_before_any_request() {
|
||||
// Nothing listens: a request would fail with a network error instead.
|
||||
let (listener, port) = bind().await;
|
||||
drop(listener);
|
||||
let mut encrypted = source(port);
|
||||
encrypted.snowflake.as_mut().unwrap().private_key_file = ENCRYPTED_KEY.into();
|
||||
let error = failure(&encrypted, None).await;
|
||||
assert_eq!(error.kind, ErrorKind::PermissionDenied);
|
||||
assert_eq!(error.message, "the private key is encrypted; enter its passphrase");
|
||||
let error = failure(&encrypted, Some("wrong")).await;
|
||||
assert_eq!(error.kind, ErrorKind::PermissionDenied);
|
||||
assert_eq!(error.message, "incorrect passphrase for the private key");
|
||||
|
||||
let mut missing = source(port);
|
||||
missing.snowflake.as_mut().unwrap().private_key_file = String::new();
|
||||
assert_eq!(failure(&missing, None).await.message, "select the private key file of the Snowflake user");
|
||||
let mut no_user = source(port);
|
||||
no_user.user = " ".into();
|
||||
assert_eq!(failure(&no_user, None).await.message, "enter the Snowflake user name");
|
||||
let mut reserved = source(port);
|
||||
reserved.properties = vec![DbProperty { name: "rows_per_resultset".into(), value: "5".into() }];
|
||||
assert!(failure(&reserved, None).await.message.contains("manages the rows_per_resultset parameter"));
|
||||
let mut no_account = source(port);
|
||||
no_account.host = String::new();
|
||||
assert!(failure(&no_account, None).await.message.contains("account identifier"));
|
||||
for auth in [DbSnowflakeAuth::Password, DbSnowflakeAuth::Token, DbSnowflakeAuth::OAuth] {
|
||||
let mut source = source(port);
|
||||
source.snowflake.as_mut().unwrap().auth = auth;
|
||||
let error = failure(&source, None).await;
|
||||
assert_eq!(error.kind, ErrorKind::PermissionDenied, "{auth:?}");
|
||||
assert!(error.message.starts_with("enter the "), "{}", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_encrypted_key_signs_in_with_its_passphrase_and_never_leaks_it() {
|
||||
let (listener, port) = bind().await;
|
||||
// A server that echoes what it was sent must not get the secrets into an error message.
|
||||
let echo =
|
||||
r#"{"success":false,"code":"002003","message":"bad statement test-passphrase session-token-1 master-token-1"}"#;
|
||||
let server = serve(listener, vec![ok(LOGIN_OK), ok(echo), ok(CLOSED_OK)]);
|
||||
let mut source = source(port);
|
||||
source.snowflake.as_mut().unwrap().private_key_file = ENCRYPTED_KEY.into();
|
||||
let pool = Pool::connect(&source, Some("test-passphrase"), None).await.unwrap();
|
||||
let error = pool.execute("select nope", 1, &CancellationToken::new()).await.unwrap_err();
|
||||
assert_eq!(error.kind, ErrorKind::InvalidInput);
|
||||
assert_eq!(error.message, "Snowflake [002003]: bad statement [redacted] [redacted] [redacted]");
|
||||
pool.close().await;
|
||||
let seen = server.await.unwrap();
|
||||
assert!(!seen[0].body.contains("test-passphrase"));
|
||||
assert!(!seen[0].head.contains("test-passphrase"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn password_and_token_methods_send_their_own_proof() {
|
||||
for (auth, secret, expected) in [
|
||||
(DbSnowflakeAuth::Password, "pa55word!", json!({ "PASSWORD": "pa55word!" })),
|
||||
(
|
||||
DbSnowflakeAuth::Token,
|
||||
"pat-value-1",
|
||||
json!({ "AUTHENTICATOR": "PROGRAMMATIC_ACCESS_TOKEN", "TOKEN": "pat-value-1" }),
|
||||
),
|
||||
(DbSnowflakeAuth::OAuth, "oauth-value-1", json!({ "AUTHENTICATOR": "OAUTH", "TOKEN": "oauth-value-1" })),
|
||||
] {
|
||||
let (listener, port) = bind().await;
|
||||
let server = serve(listener, vec![ok(LOGIN_OK), ok(CLOSED_OK)]);
|
||||
let mut source = source(port);
|
||||
source.snowflake.as_mut().unwrap().auth = auth;
|
||||
let pool = Pool::connect(&source, Some(secret), None).await.unwrap();
|
||||
pool.close().await;
|
||||
let seen = server.await.unwrap();
|
||||
let data = &seen[0].json()["data"];
|
||||
for (name, value) in expected.as_object().unwrap() {
|
||||
assert_eq!(&data[name], value, "{auth:?} {name}");
|
||||
}
|
||||
if auth == DbSnowflakeAuth::Password {
|
||||
assert!(data.get("AUTHENTICATOR").is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_wrong_password_asks_again() {
|
||||
let (listener, port) = bind().await;
|
||||
let refused = r#"{"success":false,"code":"390100","message":"Incorrect username or password was specified."}"#;
|
||||
let server = serve(listener, vec![ok(refused)]);
|
||||
let mut source = source(port);
|
||||
source.snowflake.as_mut().unwrap().auth = DbSnowflakeAuth::Password;
|
||||
let error = failure(&source, Some("pa55word!")).await;
|
||||
assert_eq!(error.kind, ErrorKind::PermissionDenied);
|
||||
assert_eq!(error.message, "Snowflake [390100]: Incorrect username or password was specified.");
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unknown_account_is_named() {
|
||||
let (listener, port) = bind().await;
|
||||
let server = serve(
|
||||
listener,
|
||||
vec![Reply { status: "403 Forbidden", body: b"<html>Forbidden</html>".to_vec(), delay: Duration::ZERO }],
|
||||
);
|
||||
let error = failure(&source(port), None).await;
|
||||
assert_eq!(error.kind, ErrorKind::InvalidInput);
|
||||
assert_eq!(error.message, "Snowflake has no account at 127.0.0.1 (HTTP 403): check the account identifier");
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pages_skip_whole_chunks_and_stop_after_the_page() {
|
||||
let (listener, port) = bind().await;
|
||||
let rowtype =
|
||||
json!([{ "name": "ID", "type": "fixed", "precision": 38, "scale": 0 }, { "name": "D", "type": "date" }]);
|
||||
let chunk = |n: u16| format!("http://127.0.0.1:{port}/chunk{n}");
|
||||
let body = json!({ "success": true, "data": {
|
||||
"rowtype": rowtype, "rowset": [["1", "0"], ["2", "1"]], "queryResultFormat": "json", "statementTypeId": 4096,
|
||||
"chunks": [
|
||||
{ "url": chunk(1), "rowCount": 2 }, { "url": chunk(2), "rowCount": 3 }, { "url": chunk(3), "rowCount": 2 },
|
||||
],
|
||||
"chunkHeaders": { "x-amz-server-side-encryption-customer-key": "key-1" },
|
||||
} })
|
||||
.to_string();
|
||||
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
std::io::Write::write_all(&mut gz, br#"["5","4"],["6","5"],["7",null]"#).unwrap();
|
||||
let second = Reply { status: "200 OK", body: gz.finish().unwrap(), delay: Duration::ZERO };
|
||||
// Rows 5 and 6 (skip 4, keep 2): the first chunk is skipped, the third never fetched.
|
||||
let server = serve(listener, vec![ok(LOGIN_OK), ok(body), second, ok(CLOSED_OK)]);
|
||||
let pool = Pool::connect(&source(port), None, None).await.unwrap();
|
||||
let page = pool.execute_from("select id, d from t", 4, 2, &CancellationToken::new()).await.unwrap();
|
||||
assert_eq!(
|
||||
page.rows,
|
||||
vec![vec![Some("5".into()), Some("1970-01-05".into())], vec![Some("6".into()), Some("1970-01-06".into())]]
|
||||
);
|
||||
assert!(page.has_more);
|
||||
assert_eq!(page.columns[1].type_name, "DATE");
|
||||
pool.close().await;
|
||||
let seen = server.await.unwrap();
|
||||
assert_eq!(seen[1].json()["parameters"]["ROWS_PER_RESULTSET"], 7);
|
||||
assert_eq!(seen[2].line(), "GET /chunk2 HTTP/1.1");
|
||||
assert_eq!(seen[2].header("x-amz-server-side-encryption-customer-key"), Some("key-1"));
|
||||
assert_eq!(seen[2].header("authorization"), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_long_statement_is_polled_until_it_finishes() {
|
||||
let (listener, port) = bind().await;
|
||||
let running = |code: &str| {
|
||||
json!({ "success": true, "code": code, "message": "Query execution in progress.",
|
||||
"data": { "getResultUrl": "/queries/01b2c3/result", "queryId": "01b2c3" } })
|
||||
.to_string()
|
||||
};
|
||||
let server =
|
||||
serve(listener, vec![ok(LOGIN_OK), ok(running("333333")), ok(running("333334")), ok(one()), ok(CLOSED_OK)]);
|
||||
let pool = Pool::connect(&source(port), None, None).await.unwrap();
|
||||
let done = pool.execute("call system$wait(60)", 5, &CancellationToken::new()).await.unwrap();
|
||||
assert_eq!(done.rows, vec![vec![Some("1".to_owned())]]);
|
||||
pool.close().await;
|
||||
let seen = server.await.unwrap();
|
||||
for poll in &seen[2..4] {
|
||||
assert_eq!(poll.line(), "GET /queries/01b2c3/result HTTP/1.1");
|
||||
assert_eq!(poll.header("authorization"), Some("Snowflake Token=\"session-token-1\""));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_expired_session_token_is_renewed_and_the_statement_repeated() {
|
||||
let (listener, port) = bind().await;
|
||||
let expired = r#"{"success":false,"code":"390112","message":"Your session has expired. Please login again."}"#;
|
||||
let renewed = r#"{"success":true,"data":{"sessionToken":"session-token-2","masterToken":"master-token-2"}}"#;
|
||||
let server = serve(listener, vec![ok(LOGIN_OK), ok(expired), ok(renewed), ok(one()), ok(CLOSED_OK)]);
|
||||
let pool = Pool::connect(&source(port), None, None).await.unwrap();
|
||||
let done = pool.execute("select 1", 5, &CancellationToken::new()).await.unwrap();
|
||||
assert_eq!(done.rows.len(), 1);
|
||||
pool.close().await;
|
||||
let seen = server.await.unwrap();
|
||||
assert!(seen[2].line().starts_with("POST /session/token-request?requestId="), "{}", seen[2].line());
|
||||
assert_eq!(seen[2].header("authorization"), Some("Snowflake Token=\"master-token-1\""));
|
||||
assert_eq!(seen[2].json(), json!({ "oldSessionToken": "session-token-1", "requestType": "RENEW" }));
|
||||
assert_eq!(seen[3].header("authorization"), Some("Snowflake Token=\"session-token-2\""));
|
||||
assert_eq!(seen[4].header("authorization"), Some("Snowflake Token=\"session-token-2\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_lost_session_signs_in_again() {
|
||||
let (listener, port) = bind().await;
|
||||
let gone = r#"{"success":false,"code":"390114","message":"Authentication token has expired. The user must authenticate again."}"#;
|
||||
let again = r#"{"success":true,"data":{"token":"session-token-3","masterToken":"master-token-3"}}"#;
|
||||
let server = serve(listener, vec![ok(LOGIN_OK), ok(gone), ok(again), ok(one()), ok(CLOSED_OK)]);
|
||||
let pool = Pool::connect(&source(port), None, None).await.unwrap();
|
||||
pool.execute("select 1", 5, &CancellationToken::new()).await.unwrap();
|
||||
pool.close().await;
|
||||
let seen = server.await.unwrap();
|
||||
assert!(seen[2].line().starts_with("POST /session/v1/login-request?"), "{}", seen[2].line());
|
||||
assert_eq!(seen[3].header("authorization"), Some("Snowflake Token=\"session-token-3\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelling_aborts_the_statement_on_the_server() {
|
||||
let (listener, port) = bind().await;
|
||||
let slow = Reply { status: "200 OK", body: one().into_bytes(), delay: Duration::from_secs(30) };
|
||||
let server = serve(listener, vec![ok(LOGIN_OK), slow, ok(r#"{"success":true,"data":null}"#), ok(CLOSED_OK)]);
|
||||
let pool = Pool::connect(&source(port), None, None).await.unwrap();
|
||||
let cancel = CancellationToken::new();
|
||||
let statement = pool.execute("call system$wait(600)", 5, &cancel);
|
||||
let stop = async {
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
cancel.cancel();
|
||||
};
|
||||
let (outcome, ()) = tokio::join!(statement, stop);
|
||||
let error = outcome.unwrap_err();
|
||||
assert_eq!(error.kind, ErrorKind::Cancelled);
|
||||
assert_eq!(error.message, "cancelled");
|
||||
pool.close().await;
|
||||
let seen = server.await.unwrap();
|
||||
let request_id =
|
||||
|line: &str| line.split(['?', '&', ' ']).find_map(|p| p.strip_prefix("requestId=")).unwrap().to_owned();
|
||||
assert!(seen[2].line().starts_with("POST /queries/v1/abort-request?"), "{}", seen[2].line());
|
||||
let abort = seen[2].json();
|
||||
assert_eq!(abort["sqlText"], "call system$wait(600)");
|
||||
assert_eq!(abort["requestId"], request_id(seen[1].line()));
|
||||
assert_ne!(request_id(seen[2].line()), request_id(seen[1].line()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn changes_report_counts_and_commands_report_nothing() {
|
||||
let (listener, port) = bind().await;
|
||||
let merged = json!({ "success": true, "data": {
|
||||
"rowtype": [{ "name": "number of rows inserted", "type": "fixed" }, { "name": "number of rows updated", "type": "fixed" }],
|
||||
"rowset": [["3", "4"]], "queryResultFormat": "json", "statementTypeId": 0x3400 } })
|
||||
.to_string();
|
||||
let created = json!({ "success": true, "data": {
|
||||
"rowtype": [{ "name": "status", "type": "text" }], "rowset": [["Table T successfully created."]],
|
||||
"queryResultFormat": "json", "statementTypeId": 0x6100 } })
|
||||
.to_string();
|
||||
let server = serve(listener, vec![ok(LOGIN_OK), ok(merged), ok(created), ok(CLOSED_OK)]);
|
||||
let pool = Pool::connect(&source(port), None, None).await.unwrap();
|
||||
let cancel = CancellationToken::new();
|
||||
let merge = pool.execute("merge into t using s on …", 10, &cancel).await.unwrap();
|
||||
assert_eq!((merge.affected, merge.columns.len(), merge.rows.len()), (Some(7.0), 0, 0));
|
||||
let create = pool.execute("create table t (id int)", 10, &cancel).await.unwrap();
|
||||
assert_eq!((create.affected, create.columns.len(), create.rows.len()), (Some(0.0), 0, 0));
|
||||
pool.close().await;
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_read_only_data_source_refuses_writes_without_sending_them() {
|
||||
let (listener, port) = bind().await;
|
||||
let server = serve(listener, vec![ok(LOGIN_OK), ok(one()), ok(one()), ok(CLOSED_OK)]);
|
||||
let mut source = source(port);
|
||||
source.read_only = true;
|
||||
source.options.startup_script = "alter session set week_start = 1".into();
|
||||
let pool = Pool::connect(&source, None, None).await.unwrap();
|
||||
let cancel = CancellationToken::new();
|
||||
for write in
|
||||
["delete from t", " -- note\n /* x */ INSERT INTO t VALUES (1)", "create table x (i int)", "// c\ncall p()"]
|
||||
{
|
||||
let error = pool.execute(write, 1, &cancel).await.unwrap_err();
|
||||
assert_eq!(error.kind, ErrorKind::InvalidInput);
|
||||
assert!(error.message.starts_with("read-only data source: "), "{}", error.message);
|
||||
}
|
||||
assert!(pool.transaction(&[("update t set a = 1".into(), true)]).await.is_err());
|
||||
pool.execute("-- reads pass\nwith x as (select 1) select * from x", 1, &cancel).await.unwrap();
|
||||
pool.close().await;
|
||||
let seen = server.await.unwrap();
|
||||
assert_eq!(seen[1].json()["sqlText"], "alter session set week_start = 1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn table_edits_commit_together_or_roll_back() {
|
||||
let changed = |n: &str| {
|
||||
json!({ "success": true, "data": { "rowtype": [{ "name": "number of rows updated", "type": "fixed" }],
|
||||
"rowset": [[n]], "queryResultFormat": "json", "statementTypeId": 0x3200 } })
|
||||
.to_string()
|
||||
};
|
||||
let status = || {
|
||||
json!({ "success": true, "data": { "rowtype": [{ "name": "status", "type": "text" }],
|
||||
"rowset": [["Statement executed successfully."]], "queryResultFormat": "json", "statementTypeId": 0x5000 } })
|
||||
.to_string()
|
||||
};
|
||||
let edits =
|
||||
[("update t set a = 1 where id = 1".to_owned(), true), ("update t set a = 2 where id = 2".to_owned(), true)];
|
||||
|
||||
let (listener, port) = bind().await;
|
||||
let server = serve(
|
||||
listener,
|
||||
vec![ok(LOGIN_OK), ok(status()), ok(changed("1")), ok(changed("1")), ok(status()), ok(CLOSED_OK)],
|
||||
);
|
||||
let pool = Pool::connect(&source(port), None, None).await.unwrap();
|
||||
assert_eq!(pool.transaction(&edits).await.unwrap(), 2);
|
||||
pool.close().await;
|
||||
let sent: Vec<String> =
|
||||
server.await.unwrap()[1..5].iter().map(|r| r.json()["sqlText"].as_str().unwrap().to_owned()).collect();
|
||||
assert_eq!(sent, ["BEGIN", "update t set a = 1 where id = 1", "update t set a = 2 where id = 2", "COMMIT"]);
|
||||
|
||||
let (listener, port) = bind().await;
|
||||
let server = serve(listener, vec![ok(LOGIN_OK), ok(status()), ok(changed("0")), ok(status()), ok(CLOSED_OK)]);
|
||||
let pool = Pool::connect(&source(port), None, None).await.unwrap();
|
||||
let error = pool.transaction(&edits).await.unwrap_err();
|
||||
assert!(error.message.contains("expected to change 1 row, changed 0; nothing was saved"), "{}", error.message);
|
||||
pool.close().await;
|
||||
let sent: Vec<String> =
|
||||
server.await.unwrap()[1..4].iter().map(|r| r.json()["sqlText"].as_str().unwrap().to_owned()).collect();
|
||||
assert_eq!(sent, ["BEGIN", "update t set a = 1 where id = 1", "ROLLBACK"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_arrow_result_is_refused_instead_of_shown_empty() {
|
||||
let (listener, port) = bind().await;
|
||||
let arrow = json!({ "success": true, "data": { "rowtype": [{ "name": "N", "type": "fixed" }],
|
||||
"rowsetBase64": "QVJST1c=", "queryResultFormat": "arrow", "statementTypeId": 4096 } })
|
||||
.to_string();
|
||||
let server = serve(listener, vec![ok(LOGIN_OK), ok(arrow), ok(CLOSED_OK)]);
|
||||
let pool = Pool::connect(&source(port), None, None).await.unwrap();
|
||||
let error = pool.execute("select 1", 1, &CancellationToken::new()).await.unwrap_err();
|
||||
assert_eq!(error.kind, ErrorKind::Protocol);
|
||||
pool.close().await;
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_only_knows_reads_from_writes() {
|
||||
for read in [
|
||||
"select 1",
|
||||
"WITH a AS (select 1) select * from a",
|
||||
"show tables",
|
||||
"desc table t",
|
||||
"explain select 1",
|
||||
"ls @stage",
|
||||
"use schema s",
|
||||
"",
|
||||
] {
|
||||
assert!(!is_write(read), "{read}");
|
||||
}
|
||||
for write in [
|
||||
"insert into t values (1)",
|
||||
"COPY INTO t FROM @s",
|
||||
"put file://x @s",
|
||||
"truncate t",
|
||||
"grant select on t to role r",
|
||||
"begin",
|
||||
] {
|
||||
assert!(is_write(write), "{write}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn browser_sign_in_presents_the_token_and_proof_key_then_reuses_the_id_token() {
|
||||
let (listener, port) = bind().await;
|
||||
let page =
|
||||
r#"{"success":true,"data":{"ssoUrl":"https://idp.example/sso?SAMLRequest=abc","proofKey":"proof-key-1"}}"#;
|
||||
let signed_in =
|
||||
r#"{"success":true,"data":{"token":"session-token-1","masterToken":"master-token-1","idToken":"id-token-1"}}"#;
|
||||
let again = r#"{"success":true,"data":{"token":"session-token-2","masterToken":"master-token-2"}}"#;
|
||||
let stale = r#"{"success":false,"code":"390195","message":"The provided ID Token is invalid."}"#;
|
||||
let server = serve(
|
||||
listener,
|
||||
vec![
|
||||
// First connection: the browser.
|
||||
ok(page),
|
||||
ok(signed_in),
|
||||
ok(CLOSED_OK),
|
||||
// Second connection: the ID token alone.
|
||||
ok(again),
|
||||
ok(CLOSED_OK),
|
||||
// Third: the ID token is refused, so the browser again.
|
||||
ok(stale),
|
||||
ok(page),
|
||||
ok(signed_in),
|
||||
ok(CLOSED_OK),
|
||||
],
|
||||
);
|
||||
let opened = Arc::new(parking_lot::Mutex::new(Vec::<String>::new()));
|
||||
let pages = opened.clone();
|
||||
// The stand-in browser: the identity provider sends it back to the listener with a token.
|
||||
*browser::TEST_OPENER.lock() = Some(Box::new(move |url, redirect_port| {
|
||||
pages.lock().push(url.to_owned());
|
||||
tokio::spawn(async move {
|
||||
let mut stream = TcpStream::connect(("127.0.0.1", redirect_port)).await.unwrap();
|
||||
// A browser also asks for the tab's icon; that request must not end the sign-in.
|
||||
stream.write_all(b"GET /favicon.ico HTTP/1.1\r\nHost: localhost\r\n\r\n").await.unwrap();
|
||||
let mut reply = Vec::new();
|
||||
stream.read_to_end(&mut reply).await.unwrap();
|
||||
assert!(reply.starts_with(b"HTTP/1.1 404"));
|
||||
let mut stream = TcpStream::connect(("127.0.0.1", redirect_port)).await.unwrap();
|
||||
stream.write_all(b"GET /?token=sso%2Btoken-1 HTTP/1.1\r\nHost: localhost\r\n\r\n").await.unwrap();
|
||||
let mut reply = Vec::new();
|
||||
stream.read_to_end(&mut reply).await.unwrap();
|
||||
let reply = String::from_utf8_lossy(&reply).into_owned();
|
||||
assert!(reply.starts_with("HTTP/1.1 200 OK"), "{reply}");
|
||||
assert!(reply.contains("return to OpenIDEA"), "{reply}");
|
||||
});
|
||||
Ok(())
|
||||
}));
|
||||
let mut source = source(port);
|
||||
source.snowflake.as_mut().unwrap().auth = DbSnowflakeAuth::Browser;
|
||||
source.snowflake.as_mut().unwrap().private_key_file = String::new();
|
||||
for _ in 0..3 {
|
||||
let pool = Pool::connect(&source, None, None).await.unwrap();
|
||||
pool.close().await;
|
||||
}
|
||||
*browser::TEST_OPENER.lock() = None;
|
||||
let seen = server.await.unwrap();
|
||||
assert_eq!(*opened.lock(), ["https://idp.example/sso?SAMLRequest=abc"; 2]);
|
||||
|
||||
assert!(seen[0].line().starts_with("POST /session/authenticator-request?requestId="), "{}", seen[0].line());
|
||||
let asked = &seen[0].json()["data"];
|
||||
assert_eq!(asked["AUTHENTICATOR"], "EXTERNALBROWSER");
|
||||
assert_eq!(asked["LOGIN_NAME"], "jane");
|
||||
assert_eq!(asked["ACCOUNT_NAME"], "127");
|
||||
assert!(asked["BROWSER_MODE_REDIRECT_PORT"].as_str().unwrap().parse::<u16>().unwrap() > 0);
|
||||
|
||||
let first = &seen[1].json()["data"];
|
||||
assert_eq!(first["AUTHENTICATOR"], "EXTERNALBROWSER");
|
||||
assert_eq!(first["TOKEN"], "sso+token-1");
|
||||
assert_eq!(first["PROOF_KEY"], "proof-key-1");
|
||||
assert_eq!(first["SESSION_PARAMETERS"]["CLIENT_STORE_TEMPORARY_CREDENTIAL"], true);
|
||||
assert!(first.get("PASSWORD").is_none());
|
||||
|
||||
let second = &seen[3].json()["data"];
|
||||
assert_eq!(second["AUTHENTICATOR"], "ID_TOKEN");
|
||||
assert_eq!(second["TOKEN"], "id-token-1");
|
||||
assert!(second.get("PROOF_KEY").is_none());
|
||||
|
||||
assert_eq!(seen[5].json()["data"]["AUTHENTICATOR"], "ID_TOKEN");
|
||||
assert!(seen[6].line().starts_with("POST /session/authenticator-request?"), "{}", seen[6].line());
|
||||
assert_eq!(seen[7].json()["data"]["AUTHENTICATOR"], "EXTERNALBROWSER");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_account_without_browser_sign_in_says_so() {
|
||||
let (listener, port) = bind().await;
|
||||
let refused = r#"{"success":false,"code":"390190","message":"There was an error related to the SAML Identity Provider account parameter."}"#;
|
||||
let server = serve(listener, vec![ok(refused)]);
|
||||
let mut source = source(port);
|
||||
source.user = "nobody".into();
|
||||
source.snowflake.as_mut().unwrap().auth = DbSnowflakeAuth::Browser;
|
||||
let error = failure(&source, None).await;
|
||||
assert_eq!(error.kind, ErrorKind::InvalidInput);
|
||||
assert!(
|
||||
error.message.starts_with("Snowflake [390190]: There was an error related to the SAML"),
|
||||
"{}",
|
||||
error.message
|
||||
);
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_console_schema_is_switched_with_its_statement_and_only_when_needed() {
|
||||
let status = || {
|
||||
json!({ "success": true, "data": { "rowtype": [{ "name": "status", "type": "text" }],
|
||||
"rowset": [["Statement executed successfully."]], "queryResultFormat": "json", "statementTypeId": 0x5000 } })
|
||||
.to_string()
|
||||
};
|
||||
let (listener, port) = bind().await;
|
||||
let mut replies = vec![ok(LOGIN_OK)];
|
||||
// use Sales, 1, 2 | restore PUBLIC, 3 | 4 | use database (the user's), then PUBLIC again, 5
|
||||
for body in [status(), one(), one(), status(), one(), one(), status(), status(), one()] {
|
||||
replies.push(ok(body));
|
||||
}
|
||||
replies.push(ok(CLOSED_OK));
|
||||
let server = serve(listener, replies);
|
||||
let pool = Pool::connect(&source(port), None, None).await.unwrap();
|
||||
let cancel = CancellationToken::new();
|
||||
let mut console = crate::conn::Held::acquire(&pool).await.unwrap();
|
||||
console.apply_context(Some("Sales \"2024\"")).await.unwrap();
|
||||
console.execute(&pool, "select 1", 1, &cancel).await.unwrap();
|
||||
console.execute(&pool, "select 2", 1, &cancel).await.unwrap();
|
||||
// The tree and other consoles run in the data source's schema.
|
||||
pool.execute("select 3", 1, &cancel).await.unwrap();
|
||||
pool.execute("select 4", 1, &cancel).await.unwrap();
|
||||
// After the user's own USE the session may be anywhere: the schema is set again.
|
||||
pool.execute("use database OTHER", 0, &cancel).await.unwrap();
|
||||
pool.execute("select 5", 1, &cancel).await.unwrap();
|
||||
pool.close().await;
|
||||
let sent: Vec<String> =
|
||||
server.await.unwrap()[1..10].iter().map(|r| r.json()["sqlText"].as_str().unwrap().to_owned()).collect();
|
||||
assert_eq!(
|
||||
sent,
|
||||
[
|
||||
"USE SCHEMA \"ANALYTICS\".\"Sales \"\"2024\"\"\"",
|
||||
"select 1",
|
||||
"select 2",
|
||||
"USE SCHEMA \"ANALYTICS\".\"PUBLIC\"",
|
||||
"select 3",
|
||||
"select 4",
|
||||
"use database OTHER",
|
||||
"USE SCHEMA \"ANALYTICS\".\"PUBLIC\"",
|
||||
"select 5",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_role_that_may_not_use_the_schema_still_runs_statements() {
|
||||
let status = || {
|
||||
json!({ "success": true, "data": { "rowtype": [{ "name": "status", "type": "text" }],
|
||||
"rowset": [["Statement executed successfully."]], "queryResultFormat": "json", "statementTypeId": 0x5000 } })
|
||||
.to_string()
|
||||
};
|
||||
let refused = r#"{"success":false,"code":"002043","message":"SQL compilation error:\nObject does not exist, or operation cannot be performed."}"#;
|
||||
let (listener, port) = bind().await;
|
||||
// use role | the schema is refused once, the statement runs | the next one does not ask again |
|
||||
// a role that is not granted | use role back, the schema is entered again
|
||||
let replies = vec![
|
||||
ok(LOGIN_OK),
|
||||
ok(status()),
|
||||
ok(refused),
|
||||
ok(one()),
|
||||
ok(one()),
|
||||
ok(refused),
|
||||
ok(status()),
|
||||
ok(status()),
|
||||
ok(one()),
|
||||
ok(CLOSED_OK),
|
||||
];
|
||||
let server = serve(listener, replies);
|
||||
let pool = Pool::connect(&source(port), None, None).await.unwrap();
|
||||
let cancel = CancellationToken::new();
|
||||
pool.execute("use role auditor", 0, &cancel).await.unwrap();
|
||||
pool.execute("select 1", 1, &cancel).await.unwrap();
|
||||
pool.execute("select 2", 1, &cancel).await.unwrap();
|
||||
let missing = pool.execute("use role nobody", 0, &cancel).await.unwrap_err();
|
||||
assert!(missing.message.contains("002043") && missing.message.contains("granted"), "{}", missing.message);
|
||||
pool.execute("use role analyst", 0, &cancel).await.unwrap();
|
||||
pool.execute("select 3", 1, &cancel).await.unwrap();
|
||||
pool.close().await;
|
||||
let sent: Vec<String> =
|
||||
server.await.unwrap()[1..9].iter().map(|r| r.json()["sqlText"].as_str().unwrap().to_owned()).collect();
|
||||
let schema = "USE SCHEMA \"ANALYTICS\".\"PUBLIC\"";
|
||||
assert_eq!(
|
||||
sent,
|
||||
["use role auditor", schema, "select 1", "select 2", "use role nobody", "use role analyst", schema, "select 3"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn databases_of_one_data_source_share_a_login() {
|
||||
let (listener, port) = bind().await;
|
||||
let status = json!({ "success": true, "data": { "rowtype": [{ "name": "status", "type": "text" }],
|
||||
"rowset": [["Statement executed successfully."]], "queryResultFormat": "json", "statementTypeId": 0x5000 } })
|
||||
.to_string();
|
||||
// One login; `select 1` in ANALYTICS; a switch and `select 2` in "Raw Data"; one session end.
|
||||
let server = serve(listener, vec![ok(LOGIN_OK), ok(one()), ok(status), ok(one()), ok(CLOSED_OK)]);
|
||||
let first = Pool::connect(&source(port), None, None).await.unwrap();
|
||||
let mut other = source(port);
|
||||
other.database = "Raw Data".into();
|
||||
other.options.default_schema = String::new();
|
||||
other.read_only = true;
|
||||
let second = Pool::connect(&other, None, None).await.unwrap();
|
||||
let cancel = CancellationToken::new();
|
||||
first.execute("select 1", 1, &cancel).await.unwrap();
|
||||
second.execute("select 2", 1, &cancel).await.unwrap();
|
||||
// Read-only is the target's own setting.
|
||||
assert!(second.execute("delete from t", 1, &cancel).await.is_err());
|
||||
first.close().await;
|
||||
assert!(first.is_closed() && !second.is_closed());
|
||||
assert!(first.execute("select 3", 1, &cancel).await.is_err());
|
||||
second.close().await;
|
||||
let seen = server.await.unwrap();
|
||||
let sent: Vec<String> = seen[1..4].iter().map(|r| r.json()["sqlText"].as_str().unwrap().to_owned()).collect();
|
||||
assert_eq!(sent, ["select 1", "USE DATABASE \"Raw Data\"", "select 2"]);
|
||||
assert!(seen[4].line().starts_with("POST /session?delete=true"), "{}", seen[4].line());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn metadata_runs_on_its_own_session_beside_a_busy_console() {
|
||||
let (listener, port) = bind().await;
|
||||
let slow = Reply { status: "200 OK", body: one().into_bytes(), delay: Duration::from_millis(1500) };
|
||||
let second = r#"{"success":true,"data":{"token":"session-token-2","masterToken":"master-token-2"}}"#;
|
||||
let status = json!({ "success": true, "data": { "rowtype": [{ "name": "status", "type": "text" }],
|
||||
"rowset": [["Statement executed successfully."]], "queryResultFormat": "json", "statementTypeId": 0x5000 } })
|
||||
.to_string();
|
||||
// Console login and its slow statement; the metadata session logs in, enters the schema and answers meanwhile.
|
||||
let server =
|
||||
serve(listener, vec![ok(LOGIN_OK), slow, ok(second), ok(status), ok(one()), ok(CLOSED_OK), ok(CLOSED_OK)]);
|
||||
let pool = Pool::connect(&source(port), None, None).await.unwrap();
|
||||
let cancel = CancellationToken::new();
|
||||
let started = Instant::now();
|
||||
let console = pool.execute("call system$wait(60)", 1, &cancel);
|
||||
let tree = async {
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
let rows = pool.rows("show terse schemas", 10).await.unwrap();
|
||||
(rows, started.elapsed())
|
||||
};
|
||||
let (console, (rows, tree_took)) = tokio::join!(console, tree);
|
||||
console.unwrap();
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert!(tree_took < Duration::from_millis(1200), "the tree waited for the console: {tree_took:?}");
|
||||
pool.close().await;
|
||||
let seen = server.await.unwrap();
|
||||
assert!(seen[2].line().starts_with("POST /session/v1/login-request?"), "{}", seen[2].line());
|
||||
assert_eq!(seen[3].json()["sqlText"], "USE SCHEMA \"ANALYTICS\".\"PUBLIC\"");
|
||||
assert_eq!(seen[4].header("authorization"), Some("Snowflake Token=\"session-token-2\""));
|
||||
// Both sessions end with the connection.
|
||||
let ended: Vec<&str> = seen[5..7].iter().filter_map(|r| r.header("authorization")).collect();
|
||||
assert_eq!(ended.len(), 2);
|
||||
assert!(
|
||||
ended.contains(&"Snowflake Token=\"session-token-1\"")
|
||||
&& ended.contains(&"Snowflake Token=\"session-token-2\"")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_database_is_introspected_schema_by_schema_when_its_whole_listing_is_refused() {
|
||||
let (listener, port) = bind().await;
|
||||
let second = r#"{"success":true,"data":{"token":"session-token-2","masterToken":"master-token-2"}}"#;
|
||||
let text = |name: &str| json!({ "name": name, "type": "text" });
|
||||
let terse = json!([text("created_on"), text("name"), text("kind"), text("database_name"), text("schema_name")]);
|
||||
let schemas = result(
|
||||
terse.clone(),
|
||||
json!([
|
||||
["", "INFORMATION_SCHEMA", null, "RAW", null],
|
||||
["", "PUBLIC", null, "RAW", null],
|
||||
["", "STAGE", null, "RAW", null]
|
||||
]),
|
||||
);
|
||||
let refused = r#"{"success":false,"code":"001003","message":"SQL compilation error: unsupported listing"}"#;
|
||||
let public = result(
|
||||
terse.clone(),
|
||||
json!([["", "ORDERS", "TABLE", "RAW", "PUBLIC"], ["", "DAILY", "VIEW", "RAW", "PUBLIC"]]),
|
||||
);
|
||||
let stage = result(terse, json!([["", "ORDERS", "TABLE", "RAW", "STAGE"]]));
|
||||
// Metadata session: login, schema context, the schemas, the refused listing, then each schema.
|
||||
let replies = vec![
|
||||
ok(LOGIN_OK),
|
||||
ok(second),
|
||||
ok(one()),
|
||||
ok(schemas),
|
||||
ok(refused),
|
||||
ok(public),
|
||||
ok(stage),
|
||||
ok(CLOSED_OK),
|
||||
ok(CLOSED_OK),
|
||||
];
|
||||
let server = serve(listener, replies);
|
||||
let pool = Pool::connect(&source(port), None, None).await.unwrap();
|
||||
let Pool::Snowflake(snowflake) = &pool else { panic!("not a Snowflake pool") };
|
||||
let catalog = super::meta::catalog(snowflake, "RAW").await.unwrap();
|
||||
let names = |i: usize| catalog[i].tables.iter().map(|t| t.name.as_str()).collect::<Vec<_>>();
|
||||
assert_eq!(catalog.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), ["PUBLIC", "STAGE"]);
|
||||
assert_eq!(names(0), ["DAILY", "ORDERS"]);
|
||||
assert_eq!(names(1), ["ORDERS"]);
|
||||
pool.close().await;
|
||||
let seen = server.await.unwrap();
|
||||
let sql = |i: usize| seen[i].json()["sqlText"].as_str().unwrap_or_default().to_owned();
|
||||
assert_eq!(sql(3), "SHOW TERSE SCHEMAS IN DATABASE \"RAW\" LIMIT 10000");
|
||||
assert_eq!(sql(4), "SHOW TERSE OBJECTS IN DATABASE \"RAW\" LIMIT 10000");
|
||||
assert_eq!(sql(5), "SHOW TERSE OBJECTS IN SCHEMA \"RAW\".\"PUBLIC\" LIMIT 10000");
|
||||
assert_eq!(sql(6), "SHOW TERSE OBJECTS IN SCHEMA \"RAW\".\"STAGE\" LIMIT 10000");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn targets_opened_at_the_same_time_sign_in_once() {
|
||||
let (listener, port) = bind().await;
|
||||
// Had each connect signed in, the server would be asked for more than this one login.
|
||||
let slow_login = Reply { status: "200 OK", body: LOGIN_OK.as_bytes().to_vec(), delay: Duration::from_millis(300) };
|
||||
let server = serve(listener, vec![slow_login, ok(CLOSED_OK)]);
|
||||
let base = source(port);
|
||||
let mut others = Vec::new();
|
||||
for database in ["RAW", "STAGING", "MARTS"] {
|
||||
let mut other = source(port);
|
||||
other.database = database.into();
|
||||
others.push(other);
|
||||
}
|
||||
let (a, b, c, d) = tokio::join!(
|
||||
Pool::connect(&base, None, None),
|
||||
Pool::connect(&others[0], None, None),
|
||||
Pool::connect(&others[1], None, None),
|
||||
Pool::connect(&others[2], None, None),
|
||||
);
|
||||
let pools = [a.unwrap(), b.unwrap(), c.unwrap(), d.unwrap()];
|
||||
for pool in &pools {
|
||||
pool.close().await;
|
||||
}
|
||||
let seen = server.await.unwrap();
|
||||
assert_eq!(seen.len(), 2);
|
||||
assert!(seen[0].line().starts_with("POST /session/v1/login-request?"), "{}", seen[0].line());
|
||||
assert!(seen[1].line().starts_with("POST /session?delete=true"), "{}", seen[1].line());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_sign_on_tokens_are_keyed_by_account_and_user_and_only_for_browser_sign_in() {
|
||||
let mut source = source(443);
|
||||
source.host = "myorg-myaccount".into();
|
||||
source.port = None;
|
||||
source.ssl = DbSslMode::Prefer;
|
||||
assert_eq!(sso_key(&source), None);
|
||||
source.snowflake.as_mut().unwrap().auth = DbSnowflakeAuth::Browser;
|
||||
let key = sso_key(&source).unwrap();
|
||||
assert_eq!(key, "myorg-myaccount.snowflakecomputing.com:443|JANE");
|
||||
assert_eq!(sso_token(&key), None);
|
||||
sso_seed(&key, "saved-id-token");
|
||||
assert_eq!(sso_token(&key).as_deref(), Some("saved-id-token"));
|
||||
// A token already in use is not replaced by an older saved one.
|
||||
sso_seed(&key, "stale");
|
||||
assert_eq!(sso_token(&key).as_deref(), Some("saved-id-token"));
|
||||
browser::forget(&key);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ pub enum DbDriver {
|
||||
Clickhouse,
|
||||
/// Oracle Database (pure-Rust thin driver, no client library).
|
||||
Oracle,
|
||||
Snowflake,
|
||||
}
|
||||
|
||||
impl DbDriver {
|
||||
@@ -33,6 +34,7 @@ impl DbDriver {
|
||||
DbDriver::SqlServer => Some(1433),
|
||||
DbDriver::Clickhouse => Some(8123),
|
||||
DbDriver::Oracle => Some(1521),
|
||||
DbDriver::Snowflake => Some(443),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,6 +133,37 @@ pub struct DbProperty {
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
/// How a Snowflake data source signs in. The data source's one saved secret is what the
|
||||
/// method needs: the private key's passphrase, the password, or the token.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum DbSnowflakeAuth {
|
||||
/// RSA key pair: a JWT signed with the private key file.
|
||||
#[default]
|
||||
KeyPair,
|
||||
/// User name and password.
|
||||
Password,
|
||||
/// Programmatic access token.
|
||||
Token,
|
||||
/// OAuth access token.
|
||||
OAuth,
|
||||
/// Single sign-on in the web browser.
|
||||
Browser,
|
||||
}
|
||||
|
||||
/// Snowflake connection settings beyond the shared ones: the account identifier is
|
||||
/// [`DataSource::host`], the schema [`DbOptions::default_schema`].
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct DbSnowflake {
|
||||
pub warehouse: String,
|
||||
pub role: String,
|
||||
pub auth: DbSnowflakeAuth,
|
||||
/// PEM private key of [`DbSnowflakeAuth::KeyPair`]; `~/` is the home directory. Only the
|
||||
/// path is stored: the key is read from the file at each login.
|
||||
pub private_key_file: String,
|
||||
}
|
||||
|
||||
/// How the SSH tunnel authenticates (shared with Settings › SSH Configurations).
|
||||
pub use ide_core::remote_settings::SshAuth as DbSshAuth;
|
||||
|
||||
@@ -185,6 +218,8 @@ pub struct DataSource {
|
||||
pub options: DbOptions,
|
||||
pub introspection: DbIntrospection,
|
||||
pub properties: Vec<DbProperty>,
|
||||
/// Snowflake settings (`None`: not a Snowflake data source, or all defaults).
|
||||
pub snowflake: Option<DbSnowflake>,
|
||||
/// Stored app-wide and listed in every project. Implied by the file it is read from,
|
||||
/// so not written to it.
|
||||
pub global: bool,
|
||||
@@ -222,6 +257,7 @@ impl Default for DataSource {
|
||||
options: DbOptions::default(),
|
||||
introspection: DbIntrospection::default(),
|
||||
properties: Vec::new(),
|
||||
snowflake: None,
|
||||
global: false,
|
||||
plugin_owner: None,
|
||||
plugin_source_id: None,
|
||||
@@ -273,6 +309,7 @@ pub fn scheme_driver(scheme: &str) -> Option<DbDriver> {
|
||||
"sqlite" | "sqlite3" => Some(DbDriver::Sqlite),
|
||||
"clickhouse" | "http" | "https" => Some(DbDriver::Clickhouse),
|
||||
"oracle" | "oracledb" => Some(DbDriver::Oracle),
|
||||
"snowflake" => Some(DbDriver::Snowflake),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -375,6 +412,9 @@ impl DataSource {
|
||||
}
|
||||
let mut properties = Vec::new();
|
||||
for (k, v) in u.params {
|
||||
if s.driver == DbDriver::Snowflake && s.snowflake_url_parameter(&k, &v) {
|
||||
continue;
|
||||
}
|
||||
match k.to_ascii_lowercase().as_str() {
|
||||
"sslmode" | "ssl-mode" => {
|
||||
s.ssl = match v.to_ascii_lowercase().replace('-', "_").as_str() {
|
||||
@@ -405,6 +445,35 @@ impl DataSource {
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// Applies a parameter of a Snowflake JDBC URL that names a connection setting.
|
||||
fn snowflake_url_parameter(&mut self, name: &str, value: &str) -> bool {
|
||||
let settings = self.snowflake.get_or_insert_with(DbSnowflake::default);
|
||||
match name.to_ascii_lowercase().as_str() {
|
||||
"db" | "database" => self.database = value.to_owned(),
|
||||
"schema" => self.options.default_schema = value.to_owned(),
|
||||
"warehouse" => settings.warehouse = value.to_owned(),
|
||||
"role" => settings.role = value.to_owned(),
|
||||
"private_key_file" => settings.private_key_file = value.to_owned(),
|
||||
"authenticator" => {
|
||||
settings.auth = match value.to_ascii_lowercase().as_str() {
|
||||
"snowflake_jwt" => DbSnowflakeAuth::KeyPair,
|
||||
"externalbrowser" => DbSnowflakeAuth::Browser,
|
||||
"oauth" => DbSnowflakeAuth::OAuth,
|
||||
"programmatic_access_token" => DbSnowflakeAuth::Token,
|
||||
"snowflake" => DbSnowflakeAuth::Password,
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The Snowflake settings (defaults when none were saved).
|
||||
pub fn snowflake(&self) -> DbSnowflake {
|
||||
self.snowflake.clone().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The password written in the URL of a [`DataSource::url_only`] data source.
|
||||
pub fn url_password(&self) -> Option<String> {
|
||||
if !self.url_only {
|
||||
@@ -613,4 +682,72 @@ mod tests {
|
||||
let error = parse_url("clickhouse://reader:[email protected]:invalid/default").unwrap_err();
|
||||
assert!(!error.message.contains("secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snowflake_settings_round_trip_and_stay_out_of_other_sources() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let snowflake = DataSource {
|
||||
id: "sf".into(),
|
||||
name: "warehouse".into(),
|
||||
driver: DbDriver::Snowflake,
|
||||
host: "myorg-myaccount".into(),
|
||||
user: "jane".into(),
|
||||
database: "ANALYTICS".into(),
|
||||
save_password: true,
|
||||
snowflake: Some(DbSnowflake {
|
||||
warehouse: "COMPUTE_WH".into(),
|
||||
role: "ANALYST".into(),
|
||||
auth: DbSnowflakeAuth::KeyPair,
|
||||
private_key_file: "~/.ssh/rsa_key.p8".into(),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let other = DataSource { id: "pg".into(), name: "pg".into(), driver: DbDriver::Postgres, ..Default::default() };
|
||||
save(tmp.path(), &[snowflake.clone(), other.clone()]).unwrap();
|
||||
assert_eq!(load(tmp.path()).unwrap(), vec![snowflake, other]);
|
||||
let text = std::fs::read_to_string(project_dir::path(tmp.path(), FILE)).unwrap();
|
||||
assert_eq!(text.matches("snowflake]").count(), 1, "{text}");
|
||||
assert!(text.contains("driver = \"snowflake\""), "{text}");
|
||||
assert!(text.contains("auth = \"keyPair\""), "{text}");
|
||||
assert!(text.contains("privateKeyFile = \"~/.ssh/rsa_key.p8\""), "{text}");
|
||||
assert_eq!(DbDriver::Snowflake.default_port(), Some(443));
|
||||
assert_eq!(DataSource::default().snowflake(), DbSnowflake::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snowflake_jdbc_urls_name_the_session_settings() {
|
||||
let s = DataSource {
|
||||
driver: DbDriver::Snowflake,
|
||||
url_only: true,
|
||||
url: "jdbc:snowflake://myorg-myaccount.snowflakecomputing.com:443/?warehouse=COMPUTE_WH&db=ANALYTICS\
|
||||
&schema=PUBLIC&role=ANALYST&user=jane&authenticator=snowflake_jwt\
|
||||
&private_key_file=%7E%2Fkeys%2Frsa_key.p8&QUERY_TAG=ide"
|
||||
.into(),
|
||||
..Default::default()
|
||||
};
|
||||
let r = s.resolved().unwrap();
|
||||
assert_eq!(r.host, "myorg-myaccount.snowflakecomputing.com");
|
||||
assert_eq!(r.port, Some(443));
|
||||
assert_eq!(r.user, "jane");
|
||||
assert_eq!(r.database, "ANALYTICS");
|
||||
assert_eq!(r.options.default_schema, "PUBLIC");
|
||||
assert_eq!(
|
||||
r.snowflake(),
|
||||
DbSnowflake {
|
||||
warehouse: "COMPUTE_WH".into(),
|
||||
role: "ANALYST".into(),
|
||||
auth: DbSnowflakeAuth::KeyPair,
|
||||
private_key_file: "~/keys/rsa_key.p8".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(r.properties, vec![DbProperty { name: "QUERY_TAG".into(), value: "ide".into() }]);
|
||||
let browser = DataSource {
|
||||
url: "snowflake://xy12345.eu-central-1.snowflakecomputing.com/DB?authenticator=externalbrowser".into(),
|
||||
..s
|
||||
};
|
||||
let r = browser.resolved().unwrap();
|
||||
assert_eq!(r.database, "DB");
|
||||
assert_eq!(r.snowflake().auth, DbSnowflakeAuth::Browser);
|
||||
assert_eq!(scheme_driver("snowflake"), Some(DbDriver::Snowflake));
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
|
||||
let clickhouse = driver == DbDriver::Clickhouse;
|
||||
// PL/SQL: `;` inside BEGIN…END / DECLARE…END blocks does not terminate a statement.
|
||||
let plsql = driver == DbDriver::Oracle;
|
||||
let snowflake = driver == DbDriver::Snowflake;
|
||||
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);
|
||||
@@ -57,6 +58,10 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
|
||||
let mut block = false;
|
||||
// `CREATE PACKAGE` / `TYPE BODY`: only `/` on its own line (or the end) ends it.
|
||||
let mut package = false;
|
||||
// The segment is a Snowflake Scripting block (`DECLARE … BEGIN … END`, `BEGIN … END`).
|
||||
let mut script = false;
|
||||
// The segment may carry a Scripting block as its body (`CREATE TASK … AS BEGIN … END`).
|
||||
let mut defines = false;
|
||||
let push = |out: &mut Vec<SqlStatement>, s: usize, e: usize, has_code: bool| {
|
||||
if !has_code {
|
||||
return;
|
||||
@@ -91,11 +96,29 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
|
||||
// Re-read this character as code (it may start BEGIN / DECLARE).
|
||||
continue;
|
||||
}
|
||||
_ if snowflake && !has_code && c.is_alphabetic() => {
|
||||
if word_at(&chars, i, "DECLARE") {
|
||||
(script, block, declarations) = (true, true, true);
|
||||
} else if word_at(&chars, i, "BEGIN") {
|
||||
// `BEGIN;`, `BEGIN TRANSACTION`… open a transaction; anything else a block.
|
||||
let after = skip_ws(&chars, i + 5);
|
||||
let transaction = chars.get(after).is_none_or(|&(_, c)| c == ';')
|
||||
|| ["TRANSACTION", "WORK", "NAME"].into_iter().any(|w| word_at(&chars, after, w));
|
||||
(script, block) = (!transaction, !transaction);
|
||||
} else {
|
||||
defines = ["CREATE", "WITH", "EXECUTE"].into_iter().any(|w| word_at(&chars, i, w));
|
||||
}
|
||||
has_code = true;
|
||||
// Re-read this character as code (it starts BEGIN / DECLARE).
|
||||
continue;
|
||||
}
|
||||
';' if depth == 0 && !declarations && !package => {
|
||||
push(&mut out, start, if block { i + 1 } else { i }, has_code);
|
||||
start = i + 1;
|
||||
has_code = false;
|
||||
block = false;
|
||||
script = false;
|
||||
defines = false;
|
||||
}
|
||||
';' => has_code = true,
|
||||
'\'' => {
|
||||
@@ -132,7 +155,16 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
|
||||
i += 1;
|
||||
}
|
||||
'#' if mysql => state = State::LineComment,
|
||||
'$' if driver == DbDriver::Postgres => {
|
||||
// The `//` of `file://…` (PUT, GET) or another URL is not a comment.
|
||||
'/' if snowflake && next == Some('/') && chars[..i].last().is_some_and(|&(_, c)| c == ':') => {
|
||||
has_code = true;
|
||||
i += 1;
|
||||
}
|
||||
'/' if snowflake && next == Some('/') => {
|
||||
state = State::LineComment;
|
||||
i += 1;
|
||||
}
|
||||
'$' if driver == DbDriver::Postgres || snowflake => {
|
||||
// `$tag$ … $tag$` (tag may be empty); `$1` parameters are not quotes.
|
||||
let rest = &text[byte_at(i)..];
|
||||
let tag_len = rest[1..].find('$').filter(|&n| {
|
||||
@@ -151,6 +183,31 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// An unquoted body: `… AS BEGIN … END`, `… AS DECLARE … BEGIN … END`, an alert's
|
||||
// `THEN BEGIN … END`. A column named `begin` is followed by `,`, `)`, `;` or FROM.
|
||||
_ if defines && !script && depth == 0 && scripting_body(&chars, i) => {
|
||||
script = true;
|
||||
let body = skip_ws(&chars, i + if word_at(&chars, i, "AS") { 2 } else { 4 });
|
||||
declarations = word_at(&chars, body, "DECLARE");
|
||||
i = body - 1;
|
||||
}
|
||||
_ if script && word_at(&chars, i, "BEGIN") => {
|
||||
depth += 1;
|
||||
declarations = false;
|
||||
}
|
||||
_ if script && word_at(&chars, i, "CASE") => depth += 1,
|
||||
_ if script && word_at(&chars, i, "END") => {
|
||||
// `END IF` / `FOR` / `WHILE` / `LOOP` / `REPEAT` close statements that opened no
|
||||
// block; `END` and `END CASE` close what BEGIN and CASE opened.
|
||||
let after = skip_ws(&chars, i + 3);
|
||||
let closing = ["IF", "FOR", "WHILE", "LOOP", "REPEAT", "CASE"]
|
||||
.into_iter()
|
||||
.find(|w| word_at(&chars, after, w));
|
||||
if matches!(closing, None | Some("CASE")) && depth > 0 {
|
||||
depth -= 1;
|
||||
}
|
||||
i = closing.map_or(i + 3, |w| after + w.len()) - 1;
|
||||
}
|
||||
_ if plsql && word_at(&chars, i, "BEGIN") => {
|
||||
depth += 1;
|
||||
declarations = false;
|
||||
@@ -183,7 +240,7 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
|
||||
_ => {}
|
||||
},
|
||||
State::Single => {
|
||||
if (mysql || clickhouse) && c == '\\' {
|
||||
if (mysql || clickhouse || snowflake) && c == '\\' {
|
||||
i += 1;
|
||||
} else if c == '\'' {
|
||||
if next == Some('\'') {
|
||||
@@ -281,6 +338,20 @@ fn word_at(chars: &[(usize, char)], i: usize, word: &str) -> bool {
|
||||
&& chars.get(i + word.len()).is_none_or(|&(_, c)| !c.is_alphanumeric() && c != '_' && c != '$')
|
||||
}
|
||||
|
||||
/// Whether `AS` or `THEN` at `i` is followed by a Snowflake Scripting block.
|
||||
fn scripting_body(chars: &[(usize, char)], i: usize) -> bool {
|
||||
let Some(word) = ["AS", "THEN"].into_iter().find(|w| word_at(chars, i, w)) else { return false };
|
||||
let body = skip_ws(chars, i + word.len());
|
||||
if word_at(chars, body, "DECLARE") {
|
||||
return true;
|
||||
}
|
||||
if !word_at(chars, body, "BEGIN") {
|
||||
return false;
|
||||
}
|
||||
let after = skip_ws(chars, body + 5);
|
||||
chars.get(after).is_some_and(|&(_, c)| !matches!(c, ',' | ')' | ';')) && !word_at(chars, after, "FROM")
|
||||
}
|
||||
|
||||
/// Whether only whitespace follows `i` until the end of its line.
|
||||
fn ends_line(chars: &[(usize, char)], i: usize) -> bool {
|
||||
chars[i + 1..].iter().take_while(|&&(_, c)| c != '\n').all(|&(_, c)| c.is_whitespace())
|
||||
@@ -467,4 +538,68 @@ mod tests {
|
||||
vec!["create view v as select 1 x from dual", "create type t as object (a number)", "select 4"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snowflake_bodies_comments_and_escapes_keep_statements_intact() {
|
||||
let procedure = "create procedure p() returns string language javascript as $$ var a = 1; return 'x;y'; $$";
|
||||
assert_eq!(
|
||||
texts(
|
||||
&format!("{procedure};\nselect 2; // done; really\nselect $1, 'it\\'s;here' from @stage;"),
|
||||
DbDriver::Snowflake
|
||||
),
|
||||
vec![procedure, "select 2", "// done; really\nselect $1, 'it\\'s;here' from @stage"]
|
||||
);
|
||||
assert_eq!(texts("select 1 / 2; select 3", DbDriver::Snowflake), vec!["select 1 / 2", "select 3"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snowflake_unquoted_bodies_and_urls_keep_statements_intact() {
|
||||
let task = "create task t warehouse = w as\nbegin\n insert into a values (1);\n delete from b;\nend";
|
||||
assert_eq!(texts(&format!("{task};\nselect 2;"), DbDriver::Snowflake), vec![task, "select 2"]);
|
||||
let procedure = "create procedure p() returns number language sql as\ndeclare\n n number default 1;\nbegin\n return n;\nend";
|
||||
assert_eq!(texts(&format!("{procedure};\nselect 2"), DbDriver::Snowflake), vec![procedure, "select 2"]);
|
||||
let alert =
|
||||
"create alert a schedule = '1 minute' if (exists (select 1)) then begin insert into h values (1); end";
|
||||
assert_eq!(texts(&format!("{alert}; select 2"), DbDriver::Snowflake), vec![alert, "select 2"]);
|
||||
// A column or alias named `begin` opens nothing.
|
||||
for plain in [
|
||||
"create view v as select 1 as begin, 2 as n",
|
||||
"create view v as select a as begin from t",
|
||||
"with x as (select 1 as begin) select * from x",
|
||||
] {
|
||||
assert_eq!(texts(&format!("{plain}; select 2"), DbDriver::Snowflake), vec![plain, "select 2"]);
|
||||
}
|
||||
assert_eq!(
|
||||
texts(
|
||||
"put file:///tmp/a.csv @s; get @s/a.csv file:///tmp/; select 1 // note; here\n; select 2",
|
||||
DbDriver::Snowflake
|
||||
),
|
||||
vec!["put file:///tmp/a.csv @s", "get @s/a.csv file:///tmp/", "select 1 // note; here", "select 2"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snowflake_scripting_blocks_are_one_statement_and_transactions_are_not() {
|
||||
let block = "DECLARE\n total NUMBER DEFAULT 0;\n c CURSOR FOR SELECT n FROM t;\nBEGIN\n FOR r IN c DO\n \
|
||||
IF (r.n > 1) THEN total := total + r.n; END IF;\n END FOR;\n \
|
||||
LET label := CASE WHEN total > 9 THEN 'big' ELSE 'small' END;\n \
|
||||
BEGIN\n INSERT INTO log VALUES (:label);\n EXCEPTION WHEN OTHER THEN RETURN 'failed';\n END;\n \
|
||||
RETURN total;\nEND;";
|
||||
assert_eq!(texts(&format!("{block}\nselect 2;"), DbDriver::Snowflake), vec![block, "select 2"]);
|
||||
let plain = "BEGIN\n LET x := 1;\n WHILE (x < 3) DO x := x + 1; END WHILE;\n RETURN x;\nEND;";
|
||||
assert_eq!(
|
||||
texts(&format!("select 1;\n{plain}\nselect 3"), DbDriver::Snowflake),
|
||||
vec!["select 1", plain, "select 3"]
|
||||
);
|
||||
for begin in ["begin", "BEGIN TRANSACTION", "begin work", "BEGIN NAME t1"] {
|
||||
assert_eq!(
|
||||
texts(&format!("{begin};\ninsert into t values (1);\ncommit;"), DbDriver::Snowflake),
|
||||
vec![begin, "insert into t values (1)", "commit"]
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
texts("select case when a then 1 end, b from t; select 2", DbDriver::Snowflake),
|
||||
vec!["select case when a then 1 end, b from t", "select 2"]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -16,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 matches!(driver, DbDriver::Mysql | DbDriver::Clickhouse) {
|
||||
if matches!(driver, DbDriver::Mysql | DbDriver::Clickhouse | DbDriver::Snowflake) {
|
||||
s = s.replace('\\', "\\\\");
|
||||
}
|
||||
format!("'{s}'")
|
||||
@@ -75,12 +75,19 @@ pub fn schemas(driver: DbDriver, system: bool) -> &'static str {
|
||||
DbDriver::Clickhouse => {
|
||||
"SELECT name FROM system.databases WHERE lower(name) NOT IN ('system', 'information_schema') ORDER BY name"
|
||||
}
|
||||
// `information_schema.schemata` lists only owned schemas before PostgreSQL 10 (and on servers
|
||||
// built on such a core): a read-only account would see none. Any schema it may use is listed.
|
||||
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"
|
||||
"SELECT nspname FROM pg_catalog.pg_namespace \
|
||||
WHERE nspname NOT LIKE 'pg\\_toast%' AND nspname NOT LIKE 'pg\\_temp%' \
|
||||
AND has_schema_privilege(nspname, 'USAGE') ORDER BY 1"
|
||||
}
|
||||
DbDriver::Mysql if system => "SELECT schema_name FROM information_schema.schemata ORDER BY 1",
|
||||
DbDriver::SqlServer if system => "SELECT schema_name FROM information_schema.schemata ORDER BY 1",
|
||||
DbDriver::Snowflake if system => "SELECT schema_name FROM information_schema.schemata ORDER BY 1",
|
||||
DbDriver::Snowflake => {
|
||||
"SELECT schema_name FROM information_schema.schemata WHERE schema_name <> 'INFORMATION_SCHEMA' ORDER BY 1"
|
||||
}
|
||||
DbDriver::Oracle if system => "SELECT username FROM all_users ORDER BY username",
|
||||
DbDriver::Oracle => {
|
||||
"SELECT username FROM all_users WHERE username NOT IN \
|
||||
@@ -88,9 +95,10 @@ pub fn schemas(driver: DbDriver, system: bool) -> &'static str {
|
||||
'OUTLN', 'SYS', 'SYSTEM', 'XDB', 'XS$NULL') ORDER BY username"
|
||||
}
|
||||
DbDriver::Postgres => {
|
||||
"SELECT schema_name FROM information_schema.schemata \
|
||||
WHERE schema_name NOT IN ('pg_catalog', 'information_schema') \
|
||||
AND schema_name NOT LIKE 'pg\\_toast%' AND schema_name NOT LIKE 'pg\\_temp%' ORDER BY 1"
|
||||
"SELECT nspname FROM pg_catalog.pg_namespace \
|
||||
WHERE nspname NOT IN ('pg_catalog', 'information_schema') \
|
||||
AND nspname NOT LIKE 'pg\\_toast%' AND nspname NOT LIKE 'pg\\_temp%' \
|
||||
AND has_schema_privilege(nspname, 'USAGE') ORDER BY 1"
|
||||
}
|
||||
DbDriver::Mysql => {
|
||||
"SELECT schema_name FROM information_schema.schemata \
|
||||
@@ -117,6 +125,11 @@ pub fn tables(driver: DbDriver, schema: &str) -> String {
|
||||
AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\' ORDER BY name",
|
||||
quote_ident(driver, schema)
|
||||
),
|
||||
DbDriver::Snowflake => format!(
|
||||
"SELECT table_name, CASE table_type WHEN 'VIEW' THEN 'view' WHEN 'MATERIALIZED VIEW' THEN 'materialized view' \
|
||||
ELSE 'table' END FROM information_schema.tables WHERE table_schema = {} ORDER BY 1",
|
||||
quote_literal(driver, schema)
|
||||
),
|
||||
DbDriver::Oracle => format!(
|
||||
"SELECT table_name, 'table' FROM all_tables WHERE owner = {} \
|
||||
UNION ALL SELECT view_name, 'view' FROM all_views WHERE owner = {} ORDER BY 1",
|
||||
@@ -142,6 +155,11 @@ pub fn tables(driver: DbDriver, schema: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// A Snowflake column type with its size, from `information_schema.columns`.
|
||||
const SNOWFLAKE_TYPE: &str = "CASE WHEN data_type = 'NUMBER' THEN 'NUMBER(' || numeric_precision || ',' || numeric_scale || ')' \
|
||||
WHEN data_type = 'TEXT' THEN 'VARCHAR(' || character_maximum_length || ')' \
|
||||
WHEN data_type LIKE 'TIME%' THEN data_type || '(' || datetime_precision || ')' ELSE data_type END";
|
||||
|
||||
/// Rows: `name, type, nullable (YES/NO or 0/1), default, is_pk (0/1)`.
|
||||
pub fn columns(driver: DbDriver, schema: &str, table: &str) -> String {
|
||||
let (s, t) = (quote_literal(driver, schema), quote_literal(driver, table));
|
||||
@@ -182,6 +200,10 @@ pub fn columns(driver: DbDriver, schema: &str, table: &str) -> String {
|
||||
FROM information_schema.columns c WHERE c.table_schema = {s} AND c.table_name = {t} \
|
||||
ORDER BY c.ordinal_position"
|
||||
),
|
||||
DbDriver::Snowflake => format!(
|
||||
"SELECT column_name, {SNOWFLAKE_TYPE}, is_nullable, column_default, 0 \
|
||||
FROM information_schema.columns WHERE table_schema = {s} AND table_name = {t} ORDER BY ordinal_position"
|
||||
),
|
||||
DbDriver::Oracle => format!(
|
||||
"SELECT c.column_name, c.data_type, DECODE(c.nullable, 'Y', 'YES', 'NO'), c.data_default, \
|
||||
CASE WHEN k.column_name IS NOT NULL THEN 1 ELSE 0 END \
|
||||
@@ -230,6 +252,7 @@ pub fn indexes(driver: DbDriver, schema: &str, table: &str) -> String {
|
||||
WHERE OBJECT_SCHEMA_NAME(i.object_id) = {s} AND OBJECT_NAME(i.object_id) = {t} AND i.name IS NOT NULL \
|
||||
ORDER BY i.name, ic.key_ordinal"
|
||||
),
|
||||
DbDriver::Snowflake => "SELECT '', 0, '' WHERE 1 = 0".to_owned(),
|
||||
DbDriver::Oracle => format!(
|
||||
"SELECT i.index_name, CASE WHEN i.uniqueness = 'UNIQUE' THEN 1 ELSE 0 END, ic.column_name \
|
||||
FROM all_indexes i JOIN all_ind_columns ic \
|
||||
@@ -296,6 +319,15 @@ pub fn completion_columns(driver: DbDriver) -> &'static str {
|
||||
WHERE c.table_schema NOT IN ('sys', 'information_schema') \
|
||||
ORDER BY c.table_schema, c.table_name, c.ordinal_position"
|
||||
}
|
||||
DbDriver::Snowflake => {
|
||||
"SELECT c.table_schema, c.table_name, CASE t.table_type WHEN 'VIEW' THEN 'view' \
|
||||
WHEN 'MATERIALIZED VIEW' THEN 'materialized view' ELSE 'table' END, c.column_name, c.data_type, \
|
||||
c.is_nullable, 0, CASE WHEN c.column_default IS NOT NULL THEN 1 ELSE 0 END \
|
||||
FROM information_schema.columns c JOIN information_schema.tables t \
|
||||
ON t.table_schema = c.table_schema AND t.table_name = c.table_name \
|
||||
WHERE c.table_schema <> 'INFORMATION_SCHEMA' \
|
||||
ORDER BY c.table_schema, c.table_name, c.ordinal_position LIMIT 50000"
|
||||
}
|
||||
DbDriver::Oracle => {
|
||||
"SELECT c.owner, c.table_name, CASE WHEN v.view_name IS NOT NULL THEN 'view' ELSE 'table' END, \
|
||||
c.column_name, c.data_type, DECODE(c.nullable, 'Y', 'YES', 'NO'), \
|
||||
@@ -320,6 +352,7 @@ pub fn completion_columns(driver: DbDriver) -> &'static str {
|
||||
pub fn foreign_keys(driver: DbDriver) -> &'static str {
|
||||
match driver {
|
||||
DbDriver::Clickhouse => "SELECT '', '', '', '', '', '', '' WHERE 0",
|
||||
DbDriver::Snowflake => "SELECT '', '', '', '', '', '', '' WHERE 1 = 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 \
|
||||
@@ -370,7 +403,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::Clickhouse | DbDriver::Snowflake => 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"
|
||||
),
|
||||
@@ -398,6 +431,7 @@ pub fn current_schema(driver: DbDriver) -> Option<&'static str> {
|
||||
DbDriver::Mysql => Some("SELECT DATABASE()"),
|
||||
DbDriver::SqlServer => Some("SELECT DB_NAME()"),
|
||||
DbDriver::Oracle => Some("SELECT SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA') FROM DUAL"),
|
||||
DbDriver::Snowflake => Some("SELECT CURRENT_SCHEMA()"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -410,6 +444,7 @@ pub fn use_schema(driver: DbDriver, schema: &str) -> Option<String> {
|
||||
DbDriver::Postgres => Some(format!("SET search_path TO {}", quote_ident(driver, schema))),
|
||||
DbDriver::Mysql | DbDriver::SqlServer => Some(format!("USE {}", quote_ident(driver, schema))),
|
||||
DbDriver::Oracle => Some(format!("ALTER SESSION SET CURRENT_SCHEMA = {}", quote_ident(driver, schema))),
|
||||
DbDriver::Snowflake => Some(format!("USE SCHEMA {}", quote_ident(driver, schema))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -520,6 +555,9 @@ pub fn edit_statement(driver: DbDriver, schema: &str, table: &str, edit: &DbRowE
|
||||
DbDriver::Mysql => format!("INSERT INTO {target} () VALUES ()"),
|
||||
// Oracle has no DEFAULT VALUES; without columns there is nothing to insert.
|
||||
DbDriver::Oracle => return Err("Oracle needs at least one column value to insert".into()),
|
||||
DbDriver::Snowflake => {
|
||||
return Err("Snowflake needs at least one column value to insert".into());
|
||||
}
|
||||
_ => format!("INSERT INTO {target} DEFAULT VALUES"),
|
||||
});
|
||||
}
|
||||
@@ -588,6 +626,7 @@ 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::Snowflake => "SELECT '', '', '' WHERE 1 = 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)
|
||||
@@ -656,6 +695,12 @@ pub fn routines(driver: DbDriver, schema: &str) -> Option<String> {
|
||||
"SELECT r.routine_name, lower(r.routine_type), '', r.data_type \
|
||||
FROM information_schema.routines r WHERE r.routine_schema = {s} ORDER BY 1"
|
||||
)),
|
||||
DbDriver::Snowflake => Some(format!(
|
||||
"SELECT function_name, 'function', argument_signature, data_type FROM information_schema.functions \
|
||||
WHERE function_schema = {s} UNION ALL \
|
||||
SELECT procedure_name, 'procedure', argument_signature, data_type FROM information_schema.procedures \
|
||||
WHERE procedure_schema = {s} ORDER BY 1, 3"
|
||||
)),
|
||||
DbDriver::Oracle => Some(format!(
|
||||
"SELECT object_name, CASE object_type WHEN 'PROCEDURE' THEN 'procedure' ELSE 'function' END, '', NULL \
|
||||
FROM all_objects WHERE owner = {s} AND object_type IN ('PROCEDURE', 'FUNCTION') \
|
||||
@@ -697,6 +742,10 @@ pub fn sequences(driver: DbDriver, schema: &str) -> Option<String> {
|
||||
"SELECT sequence_name, data_type FROM information_schema.sequences WHERE sequence_schema = {} ORDER BY 1",
|
||||
quote_literal(driver, schema)
|
||||
)),
|
||||
DbDriver::Snowflake => Some(format!(
|
||||
"SELECT sequence_name, data_type FROM information_schema.sequences WHERE sequence_schema = {} ORDER BY 1",
|
||||
quote_literal(driver, schema)
|
||||
)),
|
||||
// Every sequence is numeric; ALL_SEQUENCES has no data_type column before 18c.
|
||||
DbDriver::Oracle => Some(format!(
|
||||
"SELECT sequence_name, 'NUMBER' FROM all_sequences WHERE sequence_owner = {} ORDER BY 1",
|
||||
@@ -930,6 +979,17 @@ mod tests {
|
||||
assert!(edit_statement(DbDriver::Postgres, "s", "t", &DbRowEdit::Delete { key: vec![] }).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postgres_lists_the_schemas_the_user_may_use() {
|
||||
for system in [false, true] {
|
||||
let sql = schemas(DbDriver::Postgres, system);
|
||||
assert!(sql.contains("FROM pg_catalog.pg_namespace"), "{sql}");
|
||||
assert!(sql.contains("has_schema_privilege(nspname, 'USAGE')"), "{sql}");
|
||||
assert!(sql.contains("NOT LIKE 'pg\\_toast%'"), "{sql}");
|
||||
assert_eq!(sql.contains("NOT IN ('pg_catalog', 'information_schema')"), !system, "{sql}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlserver_quoting_and_schema() {
|
||||
assert_eq!(quote_ident(DbDriver::SqlServer, "a]b"), "[a]]b]");
|
||||
@@ -1086,4 +1146,30 @@ mod tests {
|
||||
"create table \"public\".\"t\"\n(\n \"id\" integer not null,\n \"name\" character varying(40) default 'x'::character varying,\n constraint \"t_pkey\" PRIMARY KEY (id)\n);\n\nCREATE INDEX t_name ON public.t USING btree (name);\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snowflake_quotes_names_and_reads_its_information_schema() {
|
||||
let driver = DbDriver::Snowflake;
|
||||
assert_eq!(quote_ident(driver, "My \"Table\""), "\"My \"\"Table\"\"\"");
|
||||
assert_eq!(quote_literal(driver, "it's a \\ path"), "'it''s a \\\\ path'");
|
||||
assert_eq!(qualified(driver, "PUBLIC", "ORDERS"), "\"PUBLIC\".\"ORDERS\"");
|
||||
assert_eq!(current_schema(driver), Some("SELECT CURRENT_SCHEMA()"));
|
||||
assert_eq!(use_schema(driver, "Sales"), Some("USE SCHEMA \"Sales\"".into()));
|
||||
assert!(schemas(driver, true).contains("information_schema.schemata"));
|
||||
assert!(schemas(driver, false).contains("<> 'INFORMATION_SCHEMA'"));
|
||||
assert!(tables(driver, "PUBLIC").contains("table_schema = 'PUBLIC'"));
|
||||
let columns = columns(driver, "PUBLIC", "ORDERS");
|
||||
assert!(columns.contains("'NUMBER(' || numeric_precision"), "{columns}");
|
||||
assert!(columns.contains("table_name = 'ORDERS' ORDER BY ordinal_position"), "{columns}");
|
||||
assert!(completion_columns(driver).contains("LIMIT 50000"));
|
||||
assert!(routines(driver, "PUBLIC").unwrap().contains("information_schema.procedures"));
|
||||
assert!(sequences(driver, "PUBLIC").unwrap().contains("information_schema.sequences"));
|
||||
assert_eq!(
|
||||
table_page(driver, "PUBLIC", "ORDERS", None, &[], 50, 25),
|
||||
"SELECT * FROM \"PUBLIC\".\"ORDERS\" LIMIT 26 OFFSET 50"
|
||||
);
|
||||
assert!(edit_statement(driver, "PUBLIC", "T", &DbRowEdit::Insert { values: vec![] }).is_err());
|
||||
assert_eq!(crate::conn::begin_statement(driver), "BEGIN");
|
||||
assert_eq!(crate::plan::explain_statement(driver, "select 1;"), "EXPLAIN USING TABULAR select 1");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
Throwaway keys for the Snowflake key-pair tests. They were generated for this repository,
|
||||
are registered with no account, and protect nothing.
|
||||
|
||||
| File | Format |
|
||||
|---|---|
|
||||
| `rsa_key.p8` | PKCS#8, unencrypted, RSA 2048 |
|
||||
| `rsa_key_encrypted.p8` | PKCS#8, PBES2 (PBKDF2 + AES-256-CBC), passphrase `test-passphrase` |
|
||||
| `rsa_key_scrypt.p8` | PKCS#8, PBES2 (scrypt + AES-256-CBC), passphrase `test-passphrase` |
|
||||
| `rsa_key_pkcs1.pem` | PKCS#1 (`BEGIN RSA PRIVATE KEY`), unencrypted |
|
||||
| `rsa_key_legacy_encrypted.pem` | PKCS#1 with `Proc-Type: 4,ENCRYPTED` (rejected) |
|
||||
| `rsa_key_1024.p8` | RSA 1024 (rejected: too short) |
|
||||
| `ec_key.p8` | EC P-256 (rejected: not RSA) |
|
||||
| `rsa_key.pub` | Public key of `rsa_key.p8` |
|
||||
|
||||
Fingerprint of `rsa_key.pub` from OpenSSL
|
||||
(`openssl rsa -pubin -in rsa_key.pub -outform DER | openssl dgst -sha256 -binary | openssl enc -base64`):
|
||||
`ajjL6Bf6l1FIIyTcJli4i2AWPYDVJGQnSZr+9q9ClgI=`
|
||||
|
||||
`commands.json` holds one sample statement for every command of Snowflake's SQL reference
|
||||
(<https://docs.snowflake.com/en/sql-reference/sql-all>, 786 names). The driver's tests
|
||||
(`src/snowflake/commands.rs`) and the console's (`src/features/database/sql/snowflakeCommands.test.ts`)
|
||||
run all of them; the ignored live test runs the listing ones against a real account. `reads`: allowed
|
||||
on a read-only data source. `rerunnable`: safe to run again for another page of the result. The
|
||||
samples exercise the client, not Snowflake's grammar, and name no real object.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,5 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgwYBQrdPGgoV9c58S
|
||||
q6Fc5aso2M4wVyXE9WxrpPzcrHahRANCAAR0SjYZ1Pvc5jG2ll+pTSdBiVRccnSB
|
||||
BirgCQL5f1g4CuEK4YNqTIvXMqJ2bBVx6f2MTbLeCmu8EebiJ99dI/ZM
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDPfjbU88uYwtpN
|
||||
hthRdv7h1cJJ1p2nnb8pKLMi/SdPSfSZLm0eA2Tbjp6HtGqv91enrCPyy++hQOl0
|
||||
KtfoPGbzDqxnzZPK4BtckmxHiBQifG7A0d3jpq8Y2JjIkkMItnF7kXQob89TOaFj
|
||||
+pW8/XH6Y+w5cbMYdkf3RXKjZpYRLRBxnzAl4lMhP/boE7reoWuBIwPFVXnX8AVP
|
||||
p0bjkpR2I1fgUKZxMw/mbRhWVy/9b7Vh79RwPG85rvCia2e7OdDLxNYUcEHocvfb
|
||||
8phUHtI7ZvnCJcbXJX7ON9/JdU/DN9RHc+az1ZjB4Za1cHoLXlbnUisSf1e3tnaY
|
||||
uZnGqGLPAgMBAAECggEAQ+8N4sEDSruZH8LjdiCgqK4E04oJygQyvzYNGIqL4UDU
|
||||
bYauwSHZVq0mKEYgnAY+U65fgrHUfhYzmQ4h7G5CQpfuSHodRlIDg1aJTw41kWju
|
||||
FgFgRZWgRGE9/IUun16kXxGrV0XsOlJnDcXQWrUcKR+oCtK4EW1AeKvrMaZ3wZ1J
|
||||
i0xqMlU5bk77kvskBRL4k51V3gOfDoflus2nHpES8yZSAP1uiivbtTOXYB2dRHuD
|
||||
omB4KlscUdIwT9vTMC7kxECEKDwd5fxJkdIJ/NsBi98tOZS0QrH7y7FrBA3K7gLY
|
||||
VJSIckKP8rz3kUAbW9vYV2dRwxIMQj2kZJEKa0kA6QKBgQDsNsJPJdt5BcAPYjV0
|
||||
1LX91ooLCDYux5yUNEno2rmQ/qv5g0d12L94aASklGIIfJV75dXmwa8q4erZU0hs
|
||||
9/XhyUnjDlBLd6oEztDjDA49J57g2nPmzrOtjugucH7Qe8EAJzz+O0PbtkcQzRmV
|
||||
8vnYptWHvEKmnjh8KOb5wOMVFQKBgQDg35SYkiys8V3tQApbp6U7Q3fq3vbVu+2A
|
||||
avBYIE0qgG9fhFrQdihVX1MZ3L4TtXtiikMPO8MOGBjSgN/qbelChhkQV0l+CIf0
|
||||
nOo1SeoEXp16q1NcZTEohnbaboMjWrkl6Br6SUNvEt7Y63UOi62E+ll50ye8wKgA
|
||||
yBHrGXOZUwKBgQDoTaIivJRvHNyZRHhWAatJjGgOGXFP6Ddw+UuGmTTOB+eRZy4s
|
||||
E5GaFJIcWWFf/0Xzp0EJw4lFnxCb0m/z2pgSCSeFCVqmHXUzbUG5SO3n7sMtVymM
|
||||
39OM6HIm2bOdzVctk2lEjXxa45RYTZzP/zj/fHObHeZeflQGpyMglDW/UQKBgQDE
|
||||
wRuFOzC3ySCDu6rUcyTRa8D60kdqz1mJKQhOM0URUPshue6zww6bVCKj2E6/c+UX
|
||||
XR47r/CE3nSpinVg6vZTbH11nMoC6nf6gWkzYw7M7/QeJtVzXF7ctHo9uMApKPIi
|
||||
xqkEISJNh1dQuQPczD3wwhOT0jtMnczhkiyLZeONXQKBgE4wo4IkUhH2SQRiu8Je
|
||||
sg18oa1sABaL6bCQ7vqJ2+cZzj5Pz0WxMoyc7ZJlIiKt2joGQwqmRAqVohHpJylm
|
||||
9RKvymaM0BGVKkAASfwyEf15YvZQx/9IK3uWpRoGrlu7jyu0UlzCgpHXfOu1FKCx
|
||||
DsVT35WOOVJXuDrNhpYAdg5U
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -0,0 +1,9 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAz3421PPLmMLaTYbYUXb+
|
||||
4dXCSdadp52/KSizIv0nT0n0mS5tHgNk246eh7Rqr/dXp6wj8svvoUDpdCrX6Dxm
|
||||
8w6sZ82TyuAbXJJsR4gUInxuwNHd46avGNiYyJJDCLZxe5F0KG/PUzmhY/qVvP1x
|
||||
+mPsOXGzGHZH90Vyo2aWES0QcZ8wJeJTIT/26BO63qFrgSMDxVV51/AFT6dG45KU
|
||||
diNX4FCmcTMP5m0YVlcv/W+1Ye/UcDxvOa7womtnuznQy8TWFHBB6HL32/KYVB7S
|
||||
O2b5wiXG1yV+zjffyXVPwzfUR3Pms9WYweGWtXB6C15W51IrEn9Xt7Z2mLmZxqhi
|
||||
zwIDAQAB
|
||||
-----END PUBLIC KEY-----
|
||||
@@ -0,0 +1,16 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBANqRvj6Xi55mKH0x
|
||||
96UVh/4tMgBb+5TVT5MN39diDr6iP7vFycEnnHDVXaeQJBog+P7NiYC7XY+7yUL3
|
||||
2J3VxMBzGirm5Sj12uU1C+U4f0Nxx4nVKqonQD8GHJGJxypSZvIculFV9CYSHfoc
|
||||
JYHR95k1Ce7echs7VnXfjvaOBdaLAgMBAAECgYBYecC6E5xn7VCchTqK7/FR2bew
|
||||
34syjzqb+3TN+hqf3ovQFdt6ve8c2bIEn1uN3CUO9+KeYroX8dshqbRdB+iRgx8i
|
||||
EzHAmKLkQj/QF7j1bTLD3zMq9XmUAOaUQwtlzRKSJIOa6EoYlGTcsefZhLhctegE
|
||||
dOfNbBM6En2el2O3MQJBAPX3BX2hkVU42nRYkmqoqz2DEFrcd7T3WpyxT8U42d4z
|
||||
rn8iJJAYwpg5SPf23PNrT+0IGGcI2LFWji9ENXM7H5kCQQDjfJahG/PvElaDGGlh
|
||||
JGjDn5FwR2bs8rII/gxhBXZJSyettkm0PIPnijlcQv3L6lRHi+7LXLFUbV8C6DWn
|
||||
sg3DAkAGnd2M8qK5Oy1NQfC2Y9Evd24iM5oYySqr2m097fiWbKd+DgoTwkbB15Cp
|
||||
8v8goTtmMfiOFQPXSW/Lv7b8YZPJAkEA0aC1jbEckGntntCYCxRqFhqtAzJI/oCG
|
||||
3xJ8hctqvQ+seL9KG/TH+E2/ne19wrMlBulEG9JG9u3yZ9Nj3iyN0QJAM6ROygXj
|
||||
2ReQpmst38WnAaQvT+0Nd8cWoliGz24Vx1cAmlHI4NrCQsODNuR+gJ4vCbfq8Sa/
|
||||
qJQfaDmu34JhJw==
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -0,0 +1,30 @@
|
||||
-----BEGIN ENCRYPTED PRIVATE KEY-----
|
||||
MIIFNTBfBgkqhkiG9w0BBQ0wUjAxBgkqhkiG9w0BBQwwJAQQrUgr104rqBLzsVP2
|
||||
13KRWQICCAAwDAYIKoZIhvcNAgkFADAdBglghkgBZQMEASoEEKZLvVKRzfkt0lie
|
||||
Hvjy+vYEggTQ05I/45BV/X9VcWZ0W4OC8JdtYC0nosK3ymyl4iQGo4sOSLkJGpzu
|
||||
0LhexFRDICNLqHkcs2XQ4Jti8lZyArY3Wql+vl9Z/FVl9s4zJIg8VDXqrPSJ6Ha+
|
||||
wvVhY59UreqlQeLoFdhCK8O9/QeJow/ZQnVshJCLLQ4jiwYUHpuTrRPInZp/Jt5X
|
||||
KD7N4oLsXg7NrBDyQu/DxRiPbtOu07NqMFHel4mRXCz0oKhG0pQ0KuqP0arA9RGN
|
||||
CpT2SuhA3xMl6DMqQhCsGyUVhJhD7FyH/2fxlWHH2WVGWbza3L6l293fpRP2d0Nh
|
||||
VET2Ia93brNpYsXT1BVAvnXG+yddbCSgTjIh/YU+ciKcaaRsZXSpB3ON2yysBXo1
|
||||
XRiXbqfzx22ah9p1sT3ed3QRZ5PPOAYLNqEvUz/eNDBBiwOAdSuS4JAYYS0VyJdG
|
||||
v93pWEf6fuFXdzJsOcmXy86t6JMfgV45zuiUFBD7+coNh8Yn1O+SMSTe3kKjgcaw
|
||||
j7xMVDlNy6okpDv5mIFTkzhLCPZPNhGTjYBs21edEmB7PyGf9l8d+Mv+ItT9Ualy
|
||||
5MUJ4ZzOPIT+Umv97DUB8CIvRSuLq34K1puvjoPa1oz6etUkMVxOEG40+h+b3JNo
|
||||
RiwONkO/0Mvew8uUywnwrdS08I8Y2NeFwQsYf90371xKqSqtwLKS9CLI225WSOXQ
|
||||
CTeEItkmJRa+RQApLZIvhJZQ0Hreb89W0wjsz4VStsS0laLekfh1UCZnvVZRNxCn
|
||||
FM0aTTLL5TJAFMiEYDzfrADgbgm56DCMW80ByBpHiqwmysXnAX20IhDcnP6A4iDJ
|
||||
ukW0qlojrAEh79Y7nCHC8y4CCsEwwUxuxXfSRrFVQeS6K2crGoalnJfXFMhHr8SW
|
||||
YBD9J4x6tVhKB++rmy3LkX7lHzVLlZbfd8lJouNxTlj9BWwzm3k96v9V6KO/vDLn
|
||||
Iqh2qENSNAWbRrFoCn1d6XPYt4L9kFRUrKClGVbgMyVpKNX0izVb2TElViRGiA2+
|
||||
Tq2gXNGuCNcz2Y5EURaJozjCb4udRazXTraZJ4YZgfwdrHzTz+neBSEsiXU7OGrY
|
||||
R+Q5ijkahA7I0BUUabPEVmxup7iOkJmagjPu8ve75c+VTqs3QHMFjEkYLazxB7a3
|
||||
N6LtM9fp9X5RtQQBM+ppGjMtQD6Jkp0/npUUCbfPNnH0ZXxiNh6GaYuTY58C9ob8
|
||||
zBBgfiBH2Olzrd/bZv695Ohxtd97eEqMosixKJLcYsvSqeJlmx8XmcNoWesVmooT
|
||||
gR4QB1x22UdcZa0ShQLSuDTDEGiU33uJF9VM1CAHWQNEHHfVMUpdCV/m5AkyPm/1
|
||||
rp4IRG2oJkpId4PHu8aGD+/4T8mnALbSzooButfj9m1u9Mo1156hi+F9Z2jN63aA
|
||||
Ehnzc0aRAJ5wbnq3RzaB7sfP0+/fh6e97gW+aNeLP5l6wYzDVqwLdIpt8o7s5rvq
|
||||
FFFqChnzTRUXAkRMEn7WqMU1LeAF9S+rMTcawuolADVd2ntNWMmA6bqrpgq2C/0w
|
||||
qk3npanYBmHg7KzJMg8jJNIPhRNV0PyMM9RYdwfDiuMW9Gw+pi3DgUOO027Vr7j1
|
||||
VNlZgdaA9uYT2qETf4/N1tyqKxsrimdUaG9dQe3w2R9SEMXw28doihc=
|
||||
-----END ENCRYPTED PRIVATE KEY-----
|
||||
@@ -0,0 +1,30 @@
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
Proc-Type: 4,ENCRYPTED
|
||||
DEK-Info: AES-256-CBC,A5A125F46CCED791DF3B43E3BFFC72EF
|
||||
|
||||
j/qWilDpPFE84GAHLgUM4m+yXGbgjePy/G9GB8jV3doCyDRJhjvqKfJmXmypj5IM
|
||||
tz2Ab059Gz396FK2N0OEtj4Us9TgPtvW/fTPNoLQp9mcIrRB7+s3TFoxNn9sUxuv
|
||||
fnEcYWmrrTElin7DbWn1RrilCkf3jiTINysFt2ypUwCqpDKebQ2WRXdUCjIevul4
|
||||
8hmNoS8YCbr++HkcRqnDTYPrNU1sV4Fl8cXs4BnEBlWULxlA6Y6xBsDur58X/UO3
|
||||
Up+WLZ8ptCWZMNYlOGNnS3TUzMn6sWWVJaShChvJBaqwTfNoFThzD8bBxf1PXN+7
|
||||
PkdkDcX5oHt+p0q1EtteFZ55VwWWMDdQTfG7dYAqTm+K4YCVaRh2xm4QTke5Kbol
|
||||
0B9C3/RaR9zLA4XEf2mq+5yc3PPBcKq+aWX7pGL+9kxMXz2HsiMWb9ir+FNxb2nc
|
||||
4mG5P/bmersRXhlmUl3bGXx3m5+4nnfBzO88S1MgZo4Z2L4TzJEaF7KjQENicDlA
|
||||
UVMaVvq9+xUPPtIT8o/VTc5x/drO0HayhMfkz8Yxka2DLsMnLhU9NiDO6nKREEjL
|
||||
GQvsan0/gIDweXzRFUY5AhPQHAOpjf/BnmTa80/DvW+WxA/B2EMMABbjxGcKK1aP
|
||||
FyJf0oVvNuC91RPNhCJ2jiGKlLxnWspcEG1hiFZJvQ4Z8u1TRItqIadE0Ar0z54A
|
||||
qJIr56SoU+xPyfXEt2GWwTPRu9W6l7EKo1yVKvqSdz1UGzrW3WuHI3G/UYvkqAs9
|
||||
fBQK47sYlrpLYS3qnFbYlrbR0RCHlNLVCs0V1Xb6U+qU3ExQRlo1YvkvH7dkvhqn
|
||||
Bdqv9iYnlryVP5SJcAeKZ7VqmM/LXq+bF7g/hTcHkg1yISqC1qaYDNi5D7uOVwW6
|
||||
ALiD5zFyLArnG7girpuT1hc8N+dkh1ZckOGf2nW3I27czY4Xsaz+9keXFvAy5F2K
|
||||
OGSu5iu/8p7M1bPRdiutcEVrT07FFvVZufASo4brMXptxpozd9o14uMhudneIqtb
|
||||
qgw+F4nl6LZkTmbdn2suXjG1jNxlv86bKsLuY/IoaOO70YTho2HS1zpQ7SS1Jj63
|
||||
5v4y6205m9xMJVZywbCwo7FQhxfBT3cmS2KkD9mYSZiFtYkRDLPxneNHKyegnuRy
|
||||
+rYelwbKCiFoUXg7QMOYfYDfC2UckCblcWlYgtSkL6TRnzl0aKBO8SQiph8LMvtB
|
||||
+/Om76T4OGlkk4PJsbLjt6uSEgcAwJBjG2ox0FpE75tLIO6ZBWdgn6TnBLFiIniw
|
||||
apomJZNc3bybpiuH+w9F1gOy/1c+nS6O82559Zn03ez6tX2SHqO7ROX06zLdcdVO
|
||||
Yg0PJiBaAPNgfKeSb/K4XX4L/bJi9lH6VjUBcQqDqHKSAonmZDLGrWxHyuHbBE1u
|
||||
wHXigKzIxR0nw8VUPIFDpdgnNlKe9eputx0+7rgrh6sW1rZL3EfuyX/62SLG8Y4L
|
||||
YYrIx1AcrkKct1yPUXgwUKYn1TItPhK3JSXxmCYbkEuVdZTKKtf/irqoauEIPjm2
|
||||
ufvhM0Y+PIGGOwAh7EvuDUUemeHd4Q2HnHwnROx5L1rSf2izC1GoxCtP8/UE2OTK
|
||||
-----END RSA PRIVATE KEY-----
|
||||
@@ -0,0 +1,27 @@
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
MIIEpAIBAAKCAQEAz3421PPLmMLaTYbYUXb+4dXCSdadp52/KSizIv0nT0n0mS5t
|
||||
HgNk246eh7Rqr/dXp6wj8svvoUDpdCrX6Dxm8w6sZ82TyuAbXJJsR4gUInxuwNHd
|
||||
46avGNiYyJJDCLZxe5F0KG/PUzmhY/qVvP1x+mPsOXGzGHZH90Vyo2aWES0QcZ8w
|
||||
JeJTIT/26BO63qFrgSMDxVV51/AFT6dG45KUdiNX4FCmcTMP5m0YVlcv/W+1Ye/U
|
||||
cDxvOa7womtnuznQy8TWFHBB6HL32/KYVB7SO2b5wiXG1yV+zjffyXVPwzfUR3Pm
|
||||
s9WYweGWtXB6C15W51IrEn9Xt7Z2mLmZxqhizwIDAQABAoIBAEPvDeLBA0q7mR/C
|
||||
43YgoKiuBNOKCcoEMr82DRiKi+FA1G2GrsEh2VatJihGIJwGPlOuX4Kx1H4WM5kO
|
||||
IexuQkKX7kh6HUZSA4NWiU8ONZFo7hYBYEWVoERhPfyFLp9epF8Rq1dF7DpSZw3F
|
||||
0Fq1HCkfqArSuBFtQHir6zGmd8GdSYtMajJVOW5O+5L7JAUS+JOdVd4Dnw6H5brN
|
||||
px6REvMmUgD9boor27Uzl2AdnUR7g6JgeCpbHFHSME/b0zAu5MRAhCg8HeX8SZHS
|
||||
CfzbAYvfLTmUtEKx+8uxawQNyu4C2FSUiHJCj/K895FAG1vb2FdnUcMSDEI9pGSR
|
||||
CmtJAOkCgYEA7DbCTyXbeQXAD2I1dNS1/daKCwg2LseclDRJ6Nq5kP6r+YNHddi/
|
||||
eGgEpJRiCHyVe+XV5sGvKuHq2VNIbPf14clJ4w5QS3eqBM7Q4wwOPSee4Npz5s6z
|
||||
rY7oLnB+0HvBACc8/jtD27ZHEM0ZlfL52KbVh7xCpp44fCjm+cDjFRUCgYEA4N+U
|
||||
mJIsrPFd7UAKW6elO0N36t721bvtgGrwWCBNKoBvX4Ra0HYoVV9TGdy+E7V7YopD
|
||||
DzvDDhgY0oDf6m3pQoYZEFdJfgiH9JzqNUnqBF6deqtTXGUxKIZ22m6DI1q5Jega
|
||||
+klDbxLe2Ot1DouthPpZedMnvMCoAMgR6xlzmVMCgYEA6E2iIryUbxzcmUR4VgGr
|
||||
SYxoDhlxT+g3cPlLhpk0zgfnkWcuLBORmhSSHFlhX/9F86dBCcOJRZ8Qm9Jv89qY
|
||||
EgknhQlaph11M21BuUjt5+7DLVcpjN/TjOhyJtmznc1XLZNpRI18WuOUWE2cz/84
|
||||
/3xzmx3mXn5UBqcjIJQ1v1ECgYEAxMEbhTswt8kgg7uq1HMk0WvA+tJHas9ZiSkI
|
||||
TjNFEVD7Ibnus8MOm1Qio9hOv3PlF10eO6/whN50qYp1YOr2U2x9dZzKAup3+oFp
|
||||
M2MOzO/0HibVc1xe3LR6PbjAKSjyIsapBCEiTYdXULkD3Mw98MITk9I7TJ3M4ZIs
|
||||
i2XjjV0CgYBOMKOCJFIR9kkEYrvCXrINfKGtbAAWi+mwkO76idvnGc4+T89FsTKM
|
||||
nO2SZSIirdo6BkMKpkQKlaIR6ScpZvUSr8pmjNARlSpAAEn8MhH9eWL2UMf/SCt7
|
||||
lqUaBq5bu48rtFJcwoKR13zrtRSgsQ7FU9+VjjlSV7g6zYaWAHYOVA==
|
||||
-----END RSA PRIVATE KEY-----
|
||||
@@ -0,0 +1,30 @@
|
||||
-----BEGIN ENCRYPTED PRIVATE KEY-----
|
||||
MIIFLTBXBgkqhkiG9w0BBQ0wSjApBgkrBgEEAdpHBAswHAQQRIkBIQM391D42515
|
||||
x8qX7AICQAACAQgCAQEwHQYJYIZIAWUDBAEqBBAV4ik0WbzhBWcA0q2nB8/oBIIE
|
||||
0KHh9w0Hi98G2eAdpBRCpAC2FryLKvfPcD6puChTFFwSIryfPt9dSrLJHB83c6/+
|
||||
uYfaQskgdoA1aCx97A+lMAXFemURUBnI59+2eqdkFdMPsWTkRQC0iL0XiRzuwUA7
|
||||
SbhSopssc21ZtiVvlxpDWaFupOqwu8mb79Lp17Rriirc/dy/e2ODStFzQyCvBJmh
|
||||
rSbWwFBsZc0zB4Ptp4Ekeah5DEbxKXG5Lw56DetDiLukXlVeVJg+Gw2ZXHlipj4s
|
||||
/wqbrnbX3VQCWuBJot3F3wYqhGZO7e52Ge+jiojfU1zT4NwMgIAjIv7jIxcxHINj
|
||||
c+Iuql1Vq3QSM+sU7MYDMxsfgsEEzYgZDtcBMqJTYy0Dq4MixkSgc8zYI+V2axl9
|
||||
RKkBQAOQQcgCN+hrPgV8JKmeNP8OBsIX3VmXgTBhRt6xpTgW2YtV0Xc4HbTY9YoF
|
||||
tm1NI6gtcOlBPewJWI6GD8JueqY88lmrG/Qf2g4x7cQz3TCTlwpB6DXHX/blRSVe
|
||||
kGJVu0CWTRy9yM49xnnZGOrK8UjL61Nd6on8vb9HC+wFsDFh2kJ1/8RvCWLyqPJY
|
||||
xM8PVmPOwKhigEF2QN/bt1eESm3D1OTeV05TnBDgRPcDwVm/ym80D7tS4Vwj7xgf
|
||||
gWzVQNoI1wtXMaJalmxSQ5qDdm78T/Mj3EJIDLoBtnkGJGQFkDxpZ4y5jQSHAAc6
|
||||
7kXnM7SAI4LUvME5LcwJZlHcUnZ7FHqCdJWh+FrMUpsL807Gcd7KMcsx++lQAAVR
|
||||
B/OUVykJHD4JnZtt3W5yyTA8E8nWbiDHro9n1w6n/fdSTj1AnwWncEx4o0CrSulf
|
||||
eZ/78eE2ba4cOyOnE1VuMmvqfH98xcXwk1mME59HWbxxuryUgqoJX40dVSay0tTt
|
||||
Ccfn0oi0WN+P8Qoq/7AueyeBbzN2dTEyDTj/C7dFFeerJT/gV3bfke3DL+J3E4zq
|
||||
+U5qrI0Z+FXw3cSF66pCY+uWruSllkwGXLjTYWSXJ7oXaANu56zAjhjGgdzZ3o9F
|
||||
Ub2JuvIuAZ+cYCJnSDPU1r4hUvMoJcfBu7YqkFQXztLU0kvdWR3fmOCapKFHaKXh
|
||||
y0KVUzi2tkTwj22+hYOTuHTLtleiFmtJERWeCkmn2eyMhSJsbxanlSRddVsMs5mJ
|
||||
vMpnXh1AlDbKHRqQIxOUhf01Dw1Q4VpQfMuxPcN4GQxaj2N0Kn6OmvxF42ze8OuA
|
||||
vO1orsV8VRDApFAVmhsfjet0hXo/EnWL+oJ5WadY5hfYX1cXe8BdjiDRyCCDv9ob
|
||||
lZUHadw2sbr1FkJ3TsxnX0OkOQ+07pDq03EjyScHyNyCForw8F0a867F23TBKunq
|
||||
q0XuwGG+EgkVEHvy/Ksqrfc60jbDyu3kAusmnLMQv07OHUiiqlBbtLQO31FQH7QE
|
||||
KIm6BxiZ8A2DxdsbyWO1LZ9+SHttUA3OAAKjFeOO89A7iK+8asRTjIRSt6NtDI6V
|
||||
rbbWFKrZY9ACfXWoFuCrfIjipPrc8q293rJ8mVxZf34rvEjt0WuV17VDEbozt8XZ
|
||||
4F2mnYeVF8LX0sdy+OBVfpTGpmn8Ubsxond4OQyzxI3MbT4WOey2BdznmdROEYbJ
|
||||
RmVzKCBEWVuHptyL6LrWbO3pu+Kdkh/1mK/E+UhFyrjk
|
||||
-----END ENCRYPTED PRIVATE KEY-----
|
||||
@@ -0,0 +1,365 @@
|
||||
//! Against a real Snowflake account, with key-pair authentication:
|
||||
//!
|
||||
//! ```sh
|
||||
//! OPENIDEA_TEST_SNOWFLAKE_ACCOUNT=myorg-myaccount OPENIDEA_TEST_SNOWFLAKE_USER=jane \
|
||||
//! OPENIDEA_TEST_SNOWFLAKE_KEY_FILE=~/.ssh/rsa_key.p8 \
|
||||
//! cargo test -p ide-db --test snowflake -- --ignored --nocapture
|
||||
//! ```
|
||||
//!
|
||||
//! Optional: `…_KEY_PASSPHRASE`, `…_WAREHOUSE`, `…_ROLE`, `…_DATABASE`, `…_SCHEMA`.
|
||||
//! The output holds no credential.
|
||||
|
||||
use ide_core::{CancellationToken, error::ErrorKind};
|
||||
use ide_db::{DataSource, DbDriver, DbOptions, DbSnowflake, DbSnowflakeAuth, Pool};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn setting(name: &str) -> Option<String> {
|
||||
std::env::var(format!("OPENIDEA_TEST_SNOWFLAKE_{name}")).ok().filter(|v| !v.trim().is_empty())
|
||||
}
|
||||
|
||||
fn source() -> DataSource {
|
||||
DataSource {
|
||||
id: "snowflake".into(),
|
||||
name: "snowflake".into(),
|
||||
driver: DbDriver::Snowflake,
|
||||
host: setting("ACCOUNT").expect("OPENIDEA_TEST_SNOWFLAKE_ACCOUNT"),
|
||||
user: setting("USER").expect("OPENIDEA_TEST_SNOWFLAKE_USER"),
|
||||
database: setting("DATABASE").unwrap_or_default(),
|
||||
options: DbOptions { default_schema: setting("SCHEMA").unwrap_or_default(), ..Default::default() },
|
||||
snowflake: Some(DbSnowflake {
|
||||
warehouse: setting("WAREHOUSE").unwrap_or_default(),
|
||||
role: setting("ROLE").unwrap_or_default(),
|
||||
auth: DbSnowflakeAuth::KeyPair,
|
||||
private_key_file: setting("KEY_FILE").expect("OPENIDEA_TEST_SNOWFLAKE_KEY_FILE"),
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires OPENIDEA_TEST_SNOWFLAKE_ACCOUNT, _USER and _KEY_FILE"]
|
||||
async fn key_pair_session_against_a_real_account() {
|
||||
let passphrase = setting("KEY_PASSPHRASE");
|
||||
let started = Instant::now();
|
||||
let pool = match Pool::connect(&source(), passphrase.as_deref(), None).await {
|
||||
Ok(pool) => pool,
|
||||
Err(error) => panic!("login failed ({:?}): {}", error.kind, error.message),
|
||||
};
|
||||
println!("[1] logged in with the key pair in {:?}", started.elapsed());
|
||||
let cancel = CancellationToken::new();
|
||||
let cell = |rows: &[Vec<Option<String>>], i: usize| rows[0][i].clone().unwrap_or_else(|| "<null>".into());
|
||||
|
||||
let version = pool.execute("SELECT 'Snowflake ' || CURRENT_VERSION()", 1, &cancel).await.unwrap();
|
||||
println!("[2] {}", cell(&version.rows, 0));
|
||||
|
||||
let context = pool
|
||||
.execute("SELECT CURRENT_WAREHOUSE(), CURRENT_DATABASE(), CURRENT_SCHEMA(), CURRENT_ROLE()", 1, &cancel)
|
||||
.await
|
||||
.unwrap();
|
||||
println!(
|
||||
"[3] context: warehouse={} database={} schema={} role={}",
|
||||
cell(&context.rows, 0),
|
||||
cell(&context.rows, 1),
|
||||
cell(&context.rows, 2),
|
||||
cell(&context.rows, 3)
|
||||
);
|
||||
for (wanted, i) in
|
||||
[(setting("WAREHOUSE"), 0), (setting("DATABASE"), 1), (setting("SCHEMA"), 2), (setting("ROLE"), 3)]
|
||||
{
|
||||
if let Some(wanted) = wanted {
|
||||
assert!(cell(&context.rows, i).eq_ignore_ascii_case(&wanted), "context {i} is not {wanted}");
|
||||
}
|
||||
}
|
||||
|
||||
let types = pool
|
||||
.execute(
|
||||
"SELECT 12345.67::NUMBER(10,2) AS n, 1.5::FLOAT AS f, 'text' AS t, TRUE AS b, \
|
||||
'2023-11-14'::DATE AS d, '01:02:03.5'::TIME(3) AS tm, \
|
||||
'2023-11-14 22:13:20.123'::TIMESTAMP_NTZ(3) AS ntz, \
|
||||
'2023-11-14 22:13:20 +02:00'::TIMESTAMP_TZ(0) AS tz, \
|
||||
'2023-11-14 22:13:20'::TIMESTAMP_LTZ(0) AS ltz, \
|
||||
PARSE_JSON('{\"a\":[1,2]}') AS v, TO_BINARY('DEADBEEF', 'HEX') AS bin, NULL AS nothing",
|
||||
1,
|
||||
&cancel,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
for (column, value) in types.columns.iter().zip(&types.rows[0]) {
|
||||
println!("[4] {:8} {:18} {}", column.name, column.type_name, value.as_deref().unwrap_or("<null>"));
|
||||
}
|
||||
let shown: Vec<Option<&str>> = types.rows[0].iter().map(|v| v.as_deref()).collect();
|
||||
assert_eq!(shown[0], Some("12345.67"));
|
||||
assert_eq!(shown[2], Some("text"));
|
||||
assert_eq!(shown[3], Some("true"));
|
||||
assert_eq!(shown[4], Some("2023-11-14"));
|
||||
assert_eq!(shown[5], Some("01:02:03.500"));
|
||||
assert_eq!(shown[6], Some("2023-11-14 22:13:20.123"));
|
||||
assert_eq!(shown[7], Some("2023-11-14 22:13:20 +02:00"));
|
||||
assert_eq!(shown[10], Some("0xDEADBEEF"));
|
||||
assert_eq!(shown[11], None);
|
||||
assert_eq!(types.columns[0].type_name, "NUMBER(10,2)");
|
||||
|
||||
pool.execute("SET openidea_probe = 41", 0, &cancel).await.unwrap();
|
||||
let kept = pool.execute("SELECT $openidea_probe + 1", 1, &cancel).await.unwrap();
|
||||
assert_eq!(cell(&kept.rows, 0), "42");
|
||||
println!("[5] session state is kept between statements");
|
||||
|
||||
let databases = pool.execute("SHOW TERSE DATABASES", 1000, &cancel).await.unwrap();
|
||||
println!(
|
||||
"[6] SHOW TERSE DATABASES: {} databases, columns {:?}",
|
||||
databases.rows.len(),
|
||||
databases.columns.iter().map(|c| c.name.as_str()).collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
let missing = pool.execute("SELECT * FROM openidea_no_such_table_x", 1, &cancel).await.unwrap_err();
|
||||
println!("[7] error for a missing table ({:?}): {}", missing.kind, missing.message.replace('\n', " | "));
|
||||
assert_eq!(missing.kind, ErrorKind::InvalidInput);
|
||||
|
||||
let limited = pool.execute("SELECT SEQ4() FROM TABLE(GENERATOR(ROWCOUNT => 100))", 2, &cancel).await;
|
||||
match limited {
|
||||
Ok(limited) => {
|
||||
assert_eq!(limited.rows.len(), 2);
|
||||
assert!(limited.has_more);
|
||||
println!("[8] a row limit keeps 2 of 100 rows and reports more");
|
||||
let page = pool
|
||||
.execute_from(
|
||||
"SELECT SEQ4() AS n, REPEAT('x', 200) FROM TABLE(GENERATOR(ROWCOUNT => 300000)) ORDER BY n",
|
||||
250_000,
|
||||
3,
|
||||
&cancel,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
page.rows.iter().map(|r| r[0].clone().unwrap()).collect::<Vec<_>>(),
|
||||
["250000", "250001", "250002"]
|
||||
);
|
||||
println!(
|
||||
"[9] a page deep in a large result: {:?} total, {:?} to first answer",
|
||||
page.elapsed_ms, page.execution_ms
|
||||
);
|
||||
|
||||
let stop = CancellationToken::new();
|
||||
let waited = Instant::now();
|
||||
let statement = pool.execute("CALL SYSTEM$WAIT(120)", 1, &stop);
|
||||
let cancelling = async {
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
stop.cancel();
|
||||
};
|
||||
let (outcome, ()) = tokio::join!(statement, cancelling);
|
||||
let error = outcome.unwrap_err();
|
||||
assert_eq!(error.kind, ErrorKind::Cancelled, "{}", error.message);
|
||||
assert!(waited.elapsed() < Duration::from_secs(30));
|
||||
println!("[10] cancelled a 120 s statement after {:?}: {}", waited.elapsed(), error.message);
|
||||
let after = pool.execute("SELECT 1", 1, &cancel).await.unwrap();
|
||||
assert_eq!(cell(&after.rows, 0), "1");
|
||||
println!("[11] the session works after the cancellation");
|
||||
}
|
||||
Err(error) => {
|
||||
println!("[8] skipped warehouse checks ({:?}): {}", error.kind, error.message.replace('\n', " | "))
|
||||
}
|
||||
}
|
||||
|
||||
pool.close().await;
|
||||
assert!(pool.is_closed());
|
||||
println!("[12] session closed");
|
||||
}
|
||||
|
||||
/// The Database tree's metadata calls. Prints counts and column shapes only, never object names.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires OPENIDEA_TEST_SNOWFLAKE_ACCOUNT, _USER and _KEY_FILE"]
|
||||
async fn explorer_metadata_against_a_real_account() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let dir = root.path().to_path_buf();
|
||||
let ssh = std::sync::Arc::new(ide_ssh::SshManager::new(dir.join("ssh-passwords.json")));
|
||||
let db = ide_db::DbManager::new(dir.clone(), dir.join("passwords.json"), dir.join("history.json"), ssh);
|
||||
db.save_sources(vec![source()]).await.unwrap();
|
||||
db.set_password("snowflake", setting("KEY_PASSPHRASE"), false).await.unwrap();
|
||||
|
||||
let databases = db.databases("snowflake").await.unwrap();
|
||||
println!("[m1] databases: {} (current set: {})", databases.names.len(), !databases.current.is_empty());
|
||||
assert!(!databases.names.is_empty());
|
||||
let database = setting("DATABASE")
|
||||
.map(|d| d.to_uppercase())
|
||||
.filter(|d| databases.names.contains(d))
|
||||
.unwrap_or_else(|| databases.names[0].clone());
|
||||
let target = format!("snowflake:{database}");
|
||||
|
||||
let schemas = db.schemas(&target).await.unwrap();
|
||||
println!("[m2] schemas in the chosen database: {}", schemas.len());
|
||||
assert!(!schemas.iter().any(|s| s == "INFORMATION_SCHEMA"));
|
||||
|
||||
// The first schema that has tables, so the deeper calls have something to read.
|
||||
let mut found = None;
|
||||
for schema in schemas.iter().take(25) {
|
||||
let tables = db.tables(&target, schema).await.unwrap();
|
||||
if !tables.is_empty() {
|
||||
found = Some((schema.clone(), tables));
|
||||
break;
|
||||
}
|
||||
}
|
||||
match found {
|
||||
None => println!("[m3] no tables in the first 25 schemas: table checks skipped"),
|
||||
Some((schema, tables)) => {
|
||||
let views = tables.iter().filter(|t| t.kind != ide_db::DbTableKind::Table).count();
|
||||
println!("[m3] tables and views in one schema: {} ({} views)", tables.len(), views);
|
||||
let details = db.table_details(&target, &schema, &tables[0].name).await.unwrap();
|
||||
let typed = details.columns.iter().filter(|c| !c.data_type.is_empty()).count();
|
||||
println!(
|
||||
"[m4] first table: {} columns ({} typed, {} primary key, {} nullable), {} foreign keys; first type: {}",
|
||||
details.columns.len(),
|
||||
typed,
|
||||
details.columns.iter().filter(|c| c.primary_key).count(),
|
||||
details.columns.iter().filter(|c| c.nullable).count(),
|
||||
details.foreign_keys.len(),
|
||||
details.columns.first().map_or("", |c| c.data_type.as_str())
|
||||
);
|
||||
assert!(!details.columns.is_empty());
|
||||
assert_eq!(typed, details.columns.len());
|
||||
let objects = db.schema_objects(&target, &schema).await.unwrap();
|
||||
let mut kinds = std::collections::BTreeMap::new();
|
||||
for other in &objects.others {
|
||||
*kinds.entry(other.kind.as_str()).or_insert(0) += 1;
|
||||
}
|
||||
println!(
|
||||
"[m5] schema objects: {} routines, {} sequences, others {:?}",
|
||||
objects.routines.len(),
|
||||
objects.sequences.len(),
|
||||
kinds
|
||||
);
|
||||
let ddl = db.ddl(&target, &schema, &tables[0].name).await.unwrap();
|
||||
println!(
|
||||
"[m6] DDL of the first table: {} characters, starts with {:?}",
|
||||
ddl.len(),
|
||||
ddl.split_whitespace().next()
|
||||
);
|
||||
assert!(ddl.to_lowercase().starts_with("create"));
|
||||
let page =
|
||||
db.table_page(&target, &schema, &tables[0].name, None, &[], 0, 5, &CancellationToken::new()).await;
|
||||
match page {
|
||||
Ok(page) => println!(
|
||||
"[m7] table data page: {} rows, {} columns",
|
||||
page.result.rows.len(),
|
||||
page.result.columns.len()
|
||||
),
|
||||
Err(error) => {
|
||||
println!("[m7] table data page failed ({:?}): {}", error.kind, error.message.replace('\n', " | "))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let loading = Instant::now();
|
||||
let completion = db.completion(&target).await.unwrap();
|
||||
println!("[m8] completion model loaded in {:?}", loading.elapsed());
|
||||
println!(
|
||||
"[m8] completion model: {} tables ({} with columns), {} columns, default schema set: {}",
|
||||
completion.tables.len(),
|
||||
completion.tables.iter().filter(|t| !t.columns.is_empty()).count(),
|
||||
completion.tables.iter().map(|t| t.columns.len()).sum::<usize>(),
|
||||
!completion.default_schema.is_empty()
|
||||
);
|
||||
|
||||
let session = db.session_context(&target).await.unwrap();
|
||||
println!(
|
||||
"[m9] session: warehouse set: {}, role set: {}, {} warehouses, {} roles to pick from",
|
||||
!session.warehouse.is_empty(),
|
||||
!session.role.is_empty(),
|
||||
session.warehouses.len(),
|
||||
session.roles.len()
|
||||
);
|
||||
assert!(!session.role.is_empty());
|
||||
|
||||
let plan = db.explain(&target, "SELECT 1 AS n", &CancellationToken::new(), None).await;
|
||||
match plan {
|
||||
Ok(plan) => println!(
|
||||
"[m10] explain plan: {} operations, first {:?}",
|
||||
plan.len(),
|
||||
plan.first().map(|n| n.operation.clone())
|
||||
),
|
||||
Err(error) => println!("[m10] explain failed ({:?}): {}", error.kind, error.message.replace('\n', " | ")),
|
||||
}
|
||||
// Introspection as the app runs it: every database through the data source itself.
|
||||
let listed = db.databases("snowflake").await.unwrap();
|
||||
let (mut read, mut failed, mut schemas, mut tables) = (0, 0, 0, 0);
|
||||
let mut first_failure = None;
|
||||
for database in &listed.names {
|
||||
match db.database_catalog("snowflake", database).await {
|
||||
Ok(catalog) => {
|
||||
read += 1;
|
||||
schemas += catalog.len();
|
||||
tables += catalog.iter().map(|s| s.tables.len()).sum::<usize>();
|
||||
}
|
||||
Err(error) => {
|
||||
failed += 1;
|
||||
// Names stay out of the output: quoted parts of the message are masked.
|
||||
let mut masked = String::new();
|
||||
let mut quote = None;
|
||||
for c in error.message.replace('\n', " | ").chars() {
|
||||
match quote {
|
||||
Some(q) if c == q => quote = None,
|
||||
Some(_) => continue,
|
||||
None if c == '\'' || c == '"' => quote = Some(c),
|
||||
None => {}
|
||||
}
|
||||
masked.push(c);
|
||||
}
|
||||
first_failure.get_or_insert(format!("{:?}: {masked}", error.kind));
|
||||
}
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"[m11] introspection: {} databases listed, {read} read, {failed} failed, {schemas} schemas, {tables} tables and views",
|
||||
listed.names.len()
|
||||
);
|
||||
if let Some(failure) = first_failure {
|
||||
println!("[m11] first failure: {failure}");
|
||||
}
|
||||
assert!(read > 0, "no database could be introspected");
|
||||
// The reference's commands that only list things and name no object, against the real server:
|
||||
// each is either answered or refused by Snowflake (an edition or privilege it needs), never
|
||||
// misread by the client. `USE` goes to what the session already has.
|
||||
let fixture: serde_json::Value =
|
||||
serde_json::from_str(include_str!("fixtures/snowflake/commands.json")).expect("the fixture parses");
|
||||
let mut statements: Vec<(String, String)> = fixture["commands"]
|
||||
.as_array()
|
||||
.expect("commands")
|
||||
.iter()
|
||||
.map(|c| {
|
||||
(c["command"].as_str().unwrap_or_default().to_owned(), c["sql"].as_str().unwrap_or_default().to_owned())
|
||||
})
|
||||
.filter(|(_, sql)| sql.starts_with("SHOW ") && !sql.to_lowercase().contains("my_") && !sql.contains("analyst"))
|
||||
.collect();
|
||||
let quoted = |name: &str| format!("\"{}\"", name.replace('"', "\"\""));
|
||||
statements.push(("USE ROLE".into(), format!("USE ROLE {}", quoted(&session.role))));
|
||||
if !session.warehouse.is_empty() {
|
||||
statements.push(("USE WAREHOUSE".into(), format!("USE WAREHOUSE {}", quoted(&session.warehouse))));
|
||||
}
|
||||
statements.push(("USE DATABASE".into(), format!("USE DATABASE {}", quoted(&database))));
|
||||
statements.push(("USE SECONDARY ROLES".into(), "USE SECONDARY ROLES ALL".into()));
|
||||
let (mut answered, mut refused, mut broken) = (0, Vec::new(), Vec::new());
|
||||
for (command, sql) in &statements {
|
||||
match db.execute(&target, sql, 1, &CancellationToken::new()).await {
|
||||
Ok(_) => answered += 1,
|
||||
Err(error) if error.kind == ErrorKind::InvalidInput => {
|
||||
// Snowflake's own refusal: the command and the error code only.
|
||||
let code = error.message.split(['[', ']']).nth(1).unwrap_or("?").to_owned();
|
||||
refused.push(format!("{command} [{code}]"));
|
||||
}
|
||||
Err(error) => broken.push(format!("{command} ({:?})", error.kind)),
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"[m12] commands: {} run, {answered} answered, {} refused by Snowflake, {} failed in the client",
|
||||
statements.len(),
|
||||
refused.len(),
|
||||
broken.len()
|
||||
);
|
||||
if !refused.is_empty() {
|
||||
println!("[m12] refused: {}", refused.join(", "));
|
||||
}
|
||||
assert!(broken.is_empty(), "the client failed on: {}", broken.join(", "));
|
||||
assert!(answered > 0);
|
||||
db.disconnect_source("snowflake").await;
|
||||
println!("[m13] disconnected");
|
||||
}
|
||||
@@ -71,6 +71,22 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
let id = c.arg::<String>("id")?;
|
||||
databases(&c.host, &c.window, &id).await
|
||||
});
|
||||
d.add("db_database_catalog", |c| async move {
|
||||
let (id, database) = (c.arg::<String>("id")?, c.arg::<String>("database")?);
|
||||
database_catalog(&c.host, &c.window, &id, &database).await
|
||||
});
|
||||
d.add("db_saved_catalog", |c| async move {
|
||||
let id = c.arg::<String>("id")?;
|
||||
saved_catalog(&c.host, &c.window, &id).await
|
||||
});
|
||||
d.add("db_retain_catalog", |c| async move {
|
||||
let (id, databases) = (c.arg::<String>("id")?, c.arg::<Vec<String>>("databases")?);
|
||||
retain_catalog(&c.host, &c.window, &id, databases).await
|
||||
});
|
||||
d.add("db_session_context", |c| async move {
|
||||
let id = c.arg::<String>("id")?;
|
||||
session_context(&c.host, &c.window, &id).await
|
||||
});
|
||||
d.add("db_schemas", |c| async move {
|
||||
let id = c.arg::<String>("id")?;
|
||||
schemas(&c.host, &c.window, &id).await
|
||||
@@ -246,6 +262,35 @@ pub async fn databases(host: &Host, window: &str, id: &str) -> IdeResult<Databas
|
||||
db.databases(id).await
|
||||
}
|
||||
|
||||
/// The schemas of `database` of Snowflake data source `id` with their tables and views.
|
||||
pub async fn database_catalog(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
id: &str,
|
||||
database: &str,
|
||||
) -> IdeResult<Vec<ide_db::CatalogSchema>> {
|
||||
let db = host.project(window)?.db.clone();
|
||||
db.database_catalog(id, database).await
|
||||
}
|
||||
|
||||
/// What earlier introspection of data source `id` saved; the data source is not connected to.
|
||||
pub async fn saved_catalog(host: &Host, window: &str, id: &str) -> IdeResult<Option<ide_db::SourceCatalog>> {
|
||||
let db = host.project(window)?.db.clone();
|
||||
db.saved_catalog(id).await
|
||||
}
|
||||
|
||||
/// Drops the saved databases of data source `id` that are not in `databases` any more.
|
||||
pub async fn retain_catalog(host: &Host, window: &str, id: &str, databases: Vec<String>) -> IdeResult<()> {
|
||||
let db = host.project(window)?.db.clone();
|
||||
db.retain_catalog(id, databases).await
|
||||
}
|
||||
|
||||
/// The warehouse and role of the Snowflake session of data source `id`, and what it may switch to.
|
||||
pub async fn session_context(host: &Host, window: &str, id: &str) -> IdeResult<ide_db::SessionContext> {
|
||||
let db = host.project(window)?.db.clone();
|
||||
db.session_context(id).await
|
||||
}
|
||||
|
||||
/// The schemas of data source `id`.
|
||||
pub async fn schemas(host: &Host, window: &str, id: &str) -> IdeResult<Vec<String>> {
|
||||
let db = host.project(window)?.db.clone();
|
||||
|
||||
@@ -48,6 +48,91 @@ pub fn spawn(
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// Tools › Start SSH Session: a login shell on SSH configuration `id`, opened by the
|
||||
/// built-in client with the configuration's saved password, key or agent.
|
||||
pub async fn spawn_ssh(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
id: &str,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
on_output: Emit<String>,
|
||||
) -> IdeResult<TerminalInfo> {
|
||||
let config = host.ssh.config(id)?;
|
||||
if config.host.trim().is_empty() {
|
||||
return Err(ide_core::IdeError::invalid(format!("SSH configuration {} has no host", config.name)));
|
||||
}
|
||||
let size = (if cols == 0 { 80 } else { cols }, if rows == 0 { 24 } else { rows });
|
||||
let session = host.ssh.session(id).await?;
|
||||
let (feed, output) = ide_terminal::feed();
|
||||
let out = feed.clone();
|
||||
let process = session
|
||||
.spawn(
|
||||
"",
|
||||
Some(size),
|
||||
Box::new(move |chunk| match chunk {
|
||||
ide_ssh::Output::Stdout(data) | ide_ssh::Output::Stderr(data) => out.output(data),
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
let exit = process.exit_status();
|
||||
let (input, mut inputs) = tokio::sync::mpsc::unbounded_channel();
|
||||
host.runtime.spawn(async move {
|
||||
let p = process;
|
||||
while let Some(op) = inputs.recv().await {
|
||||
let result = match op {
|
||||
SshInput::Data(data) => p.write(&data).await,
|
||||
SshInput::Resize(cols, rows) => p.resize(cols, rows).await,
|
||||
SshInput::Close => {
|
||||
p.close().await;
|
||||
break;
|
||||
}
|
||||
};
|
||||
if let Err(e) = result {
|
||||
tracing::debug!("ssh terminal: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
host.runtime.spawn(async move {
|
||||
let code = exit.await;
|
||||
feed.exited(code.and_then(|c| i32::try_from(c).ok()));
|
||||
});
|
||||
let title = if config.name.trim().is_empty() { config.label() } else { config.name.clone() };
|
||||
host.terminals.attach(
|
||||
title,
|
||||
size,
|
||||
Box::new(SshTerminal(input)),
|
||||
output,
|
||||
host.owner_window(window).to_owned(),
|
||||
host.window_sink(window),
|
||||
Box::new(move |chunk| on_output(chunk)),
|
||||
)
|
||||
}
|
||||
|
||||
enum SshInput {
|
||||
Data(Vec<u8>),
|
||||
Resize(u16, u16),
|
||||
Close,
|
||||
}
|
||||
|
||||
/// Input of an SSH shell, sent in order by one task.
|
||||
struct SshTerminal(tokio::sync::mpsc::UnboundedSender<SshInput>);
|
||||
|
||||
impl ide_terminal::TerminalChannel for SshTerminal {
|
||||
fn write(&self, data: &[u8]) -> IdeResult<()> {
|
||||
self.0.send(SshInput::Data(data.to_vec())).map_err(|_| ide_core::IdeError::invalid("the SSH session has ended"))
|
||||
}
|
||||
|
||||
fn resize(&self, cols: u16, rows: u16) -> IdeResult<()> {
|
||||
let _ = self.0.send(SshInput::Resize(cols, rows));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn close(&self) {
|
||||
let _ = self.0.send(SshInput::Close);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write(host: &Host, id: TerminalId, data: &str) -> IdeResult<()> {
|
||||
host.terminals.write(id, data)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
use ide_core::remote_settings::{SshAuth, SshConfiguration};
|
||||
use ide_core::{IdeEvent, SharedSink};
|
||||
use ide_host::{Host, HostConfig};
|
||||
use ide_ssh::{SshFiles, SshManager};
|
||||
use std::sync::{Arc, Mutex, mpsc};
|
||||
use std::time::Duration;
|
||||
|
||||
fn read_until(rx: &mpsc::Receiver<String>, needle: &str) -> String {
|
||||
let mut all = String::new();
|
||||
while !all.contains(needle) {
|
||||
match rx.recv_timeout(Duration::from_secs(10)) {
|
||||
Ok(s) => all.push_str(&s),
|
||||
Err(_) => panic!("timed out waiting for {needle:?}; got {all:?}"),
|
||||
}
|
||||
}
|
||||
all
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn ssh_session_opens_a_shell_with_the_saved_password() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let port =
|
||||
ide_ssh::testing::sshd(tmp.path().join("server"), Arc::new(|cmd| panic!("unexpected exec: {cmd}"))).await;
|
||||
let ssh = Arc::new(SshManager::with_files(
|
||||
tmp.path().join("secrets.json"),
|
||||
SshFiles { known_hosts: tmp.path().join("known_hosts"), config: None },
|
||||
));
|
||||
ssh.set_configs(vec![SshConfiguration {
|
||||
id: "box".into(),
|
||||
name: "Build box".into(),
|
||||
host: "127.0.0.1".into(),
|
||||
port: Some(port),
|
||||
user: "dev".into(),
|
||||
auth: SshAuth::Password,
|
||||
save_secret: true,
|
||||
..Default::default()
|
||||
}]);
|
||||
ssh.set_secret("box", Some("secret".into()), true);
|
||||
assert!(ssh.session("box").await.is_err(), "unknown host key first");
|
||||
ssh.trust_host("box").await.unwrap();
|
||||
|
||||
let (events_tx, events) = mpsc::channel();
|
||||
let events_tx = Mutex::new(events_tx);
|
||||
let sink: SharedSink = Arc::new(move |e: IdeEvent| {
|
||||
let _ = events_tx.lock().unwrap().send(e);
|
||||
});
|
||||
let base = tmp.path().join("app");
|
||||
let mut host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(move |_| sink.clone()),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}));
|
||||
Arc::get_mut(&mut host).unwrap().ssh = ssh;
|
||||
|
||||
let (tx, output) = mpsc::channel();
|
||||
let tx = Mutex::new(tx);
|
||||
let info = ide_host::ops::term::spawn_ssh(
|
||||
&host,
|
||||
"main",
|
||||
"box",
|
||||
100,
|
||||
30,
|
||||
Arc::new(move |s| {
|
||||
let _ = tx.lock().unwrap().send(s);
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!((info.title.as_str(), info.pid), ("Build box", None));
|
||||
assert!(read_until(&output, "$ ").contains("pty 100x30"));
|
||||
host.terminals.write(info.id, "hello ").unwrap();
|
||||
read_until(&output, "hello ");
|
||||
host.terminals.resize(info.id, 120, 40).unwrap();
|
||||
read_until(&output, "resized 120x40");
|
||||
host.terminals.write(info.id, "exit\r").unwrap();
|
||||
let code = loop {
|
||||
match events.recv_timeout(Duration::from_secs(10)).expect("exit event") {
|
||||
IdeEvent::TerminalExited { terminal, code } if terminal == info.id => break code,
|
||||
_ => {}
|
||||
}
|
||||
};
|
||||
assert_eq!(code, Some(0));
|
||||
assert!(!host.terminals.contains(info.id));
|
||||
|
||||
// Closing the tab hangs up a running shell.
|
||||
let info = ide_host::ops::term::spawn_ssh(&host, "main", "box", 80, 24, Arc::new(|_| {})).await.unwrap();
|
||||
host.terminals.kill(info.id).unwrap();
|
||||
let code = loop {
|
||||
match events.recv_timeout(Duration::from_secs(10)).expect("exit event") {
|
||||
IdeEvent::TerminalExited { terminal, code } if terminal == info.id => break code,
|
||||
_ => {}
|
||||
}
|
||||
};
|
||||
assert_eq!(code, None);
|
||||
host.shutdown().await;
|
||||
}
|
||||
@@ -24,8 +24,9 @@ pub use registry::{FileTypes, LanguageRegistry};
|
||||
pub use syntax::{
|
||||
CSS_CLASS, CSS_ID, CSS_VARIABLE, ConfigurationKey, ExternalFold, ExternalSymbol, ExternalSyntax, ExternalToken,
|
||||
FileReference, Literal, LiteralKind, MAX_CACHED_TREES, MAX_PARSE_BYTES, ParsedTree, SpellingKind, SpellingRegion,
|
||||
Syntax, SyntaxCache, SyntaxCacheStats, SyntaxHighlightKind, SyntaxHighlights, TreeError, WebReference,
|
||||
WebReferenceKind, merge_visibility, set_external_syntax, shift_pos, shift_range,
|
||||
Syntax, SyntaxCache, SyntaxCacheStats, SyntaxHighlightKind, SyntaxHighlights, TreeError, VueComponentImport,
|
||||
VueRefAccess, VueRefElement, WebReference, WebReferenceKind, merge_visibility, set_external_syntax, shift_pos,
|
||||
shift_range,
|
||||
};
|
||||
|
||||
/// Parsed form of `languages/<id>.toml`. Backend-only; the UI gets [`LanguageInfo`].
|
||||
|
||||
@@ -12,6 +12,7 @@ mod outline;
|
||||
mod rst;
|
||||
mod selection;
|
||||
mod sfc;
|
||||
mod vue;
|
||||
mod web;
|
||||
|
||||
use crate::{FoldRange, LanguageConfig, LanguageRegistry, ScriptRegion, SymbolTag};
|
||||
@@ -28,6 +29,7 @@ use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::OnceLock;
|
||||
use tree_sitter::{Language, Parser, Tree};
|
||||
pub use vue::{VueComponentImport, VueRefAccess, VueRefElement};
|
||||
pub use web::{CSS_CLASS, CSS_ID, CSS_VARIABLE, FileReference, WebReference, WebReferenceKind};
|
||||
|
||||
/// Texts larger than this are not parsed (minified bundles, generated dumps).
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
//! Vue Options API `this.$refs`: Vue 2.7 types `$refs` as an index signature, so the
|
||||
//! TypeScript service resolves `this.$refs.child` to that signature and `.child.focusIt`
|
||||
//! to nothing. The template names each ref (`ref="child"`) and the element it is on,
|
||||
//! which is all navigation needs.
|
||||
|
||||
use super::lines::{LineIndex, byte_at};
|
||||
use super::web::walk;
|
||||
use super::{Syntax, parse};
|
||||
use crate::ScriptRegion;
|
||||
use ide_core::{LanguageId, Pos, Range};
|
||||
use tree_sitter::Node;
|
||||
|
||||
/// The `$refs.<name>` (or `$refs.<name>.<member>`) expression a position is on.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct VueRefAccess {
|
||||
pub name: String,
|
||||
/// The member read from the referenced element, when the position is on it.
|
||||
pub member: Option<String>,
|
||||
}
|
||||
|
||||
/// The template element that carries `ref="<name>"`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct VueRefElement {
|
||||
/// The attribute value: the ref's declaration.
|
||||
pub value: Range,
|
||||
pub tag: String,
|
||||
}
|
||||
|
||||
/// A default import in a component's `<script>`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct VueComponentImport {
|
||||
pub specifier: String,
|
||||
/// The imported name, where the language service resolves the module.
|
||||
pub binding: Range,
|
||||
}
|
||||
|
||||
/// Options whose members are on the component instance.
|
||||
const INSTANCE_OPTIONS: [&str; 5] = ["methods", "computed", "props", "data", "setup"];
|
||||
|
||||
impl Syntax {
|
||||
/// The `$refs` access at `pos` in a Vue SFC's `<script>`.
|
||||
pub fn vue_ref_access(&self, text: &str, pos: Pos) -> Option<VueRefAccess> {
|
||||
let at = byte_at(text, pos)?;
|
||||
let region = self.script_regions(&LanguageId::new("vue"), text).into_iter().find(|r| contains(r, at))?;
|
||||
let g = self.grammar(®ion.language)?;
|
||||
let tree = parse(&g.language, ®ion.text, None)?;
|
||||
let rel = at - region.base;
|
||||
let root = tree.root_node();
|
||||
let node = [rel, rel.saturating_sub(1)]
|
||||
.into_iter()
|
||||
.filter_map(|b| root.descendant_for_byte_range(b, b))
|
||||
.find(|n| n.kind() == "property_identifier")?;
|
||||
let src = region.text.as_str();
|
||||
let read = node.parent().filter(|p| p.kind() == "member_expression")?;
|
||||
let object = read.child_by_field_name("object")?;
|
||||
if is_refs(object, src) {
|
||||
return Some(VueRefAccess { name: text_of(node, src).to_owned(), member: None });
|
||||
}
|
||||
if object.kind() == "member_expression"
|
||||
&& let Some(refs) = object.child_by_field_name("object")
|
||||
&& is_refs(refs, src)
|
||||
&& let Some(name) = object.child_by_field_name("property")
|
||||
{
|
||||
return Some(VueRefAccess {
|
||||
name: text_of(name, src).to_owned(),
|
||||
member: Some(text_of(node, src).to_owned()),
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The element with `ref="<name>"` in a Vue SFC's template; the last one when several
|
||||
/// share the name, as Vue keeps the last.
|
||||
pub fn vue_ref_element(&self, text: &str, name: &str) -> Option<VueRefElement> {
|
||||
let g = self.web_grammar(&LanguageId::new("vue"))?;
|
||||
let tree = parse(&g.language, text, None)?;
|
||||
let idx = LineIndex::new(text);
|
||||
let mut found = None;
|
||||
walk(tree.root_node(), &mut |n| {
|
||||
if n.kind() != "attribute" {
|
||||
return !matches!(n.kind(), "script_element" | "style_element");
|
||||
}
|
||||
if n.child(0).is_some_and(|a| text_of(a, text) == "ref")
|
||||
&& let Some(value) = attribute_value(n)
|
||||
&& text_of(value, text) == name
|
||||
&& let Some(tag) = n.parent().and_then(|t| t.child(1)).filter(|t| t.kind() == "tag_name")
|
||||
{
|
||||
found = Some(VueRefElement {
|
||||
value: idx.range(value.start_byte(), value.end_byte()),
|
||||
tag: text_of(tag, text).to_owned(),
|
||||
});
|
||||
}
|
||||
false
|
||||
});
|
||||
found
|
||||
}
|
||||
|
||||
/// The default import in a Vue SFC's `<script>` that a template tag names
|
||||
/// (`<child-button>` / `<ChildButton>` → `import ChildButton from …`).
|
||||
pub fn vue_component_import(&self, text: &str, tag: &str) -> Option<VueComponentImport> {
|
||||
let wanted = component_key(tag);
|
||||
let idx = LineIndex::new(text);
|
||||
self.script_regions(&LanguageId::new("vue"), text).into_iter().find_map(|region| {
|
||||
let g = self.grammar(®ion.language)?;
|
||||
let tree = parse(&g.language, ®ion.text, None)?;
|
||||
let src = region.text.as_str();
|
||||
let mut c = tree.root_node().walk();
|
||||
tree.root_node().children(&mut c).filter(|n| n.kind() == "import_statement").find_map(|n| {
|
||||
let clause = n.children(&mut n.walk()).find(|ch| ch.kind() == "import_clause")?;
|
||||
let binding = clause.children(&mut clause.walk()).find(|ch| ch.kind() == "identifier")?;
|
||||
if component_key(text_of(binding, src)) != wanted {
|
||||
return None;
|
||||
}
|
||||
let source = n.child_by_field_name("source")?;
|
||||
let specifier = source.named_child(0).map(|f| text_of(f, src)).unwrap_or_default();
|
||||
Some(VueComponentImport {
|
||||
specifier: specifier.to_owned(),
|
||||
binding: idx.range(region.base + binding.start_byte(), region.base + binding.end_byte()),
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Where a component declares the instance member `name` in its options object
|
||||
/// (`methods`, `computed`, `props`, `data()` / `setup()` return). `lang` is `vue` for an
|
||||
/// SFC, or the script language of a component written as a plain module.
|
||||
pub fn vue_component_member(&self, lang: &LanguageId, text: &str, name: &str) -> Option<Range> {
|
||||
let regions = if lang.as_str() == "vue" {
|
||||
self.script_regions(lang, text)
|
||||
} else {
|
||||
vec![ScriptRegion { base: 0, language: lang.clone(), text: text.to_owned() }]
|
||||
};
|
||||
let idx = LineIndex::new(text);
|
||||
regions.into_iter().find_map(|region| {
|
||||
let g = self.grammar(®ion.language)?;
|
||||
let tree = parse(&g.language, ®ion.text, None)?;
|
||||
let src = region.text.as_str();
|
||||
let options = default_options(tree.root_node(), src)?;
|
||||
let key = instance_member(options, src, name)?;
|
||||
Some(idx.range(region.base + key.start_byte(), region.base + key.end_byte()))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn contains(region: &ScriptRegion, at: usize) -> bool {
|
||||
region.base <= at && at <= region.base + region.text.len()
|
||||
}
|
||||
|
||||
fn text_of<'t>(n: Node, text: &'t str) -> &'t str {
|
||||
n.utf8_text(text.as_bytes()).unwrap_or("")
|
||||
}
|
||||
|
||||
/// `<anything>.$refs`.
|
||||
fn is_refs(n: Node, src: &str) -> bool {
|
||||
n.kind() == "member_expression" && n.child_by_field_name("property").is_some_and(|p| text_of(p, src) == "$refs")
|
||||
}
|
||||
|
||||
fn attribute_value(attr: Node) -> Option<Node> {
|
||||
let value =
|
||||
attr.children(&mut attr.walk()).find(|ch| matches!(ch.kind(), "quoted_attribute_value" | "attribute_value"))?;
|
||||
if value.kind() == "attribute_value" { Some(value) } else { value.named_child(0) }
|
||||
}
|
||||
|
||||
/// Vue matches a tag to a registered component in kebab-case or PascalCase.
|
||||
fn component_key(name: &str) -> String {
|
||||
name.chars().filter(|c| *c != '-').flat_map(char::to_lowercase).collect()
|
||||
}
|
||||
|
||||
/// The options object of `export default { … }`, `export default defineComponent({ … })`
|
||||
/// or `export default Vue.extend({ … })`.
|
||||
fn default_options<'t>(root: Node<'t>, src: &str) -> Option<Node<'t>> {
|
||||
let mut c = root.walk();
|
||||
let export = root.children(&mut c).find(|n| {
|
||||
n.kind() == "export_statement" && n.children(&mut n.walk()).any(|ch| text_of(ch, src) == "default")
|
||||
})?;
|
||||
let value = export.child_by_field_name("value").or_else(|| export.child_by_field_name("declaration"))?;
|
||||
object_of(value)
|
||||
}
|
||||
|
||||
fn object_of(n: Node) -> Option<Node> {
|
||||
match n.kind() {
|
||||
"object" => Some(n),
|
||||
"parenthesized_expression" | "as_expression" | "satisfies_expression" => object_of(n.named_child(0)?),
|
||||
"call_expression" => object_of(n.child_by_field_name("arguments")?.named_child(0)?),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn instance_member<'t>(options: Node<'t>, src: &str, name: &str) -> Option<Node<'t>> {
|
||||
for option in INSTANCE_OPTIONS {
|
||||
let Some(entry) = entry(options, src, option) else { continue };
|
||||
let found = match option {
|
||||
"data" | "setup" => returned_objects(entry).into_iter().find_map(|o| key(o, src, name)),
|
||||
"props" => match entry.child_by_field_name("value") {
|
||||
Some(array) if array.kind() == "array" => array
|
||||
.named_children(&mut array.walk())
|
||||
.find(|s| s.kind() == "string" && string_value(*s, src) == name),
|
||||
value => value.and_then(object_of).and_then(|o| key(o, src, name)),
|
||||
},
|
||||
_ => entry.child_by_field_name("value").and_then(object_of).and_then(|o| key(o, src, name)),
|
||||
};
|
||||
if found.is_some() {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The `pair` or `method_definition` of `object` named `name`.
|
||||
fn entry<'t>(object: Node<'t>, src: &str, name: &str) -> Option<Node<'t>> {
|
||||
object.named_children(&mut object.walk()).find(|n| {
|
||||
matches!(n.kind(), "pair" | "method_definition")
|
||||
&& n.child_by_field_name(if n.kind() == "pair" { "key" } else { "name" })
|
||||
.is_some_and(|k| property_name(k, src) == name)
|
||||
})
|
||||
}
|
||||
|
||||
/// The key node of `object`'s member `name`, shorthand properties included.
|
||||
fn key<'t>(object: Node<'t>, src: &str, name: &str) -> Option<Node<'t>> {
|
||||
object.named_children(&mut object.walk()).find_map(|n| match n.kind() {
|
||||
"pair" => n.child_by_field_name("key").filter(|k| property_name(*k, src) == name),
|
||||
"method_definition" => n.child_by_field_name("name").filter(|k| property_name(*k, src) == name),
|
||||
"shorthand_property_identifier" => (text_of(n, src) == name).then_some(n),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn property_name<'t>(key: Node, src: &'t str) -> &'t str {
|
||||
if key.kind() == "string" { string_value(key, src) } else { text_of(key, src) }
|
||||
}
|
||||
|
||||
fn string_value<'t>(s: Node, src: &'t str) -> &'t str {
|
||||
s.named_child(0).map(|f| text_of(f, src)).unwrap_or("")
|
||||
}
|
||||
|
||||
/// The objects a `data()` / `setup()` option returns (`data: () => ({ … })` included).
|
||||
fn returned_objects(entry: Node) -> Vec<Node> {
|
||||
let function = if entry.kind() == "method_definition" { Some(entry) } else { entry.child_by_field_name("value") };
|
||||
let Some(function) = function else { return Vec::new() };
|
||||
let Some(body) = function.child_by_field_name("body") else { return Vec::new() };
|
||||
if let Some(object) = object_of(body) {
|
||||
return vec![object];
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
walk(body, &mut |n| match n.kind() {
|
||||
"return_statement" => {
|
||||
out.extend(n.named_child(0).and_then(object_of));
|
||||
false
|
||||
}
|
||||
// A nested function's `return` is not the option's.
|
||||
"function_expression" | "arrow_function" | "function_declaration" | "method_definition" | "class_body" => false,
|
||||
_ => true,
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const APP: &str = r#"<template>
|
||||
<div>
|
||||
<child-button ref="child" @my-event="handler" />
|
||||
<input ref='field'>
|
||||
<span>{{ $refs.child }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ChildButton from './components/ChildButton.vue'
|
||||
|
||||
export default {
|
||||
name: 'App',
|
||||
components: { ChildButton },
|
||||
methods: {
|
||||
handler() {
|
||||
this.$refs.child
|
||||
this.$refs.child.focusIt()
|
||||
this.$refs.field?.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
"#;
|
||||
|
||||
fn pos_of(text: &str, needle: &str, nth: usize, offset: usize) -> Pos {
|
||||
let byte = text.match_indices(needle).nth(nth).unwrap().0 + offset;
|
||||
LineIndex::new(text).pos(byte)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_the_ref_and_the_member_read_from_it() {
|
||||
let s = Syntax::new();
|
||||
let child = s.vue_ref_access(APP, pos_of(APP, "this.$refs.child", 0, 12)).unwrap();
|
||||
assert_eq!(child, VueRefAccess { name: "child".into(), member: None });
|
||||
// At the end of the name too.
|
||||
let end = s.vue_ref_access(APP, pos_of(APP, "this.$refs.child", 0, 16)).unwrap();
|
||||
assert_eq!(end.name, "child");
|
||||
let member = s.vue_ref_access(APP, pos_of(APP, "focusIt", 0, 2)).unwrap();
|
||||
assert_eq!(member, VueRefAccess { name: "child".into(), member: Some("focusIt".into()) });
|
||||
let optional = s.vue_ref_access(APP, pos_of(APP, "field?.focus", 0, 1)).unwrap();
|
||||
assert_eq!(optional.name, "field");
|
||||
assert!(s.vue_ref_access(APP, pos_of(APP, "$refs.child", 1, 2)).is_none(), "$refs itself");
|
||||
assert!(s.vue_ref_access(APP, pos_of(APP, "handler", 1, 1)).is_none());
|
||||
assert!(s.vue_ref_access(APP, pos_of(APP, "$refs.child }}", 0, 7)).is_none(), "template is the server's");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_the_element_with_the_ref() {
|
||||
let s = Syntax::new();
|
||||
let child = s.vue_ref_element(APP, "child").unwrap();
|
||||
assert_eq!(child.tag, "child-button");
|
||||
assert_eq!(child.value.start, pos_of(APP, "\"child\"", 0, 1));
|
||||
let field = s.vue_ref_element(APP, "field").unwrap();
|
||||
assert_eq!(field.tag, "input");
|
||||
assert!(s.vue_ref_element(APP, "missing").is_none());
|
||||
let last = "<template><a ref=\"x\"/><b ref=\"x\"></b></template>";
|
||||
assert_eq!(s.vue_ref_element(last, "x").unwrap().tag, "b");
|
||||
assert!(s.vue_ref_element("<template><a :ref=\"x\"/></template>", "x").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_the_import_a_tag_names() {
|
||||
let s = Syntax::new();
|
||||
let import = s.vue_component_import(APP, "child-button").unwrap();
|
||||
assert_eq!(import.specifier, "./components/ChildButton.vue");
|
||||
assert_eq!(import.binding.start, pos_of(APP, "ChildButton from", 0, 0));
|
||||
assert_eq!(s.vue_component_import(APP, "ChildButton").unwrap().specifier, import.specifier);
|
||||
assert!(s.vue_component_import(APP, "input").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_instance_members_in_the_options() {
|
||||
let s = Syntax::new();
|
||||
let child = r#"<template><button /></template>
|
||||
<script>
|
||||
export default {
|
||||
name: 'ChildButton',
|
||||
props: ['label', 'size'],
|
||||
data() {
|
||||
const helper = () => { return { nope: 1 } }
|
||||
return { count: 0, helper }
|
||||
},
|
||||
computed: { double() { return this.count * 2 }, 'quoted': function () {} },
|
||||
methods: {
|
||||
focusIt() { this.$el.focus() },
|
||||
reset: function () {},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
"#;
|
||||
let vue = LanguageId::new("vue");
|
||||
let at = |name: &str| s.vue_component_member(&vue, child, name);
|
||||
assert_eq!(at("focusIt").unwrap().start, pos_of(child, "focusIt", 0, 0));
|
||||
assert_eq!(at("reset").unwrap().start, pos_of(child, "reset", 0, 0));
|
||||
assert_eq!(at("double").unwrap().start, pos_of(child, "double", 0, 0));
|
||||
assert_eq!(at("quoted").unwrap().start, pos_of(child, "'quoted'", 0, 0));
|
||||
assert_eq!(at("size").unwrap().start, pos_of(child, "'size'", 0, 0));
|
||||
assert_eq!(at("count").unwrap().start, pos_of(child, "count: 0", 0, 0));
|
||||
assert_eq!(at("helper").unwrap().start, pos_of(child, "helper }", 0, 0));
|
||||
assert!(at("nope").is_none());
|
||||
assert!(at("name").is_none());
|
||||
|
||||
let ts = "import { defineComponent } from 'vue'\nexport default defineComponent({ methods: { go() {} } })\n";
|
||||
let at = s.vue_component_member(&LanguageId::new("typescript"), ts, "go").unwrap();
|
||||
assert_eq!(at.start, pos_of(ts, "go()", 0, 0));
|
||||
let props = "export default { props: { value: String } }";
|
||||
assert!(s.vue_component_member(&LanguageId::new("javascript"), props, "value").is_some());
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,7 @@ struct Response {
|
||||
#[serde(default)]
|
||||
incomplete: bool,
|
||||
}
|
||||
fn syntax() -> &'static Syntax {
|
||||
pub(crate) fn syntax() -> &'static Syntax {
|
||||
static SYNTAX: OnceLock<Syntax> = OnceLock::new();
|
||||
SYNTAX.get_or_init(Syntax::new)
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ pub mod types;
|
||||
mod uri;
|
||||
mod virtual_doc;
|
||||
mod vue_navigation;
|
||||
mod vue_refs;
|
||||
|
||||
pub use injections::{InjectedRange, InjectionProvider, MAX_INJECTED_DOCUMENT_BYTES};
|
||||
pub use inspect::{FileInspection, InspectHost, InspectOptions};
|
||||
@@ -1602,6 +1603,9 @@ impl LspManager {
|
||||
{
|
||||
return Ok(result.locations);
|
||||
}
|
||||
if let Some(found) = self.vue_ref_navigation(doc, &snap, pos).await {
|
||||
return Ok(found);
|
||||
}
|
||||
}
|
||||
let (method, cap, feature) = goto_method(kind);
|
||||
let (snap, targets) = self.doc_targets(doc, feature, |c| c.has(cap)).await?;
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
//! Vue Options API `this.$refs`: the Vue 2.7 types declare `$refs` as an index signature,
|
||||
//! so the TypeScript service sends `this.$refs.child` to that signature and finds nothing
|
||||
//! for `this.$refs.child.focusIt`. The template says which element each ref is on:
|
||||
//! `this.$refs.child` goes to its `ref="child"`, and a member read from a component's ref
|
||||
//! goes to that member in the component's options.
|
||||
|
||||
use crate::jdtls::configuration::syntax;
|
||||
use crate::types::GotoKind;
|
||||
use crate::{DocSnap, LspManager, goto_method};
|
||||
use ide_core::{DocId, LanguageId, Location, Pos};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
impl LspManager {
|
||||
pub(crate) async fn vue_ref_navigation(&self, doc: DocId, snap: &DocSnap, pos: Pos) -> Option<Vec<Location>> {
|
||||
if snap.language.as_str() != "vue" || !snap.text.as_str().contains("$refs") {
|
||||
return None;
|
||||
}
|
||||
let text = snap.text.clone();
|
||||
let (member, element, import) = tokio::task::spawn_blocking(move || {
|
||||
let s = syntax();
|
||||
let access = s.vue_ref_access(text.as_str(), pos)?;
|
||||
let element = s.vue_ref_element(text.as_str(), &access.name)?;
|
||||
let import = access.member.as_ref().and_then(|_| s.vue_component_import(text.as_str(), &element.tag));
|
||||
Some((access.member, element, import))
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten()?;
|
||||
let Some(member) = member else {
|
||||
return Some(vec![Location { path: snap.path.clone().into(), range: element.value, uri: None }]);
|
||||
};
|
||||
let import = import?;
|
||||
let file = match relative_module(&snap.path, &import.specifier) {
|
||||
Some(file) => file,
|
||||
// An alias (`@/components/…`): the language service knows the project's paths;
|
||||
// without them configured, `@` is the Vue CLI / Vite templates' `src`.
|
||||
None => match self.definition_file(doc, import.binding.start).await {
|
||||
Some(file) => file,
|
||||
None => src_alias(&self.project_root_for(&snap.path), &import.specifier)?,
|
||||
},
|
||||
};
|
||||
let lang = script_language(&file)?;
|
||||
let open = self.docs.lock().values().find(|d| d.path == file).map(|d| d.text.clone());
|
||||
let range = tokio::task::spawn_blocking({
|
||||
let file = file.clone();
|
||||
move || {
|
||||
let text = match open {
|
||||
Some(text) => text.as_str().to_owned(),
|
||||
None => std::fs::read_to_string(&file).ok()?,
|
||||
};
|
||||
syntax().vue_component_member(&lang, &text, &member)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten()?;
|
||||
Some(vec![Location { path: file.into(), range, uri: None }])
|
||||
}
|
||||
|
||||
async fn definition_file(&self, doc: DocId, pos: Pos) -> Option<PathBuf> {
|
||||
let (method, cap, feature) = goto_method(GotoKind::Definition);
|
||||
let (snap, targets) = self.doc_targets(doc, feature, |c| c.has(cap)).await.ok()?;
|
||||
let found = self.goto_in(&snap, &targets, method, pos, None, None).await.ok()?;
|
||||
// The import binding itself is no answer.
|
||||
found.into_iter().find(|l| l.uri.is_none() && *l.path != snap.path).map(|l| l.path.into_path_buf())
|
||||
}
|
||||
}
|
||||
|
||||
/// The file a relative import names, with the extensions a bundler tries.
|
||||
fn relative_module(from: &Path, specifier: &str) -> Option<PathBuf> {
|
||||
if !specifier.starts_with("./") && !specifier.starts_with("../") {
|
||||
return None;
|
||||
}
|
||||
let mut base = from.parent()?.to_path_buf();
|
||||
for part in Path::new(specifier).components() {
|
||||
match part {
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir => {
|
||||
base.pop();
|
||||
}
|
||||
part => base.push(part),
|
||||
}
|
||||
}
|
||||
let candidates = [String::new(), ".vue".into(), ".ts".into(), ".js".into()]
|
||||
.into_iter()
|
||||
.map(|ext| PathBuf::from(format!("{}{ext}", base.display())))
|
||||
.chain(["index.vue", "index.ts", "index.js"].into_iter().map(|index| base.join(index)));
|
||||
candidates.into_iter().find(|p| p.is_file())
|
||||
}
|
||||
|
||||
fn src_alias(root: &Path, specifier: &str) -> Option<PathBuf> {
|
||||
let rest = specifier.strip_prefix("@/")?;
|
||||
relative_module(&root.join("src").join("_"), &format!("./{rest}"))
|
||||
}
|
||||
|
||||
fn script_language(file: &Path) -> Option<LanguageId> {
|
||||
let id = match file.extension()?.to_str()? {
|
||||
"vue" => "vue",
|
||||
"ts" | "mts" | "cts" => "typescript",
|
||||
"tsx" => "tsx",
|
||||
"js" | "mjs" | "cjs" | "jsx" => "javascript",
|
||||
_ => return None,
|
||||
};
|
||||
Some(LanguageId::new(id))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolves_relative_component_imports() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let src = dir.path().join("src");
|
||||
std::fs::create_dir_all(src.join("components/Panel")).unwrap();
|
||||
std::fs::write(src.join("components/ChildButton.vue"), "").unwrap();
|
||||
std::fs::write(src.join("components/Panel/index.vue"), "").unwrap();
|
||||
let app = src.join("App.vue");
|
||||
assert_eq!(relative_module(&app, "./components/ChildButton.vue"), Some(src.join("components/ChildButton.vue")));
|
||||
assert_eq!(relative_module(&app, "./components/ChildButton"), Some(src.join("components/ChildButton.vue")));
|
||||
assert_eq!(relative_module(&app, "./components/Panel"), Some(src.join("components/Panel/index.vue")));
|
||||
assert_eq!(
|
||||
relative_module(&src.join("components/Panel/index.vue"), "../ChildButton.vue"),
|
||||
Some(src.join("components/ChildButton.vue"))
|
||||
);
|
||||
assert_eq!(relative_module(&app, "@/components/ChildButton.vue"), None);
|
||||
assert_eq!(relative_module(&app, "./components/Missing.vue"), None);
|
||||
assert_eq!(src_alias(dir.path(), "@/components/ChildButton"), Some(src.join("components/ChildButton.vue")));
|
||||
assert_eq!(src_alias(dir.path(), "~/components/ChildButton"), None);
|
||||
}
|
||||
}
|
||||
@@ -207,6 +207,20 @@ impl Deployment<'_> {
|
||||
async fn remote_files(&self, dir: &str, out: &mut Vec<ide_ssh::RemoteEntry>) -> IdeResult<()> {
|
||||
use futures::stream::{FuturesUnordered, StreamExt};
|
||||
let start = out.len();
|
||||
// One `find` over SSH instead of a listing per folder (thousands of round trips on a
|
||||
// big project); the same entries the walk below would keep.
|
||||
if let Some(entries) = self.fs.tree(dir, self.exclude.prunable_names()).await {
|
||||
if self.cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let base = dir.trim_end_matches('/');
|
||||
out.extend(entries.into_iter().filter(|e| {
|
||||
let rel = e.path.strip_prefix(base).unwrap_or(&e.path);
|
||||
!self.exclude.remote(&e.path) && !rel.split('/').any(|name| !name.is_empty() && self.exclude.name(name))
|
||||
}));
|
||||
out[start..].sort_by(|a, b| a.path.cmp(&b.path));
|
||||
return Ok(());
|
||||
}
|
||||
let mut pending = vec![dir.to_owned()];
|
||||
let mut listing = FuturesUnordered::new();
|
||||
loop {
|
||||
|
||||
@@ -175,6 +175,8 @@ impl Mappings {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Excludes {
|
||||
names: GlobSet,
|
||||
/// The name patterns `find -name` matches the same way (no `/`, braces, classes or `**`).
|
||||
plain: Vec<String>,
|
||||
/// Remote paths (absolute) never touched.
|
||||
remote: Vec<String>,
|
||||
}
|
||||
@@ -182,10 +184,14 @@ pub struct Excludes {
|
||||
impl Excludes {
|
||||
pub fn new(settings: &DeploymentSettings, server: &DeploymentServer, remote_root: &str) -> Self {
|
||||
let mut b = GlobSetBuilder::new();
|
||||
let mut plain = Vec::new();
|
||||
for pattern in settings.exclude.iter().flat_map(|p| p.split(';')).map(str::trim).filter(|p| !p.is_empty()) {
|
||||
match Glob::new(pattern) {
|
||||
Ok(g) => {
|
||||
b.add(g);
|
||||
if !pattern.contains(['/', '\\', '{', '}', '[', ']']) && !pattern.contains("**") {
|
||||
plain.push(pattern.to_owned());
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!("deployment exclude {pattern:?}: {e}"),
|
||||
}
|
||||
@@ -197,7 +203,12 @@ impl Excludes {
|
||||
.filter(|p| !p.is_empty())
|
||||
.map(|p| join(remote_root, p.trim_start_matches('/')).trim_end_matches('/').to_owned())
|
||||
.collect();
|
||||
Self { names: b.build().unwrap_or_else(|_| GlobSet::empty()), remote }
|
||||
Self { names: b.build().unwrap_or_else(|_| GlobSet::empty()), plain, remote }
|
||||
}
|
||||
|
||||
/// Name patterns a remote `find` can skip whole folders by; the rest is filtered afterwards.
|
||||
pub fn prunable_names(&self) -> &[String] {
|
||||
&self.plain
|
||||
}
|
||||
|
||||
pub fn name(&self, name: &str) -> bool {
|
||||
|
||||
@@ -87,7 +87,7 @@ impl RemoteService {
|
||||
return Err(IdeError::invalid("the deployment server has no SSH configuration"));
|
||||
}
|
||||
let session = self.ssh.session(ssh).await?;
|
||||
Ok(RemoteFs::new(session.sftp().await?))
|
||||
Ok(RemoteFs::new(session.sftp().await?).with_shell(session))
|
||||
}
|
||||
FsConnection::Ftp { server, secure, ftp } => {
|
||||
let password = self.ssh.password(&ide_ssh::ftp::secret_key(server));
|
||||
@@ -418,7 +418,7 @@ impl RemoteService {
|
||||
return Err(IdeError::internal("container run targets start as local runs of the docker CLI"));
|
||||
}
|
||||
let session = self.ssh.session(&target.ssh_config).await?;
|
||||
let fs = RemoteFs::new(session.sftp().await?);
|
||||
let fs = RemoteFs::new(session.sftp().await?).with_shell(session.clone());
|
||||
let folder = if target.project_root.trim().is_empty() {
|
||||
format!(
|
||||
"~/{}",
|
||||
|
||||
@@ -16,6 +16,11 @@ struct Env {
|
||||
}
|
||||
|
||||
async fn env() -> Env {
|
||||
env_with(|_| Arc::new(|cmd| (format!("ran: {cmd}"), 0))).await
|
||||
}
|
||||
|
||||
/// `exec`: the server's command handler, given the folder that backs `/`.
|
||||
async fn env_with(exec: impl FnOnce(std::path::PathBuf) -> ide_ssh::testing::ExecFn) -> Env {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let project = tmp.path().join("project");
|
||||
let server_root = tmp.path().join("server");
|
||||
@@ -25,7 +30,7 @@ async fn env() -> Env {
|
||||
std::fs::write(project.join("src/main.py"), "print('hi')\n").unwrap();
|
||||
std::fs::write(project.join("README.md"), "# demo\n").unwrap();
|
||||
std::fs::write(project.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap();
|
||||
let port = ide_ssh::testing::sshd(server_root.clone(), Arc::new(|cmd| (format!("ran: {cmd}"), 0))).await;
|
||||
let port = ide_ssh::testing::sshd(server_root.clone(), exec(server_root.clone())).await;
|
||||
let files = SshFiles { known_hosts: tmp.path().join("known_hosts"), config: None };
|
||||
let target = SshTarget {
|
||||
host: "127.0.0.1".into(),
|
||||
@@ -184,6 +189,72 @@ async fn uploads_compares_syncs_and_downloads() {
|
||||
assert_eq!(d.push_changes(&env.project, &mut no_progress()).await.unwrap().files, 0);
|
||||
}
|
||||
|
||||
/// GNU `find -printf '%y\t%s\t%T@\t%p\0'` over the test server's files (no pruning: the
|
||||
/// deployment filters what it excludes itself).
|
||||
fn find_printf(root: std::path::PathBuf, calls: Arc<std::sync::atomic::AtomicUsize>) -> ide_ssh::testing::ExecFn {
|
||||
Arc::new(move |cmd| {
|
||||
let Some(dir) = cmd.strip_prefix("LC_ALL=C find ").and_then(|c| c.split(" -mindepth").next()) else {
|
||||
return (String::new(), 127);
|
||||
};
|
||||
calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
fn walk(local: &std::path::Path, remote: &str, out: &mut String) {
|
||||
for e in std::fs::read_dir(local).unwrap().flatten() {
|
||||
let (path, meta) = (format!("{remote}/{}", e.file_name().to_string_lossy()), e.metadata().unwrap());
|
||||
if meta.is_dir() {
|
||||
walk(&e.path(), &path, out);
|
||||
} else {
|
||||
let mtime = meta.modified().unwrap().duration_since(std::time::UNIX_EPOCH).unwrap();
|
||||
out.push_str(&format!(
|
||||
"f\t{}\t{}.{:09}0\t{path}\0",
|
||||
meta.len(),
|
||||
mtime.as_secs(),
|
||||
mtime.subsec_nanos()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = String::new();
|
||||
let local = root.join(dir.trim_start_matches('/'));
|
||||
if local.is_dir() {
|
||||
walk(&local, dir, &mut out);
|
||||
}
|
||||
(out, 0)
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compares_with_one_find_over_ssh_and_falls_back_to_listing_folders() {
|
||||
let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let counted = calls.clone();
|
||||
let env = env_with(move |root| find_printf(root, counted)).await;
|
||||
let server = server();
|
||||
let settings = DeploymentSettings::default();
|
||||
let map = Mappings::new(&server, &env.project, "/srv");
|
||||
let exclude = Excludes::new(&settings, &server, "/srv");
|
||||
// An excluded folder on the server is not listed, whatever `find` returns.
|
||||
std::fs::create_dir_all(env.server_root.join("srv/app/.git")).unwrap();
|
||||
std::fs::write(env.server_root.join("srv/app/.git/HEAD"), "x\n").unwrap();
|
||||
std::fs::write(env.server_root.join("srv/app/remote.txt"), "server side\n").unwrap();
|
||||
for shell in [true, false] {
|
||||
let fs = RemoteFs::new(env.session.sftp().await.unwrap());
|
||||
let fs = if shell { fs.with_shell(env.session.clone()) } else { fs };
|
||||
let d =
|
||||
Deployment { fs: &fs, map: &map, exclude: &exclude, preserve_time: true, cancel: CancellationToken::new() };
|
||||
let before = calls.load(std::sync::atomic::Ordering::SeqCst);
|
||||
if shell {
|
||||
assert_eq!(d.push_changes(&env.project, &mut no_progress()).await.unwrap().files, 2);
|
||||
}
|
||||
let entries = d.compare(&env.project).await.unwrap();
|
||||
let status = |p: &str| entries.iter().find(|e| e.path == p).map(|e| e.status);
|
||||
assert_eq!(status("src/main.py"), Some(SyncStatus::Same), "{entries:?}");
|
||||
assert_eq!(status("README.md"), Some(SyncStatus::Same));
|
||||
assert_eq!(status("remote.txt"), Some(SyncStatus::RemoteOnly));
|
||||
assert!(entries.iter().all(|e| !e.path.starts_with(".git")), "{entries:?}");
|
||||
let used = calls.load(std::sync::atomic::Ordering::SeqCst) - before;
|
||||
assert_eq!(used > 0, shell, "find is used only with a shell");
|
||||
}
|
||||
}
|
||||
|
||||
struct Sink(Mutex<Vec<IdeEvent>>);
|
||||
|
||||
impl ide_core::EventSink for Sink {
|
||||
|
||||
@@ -108,6 +108,7 @@ pub fn project_libraries_stream(
|
||||
})
|
||||
.collect();
|
||||
let rust = s.spawn(|| {
|
||||
let project = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut groups = Vec::new();
|
||||
for dir in &roots {
|
||||
@@ -136,7 +137,11 @@ pub fn project_libraries_stream(
|
||||
if !seen.insert(workspace.clone()) {
|
||||
continue;
|
||||
}
|
||||
let batch = rust_groups(&workspace, &settings, cancel);
|
||||
// Resolving writes a Cargo.lock. A crate outside the project's workspace (one in its
|
||||
// `exclude`) without its own keeps none, as RustRover leaves crates it doesn't attach.
|
||||
let locked = workspace.canonicalize().unwrap_or_else(|_| workspace.clone()) != project
|
||||
&& !workspace.join("Cargo.lock").is_file();
|
||||
let batch = rust_groups(&workspace, &settings, locked, cancel);
|
||||
if !cancel.is_cancelled() && !batch.is_empty() {
|
||||
on_groups(batch.clone());
|
||||
groups.extend(batch);
|
||||
@@ -768,7 +773,8 @@ pub(crate) fn package_dependencies(package_json: &str) -> Vec<String> {
|
||||
|
||||
// ------------------------------------------------------------------ rust
|
||||
|
||||
fn rust_groups(root: &Path, s: &Settings, cancel: &CancellationToken) -> Vec<LibraryGroup> {
|
||||
/// `locked`: `cargo metadata --locked`, which fails instead of writing a Cargo.lock.
|
||||
fn rust_groups(root: &Path, s: &Settings, locked: bool, cancel: &CancellationToken) -> Vec<LibraryGroup> {
|
||||
let Some(files) = manifests(root, CARGO_MANIFESTS) else { return Vec::new() };
|
||||
if !root.join("Cargo.toml").is_file() {
|
||||
return Vec::new();
|
||||
@@ -793,14 +799,11 @@ fn rust_groups(root: &Path, s: &Settings, cancel: &CancellationToken) -> Vec<Lib
|
||||
});
|
||||
if let Some(cargo) = tool(bin, "cargo") {
|
||||
let graph = cached_optional(root, "cargo", r.toolchain.clone(), &files, || {
|
||||
let json = query_in_cancellable(
|
||||
&cargo,
|
||||
&["metadata", "--format-version", "1", "--offline"],
|
||||
root,
|
||||
&[],
|
||||
GRAPH_TIMEOUT,
|
||||
cancel,
|
||||
)?;
|
||||
let mut args = vec!["metadata", "--format-version", "1", "--offline"];
|
||||
if locked {
|
||||
args.push("--locked");
|
||||
}
|
||||
let json = query_in_cancellable(&cargo, &args, root, &[], GRAPH_TIMEOUT, cancel)?;
|
||||
let roots = parse_cargo_metadata(&json)?;
|
||||
Some(group("Cargo dependencies", "cargo", named_roots(roots)).into_iter().collect())
|
||||
});
|
||||
@@ -1106,6 +1109,50 @@ mod go_cache_tests {
|
||||
assert_eq!(std::fs::read_to_string(calls).unwrap().lines().count(), 1);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn cargo_metadata_writes_no_lock_file_for_a_crate_outside_the_workspace() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().canonicalize().unwrap();
|
||||
std::fs::write(root.join("Cargo.toml"), "[workspace]\nmembers=['app']\nexclude=['vendored']\n").unwrap();
|
||||
for name in ["app", "vendored"] {
|
||||
std::fs::create_dir(root.join(name)).unwrap();
|
||||
std::fs::write(root.join(name).join("Cargo.toml"), format!("[package]\nname='{name}'\nversion='0.1.0'\n"))
|
||||
.unwrap();
|
||||
}
|
||||
let bin = root.join("tools");
|
||||
std::fs::create_dir(&bin).unwrap();
|
||||
let calls = root.join("metadata-calls");
|
||||
// Like cargo: an excluded crate is its own workspace.
|
||||
let cargo = format!(
|
||||
"#!/bin/sh\nif [ \"$1\" = locate-project ]; then case \"$(pwd -P)\" in */vendored) echo \"$(pwd -P)/Cargo.toml\";; *) echo '{}';; esac; exit; fi\necho \"$(pwd -P) $*\" >> '{}'\nexit 1\n",
|
||||
root.join("Cargo.toml").display(),
|
||||
calls.display()
|
||||
);
|
||||
for (name, script) in [("cargo", cargo.as_str()), ("rustc", "#!/bin/sh\nexit 1\n")] {
|
||||
std::fs::write(bin.join(name), script).unwrap();
|
||||
std::fs::set_permissions(bin.join(name), std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
let mut settings = Settings::default();
|
||||
settings.languages.rust.toolchain = "tools".into();
|
||||
let metadata = |dir: &Path| {
|
||||
let prefix = format!("{} metadata ", dir.display());
|
||||
let calls = std::fs::read_to_string(&calls).unwrap();
|
||||
calls.lines().rev().find_map(|l| l.strip_prefix(&prefix).map(str::to_owned))
|
||||
};
|
||||
|
||||
project_libraries(&root, &settings, &[]);
|
||||
// The project's own workspace resolves as before, writing its Cargo.lock if it has none.
|
||||
assert_eq!(metadata(&root).as_deref(), Some("--format-version 1 --offline"));
|
||||
assert_eq!(metadata(&root.join("vendored")).as_deref(), Some("--format-version 1 --offline --locked"));
|
||||
|
||||
// A crate with its own Cargo.lock keeps resolving with it.
|
||||
std::fs::write(root.join("vendored/Cargo.lock"), "version = 4\n").unwrap();
|
||||
project_libraries(&root, &settings, &[]);
|
||||
assert_eq!(metadata(&root.join("vendored")).as_deref(), Some("--format-version 1 --offline"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn libraries_publish_node_and_maven_while_cargo_is_running_and_cancel_it() {
|
||||
|
||||
@@ -2260,7 +2260,7 @@ fn file_runtime_uses_nearest_virtualenv_and_explicit_project_settings_win() {
|
||||
let outer = fake_venv(root, ".venv");
|
||||
let inner = fake_venv(&root.join("apps/api"), ".venv");
|
||||
let near = root.join("apps/api/src/package");
|
||||
let mut settings = Settings::default();
|
||||
let settings = Settings::default();
|
||||
assert_eq!(crate::apply::python_interpreter_near(root, Some(&near), &settings), Some(inner));
|
||||
assert_eq!(crate::apply::python_interpreter_near(root, Some(&root.join("apps/other")), &settings), Some(outer));
|
||||
let mut settings = Settings::default();
|
||||
|
||||
@@ -35,6 +35,22 @@ impl RemoteFs {
|
||||
Self::Sftp(SftpFs::new(sftp))
|
||||
}
|
||||
|
||||
/// Lets [`RemoteFs::tree`] list a whole folder tree with one command over `shell`.
|
||||
pub fn with_shell(self, shell: std::sync::Arc<crate::Session>) -> Self {
|
||||
match self {
|
||||
Self::Sftp(fs) => Self::Sftp(fs.with_shell(shell)),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// See [`SftpFs::tree`]; `None` means list folder by folder.
|
||||
pub async fn tree(&self, dir: &str, prune: &[String]) -> Option<Vec<RemoteEntry>> {
|
||||
match self {
|
||||
Self::Sftp(fs) => fs.tree(dir, prune).await,
|
||||
Self::Ftp(_) | Self::Local => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn home(&self) -> IdeResult<String> {
|
||||
match self {
|
||||
Self::Sftp(fs) => fs.home().await,
|
||||
|
||||
@@ -227,6 +227,8 @@ impl Session {
|
||||
}
|
||||
let (mut read, write) = channel.split();
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let hangup = Arc::new(tokio::sync::Notify::new());
|
||||
let hung_up = hangup.clone();
|
||||
let mut on_output = on_output;
|
||||
tokio::spawn(async move {
|
||||
let mut code = None;
|
||||
@@ -234,7 +236,11 @@ impl Session {
|
||||
// After the exit status, output still in flight is read until the channel goes
|
||||
// quiet: a child that inherited stdout keeps the channel open past the exit.
|
||||
let msg = match code {
|
||||
None => read.wait().await,
|
||||
// russh forwards no close to a channel this side closed.
|
||||
None => tokio::select! {
|
||||
msg = read.wait() => msg,
|
||||
() = hung_up.notified() => None,
|
||||
},
|
||||
Some(_) => tokio::time::timeout(EXIT_DRAIN, read.wait()).await.ok().flatten(),
|
||||
};
|
||||
let Some(msg) = msg else { break };
|
||||
@@ -242,12 +248,14 @@ impl Session {
|
||||
ChannelMsg::Data { data } => on_output(Output::Stdout(&data)),
|
||||
ChannelMsg::ExtendedData { data, ext: 1 } => on_output(Output::Stderr(&data)),
|
||||
ChannelMsg::ExitStatus { exit_status } => code = Some(exit_status),
|
||||
// Closed without a status (hung up): nothing more arrives.
|
||||
ChannelMsg::Close => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let _ = tx.send(code);
|
||||
});
|
||||
Ok(RemoteProcess { write: Arc::new(write), exit: Mutex::new(Some(rx)) })
|
||||
Ok(RemoteProcess { write: Arc::new(write), exit: Mutex::new(Some(rx)), hangup })
|
||||
}
|
||||
|
||||
/// Writes `data` to `remote` (absolute; its folder is created) through an
|
||||
@@ -337,6 +345,7 @@ pub enum Output<'a> {
|
||||
pub struct RemoteProcess {
|
||||
write: Arc<ChannelWriteHalf<Msg>>,
|
||||
exit: Mutex<Option<oneshot::Receiver<Option<u32>>>>,
|
||||
hangup: Arc<tokio::sync::Notify>,
|
||||
}
|
||||
|
||||
impl RemoteProcess {
|
||||
@@ -363,10 +372,22 @@ impl RemoteProcess {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Closes the channel (an interactive shell hangs up); the process ends without a status.
|
||||
pub async fn close(&self) {
|
||||
let _ = self.write.close().await;
|
||||
self.hangup.notify_one();
|
||||
}
|
||||
|
||||
/// Waits for the exit status (`None`: killed, or no status reported). Once.
|
||||
pub async fn wait(&self) -> Option<u32> {
|
||||
let rx = self.exit.lock().take()?;
|
||||
rx.await.ok().flatten()
|
||||
self.exit_status().await
|
||||
}
|
||||
|
||||
/// [`Self::wait`] without borrowing the process, so dropping it meanwhile still
|
||||
/// releases the channel. Once.
|
||||
pub fn exit_status(&self) -> impl Future<Output = Option<u32>> + Send + 'static {
|
||||
let rx = self.exit.lock().take();
|
||||
async move { rx?.await.ok().flatten() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -80,11 +80,29 @@ fn err(path: &str, e: SftpError) -> IdeError {
|
||||
/// Higher-level operations on an [`SftpSession`].
|
||||
pub struct SftpFs {
|
||||
sftp: SftpSession,
|
||||
/// The SSH session behind it, for commands that list a whole tree at once ([`SftpFs::tree`]).
|
||||
shell: Option<std::sync::Arc<crate::Session>>,
|
||||
}
|
||||
|
||||
impl SftpFs {
|
||||
pub fn new(sftp: SftpSession) -> Self {
|
||||
Self { sftp }
|
||||
Self { sftp, shell: None }
|
||||
}
|
||||
|
||||
pub fn with_shell(self, shell: std::sync::Arc<crate::Session>) -> Self {
|
||||
Self { shell: Some(shell), ..self }
|
||||
}
|
||||
|
||||
/// Every file and symlink below `dir` (not into folders named by `prune`) in one `find`
|
||||
/// command instead of a listing per folder. `None` when there is no shell or the host's
|
||||
/// `find` can't do it (no GNU `-printf`, an unreadable folder): list folder by folder then.
|
||||
pub async fn tree(&self, dir: &str, prune: &[String]) -> Option<Vec<RemoteEntry>> {
|
||||
let shell = self.shell.as_ref()?;
|
||||
let out = shell.exec(&tree_command(dir, prune), None).await.ok()?;
|
||||
if !out.success() {
|
||||
return None;
|
||||
}
|
||||
parse_tree(&out.stdout)
|
||||
}
|
||||
|
||||
/// The login directory.
|
||||
@@ -269,10 +287,61 @@ pub fn mtime_of(path: &Path) -> Option<u32> {
|
||||
u32::try_from(t.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs()).ok()
|
||||
}
|
||||
|
||||
/// `find` listing the files and symlinks below `dir` as `type\tsize\tmtime\tpath\0` records.
|
||||
fn tree_command(dir: &str, prune: &[String]) -> String {
|
||||
let mut cmd = format!("LC_ALL=C find {} -mindepth 1 ", crate::shell::quote(dir));
|
||||
if !prune.is_empty() {
|
||||
let names: Vec<String> = prune.iter().map(|p| format!("-name {}", crate::shell::quote(p))).collect();
|
||||
cmd.push_str(&format!("\\( {} \\) -prune -o ", names.join(" -o ")));
|
||||
}
|
||||
cmd.push_str("\\( -type f -o -type l \\) -printf '%y\\t%s\\t%T@\\t%p\\0'");
|
||||
cmd
|
||||
}
|
||||
|
||||
fn parse_tree(out: &[u8]) -> Option<Vec<RemoteEntry>> {
|
||||
let mut entries = Vec::new();
|
||||
for record in out.split(|&b| b == 0).filter(|r| !r.is_empty()) {
|
||||
let record = std::str::from_utf8(record).ok()?;
|
||||
let mut fields = record.splitn(4, '\t');
|
||||
let (kind, size, mtime, path) = (fields.next()?, fields.next()?, fields.next()?, fields.next()?);
|
||||
let mtime = mtime.split('.').next()?.parse::<u64>().ok()?;
|
||||
entries.push(RemoteEntry {
|
||||
name: path.rsplit('/').next().unwrap_or(path).to_owned(),
|
||||
path: path.to_owned(),
|
||||
is_dir: false,
|
||||
is_symlink: kind == "l",
|
||||
size: size.parse().ok()?,
|
||||
mtime: u32::try_from(mtime).ok(),
|
||||
permissions: String::new(),
|
||||
});
|
||||
}
|
||||
Some(entries)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lists_a_tree_with_one_find() {
|
||||
assert_eq!(
|
||||
tree_command("/srv/my app", &[".git".into(), "*.pyc".into()]),
|
||||
"LC_ALL=C find '/srv/my app' -mindepth 1 \\( -name .git -o -name '*.pyc' \\) -prune -o \
|
||||
\\( -type f -o -type l \\) -printf '%y\\t%s\\t%T@\\t%p\\0'"
|
||||
);
|
||||
let out = b"f\t12\t1700000000.5000000000\t/srv/a b\ttab.txt\0l\t7\t1700000001.0\t/srv/link\0";
|
||||
let entries = parse_tree(out).unwrap();
|
||||
assert_eq!(entries.len(), 2);
|
||||
assert_eq!(
|
||||
(entries[0].path.as_str(), entries[0].name.as_str(), entries[0].size, entries[0].mtime),
|
||||
("/srv/a b\ttab.txt", "a b\ttab.txt", 12, Some(1_700_000_000))
|
||||
);
|
||||
assert!(entries[1].is_symlink && !entries[1].is_dir);
|
||||
assert!(parse_tree(b"").unwrap().is_empty());
|
||||
// Not `find -printf` output (BusyBox, an error message): list folder by folder instead.
|
||||
assert!(parse_tree(b"find: unrecognized: -printf\n").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn joins_and_splits_paths() {
|
||||
assert_eq!(join("/srv/app/", "a.txt"), "/srv/app/a.txt");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! An in-process SSH server for tests (feature `testing`): password `dev` /
|
||||
//! `secret`, commands answered by a closure, and SFTP served from a local
|
||||
//! folder (remote `/x` is `<root>/x`).
|
||||
//! `secret`, commands answered by a closure, SFTP served from a local
|
||||
//! folder (remote `/x` is `<root>/x`), and a shell that echoes its input
|
||||
//! (`exit` ends it with status 0).
|
||||
|
||||
use russh::server::{self, Auth, ChannelOpenHandle, Msg, Server as _, Session};
|
||||
use russh::{Channel, ChannelId};
|
||||
@@ -41,7 +42,7 @@ struct Sshd {
|
||||
impl server::Server for Sshd {
|
||||
type Handler = Conn;
|
||||
fn new_client(&mut self, _: Option<std::net::SocketAddr>) -> Conn {
|
||||
Conn { root: self.root.clone(), exec: self.exec.clone(), channels: Arc::default() }
|
||||
Conn { root: self.root.clone(), exec: self.exec.clone(), channels: Arc::default(), shells: Default::default() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +50,7 @@ struct Conn {
|
||||
root: PathBuf,
|
||||
exec: ExecFn,
|
||||
channels: Arc<Mutex<HashMap<ChannelId, Channel<Msg>>>>,
|
||||
shells: std::collections::HashSet<ChannelId>,
|
||||
}
|
||||
|
||||
impl server::Handler for Conn {
|
||||
@@ -84,6 +86,61 @@ impl server::Handler for Conn {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn pty_request(
|
||||
&mut self,
|
||||
channel: ChannelId,
|
||||
_term: &str,
|
||||
cols: u32,
|
||||
rows: u32,
|
||||
_pix_width: u32,
|
||||
_pix_height: u32,
|
||||
_modes: &[(russh::Pty, u32)],
|
||||
session: &mut Session,
|
||||
) -> Result<(), Self::Error> {
|
||||
session.channel_success(channel)?;
|
||||
session.data(channel, format!("pty {cols}x{rows}\r\n").into_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn shell_request(&mut self, channel: ChannelId, session: &mut Session) -> Result<(), Self::Error> {
|
||||
self.shells.insert(channel);
|
||||
session.channel_success(channel)?;
|
||||
session.data(channel, b"$ ".to_vec())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn window_change_request(
|
||||
&mut self,
|
||||
channel: ChannelId,
|
||||
cols: u32,
|
||||
rows: u32,
|
||||
_pix_width: u32,
|
||||
_pix_height: u32,
|
||||
session: &mut Session,
|
||||
) -> Result<(), Self::Error> {
|
||||
session.data(channel, format!("resized {cols}x{rows}\r\n").into_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn data(&mut self, channel: ChannelId, data: &[u8], session: &mut Session) -> Result<(), Self::Error> {
|
||||
if !self.shells.contains(&channel) {
|
||||
return Ok(());
|
||||
}
|
||||
session.data(channel, data.to_vec())?;
|
||||
if String::from_utf8_lossy(data).contains("exit") {
|
||||
self.shells.remove(&channel);
|
||||
session.exit_status_request(channel, 0)?;
|
||||
session.eof(channel)?;
|
||||
session.close(channel)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn channel_close(&mut self, channel: ChannelId, _session: &mut Session) -> Result<(), Self::Error> {
|
||||
self.shells.remove(&channel);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn subsystem_request(
|
||||
&mut self,
|
||||
channel: ChannelId,
|
||||
|
||||
@@ -85,6 +85,8 @@ const FISH_INIT: &str = concat!(
|
||||
r#"function __openidea_preexec --on-event fish_preexec; printf '\e]133;C\a'; end"#,
|
||||
);
|
||||
|
||||
const POWERSHELL_INIT: &str = r#"$openideaClearHost = (Get-Command Clear-Host).ScriptBlock; $openideaClearHandler = { & $openideaClearHost @args; [Console]::Write([char]27 + ']133;OpenIDEA;ClearScrollback' + [char]7) }.GetNewClosure(); Set-Item Function:global:Clear-Host -Value $openideaClearHandler"#;
|
||||
|
||||
/// Directory holding the generated startup files (written on demand).
|
||||
fn scripts_dir() -> PathBuf {
|
||||
std::env::temp_dir().join(format!("openidea-shell-integration-{}", env!("CARGO_PKG_VERSION")))
|
||||
@@ -104,6 +106,13 @@ fn write_if_changed(path: &Path, content: &str) -> std::io::Result<()> {
|
||||
/// resolved arguments; returns `false` (leaving everything untouched) for
|
||||
/// shells without integration or when the scripts cannot be written.
|
||||
pub(crate) fn apply(shell_name: &str, args: &mut Vec<String>, env: &mut BTreeMap<String, String>) -> bool {
|
||||
if cfg!(windows) && (shell_name.eq_ignore_ascii_case("pwsh") || shell_name.eq_ignore_ascii_case("powershell")) {
|
||||
if !args.is_empty() {
|
||||
return false;
|
||||
}
|
||||
args.extend(["-NoExit".into(), "-Command".into(), POWERSHELL_INIT.into()]);
|
||||
return true;
|
||||
}
|
||||
if !cfg!(unix) {
|
||||
return false;
|
||||
}
|
||||
@@ -157,6 +166,23 @@ pub(crate) fn apply(shell_name: &str, args: &mut Vec<String>, env: &mut BTreeMap
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn powershell_clear_integration_only_changes_plain_windows_starts() {
|
||||
for shell in ["pwsh", "powershell", "PwSh"] {
|
||||
let mut args = Vec::new();
|
||||
let mut env = BTreeMap::new();
|
||||
assert_eq!(apply(shell, &mut args, &mut env), cfg!(windows));
|
||||
if cfg!(windows) {
|
||||
assert_eq!(args, ["-NoExit", "-Command", POWERSHELL_INIT]);
|
||||
} else {
|
||||
assert!(args.is_empty());
|
||||
}
|
||||
let mut explicit = vec!["-NoProfile".into()];
|
||||
assert!(!apply(shell, &mut explicit, &mut env));
|
||||
assert_eq!(explicit, ["-NoProfile"]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bash_gets_init_file_and_login_flag() {
|
||||
if cfg!(windows) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Integrated terminal sessions over a native PTY. Rendering is xterm.js.
|
||||
//! Integrated terminal sessions over a native PTY, or over a [`TerminalChannel`]
|
||||
//! such as an SSH shell. Rendering is xterm.js.
|
||||
|
||||
mod integration;
|
||||
mod utf8;
|
||||
@@ -52,14 +53,56 @@ pub struct TerminalInfo {
|
||||
pub pid: Option<u32>,
|
||||
}
|
||||
|
||||
/// A terminal whose shell runs elsewhere (an SSH channel). Its output and exit
|
||||
/// arrive through the [`TerminalFeed`] paired with it by [`feed`].
|
||||
pub trait TerminalChannel: Send + Sync {
|
||||
fn write(&self, data: &[u8]) -> IdeResult<()>;
|
||||
fn resize(&self, cols: u16, rows: u16) -> IdeResult<()>;
|
||||
/// Hangs up; the exit is still reported through the feed.
|
||||
fn close(&self);
|
||||
}
|
||||
|
||||
/// Where a [`TerminalChannel`] reports its output and exit.
|
||||
#[derive(Clone)]
|
||||
pub struct TerminalFeed(mpsc::Sender<Msg>);
|
||||
|
||||
impl TerminalFeed {
|
||||
pub fn output(&self, data: &[u8]) {
|
||||
let _ = self.0.send(Msg::Data(data.to_vec()));
|
||||
}
|
||||
|
||||
pub fn exited(&self, code: Option<i32>) {
|
||||
let _ = self.0.send(Msg::Exited(code));
|
||||
}
|
||||
}
|
||||
|
||||
/// The receiving end of a [`TerminalFeed`], handed to [`TerminalManager::attach`].
|
||||
pub struct FeedReceiver(mpsc::Receiver<Msg>);
|
||||
|
||||
/// A feed for a channel that is about to be opened.
|
||||
pub fn feed() -> (TerminalFeed, FeedReceiver) {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
(TerminalFeed(tx), FeedReceiver(rx))
|
||||
}
|
||||
|
||||
enum Io {
|
||||
Pty {
|
||||
// Cancel input before dropping the master (ClosePseudoConsole on Windows).
|
||||
writer: ide_core::input::InputWriter,
|
||||
master: Mutex<Box<dyn MasterPty + Send>>,
|
||||
killer: Mutex<Box<dyn ChildKiller + Send + Sync>>,
|
||||
},
|
||||
Channel {
|
||||
channel: Box<dyn TerminalChannel>,
|
||||
size: Mutex<(u16, u16)>,
|
||||
},
|
||||
}
|
||||
|
||||
struct Session {
|
||||
/// The window that opened it: closing that window's project closes its terminals.
|
||||
key: String,
|
||||
info: TerminalInfo,
|
||||
// Cancel input before dropping the master (ClosePseudoConsole on Windows).
|
||||
writer: ide_core::input::InputWriter,
|
||||
master: Mutex<Box<dyn MasterPty + Send>>,
|
||||
killer: Mutex<Box<dyn ChildKiller + Send + Sync>>,
|
||||
io: Io,
|
||||
exited: Arc<AtomicBool>,
|
||||
started: Instant,
|
||||
/// The first input was logged (startup diagnostics, see `spawn`).
|
||||
@@ -200,9 +243,7 @@ impl TerminalManager {
|
||||
let session = Arc::new(Session {
|
||||
key,
|
||||
info: info.clone(),
|
||||
master: Mutex::new(pair.master),
|
||||
writer,
|
||||
killer: Mutex::new(child.clone_killer()),
|
||||
io: Io::Pty { writer, master: Mutex::new(pair.master), killer: Mutex::new(child.clone_killer()) },
|
||||
exited: exited.clone(),
|
||||
started: Instant::now(),
|
||||
input_logged: AtomicBool::new(false),
|
||||
@@ -221,7 +262,7 @@ impl TerminalManager {
|
||||
.and_then(|_| spawn_pump(id, rx, on_output, exited, self.sessions.clone(), sink, Instant::now()));
|
||||
if let Err(e) = spawned {
|
||||
if let Some(s) = self.sessions.lock().remove(&id) {
|
||||
let _ = s.killer.lock().kill();
|
||||
terminate(&s);
|
||||
}
|
||||
return Err(IdeError::internal(format!("failed to start terminal threads: {e}")));
|
||||
}
|
||||
@@ -231,6 +272,41 @@ impl TerminalManager {
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// Registers an opened `channel` as terminal `title`; its output (from `feed`)
|
||||
/// is batched and decoded as a PTY's is, and its exit emits `IdeEvent::TerminalExited`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn attach(
|
||||
&self,
|
||||
title: String,
|
||||
size: (u16, u16),
|
||||
channel: Box<dyn TerminalChannel>,
|
||||
feed: FeedReceiver,
|
||||
key: String,
|
||||
sink: SharedSink,
|
||||
on_output: Box<dyn Fn(String) + Send>,
|
||||
) -> IdeResult<TerminalInfo> {
|
||||
let id = TerminalId::next();
|
||||
let info = TerminalInfo { id, title, pid: None };
|
||||
let exited = Arc::new(AtomicBool::new(false));
|
||||
let session = Arc::new(Session {
|
||||
key,
|
||||
info: info.clone(),
|
||||
io: Io::Channel { channel, size: Mutex::new(size) },
|
||||
exited: exited.clone(),
|
||||
started: Instant::now(),
|
||||
input_logged: AtomicBool::new(false),
|
||||
});
|
||||
self.sessions.lock().insert(id, session);
|
||||
if let Err(e) = spawn_pump(id, feed.0, on_output, exited, self.sessions.clone(), sink, Instant::now()) {
|
||||
if let Some(s) = self.sessions.lock().remove(&id) {
|
||||
terminate(&s);
|
||||
}
|
||||
return Err(IdeError::internal(format!("failed to start terminal threads: {e}")));
|
||||
}
|
||||
tracing::info!(%id, title = info.title, "terminal attached");
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
pub fn write(&self, id: TerminalId, data: &str) -> IdeResult<()> {
|
||||
let session = self.session(id)?;
|
||||
if !session.input_logged.swap(true, Ordering::Relaxed) {
|
||||
@@ -238,17 +314,26 @@ impl TerminalManager {
|
||||
let ms = session.started.elapsed().as_millis();
|
||||
tracing::info!(%id, ms, bytes = data.len(), cursor_report, "terminal first input");
|
||||
}
|
||||
session.writer.write(data.as_bytes())?;
|
||||
match &session.io {
|
||||
Io::Pty { writer, .. } => writer.write(data.as_bytes())?,
|
||||
Io::Channel { channel, .. } => channel.write(data.as_bytes())?,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn resize(&self, id: TerminalId, cols: u16, rows: u16) -> IdeResult<()> {
|
||||
let session = self.session(id)?;
|
||||
session
|
||||
.master
|
||||
.lock()
|
||||
.resize(pty_size(cols, rows))
|
||||
.map_err(|e| IdeError::internal(format!("resize failed: {e}")))
|
||||
match &session.io {
|
||||
Io::Pty { master, .. } => master
|
||||
.lock()
|
||||
.resize(pty_size(cols, rows))
|
||||
.map_err(|e| IdeError::internal(format!("resize failed: {e}"))),
|
||||
Io::Channel { channel, size } => {
|
||||
let new = pty_size(cols, rows);
|
||||
*size.lock() = (new.cols, new.rows);
|
||||
channel.resize(new.cols, new.rows)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Closes the terminals `key` opened: the project of that window is closing.
|
||||
@@ -292,8 +377,13 @@ impl TerminalManager {
|
||||
/// Current PTY size of a session (for diagnostics and tests).
|
||||
pub fn size(&self, id: TerminalId) -> IdeResult<(u16, u16)> {
|
||||
let session = self.session(id)?;
|
||||
let size = session.master.lock().get_size().map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
Ok((size.cols, size.rows))
|
||||
match &session.io {
|
||||
Io::Pty { master, .. } => {
|
||||
let size = master.lock().get_size().map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
Ok((size.cols, size.rows))
|
||||
}
|
||||
Io::Channel { size, .. } => Ok(*size.lock()),
|
||||
}
|
||||
}
|
||||
|
||||
fn session(&self, id: TerminalId) -> IdeResult<Arc<Session>> {
|
||||
@@ -321,7 +411,8 @@ fn not_found(id: TerminalId) -> IdeError {
|
||||
|
||||
#[cfg(unix)]
|
||||
fn foreground_busy(session: &Session, pid: u32) -> bool {
|
||||
let leader = session.master.lock().process_group_leader();
|
||||
let Io::Pty { master, .. } = &session.io else { return false };
|
||||
let leader = master.lock().process_group_leader();
|
||||
leader.is_some_and(|fg| u32::try_from(fg).ok() != Some(pid))
|
||||
}
|
||||
|
||||
@@ -391,11 +482,20 @@ mod win {
|
||||
|
||||
/// Hangs up the shell (and its process group), escalating to SIGKILL if it lingers.
|
||||
fn terminate(session: &Session) {
|
||||
session.writer.close();
|
||||
let (writer, killer) = match &session.io {
|
||||
Io::Pty { writer, killer, .. } => (writer, killer),
|
||||
Io::Channel { channel, .. } => {
|
||||
if !session.exited.load(Ordering::Acquire) {
|
||||
channel.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
writer.close();
|
||||
if session.exited.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = session.killer.lock().kill() {
|
||||
if let Err(e) = killer.lock().kill() {
|
||||
tracing::debug!(id = %session.info.id, "terminal kill: {e}");
|
||||
}
|
||||
#[cfg(unix)]
|
||||
@@ -1082,6 +1182,77 @@ mod tests {
|
||||
assert_eq!(env["TERM"], "dumb");
|
||||
assert_eq!(env["COLORTERM"], "truecolor");
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeChannel {
|
||||
log: Arc<Mutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
impl TerminalChannel for FakeChannel {
|
||||
fn write(&self, data: &[u8]) -> IdeResult<()> {
|
||||
self.log.lock().push(format!("write {}", String::from_utf8_lossy(data)));
|
||||
Ok(())
|
||||
}
|
||||
fn resize(&self, cols: u16, rows: u16) -> IdeResult<()> {
|
||||
self.log.lock().push(format!("resize {cols}x{rows}"));
|
||||
Ok(())
|
||||
}
|
||||
fn close(&self) {
|
||||
self.log.lock().push("close".into());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attached_channel_gets_input_and_resizes_and_reports_output_and_exit() {
|
||||
let h = harness();
|
||||
let channel = FakeChannel::default();
|
||||
let log = channel.log.clone();
|
||||
let (feed, rx) = feed();
|
||||
let (tx, output) = mpsc::channel();
|
||||
let info = h
|
||||
.manager
|
||||
.attach(
|
||||
"host".into(),
|
||||
(80, 24),
|
||||
Box::new(channel),
|
||||
rx,
|
||||
"test".into(),
|
||||
h.sink.clone(),
|
||||
Box::new(move |s| {
|
||||
let _ = tx.send(s);
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((info.title.as_str(), info.pid), ("host", None));
|
||||
assert!(!h.manager.is_busy(info.id).unwrap());
|
||||
h.manager.write(info.id, "ls\r").unwrap();
|
||||
h.manager.resize(info.id, 120, 40).unwrap();
|
||||
assert_eq!(h.manager.size(info.id).unwrap(), (120, 40));
|
||||
// A character split across two chunks arrives whole.
|
||||
feed.output("pr\u{e9}".as_bytes().split_at(3).0);
|
||||
feed.output(&"pr\u{e9}mpt".as_bytes()[3..]);
|
||||
assert_eq!(read_until(&output, "mpt"), "pr\u{e9}mpt");
|
||||
feed.exited(Some(3));
|
||||
assert_eq!(wait_exit(&h, info.id), Some(3));
|
||||
assert!(!h.manager.contains(info.id));
|
||||
assert_eq!(*log.lock(), ["write ls\r", "resize 120x40"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn killing_an_attached_channel_hangs_it_up() {
|
||||
let h = harness();
|
||||
let channel = FakeChannel::default();
|
||||
let log = channel.log.clone();
|
||||
let (feed, rx) = feed();
|
||||
let info = h
|
||||
.manager
|
||||
.attach("host".into(), (80, 24), Box::new(channel), rx, "test".into(), h.sink.clone(), Box::new(|_| {}))
|
||||
.unwrap();
|
||||
h.manager.kill(info.id).unwrap();
|
||||
assert_eq!(*log.lock(), ["close"]);
|
||||
drop(feed);
|
||||
assert_eq!(wait_exit(&h, info.id), None);
|
||||
}
|
||||
}
|
||||
|
||||
/// `resolve_shell` / `default_msystem` / `default_chere_invoking` behave the
|
||||
|
||||
@@ -3,7 +3,7 @@ title: "Databases"
|
||||
description: "Connect to SQL databases, browse schemas, run queries in a console, and view, edit, import and export data."
|
||||
---
|
||||
|
||||
The **Database** tool window connects to SQLite, PostgreSQL, MySQL/MariaDB, Microsoft SQL Server, Oracle and ClickHouse, plus databases that speak one of their protocols. Drivers are built into the IDE; there are no JDBC drivers to download.
|
||||
The **Database** tool window connects to SQLite, PostgreSQL, MySQL/MariaDB, Microsoft SQL Server, Oracle, ClickHouse and Snowflake, plus databases that speak one of their protocols. Drivers are built into the IDE; there are no JDBC drivers to download.
|
||||
|
||||
Open it from **Tools › Database** or **View › Tool Windows › Database**. The **Tools** menu also has **New Data Source…** and **Open Query Console**.
|
||||
|
||||
@@ -18,6 +18,7 @@ Open it from **Tools › Database** or **View › Tool Windows › Database**. T
|
||||
| SQL Server | Microsoft SQL Server |
|
||||
| Oracle | Oracle 19 or later (built-in thin driver, no client library) |
|
||||
| ClickHouse | ClickHouse (over HTTP) |
|
||||
| Snowflake | Snowflake (over HTTPS, see below) |
|
||||
| SQLite | SQLite |
|
||||
|
||||
Presets set the default port and, where needed, SSL (for example Redshift, Neon and Supabase require SSL).
|
||||
@@ -109,9 +110,9 @@ The console toolbar has:
|
||||
- **Browse Query History**: the last 500 statements per data source, with search and **Paste**.
|
||||
- A row limit (100 to 50,000; default 500).
|
||||
- **Tx: Auto** / **Tx: Manual**. In manual mode **Commit** and **Roll Back** appear.
|
||||
- A schema picker (PostgreSQL, MySQL, SQL Server, Oracle) and the data source picker.
|
||||
- A schema picker (PostgreSQL, MySQL, SQL Server, Oracle, Snowflake) and the data source picker. Snowflake consoles also have warehouse and role pickers.
|
||||
|
||||
Statements are split on `;`, respecting strings, comments and PostgreSQL dollar quoting. Oracle PL/SQL blocks are kept whole, and a line with only `/` ends a statement. Parameters such as `:id`, `?`, `$1` or `@name` open a **Parameters** dialog; values are inserted as SQL text, so quote strings.
|
||||
Statements are split on `;`, respecting strings, comments and PostgreSQL dollar quoting. Oracle PL/SQL blocks are kept whole, and a line with only `/` ends a statement. Snowflake `$$` bodies and Snowflake Scripting blocks (`BEGIN … END`, `DECLARE …`) are kept whole too. Parameters such as `:id`, `?`, `$1` or `@name` open a **Parameters** dialog; values are inserted as SQL text, so quote strings.
|
||||
|
||||
SQL completion knows the connected schema: tables after `FROM`/`JOIN` (with join conditions from foreign keys), columns in scope, and each dialect's keywords and functions. Unresolved tables and columns are flagged, and Go to Declaration, Quick Documentation and Reformat Code work in the console. See [SQL](/en/languages/sql) for SQL files in general.
|
||||
|
||||
@@ -157,9 +158,28 @@ setting the same password again (`ALTER USER … IDENTIFIED BY …`) creates the
|
||||
The tree shows packages (their DDL is the specification and body) next to tables, views, routines and sequences.
|
||||
Oracle schemas are database users; system accounts appear only with **Show internal system schemas**.
|
||||
|
||||
## Snowflake
|
||||
|
||||
Choose **Data Source › Snowflake** and fill in:
|
||||
|
||||
- **Account:** the account identifier (`myorg-myaccount`, or `xy12345.eu-central-1`), or the account's host name.
|
||||
- **User**, and optionally **Database**, **Schema**, **Warehouse** and **Role** (empty: the user's defaults).
|
||||
- **Authentication:**
|
||||
- **Key pair** — the RSA private key file (PKCS#8 `.p8`, encrypted or not, or PKCS#1) and its passphrase. Only the path is saved; the key is read at each sign-in.
|
||||
- **Single sign-on (web browser)** — opens the default browser on your identity provider's page. Not available in remote projects.
|
||||
- **Password**, **Programmatic access token** or **OAuth token**.
|
||||
|
||||
The passphrase, password or token is kept in the operating system's credential store when **Save password** is on, and never written to the data source file. A JDBC URL (`jdbc:snowflake://…?warehouse=…&db=…&role=…`) typed in the **URL** field fills the settings.
|
||||
|
||||
The tree shows databases, then schemas with their tables, views, routines, sequences, stages, streams, tasks, pipes and file formats. It reads them with `SHOW` commands, which need no running warehouse and list what the role may see. All databases of a data source share one sign-in; consoles and the tree use separate sessions, so a long query does not block browsing.
|
||||
|
||||
**Advanced** properties are session parameters (for example `QUERY_TAG`, `STATEMENT_TIMEOUT_IN_SECONDS`).
|
||||
|
||||
Limits: `TIMESTAMP_LTZ` values are shown in UTC; SSH tunnels are not offered; results are read in JSON.
|
||||
|
||||
## Not supported
|
||||
|
||||
- MongoDB, Redis, Snowflake, BigQuery and other non-listed databases; JDBC drivers.
|
||||
- MongoDB, Redis, BigQuery and other non-listed databases; JDBC drivers.
|
||||
- SQL Server `GO` batch separators.
|
||||
- A "run the whole console" action (use a selection, or **Run SQL Script…**).
|
||||
- Exporting more than the loaded page.
|
||||
|
||||
@@ -115,8 +115,9 @@ and its `postStartCommand` runs once each time the server starts.
|
||||
## Start an SSH terminal
|
||||
|
||||
**Tools › Start SSH Session…** (or **SSH Session…** in the Terminal tool window's new-tab menu) picks an SSH
|
||||
configuration and opens a terminal tab that runs `ssh` for it. This uses the OpenSSH client on your machine, so `ssh`
|
||||
must be on your `PATH`. It does not open a remote project.
|
||||
configuration and opens a shell on that host in a terminal tab. The IDE's built-in SSH client connects with the
|
||||
configuration's saved password, key or agent, and asks for a password or passphrase that isn't saved, or to trust an
|
||||
unknown host key. No OpenSSH client is needed. It does not open a remote project.
|
||||
|
||||
## Connect to WSL (Windows)
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ Open **Settings › Appearance & Behavior › Appearance** and pick a **Theme**.
|
||||
| ----------------------- | -------------- |
|
||||
| Islands Light (default) | Islands Dark |
|
||||
| Solarized Light | Solarized Dark |
|
||||
| One Light | One Dark |
|
||||
| | High Contrast |
|
||||
|
||||
Themes from installed plugins appear in the same list, under the plugin's name.
|
||||
|
||||
@@ -13,7 +13,7 @@ The IDE does not pass your data sources to sqls. Without its own configuration f
|
||||
|
||||
## SQL files and data sources
|
||||
|
||||
Define data sources in the **Database** tool window first (see [Database](/en/guide/database)). Supported databases are SQLite, PostgreSQL, MySQL and MariaDB, Microsoft SQL Server, ClickHouse and Oracle, plus compatible products such as Amazon Aurora, Redshift, CockroachDB, TiDB and Supabase.
|
||||
Define data sources in the **Database** tool window first (see [Database](/en/guide/database)). Supported databases are SQLite, PostgreSQL, MySQL and MariaDB, Microsoft SQL Server, ClickHouse, Oracle and Snowflake, plus compatible products such as Amazon Aurora, Redshift, CockroachDB, TiDB and Supabase.
|
||||
|
||||
Every editable SQL file shows a toolbar above the editor. Its **Data source** list binds the file to one data source. With only one data source configured, files use it automatically. **Query Console** (`⌘⇧L` / `Ctrl+Shift+Q`) opens the query console of the data source selected in the Database tool window (or the only one).
|
||||
|
||||
|
||||
@@ -3,9 +3,8 @@ title: "Themes"
|
||||
description: "Write a colour theme plugin: the theme and editor scheme files, how themes are applied, importing IntelliJ themes, porting VS Code themes, and the full token reference."
|
||||
---
|
||||
|
||||
OpenIDEA ships seven built-in themes: Islands Light, Islands Dark, High Contrast,
|
||||
Solarized Light, Solarized Dark, One Light and One Dark. Other themes come from **theme
|
||||
plugins**.
|
||||
OpenIDEA ships five built-in themes: Islands Light, Islands Dark, High Contrast,
|
||||
Solarized Light and Solarized Dark. Other themes come from **theme plugins**.
|
||||
|
||||
A theme plugin is data only. Like an IntelliJ theme (a `.theme.json` plus an optional
|
||||
`.icls` editor scheme), it has no code: no WebAssembly, nothing to compile, no
|
||||
|
||||
@@ -94,25 +94,25 @@ Settings › Appearance & Behavior › System Settings, unless noted.
|
||||
|
||||
Settings › Appearance & Behavior › Appearance, unless noted.
|
||||
|
||||
| Key | Default | Description |
|
||||
| -------------------------------------------- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `theme` | `"light"` | `light` (Islands Light), `dark` (Islands Dark), `dark-hc` (High Contrast), `solarized-light`, `solarized-dark`, `one-light`, `one-dark`, or a plugin theme as `plugin:<plugin-id>/<theme-id>`. |
|
||||
| `syncWithOs` | `false` | **Sync with OS**. |
|
||||
| `preferredLightTheme` / `preferredDarkTheme` | `"light"` / `"dark"` | Themes used with **Sync with OS**. |
|
||||
| `accent` | `"#3871e1"` | **Accent color**. |
|
||||
| `fileIconTheme` | `""` | A plugin's file icon theme as `<plugin-id>/<theme-id>`; empty uses the built-in icons. |
|
||||
| `zoom` | `100` | **Zoom**, in percent (50 to 300). |
|
||||
| `useCustomFont` | `false` | **Use custom font:** apply `uiFontFamily` and `uiFontSize`. |
|
||||
| `uiFontFamily` / `uiFontSize` | `"Inter"` / `13` | UI font. |
|
||||
| `uiFontFallback` | `""` | **UI fallback font**. |
|
||||
| `contrastScrollbars` | `false` | **Use contrast scrollbars**. |
|
||||
| `redGreenVision` | `false` | **Adjust colors for red-green vision deficiency**. |
|
||||
| `compactMode` | `false` | **Compact mode**. |
|
||||
| `showMainToolbar` / `showStatusBar` | `true` / `true` | **Show main toolbar**, **Show status bar**. |
|
||||
| `navigationBar` | `"statusBar"` | **Navigation bar**: `top`, `statusBar` or `hidden`. |
|
||||
| `mainMenuStyle` | `"hamburger"` | **Main menu**: `hamburger` or `merged` (with the main toolbar). |
|
||||
| `ideAntialiasing` / `editorAntialiasing` | `"greyscale"` | `subpixel`, `greyscale` or `none`. |
|
||||
| `locale` | `"en"` | **Language** (Language and Region page): `en`, `zh`, `ko`, `fr`, `pl`, `pt`, `es`. |
|
||||
| Key | Default | Description |
|
||||
| -------------------------------------------- | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `theme` | `"light"` | `light` (Islands Light), `dark` (Islands Dark), `dark-hc` (High Contrast), `solarized-light`, `solarized-dark`, or a plugin theme as `plugin:<plugin-id>/<theme-id>`. |
|
||||
| `syncWithOs` | `false` | **Sync with OS**. |
|
||||
| `preferredLightTheme` / `preferredDarkTheme` | `"light"` / `"dark"` | Themes used with **Sync with OS**. |
|
||||
| `accent` | `"#3871e1"` | **Accent color**. |
|
||||
| `fileIconTheme` | `""` | A plugin's file icon theme as `<plugin-id>/<theme-id>`; empty uses the built-in icons. |
|
||||
| `zoom` | `100` | **Zoom**, in percent (50 to 300). |
|
||||
| `useCustomFont` | `false` | **Use custom font:** apply `uiFontFamily` and `uiFontSize`. |
|
||||
| `uiFontFamily` / `uiFontSize` | `"Inter"` / `13` | UI font. |
|
||||
| `uiFontFallback` | `""` | **UI fallback font**. |
|
||||
| `contrastScrollbars` | `false` | **Use contrast scrollbars**. |
|
||||
| `redGreenVision` | `false` | **Adjust colors for red-green vision deficiency**. |
|
||||
| `compactMode` | `false` | **Compact mode**. |
|
||||
| `showMainToolbar` / `showStatusBar` | `true` / `true` | **Show main toolbar**, **Show status bar**. |
|
||||
| `navigationBar` | `"statusBar"` | **Navigation bar**: `top`, `statusBar` or `hidden`. |
|
||||
| `mainMenuStyle` | `"hamburger"` | **Main menu**: `hamburger` or `merged` (with the main toolbar). |
|
||||
| `ideAntialiasing` / `editorAntialiasing` | `"greyscale"` | `subpixel`, `greyscale` or `none`. |
|
||||
| `locale` | `"en"` | **Language** (Language and Region page): `en`, `zh`, `ko`, `fr`, `pl`, `pt`, `es`. |
|
||||
|
||||
### `[editor]`
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ title: "数据库"
|
||||
description: "连接 SQL 数据库、浏览架构、在查询控制台中执行查询,以及查看、编辑、导入和导出数据。"
|
||||
---
|
||||
|
||||
**数据库** 工具窗口可以连接 SQLite、PostgreSQL、MySQL/MariaDB、Microsoft SQL Server、Oracle 和 ClickHouse,以及兼容这些协议的数据库。驱动内置在 IDE 中,不需要下载 JDBC 驱动。
|
||||
**数据库** 工具窗口可以连接 SQLite、PostgreSQL、MySQL/MariaDB、Microsoft SQL Server、Oracle、ClickHouse 和 Snowflake,以及兼容这些协议的数据库。驱动内置在 IDE 中,不需要下载 JDBC 驱动。
|
||||
|
||||
通过 **工具 › 数据库** 或 **视图 › 工具窗口 › 数据库** 打开。**工具** 菜单中还有 **新建数据源…** 和 **Open Query Console**(打开查询控制台)。
|
||||
|
||||
@@ -18,6 +18,7 @@ description: "连接 SQL 数据库、浏览架构、在查询控制台中执行
|
||||
| SQL Server | Microsoft SQL Server |
|
||||
| Oracle | Oracle 19 及以上(内置 thin 驱动,无需客户端库) |
|
||||
| ClickHouse | ClickHouse(通过 HTTP) |
|
||||
| Snowflake | Snowflake(通过 HTTPS,见下文) |
|
||||
| SQLite | SQLite |
|
||||
|
||||
预设会填好默认端口,必要时还会设置 SSL(例如 Redshift、Neon 和 Supabase 要求 SSL)。
|
||||
@@ -109,7 +110,7 @@ description: "连接 SQL 数据库、浏览架构、在查询控制台中执行
|
||||
- **浏览查询历史记录**:每个数据源最近 500 条语句,可搜索并粘贴回控制台。
|
||||
- 行数上限(100 到 50,000,默认 500)。
|
||||
- **事务:自动** / **事务:手动**。手动模式下会出现 **提交** 和 **回滚**。
|
||||
- 架构选择器(PostgreSQL、MySQL、SQL Server、Oracle)和数据源选择器。
|
||||
- 架构选择器(PostgreSQL、MySQL、SQL Server、Oracle、Snowflake)和数据源选择器。Snowflake 控制台还提供仓库和角色选择器。
|
||||
|
||||
语句按 `;` 拆分,会正确处理字符串、注释和 PostgreSQL 的美元引号。Oracle 的 PL/SQL 块保持完整,只有 `/` 的一行表示语句结束。`:id`、`?`、`$1`、`@name` 等参数会弹出 **参数** 对话框;值按 SQL 文本插入,字符串需要自己加引号。
|
||||
|
||||
@@ -153,9 +154,28 @@ Oracle 连接使用内置的纯 Rust thin 驱动,不需要 Oracle 客户端库
|
||||
|
||||
Oracle 的架构就是数据库用户;系统账户只在勾选 **显示内部系统架构** 后显示。
|
||||
|
||||
## Snowflake
|
||||
|
||||
选择 **数据源 › Snowflake** 并填写:
|
||||
|
||||
- **账户:** 账户标识符(`myorg-myaccount` 或 `xy12345.eu-central-1`),或账户主机名。
|
||||
- **用户**,以及可选的 **数据库**、**模式**、**仓库** 和 **角色**(留空则使用用户的默认值)。
|
||||
- **身份验证:**
|
||||
- **密钥对** — RSA 私钥文件(PKCS#8 `.p8`,加密或未加密,或 PKCS#1)及其密码短语。只保存路径,每次登录时读取密钥。
|
||||
- **单点登录(网页浏览器)** — 在默认浏览器中打开身份提供方页面。远程项目中不可用。
|
||||
- **密码**、**编程访问令牌** 或 **OAuth 令牌**。
|
||||
|
||||
勾选 **保存密码** 时,密码短语、密码或令牌保存在操作系统的凭据存储中,不会写入数据源文件。在 **URL** 字段中输入 JDBC URL(`jdbc:snowflake://…?warehouse=…&db=…&role=…`)会填充各项设置。
|
||||
|
||||
树中依次显示数据库、架构及其表、视图、例程、序列、暂存区、流、任务、管道和文件格式。这些信息通过 `SHOW` 命令读取,不需要运行中的仓库,只列出当前角色可见的对象。一个数据源的所有数据库共用一次登录;控制台和树使用各自的会话,长时间查询不会阻塞浏览。
|
||||
|
||||
**高级** 属性是会话参数(例如 `QUERY_TAG`、`STATEMENT_TIMEOUT_IN_SECONDS`)。
|
||||
|
||||
限制:`TIMESTAMP_LTZ` 值以 UTC 显示;不提供 SSH 隧道;结果以 JSON 读取。
|
||||
|
||||
## 暂不支持
|
||||
|
||||
- MongoDB、Redis、Snowflake、BigQuery 等未列出的数据库,以及 JDBC 驱动。
|
||||
- MongoDB、Redis、BigQuery 等未列出的数据库,以及 JDBC 驱动。
|
||||
- SQL Server 的 `GO` 批处理分隔符。
|
||||
- “执行整个控制台”的操作(请选中后执行,或使用 **运行 SQL 脚本…**)。
|
||||
- 导出超出已加载页面的数据。
|
||||
|
||||
@@ -90,8 +90,9 @@ OpenIDEA 自带 SSH 客户端,自行读取 `~/.ssh/config`,支持 `Host`、`
|
||||
|
||||
## 打开 SSH 终端
|
||||
|
||||
**工具 › 启动 SSH 会话…**(或终端工具窗口新建标签菜单里的 **SSH 会话…**)会让你选择一个 SSH 配置,并在终端标签页中运行对应的 `ssh`
|
||||
命令。这里用的是本机的 OpenSSH 客户端,所以 `ssh` 必须在 `PATH` 中。它不会打开远程项目。
|
||||
**工具 › 启动 SSH 会话…**(或终端工具窗口新建标签菜单里的 **SSH 会话…**)会让你选择一个 SSH 配置,并在终端标签页中打开该主机的
|
||||
Shell。IDE 内置的 SSH 客户端会使用配置里保存的密码、密钥或代理连接;密码或密码短语没有保存时会询问,遇到未知的主机密钥会询问是否信任。
|
||||
不需要本机安装 OpenSSH 客户端。它不会打开远程项目。
|
||||
|
||||
## 连接 WSL(Windows)
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ description: "选择界面主题和编辑器配色方案,导入 IntelliJ 主
|
||||
| -------------------- | -------------- |
|
||||
| Islands 浅色(默认) | Islands 深色 |
|
||||
| Solarized 浅色 | Solarized 深色 |
|
||||
| One 浅色 | One 深色 |
|
||||
| | 高对比度 |
|
||||
|
||||
已安装插件提供的主题也会出现在这个列表中,归在插件名下。
|
||||
|
||||
@@ -13,7 +13,7 @@ IDE 不会把数据源传给 sqls。sqls 没有自己的配置文件时并不知
|
||||
|
||||
## SQL 文件与数据源
|
||||
|
||||
先在 **数据库** 工具窗口中添加数据源(见[数据库](/zh/guide/database))。支持 SQLite、PostgreSQL、MySQL 与 MariaDB、Microsoft SQL Server、ClickHouse 和 Oracle,以及 Amazon Aurora、Redshift、CockroachDB、TiDB、Supabase 等兼容产品。
|
||||
先在 **数据库** 工具窗口中添加数据源(见[数据库](/zh/guide/database))。支持 SQLite、PostgreSQL、MySQL 与 MariaDB、Microsoft SQL Server、ClickHouse、Oracle 和 Snowflake,以及 Amazon Aurora、Redshift、CockroachDB、TiDB、Supabase 等兼容产品。
|
||||
|
||||
每个可编辑的 SQL 文件上方都有一条工具栏,其中的 **数据源** 下拉框把文件绑定到某个数据源。只配置了一个数据源时,文件会自动使用它。**查询控制台**(`⌘⇧L` / `Ctrl+Shift+Q`)打开数据库工具窗口中所选数据源(或唯一的数据源)的查询控制台。
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ title: "主题"
|
||||
description: "编写配色主题插件:主题文件与编辑器配色方案文件、主题如何生效、导入 IntelliJ 主题、移植 VS Code 主题,以及完整的 token 参考。"
|
||||
---
|
||||
|
||||
OpenIDEA 内置七个主题:Islands 浅色、Islands 深色、高对比度、Solarized 浅色、Solarized 深色、One 浅色和 One 深色。
|
||||
OpenIDEA 内置五个主题:Islands 浅色、Islands 深色、高对比度、Solarized 浅色和 Solarized 深色。
|
||||
其他主题都来自 **主题插件**。
|
||||
|
||||
主题插件是纯数据插件。和 IntelliJ 主题(一个 `.theme.json` 加一个可选的 `.icls` 编辑器配色方案)一样,它没有代码:
|
||||
|
||||
@@ -94,25 +94,25 @@ exclude = ["**/.git", "**/node_modules", "**/target", "**/dist"]
|
||||
|
||||
除特别说明外,对应 设置 › 外观与行为 › 外观。
|
||||
|
||||
| 键 | 默认值 | 说明 |
|
||||
| -------------------------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `theme` | `"light"` | `light`(Islands 浅色)、`dark`(Islands 深色)、`dark-hc`(高对比度)、`solarized-light`、`solarized-dark`、`one-light`、`one-dark`,或以 `plugin:<plugin-id>/<theme-id>` 表示的插件主题。 |
|
||||
| `syncWithOs` | `false` | **与操作系统同步**。 |
|
||||
| `preferredLightTheme` / `preferredDarkTheme` | `"light"` / `"dark"` | 与操作系统同步时使用的主题。 |
|
||||
| `accent` | `"#3871e1"` | **强调色**。 |
|
||||
| `fileIconTheme` | `""` | 插件提供的文件图标主题,写作 `<plugin-id>/<theme-id>`;为空时使用内置图标。 |
|
||||
| `zoom` | `100` | **缩放**,百分比(50 到 300)。 |
|
||||
| `useCustomFont` | `false` | **使用自定义字体:**,启用 `uiFontFamily` 和 `uiFontSize`。 |
|
||||
| `uiFontFamily` / `uiFontSize` | `"Inter"` / `13` | 界面字体。 |
|
||||
| `uiFontFallback` | `""` | **UI 备用字体**。 |
|
||||
| `contrastScrollbars` | `false` | **使用对比度滚动条**。 |
|
||||
| `redGreenVision` | `false` | **针对红绿色觉缺陷调整颜色**。 |
|
||||
| `compactMode` | `false` | **紧凑模式**。 |
|
||||
| `showMainToolbar` / `showStatusBar` | `true` / `true` | **显示主工具栏**、**显示状态栏**。 |
|
||||
| `navigationBar` | `"statusBar"` | **导航栏**:`top`、`statusBar` 或 `hidden`。 |
|
||||
| `mainMenuStyle` | `"hamburger"` | **主菜单**:`hamburger`(汉堡按钮)或 `merged`(与主工具栏合并)。 |
|
||||
| `ideAntialiasing` / `editorAntialiasing` | `"greyscale"` | `subpixel`、`greyscale` 或 `none`。 |
|
||||
| `locale` | `"en"` | **语言**(语言和地区页):`en`、`zh`、`ko`、`fr`、`pl`、`pt`、`es`。 |
|
||||
| 键 | 默认值 | 说明 |
|
||||
| -------------------------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `theme` | `"light"` | `light`(Islands 浅色)、`dark`(Islands 深色)、`dark-hc`(高对比度)、`solarized-light`、`solarized-dark`,或以 `plugin:<plugin-id>/<theme-id>` 表示的插件主题。 |
|
||||
| `syncWithOs` | `false` | **与操作系统同步**。 |
|
||||
| `preferredLightTheme` / `preferredDarkTheme` | `"light"` / `"dark"` | 与操作系统同步时使用的主题。 |
|
||||
| `accent` | `"#3871e1"` | **强调色**。 |
|
||||
| `fileIconTheme` | `""` | 插件提供的文件图标主题,写作 `<plugin-id>/<theme-id>`;为空时使用内置图标。 |
|
||||
| `zoom` | `100` | **缩放**,百分比(50 到 300)。 |
|
||||
| `useCustomFont` | `false` | **使用自定义字体:**,启用 `uiFontFamily` 和 `uiFontSize`。 |
|
||||
| `uiFontFamily` / `uiFontSize` | `"Inter"` / `13` | 界面字体。 |
|
||||
| `uiFontFallback` | `""` | **UI 备用字体**。 |
|
||||
| `contrastScrollbars` | `false` | **使用对比度滚动条**。 |
|
||||
| `redGreenVision` | `false` | **针对红绿色觉缺陷调整颜色**。 |
|
||||
| `compactMode` | `false` | **紧凑模式**。 |
|
||||
| `showMainToolbar` / `showStatusBar` | `true` / `true` | **显示主工具栏**、**显示状态栏**。 |
|
||||
| `navigationBar` | `"statusBar"` | **导航栏**:`top`、`statusBar` 或 `hidden`。 |
|
||||
| `mainMenuStyle` | `"hamburger"` | **主菜单**:`hamburger`(汉堡按钮)或 `merged`(与主工具栏合并)。 |
|
||||
| `ideAntialiasing` / `editorAntialiasing` | `"greyscale"` | `subpixel`、`greyscale` 或 `none`。 |
|
||||
| `locale` | `"en"` | **语言**(语言和地区页):`en`、`zh`、`ko`、`fr`、`pl`、`pt`、`es`。 |
|
||||
|
||||
### `[editor]`
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
|
||||
test("configures a Snowflake source with key-pair authentication", 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, password: unknown) => {
|
||||
document.documentElement.dataset.testedSource = JSON.stringify(source);
|
||||
document.documentElement.dataset.testedSecret = JSON.stringify(password);
|
||||
return { status: "ok", data: "Snowflake 10.0.0" };
|
||||
};
|
||||
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();
|
||||
// The type list is virtualised: reach the entry by typing, then confirm it.
|
||||
await page.keyboard.type("snowflake");
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
// The account has no default, an HTTPS driver has no SSH tunnel, and a key pair is the default method.
|
||||
const testButton = dialog.getByRole("button", { name: "Test Connection", exact: true });
|
||||
await expect(dialog.getByText("Account:", { exact: true })).toBeVisible();
|
||||
await expect(dialog.locator("#db-host")).toHaveValue("");
|
||||
await expect(dialog.locator("#db-port")).toHaveAttribute("placeholder", "443");
|
||||
await expect(dialog.getByRole("tab", { name: "SSH/SSL", exact: true })).toHaveCount(0);
|
||||
await expect(dialog.getByRole("button", { name: "Snowflake authentication", exact: true })).toHaveText("Key pair");
|
||||
await expect(dialog.getByText("Passphrase:", { exact: true })).toBeVisible();
|
||||
await expect(testButton).toBeDisabled();
|
||||
|
||||
await dialog.locator("#db-host").fill("myorg-myaccount");
|
||||
await dialog.locator("#db-user").fill("jane");
|
||||
await expect(testButton).toBeDisabled();
|
||||
await dialog.locator("#db-snowflake-key").fill("~/.ssh/rsa_key.p8");
|
||||
await expect(testButton).toBeEnabled();
|
||||
await dialog.locator("#db-password").fill("key passphrase");
|
||||
await dialog.locator("#db-database").fill("ANALYTICS");
|
||||
await dialog.locator("#db-snowflake-schema").fill("PUBLIC");
|
||||
await dialog.locator("#db-snowflake-warehouse").fill("COMPUTE_WH");
|
||||
await dialog.locator("#db-snowflake-role").fill("ANALYST");
|
||||
await expect(dialog.locator("#db-url")).toHaveValue(
|
||||
"jdbc:snowflake://myorg-myaccount.snowflakecomputing.com/?db=ANALYTICS&schema=PUBLIC&warehouse=COMPUTE_WH&role=ANALYST",
|
||||
);
|
||||
await dialog.screenshot({ path: info.outputPath("snowflake-source-key-pair.png") });
|
||||
|
||||
await testButton.click();
|
||||
await expect(dialog.getByRole("status")).toHaveText("Succeeded: Snowflake 10.0.0");
|
||||
expect(await page.evaluate(() => JSON.parse(document.documentElement.dataset.testedSource!))).toMatchObject({
|
||||
driver: "snowflake",
|
||||
host: "myorg-myaccount",
|
||||
user: "jane",
|
||||
database: "ANALYTICS",
|
||||
options: { defaultSchema: "PUBLIC" },
|
||||
snowflake: { warehouse: "COMPUTE_WH", role: "ANALYST", auth: "keyPair", privateKeyFile: "~/.ssh/rsa_key.p8" },
|
||||
});
|
||||
expect(await page.evaluate(() => JSON.parse(document.documentElement.dataset.testedSecret!))).toBe("key passphrase");
|
||||
|
||||
// Other methods keep the one secret field and name what it holds.
|
||||
await dialog.getByRole("button", { name: "Snowflake authentication", exact: true }).click();
|
||||
await page.getByRole("option", { name: "Programmatic access token", exact: true }).click();
|
||||
await expect(dialog.locator("#db-snowflake-key")).toHaveCount(0);
|
||||
await expect(dialog.getByText("Token:", { exact: true })).toBeVisible();
|
||||
await expect(dialog.locator("#db-password")).toHaveAttribute("type", "password");
|
||||
await dialog.screenshot({ path: info.outputPath("snowflake-source-token.png") });
|
||||
|
||||
// Browser sign-in has nothing to type: the secret field goes away.
|
||||
await dialog.getByRole("button", { name: "Snowflake authentication", exact: true }).click();
|
||||
await page.getByRole("option", { name: "Single sign-on (web browser)", exact: true }).click();
|
||||
await expect(dialog.locator("#db-password")).toBeHidden();
|
||||
await expect(dialog.getByText("Opens your web browser to sign in", { exact: false })).toBeVisible();
|
||||
await expect(testButton).toBeEnabled();
|
||||
await dialog.screenshot({ path: info.outputPath("snowflake-source-browser.png") });
|
||||
|
||||
// A pasted JDBC URL fills the settings it names.
|
||||
await dialog
|
||||
.locator("#db-url")
|
||||
.fill(
|
||||
"jdbc:snowflake://xy12345.eu-central-1.snowflakecomputing.com/?db=RAW&warehouse=LOAD_WH&role=LOADER&schema=STAGE",
|
||||
);
|
||||
await expect(dialog.locator("#db-host")).toHaveValue("xy12345.eu-central-1.snowflakecomputing.com");
|
||||
await expect(dialog.locator("#db-database")).toHaveValue("RAW");
|
||||
await expect(dialog.locator("#db-snowflake-schema")).toHaveValue("STAGE");
|
||||
await expect(dialog.locator("#db-snowflake-warehouse")).toHaveValue("LOAD_WH");
|
||||
await expect(dialog.locator("#db-snowflake-role")).toHaveValue("LOADER");
|
||||
});
|
||||
@@ -51,12 +51,12 @@ test("stripe tooltips stay inside the viewport with a gap on either side and at
|
||||
|
||||
test("stripe themes inherit base tooltip tokens and keep shortcut text readable", async ({ workbench: page }) => {
|
||||
const button = page.locator('[data-tooltip-stripe="left"][data-tooltip-shortcut]').first();
|
||||
for (const theme of ["dark", "light", "dark-hc", "solarized-light", "solarized-dark", "one-light", "one-dark"]) {
|
||||
for (const theme of ["dark", "light", "dark-hc", "solarized-light", "solarized-dark"]) {
|
||||
await page.mouse.move(700, 400);
|
||||
await page.evaluate((theme) => {
|
||||
const root = document.documentElement;
|
||||
root.dataset.theme = theme.endsWith("light") ? "light" : theme === "dark-hc" ? "dark-hc" : "dark";
|
||||
if (theme.includes("-light") || ["solarized-dark", "one-dark"].includes(theme)) root.dataset.colorTheme = theme;
|
||||
if (theme.includes("-light") || theme === "solarized-dark") root.dataset.colorTheme = theme;
|
||||
else delete root.dataset.colorTheme;
|
||||
}, theme);
|
||||
await button.hover();
|
||||
|
||||
+13
-45
@@ -20,22 +20,6 @@ const palettes = [
|
||||
fg: "rgb(131, 148, 150)",
|
||||
frame: [7, 54, 66, 255],
|
||||
},
|
||||
{
|
||||
id: "one-light",
|
||||
label: "One Light",
|
||||
kind: "light",
|
||||
bg: "rgb(250, 250, 250)",
|
||||
fg: "rgb(56, 58, 66)",
|
||||
frame: [234, 234, 235, 255],
|
||||
},
|
||||
{
|
||||
id: "one-dark",
|
||||
label: "One Dark",
|
||||
kind: "dark",
|
||||
bg: "rgb(40, 44, 52)",
|
||||
fg: "rgb(171, 178, 191)",
|
||||
frame: [33, 37, 43, 255],
|
||||
},
|
||||
];
|
||||
|
||||
async function choose(page: Page, control: string, label: string) {
|
||||
@@ -100,15 +84,7 @@ for (const palette of palettes) {
|
||||
return activeSession().term.options.theme.background;
|
||||
}),
|
||||
)
|
||||
.toBe(
|
||||
palette.id === "solarized-light"
|
||||
? "#fdf6e3"
|
||||
: palette.id === "solarized-dark"
|
||||
? "#002b36"
|
||||
: palette.id === "one-light"
|
||||
? "#fafafa"
|
||||
: "#282c34",
|
||||
);
|
||||
.toBe(palette.id === "solarized-light" ? "#fdf6e3" : "#002b36");
|
||||
await editor.locator(".cm-content").click();
|
||||
await page.keyboard.press("Meta+a");
|
||||
await expect(editor.locator(".cm-selectionBackground:visible").first()).toBeVisible();
|
||||
@@ -135,15 +111,7 @@ for (const palette of palettes) {
|
||||
test("same-kind switching, legacy themes, system mode and explicit editor schemes", async ({ workbench: page }) => {
|
||||
await openFromTree(page, "build.gradle.kts");
|
||||
const dialog = await settings(page);
|
||||
for (const label of [
|
||||
"Solarized Light",
|
||||
"One Light",
|
||||
"Solarized Dark",
|
||||
"One Dark",
|
||||
"Islands Light",
|
||||
"Islands Dark",
|
||||
"High Contrast",
|
||||
]) {
|
||||
for (const label of ["Solarized Light", "Solarized Dark", "Islands Light", "Islands Dark", "High Contrast"]) {
|
||||
await choose(page, "Theme", label);
|
||||
await dialog.getByRole("button", { name: "Apply", exact: true }).click();
|
||||
if (label === "High Contrast") await expect(page.locator("html")).toHaveAttribute("data-contrast", "high");
|
||||
@@ -160,7 +128,7 @@ test("same-kind switching, legacy themes, system mode and explicit editor scheme
|
||||
: "rgb(0, 0, 0)");
|
||||
await expect(page.locator(".cm-editor").first()).toHaveCSS("background-color", bg);
|
||||
}
|
||||
await choose(page, "Theme", "One Light");
|
||||
await choose(page, "Theme", "Solarized Light");
|
||||
await choose(page, "Editor color scheme", "Solarized Dark");
|
||||
await dialog.getByRole("button", { name: "Apply", exact: true }).click();
|
||||
await expect(page.locator(".cm-editor").first()).toHaveCSS("background-color", "rgb(0, 43, 54)");
|
||||
@@ -168,16 +136,16 @@ test("same-kind switching, legacy themes, system mode and explicit editor scheme
|
||||
await dialog.getByLabel("Sync with OS", { exact: true }).check();
|
||||
await dialog.getByRole("button", { name: "Preferred themes", exact: true }).click();
|
||||
await choose(page, "Preferred light theme", "Solarized Light");
|
||||
await choose(page, "Preferred dark theme", "One Dark");
|
||||
await choose(page, "Preferred dark theme", "Solarized Dark");
|
||||
await page.keyboard.press("Escape");
|
||||
await dialog.getByRole("button", { name: "Apply", exact: true }).click();
|
||||
await page.emulateMedia({ colorScheme: "light" });
|
||||
await expect(page.locator("html")).toHaveAttribute("data-color-theme", "solarized-light");
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
await expect(page.locator("html")).toHaveAttribute("data-color-theme", "one-dark");
|
||||
await expect(page.locator("html")).toHaveAttribute("data-color-theme", "solarized-dark");
|
||||
});
|
||||
|
||||
test("quick theme menu offers all four palettes and applies their kind", async ({ workbench: page }) => {
|
||||
test("quick theme menu offers both palettes and applies their kind", async ({ workbench: page }) => {
|
||||
for (const palette of palettes) {
|
||||
await page.getByRole("button", { name: "IDE and Project Settings", exact: true }).click();
|
||||
await page.getByText("Theme", { exact: true }).click();
|
||||
@@ -206,14 +174,14 @@ test("gear › Theme previews the theme under the pointer and keeps the one chos
|
||||
|
||||
await gear.click();
|
||||
await page.getByRole("menuitem", { name: "Theme", exact: true }).hover();
|
||||
await theme("One Dark").hover();
|
||||
await expect(html).toHaveAttribute("data-color-theme", "one-dark");
|
||||
await theme("One Dark").click();
|
||||
await expect(html).toHaveAttribute("data-color-theme", "one-dark");
|
||||
await theme("Solarized Dark").hover();
|
||||
await expect(html).toHaveAttribute("data-color-theme", "solarized-dark");
|
||||
await theme("Solarized Dark").click();
|
||||
await expect(html).toHaveAttribute("data-color-theme", "solarized-dark");
|
||||
// Chosen, not previewed: the next start-up opens with it.
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("openidea.theme") ?? "{}").colorTheme))
|
||||
.toBe("one-dark");
|
||||
.toBe("solarized-dark");
|
||||
});
|
||||
|
||||
test("Settings › Appearance › Theme previews the highlighted theme until Cancel", async ({ workbench: page }) => {
|
||||
@@ -226,8 +194,8 @@ test("Settings › Appearance › Theme previews the highlighted theme until Can
|
||||
await expect(html).toHaveAttribute("data-theme", "light");
|
||||
await expect(html).not.toHaveAttribute("data-color-theme");
|
||||
// Picked: it shows until Apply or Cancel.
|
||||
await choose(page, "Theme", "One Dark");
|
||||
await expect(html).toHaveAttribute("data-color-theme", "one-dark");
|
||||
await choose(page, "Theme", "Solarized Dark");
|
||||
await expect(html).toHaveAttribute("data-color-theme", "solarized-dark");
|
||||
await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await expect(html).toHaveAttribute("data-theme", "light");
|
||||
await expect(html).not.toHaveAttribute("data-color-theme");
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
"@codemirror/lang-java": "^6.0.2",
|
||||
"@codemirror/lang-javascript": "^6.2.5",
|
||||
"@codemirror/lang-sass": "^6.0.2",
|
||||
"@codemirror/lang-sql": "^6.10.0",
|
||||
"@codemirror/lang-vue": "^0.1.3",
|
||||
"@codemirror/language": "^6.13.1",
|
||||
"@codemirror/language-data": "^6.5.2",
|
||||
|
||||
Створено
@@ -29,6 +29,9 @@ importers:
|
||||
'@codemirror/lang-sass':
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.2
|
||||
'@codemirror/lang-sql':
|
||||
specifier: ^6.10.0
|
||||
version: 6.10.0
|
||||
'@codemirror/lang-vue':
|
||||
specifier: ^0.1.3
|
||||
version: 0.1.3
|
||||
|
||||
@@ -108,6 +108,48 @@ pub async fn db_databases(window: Window, state: State<'_, AppState>, id: String
|
||||
ide_host::ops::db::databases(&state.host, window.label(), &id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn db_database_catalog(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
database: String,
|
||||
) -> IdeResult<Vec<ide_db::CatalogSchema>> {
|
||||
ide_host::ops::db::database_catalog(&state.host, window.label(), &id, &database).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn db_saved_catalog(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
) -> IdeResult<Option<ide_db::SourceCatalog>> {
|
||||
ide_host::ops::db::saved_catalog(&state.host, window.label(), &id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn db_retain_catalog(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
databases: Vec<String>,
|
||||
) -> IdeResult<()> {
|
||||
ide_host::ops::db::retain_catalog(&state.host, window.label(), &id, databases).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn db_session_context(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
) -> IdeResult<ide_db::SessionContext> {
|
||||
ide_host::ops::db::session_context(&state.host, window.label(), &id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn db_schemas(window: Window, state: State<'_, AppState>, id: String) -> IdeResult<Vec<String>> {
|
||||
|
||||
@@ -677,7 +677,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
ssh::ssh_askpass_answer,
|
||||
ssh::git_credentials,
|
||||
ssh::git_credential_forget,
|
||||
ssh::ssh_terminal_command,
|
||||
ssh::ssh_open_terminal,
|
||||
deploy::deploy_coverage,
|
||||
deploy::deploy_upload,
|
||||
deploy::deploy_download,
|
||||
@@ -709,6 +709,10 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
db::db_connect,
|
||||
db::db_disconnect,
|
||||
db::db_databases,
|
||||
db::db_session_context,
|
||||
db::db_database_catalog,
|
||||
db::db_saved_catalog,
|
||||
db::db_retain_catalog,
|
||||
db::db_schemas,
|
||||
db::db_tables,
|
||||
db::db_table_details,
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
use crate::state::AppState;
|
||||
use ide_core::remote_settings::SshConfiguration;
|
||||
use ide_core::{IdeError, IdeEvent, IdeResult};
|
||||
use ide_ssh::SshTarget;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use ide_core::{IdeEvent, IdeResult};
|
||||
use tauri::State;
|
||||
|
||||
/// Settings › SSH Configurations: the configurations in effect.
|
||||
@@ -84,29 +81,28 @@ pub fn git_credential_forget(_state: State<'_, AppState>, key: String) {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SshTerminalCommand {
|
||||
pub shell: String,
|
||||
pub args: Vec<String>,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
/// Tools › Start SSH Session: the system `ssh` command line for configuration `id`
|
||||
/// (run in a terminal tab, so prompts and host-key questions are answered there).
|
||||
/// Tools › Start SSH Session: a shell on configuration `id` in a terminal tab, through the
|
||||
/// built-in client with the configuration's saved credentials.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn ssh_terminal_command(state: State<'_, AppState>, id: String) -> IdeResult<SshTerminalCommand> {
|
||||
let config = state.ssh.config(&id)?;
|
||||
if config.host.trim().is_empty() {
|
||||
return Err(IdeError::invalid(format!("SSH configuration {} has no host", config.name)));
|
||||
}
|
||||
let path = std::env::var_os("PATH").unwrap_or_default();
|
||||
let shell = ide_run::which::find_executable("ssh", &path)
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.ok_or_else(|| IdeError::unavailable("ssh is not installed (OpenSSH client not found on PATH)"))?;
|
||||
let mut args = vec!["-t".to_owned()];
|
||||
args.extend(ide_ssh::openssh_args(&SshTarget::from(&config)));
|
||||
let title = if config.name.trim().is_empty() { config.label() } else { config.name.clone() };
|
||||
Ok(SshTerminalCommand { shell, args, title })
|
||||
pub async fn ssh_open_terminal(
|
||||
window: tauri::Window,
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
on_output: tauri::ipc::Channel<String>,
|
||||
) -> IdeResult<ide_terminal::TerminalInfo> {
|
||||
let host = state.host.clone();
|
||||
ide_host::ops::term::spawn_ssh(
|
||||
&host,
|
||||
window.label(),
|
||||
&id,
|
||||
cols,
|
||||
rows,
|
||||
std::sync::Arc::new(move |chunk| {
|
||||
let _ = on_output.send(chunk);
|
||||
}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -133,8 +133,6 @@ mod tests {
|
||||
("dark-hc", Color(0x1a, 0x1b, 0x1e, 0xff)),
|
||||
("solarized-light", Color(0xee, 0xe8, 0xd5, 0xff)),
|
||||
("solarized-dark", Color(0x07, 0x36, 0x42, 0xff)),
|
||||
("one-light", Color(0xea, 0xea, 0xeb, 0xff)),
|
||||
("one-dark", Color(0x21, 0x25, 0x2b, 0xff)),
|
||||
] {
|
||||
assert_eq!(builtin_frame(id), Some(color), "{id}");
|
||||
}
|
||||
|
||||
@@ -1803,12 +1803,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn additional_palettes_keep_native_startup_and_os_preferences_in_sync() {
|
||||
for (id, color) in [
|
||||
("solarized-light", Color(0xee, 0xe8, 0xd5, 0xff)),
|
||||
("solarized-dark", Color(0x07, 0x36, 0x42, 0xff)),
|
||||
("one-light", Color(0xea, 0xea, 0xeb, 0xff)),
|
||||
("one-dark", Color(0x21, 0x25, 0x2b, 0xff)),
|
||||
] {
|
||||
for (id, color) in
|
||||
[("solarized-light", Color(0xee, 0xe8, 0xd5, 0xff)), ("solarized-dark", Color(0x07, 0x36, 0x42, 0xff))]
|
||||
{
|
||||
let mut a = AppearanceSettings { theme: id.into(), ..Default::default() };
|
||||
assert_eq!(frame_color(&a, false), color);
|
||||
assert_eq!(frame_color(&a, true), color);
|
||||
|
||||
+150
-13
@@ -1574,10 +1574,10 @@ export const commands = {
|
||||
/** Forgets the credential of `key` (a `git_credentials` entry). */
|
||||
gitCredentialForget: (key: string) => __TAURI_INVOKE<void>("git_credential_forget", { key }),
|
||||
/**
|
||||
* Tools › Start SSH Session: the system `ssh` command line for configuration `id`
|
||||
* (run in a terminal tab, so prompts and host-key questions are answered there).
|
||||
* Tools › Start SSH Session: a shell on configuration `id` in a terminal tab, through the
|
||||
* built-in client with the configuration's saved credentials.
|
||||
*/
|
||||
sshTerminalCommand: (id: string) => typedError<SshTerminalCommand, IdeError>(__TAURI_INVOKE("ssh_terminal_command", { id })),
|
||||
sshOpenTerminal: (id: string, cols: number, rows: number, onOutput: Channel<string>) => typedError<TerminalInfo, IdeError>(__TAURI_INVOKE("ssh_open_terminal", { id, cols, rows, onOutput })),
|
||||
deployCoverage: (paths: IpcPath[]) => typedError<DeploymentCoverage[], IdeError>(__TAURI_INVOKE("deploy_coverage", { paths })),
|
||||
deployUpload: (paths: IpcPath[], server: string | null, request: RequestId) => typedError<TransferReport, IdeError>(__TAURI_INVOKE("deploy_upload", { paths, server, request })),
|
||||
deployDownload: (paths: IpcPath[], server: string | null, request: RequestId) => typedError<TransferReport, IdeError>(__TAURI_INVOKE("deploy_download", { paths, server, request })),
|
||||
@@ -1643,6 +1643,12 @@ export const commands = {
|
||||
dbConnect: (id: string) => typedError<null, IdeError>(__TAURI_INVOKE("db_connect", { id })),
|
||||
dbDisconnect: (id: string) => typedError<null, IdeError>(__TAURI_INVOKE("db_disconnect", { id })),
|
||||
dbDatabases: (id: string) => typedError<DbDatabases, IdeError>(__TAURI_INVOKE("db_databases", { id })),
|
||||
dbSessionContext: (id: string) => typedError<DbSessionContext, IdeError>(__TAURI_INVOKE("db_session_context", { id })),
|
||||
dbDatabaseCatalog: (id: string, database: string) => typedError<DbCatalogSchema[], IdeError>(__TAURI_INVOKE("db_database_catalog", { id, database })),
|
||||
dbSavedCatalog: (id: string) => typedError<{
|
||||
databases?: DbCatalogDatabase[],
|
||||
} | null, IdeError>(__TAURI_INVOKE("db_saved_catalog", { id })),
|
||||
dbRetainCatalog: (id: string, databases: string[]) => typedError<null, IdeError>(__TAURI_INVOKE("db_retain_catalog", { id, databases })),
|
||||
dbSchemas: (id: string) => typedError<string[], IdeError>(__TAURI_INVOKE("db_schemas", { id })),
|
||||
dbTables: (id: string, schema: string) => typedError<DbTableInfo[], IdeError>(__TAURI_INVOKE("db_tables", { id, schema })),
|
||||
dbTableDetails: (id: string, schema: string, table: string) => typedError<DbTableDetails, IdeError>(__TAURI_INVOKE("db_table_details", { id, schema, table })),
|
||||
@@ -2269,7 +2275,9 @@ export type AppMetrics = {
|
||||
memory: ProcessMemory[],
|
||||
};
|
||||
|
||||
export type AppearanceSettings = {
|
||||
export type AppearanceSettings = AppearanceSettings_Serialize | AppearanceSettings_Deserialize;
|
||||
|
||||
export type AppearanceSettings_Deserialize = {
|
||||
/**
|
||||
* A built-in theme id (`light`, `dark`, `dark-hc`, …), `system` (legacy
|
||||
* "Sync with OS"), or a plugin's theme: `plugin:<plugin-id>/<theme-id>`.
|
||||
@@ -2331,6 +2339,68 @@ export type AppearanceSettings = {
|
||||
mainToolbar?: MainToolbarSettings | null,
|
||||
};
|
||||
|
||||
export type AppearanceSettings_Serialize = {
|
||||
/**
|
||||
* A built-in theme id (`light`, `dark`, `dark-hc`, …), `system` (legacy
|
||||
* "Sync with OS"), or a plugin's theme: `plugin:<plugin-id>/<theme-id>`.
|
||||
*/
|
||||
theme: string,
|
||||
accent: string,
|
||||
uiFontSize: number | null,
|
||||
locale: string,
|
||||
/** Follow the OS light/dark mode with the preferred themes below. */
|
||||
syncWithOs: boolean,
|
||||
preferredLightTheme: string,
|
||||
preferredDarkTheme: string,
|
||||
/** A plugin's file icon theme (`<plugin-id>/<theme-id>`); empty = the built-in icons. */
|
||||
fileIconTheme: string,
|
||||
fileIconThemeOptions: { [key in string]: FileIconThemeOptions },
|
||||
/** IDE zoom in percent (50–300). */
|
||||
zoom: number,
|
||||
/** Apply `ui_font_family` / `ui_font_size` (otherwise the theme's). */
|
||||
useCustomFont: boolean,
|
||||
uiFontFamily: string,
|
||||
/** Font tried when `ui_font_family` lacks a glyph; empty = none. */
|
||||
uiFontFallback: string,
|
||||
contrastScrollbars: boolean,
|
||||
/** Adjust colours for red-green vision deficiency. */
|
||||
redGreenVision: boolean,
|
||||
differentToolWindowBackground: boolean,
|
||||
compactMode: boolean,
|
||||
/** IDEA "Use smaller indents in trees": tool window trees indent less per level. */
|
||||
smallTreeIndents: boolean,
|
||||
fullPathInTitle: boolean,
|
||||
projectColorsInToolbar: boolean,
|
||||
dndWithAltOnly: boolean,
|
||||
menuIcons: boolean,
|
||||
/** `hamburger` | `merged` | `toolbar` (a separate row): main menu presentation in the frameless title bar. */
|
||||
mainMenuStyle: string,
|
||||
/** A lone Alt press focuses or opens the main menu (IDEA `ide.windows.main.menu.focus.on.alt`). */
|
||||
mainMenuAlt: boolean,
|
||||
/** `subpixel` | `greyscale` | `none` */
|
||||
ideAntialiasing: string,
|
||||
editorAntialiasing: string,
|
||||
showToolWindowBars: boolean,
|
||||
showToolWindowNames: boolean,
|
||||
showToolWindowBadges: boolean,
|
||||
widescreenToolWindows: boolean,
|
||||
/** View › Appearance › Toolbar; hidden leaves IDEA's compact window header. */
|
||||
showMainToolbar: boolean,
|
||||
showStatusBar: boolean,
|
||||
/**
|
||||
* View › Appearance › Navigation Bar: the file path breadcrumbs, shown in
|
||||
* the status bar by default, above the editor, or not at all.
|
||||
*/
|
||||
navigationBar: NavigationBarPosition,
|
||||
/**
|
||||
* Status bar widgets shown (`true`) or hidden (`false`) by the user
|
||||
* (status bar context menu); widgets not listed use their default.
|
||||
*/
|
||||
statusBarWidgets: { [key in string]: boolean },
|
||||
/** Customize Main Toolbar; `None` = the default toolbar. */
|
||||
mainToolbar: MainToolbarSettings | null,
|
||||
};
|
||||
|
||||
export type ApplyOutcome = {
|
||||
/** Repository-relative paths changed without conflicts. */
|
||||
applied: string[],
|
||||
@@ -3386,6 +3456,8 @@ export type DataSource_Deserialize = {
|
||||
options?: DbOptions,
|
||||
introspection?: DbIntrospection,
|
||||
properties?: DbProperty[],
|
||||
/** Snowflake settings (`None`: not a Snowflake data source, or all defaults). */
|
||||
snowflake?: DbSnowflake | null,
|
||||
/**
|
||||
* Stored app-wide and listed in every project. Implied by the file it is read from,
|
||||
* so not written to it.
|
||||
@@ -3428,6 +3500,8 @@ export type DataSource_Serialize = {
|
||||
options: DbOptions,
|
||||
introspection: DbIntrospection,
|
||||
properties: DbProperty[],
|
||||
/** Snowflake settings (`None`: not a Snowflake data source, or all defaults). */
|
||||
snowflake: DbSnowflake | null,
|
||||
/**
|
||||
* Stored app-wide and listed in every project. Implied by the file it is read from,
|
||||
* so not written to it.
|
||||
@@ -3450,6 +3524,19 @@ export type DateFormatSettings = {
|
||||
pretty?: boolean,
|
||||
};
|
||||
|
||||
export type DbCatalogDatabase = {
|
||||
name: string,
|
||||
/** When this database was read, in milliseconds since the Unix epoch. */
|
||||
readAt: number | null,
|
||||
schemas: DbCatalogSchema[],
|
||||
};
|
||||
|
||||
/** A schema of a database and its tables and views. */
|
||||
export type DbCatalogSchema = {
|
||||
name: string,
|
||||
tables: DbTableInfo[],
|
||||
};
|
||||
|
||||
export type DbCellValue = {
|
||||
column: string,
|
||||
/** `None` is SQL NULL. */
|
||||
@@ -3503,7 +3590,7 @@ export type DbDriver = "sqlite" | "postgres" |
|
||||
/** Microsoft SQL Server. */
|
||||
"sqlServer" | "clickhouse" |
|
||||
/** Oracle Database (pure-Rust thin driver, no client library). */
|
||||
"oracle";
|
||||
"oracle" | "snowflake";
|
||||
|
||||
/** `schema.table (columns) REFERENCES ref_schema.ref_table (ref_columns)`. */
|
||||
export type DbForeignKey = {
|
||||
@@ -3628,11 +3715,22 @@ export type DbRoutineInfo = {
|
||||
*/
|
||||
export type DbRowEdit = { kind: "update"; key: DbCellValue[]; values: DbCellValue[] } | { kind: "insert"; values: DbCellValue[] } | { kind: "delete"; key: DbCellValue[] };
|
||||
|
||||
/** A schema object the tree lists by name under a folder of its kind. */
|
||||
export type DbSchemaObject = {
|
||||
/** `stage`, `stream`, `task`, `pipe`, `fileFormat`. */
|
||||
kind: string,
|
||||
name: string,
|
||||
/** What the list says about it (a stage's type, a stream's table, a task's schedule). */
|
||||
detail: string,
|
||||
};
|
||||
|
||||
/** A schema's routines (procedures and functions), packages and sequences. */
|
||||
export type DbSchemaObjects = {
|
||||
routines: DbRoutineInfo[],
|
||||
packages: DbPackageInfo[],
|
||||
sequences: DbSequenceInfo[],
|
||||
/** Objects of kinds only some databases have (Snowflake stages, streams, tasks…). */
|
||||
others?: DbSchemaObject[],
|
||||
};
|
||||
|
||||
export type DbSequenceInfo = {
|
||||
@@ -3640,11 +3738,56 @@ export type DbSequenceInfo = {
|
||||
dataType: string,
|
||||
};
|
||||
|
||||
/** What a Snowflake session computes with and acts as, and what it may switch to. */
|
||||
export type DbSessionContext = {
|
||||
/** Empty: no warehouse is in use. */
|
||||
warehouse: string,
|
||||
role: string,
|
||||
warehouses: string[],
|
||||
roles: string[],
|
||||
};
|
||||
|
||||
/**
|
||||
* Snowflake connection settings beyond the shared ones: the account identifier is
|
||||
* [`DataSource::host`], the schema [`DbOptions::default_schema`].
|
||||
*/
|
||||
export type DbSnowflake = {
|
||||
warehouse?: string,
|
||||
role?: string,
|
||||
auth?: DbSnowflakeAuth,
|
||||
/**
|
||||
* PEM private key of [`DbSnowflakeAuth::KeyPair`]; `~/` is the home directory. Only the
|
||||
* path is stored: the key is read from the file at each login.
|
||||
*/
|
||||
privateKeyFile?: string,
|
||||
};
|
||||
|
||||
/**
|
||||
* How a Snowflake data source signs in. The data source's one saved secret is what the
|
||||
* method needs: the private key's passphrase, the password, or the token.
|
||||
*/
|
||||
export type DbSnowflakeAuth =
|
||||
/** RSA key pair: a JWT signed with the private key file. */
|
||||
"keyPair" |
|
||||
/** User name and password. */
|
||||
"password" |
|
||||
/** Programmatic access token. */
|
||||
"token" |
|
||||
/** OAuth access token. */
|
||||
"oAuth" |
|
||||
/** Single sign-on in the web browser. */
|
||||
"browser";
|
||||
|
||||
export type DbSortKey = {
|
||||
column: string,
|
||||
descending: boolean,
|
||||
};
|
||||
|
||||
/** What introspection has read of a data source. */
|
||||
export type DbSourceCatalog = {
|
||||
databases?: DbCatalogDatabase[],
|
||||
};
|
||||
|
||||
/** Connect through an SSH tunnel: the database host and port are reached from `host`. */
|
||||
export type DbSsh = {
|
||||
enabled?: boolean,
|
||||
@@ -9067,7 +9210,7 @@ export type SettingsScope = "global" | "project";
|
||||
|
||||
export type Settings_Deserialize = {
|
||||
general?: GeneralSettings,
|
||||
appearance?: AppearanceSettings,
|
||||
appearance?: AppearanceSettings_Deserialize,
|
||||
editor?: EditorSettings_Deserialize,
|
||||
keymap?: string,
|
||||
files?: FileSettings,
|
||||
@@ -9131,7 +9274,7 @@ export type Settings_Deserialize = {
|
||||
|
||||
export type Settings_Serialize = {
|
||||
general: GeneralSettings,
|
||||
appearance: AppearanceSettings,
|
||||
appearance: AppearanceSettings_Serialize,
|
||||
editor: EditorSettings_Serialize,
|
||||
keymap: string,
|
||||
files: FileSettings,
|
||||
@@ -9372,12 +9515,6 @@ export type SshSettings = {
|
||||
configs?: SshConfiguration[],
|
||||
};
|
||||
|
||||
export type SshTerminalCommand = {
|
||||
shell: string,
|
||||
args: string[],
|
||||
title: string,
|
||||
};
|
||||
|
||||
export type StackFrame = {
|
||||
id: number,
|
||||
name: string,
|
||||
|
||||
@@ -31,6 +31,7 @@ import type {
|
||||
} from "@/bindings";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { call, commands, onIdeEvent } from "@/platform/ipc";
|
||||
import { isSameOrInside } from "@/platform/paths";
|
||||
import { basename, relativeDir, useProject } from "@/platform/project";
|
||||
import { notifications, useEditorShown, type EditorInput } from "@/workbench/contributions";
|
||||
import { confirm } from "@/workbench/messages";
|
||||
@@ -171,11 +172,17 @@ function TaskSession({ id, docked }: { id: string; docked: boolean }) {
|
||||
if (!useAgents.getState().entries.has(id)) void loadEntries(id).catch(() => undefined);
|
||||
void refreshChanges(id);
|
||||
}, [id, connected]);
|
||||
// A commit in the task's worktree moves its HEAD: pick it up without waiting for the agent.
|
||||
// A commit in the task's checkout moves its HEAD: pick it up without waiting for the agent.
|
||||
// The watcher reports every repository under the project, so the main checkout and other
|
||||
// tasks' worktrees would otherwise re-read this task's commits for nothing.
|
||||
const cwd = task?.cwd ?? null;
|
||||
useEffect(() => {
|
||||
if (!connected) return;
|
||||
return onIdeEvent("vcsChanged", () => scheduleChanges(id));
|
||||
}, [id, connected]);
|
||||
return onIdeEvent("vcsChanged", (e) => {
|
||||
if (!cwd || !isSameOrInside(cwd, e.root)) return;
|
||||
scheduleChanges(id);
|
||||
});
|
||||
}, [id, connected, cwd]);
|
||||
// A kept-alive tab stays mounted behind the shown one: it isn't read there, nor searched.
|
||||
const shown = useEditorShown();
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { registerActions } from "@/platform/actions";
|
||||
import { emitLocalIdeEvent } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { EditorShownProvider } from "@/workbench/contributions";
|
||||
import { hideContextMenu, useContextMenuStore } from "@/ui";
|
||||
@@ -19,6 +20,7 @@ import tagStyles from "@/ui/InlineTag.module.css";
|
||||
const backend = vi.hoisted(() => ({
|
||||
entries: new Map<string, AgentEntry[]>(),
|
||||
changes: [] as AgentChange[],
|
||||
changesCalls: 0,
|
||||
grouped: { uncommitted: [] as AgentChange[], commits: [] as AgentCommit[] },
|
||||
rolledBack: [] as Array<[string, string[] | null, string | null]>,
|
||||
answers: [] as Array<[string, number, string | null]>,
|
||||
@@ -53,7 +55,10 @@ vi.mock("@/platform/ipc", async (original) => ({
|
||||
return { status: "error", error: { kind: "unavailable", message: backend.entryError } };
|
||||
return { status: "ok", data: backend.entries.get(key) ?? [] };
|
||||
},
|
||||
agentChanges: async () => ({ status: "ok", data: backend.changes }),
|
||||
agentChanges: async () => {
|
||||
backend.changesCalls += 1;
|
||||
return { status: "ok", data: backend.changes };
|
||||
},
|
||||
agentGroupedChanges: async () => ({ status: "ok", data: backend.grouped }),
|
||||
agentRollbackChanges: async (task: string, paths: string[] | null, view: string | null) => {
|
||||
backend.rolledBack.push([task, paths, view]);
|
||||
@@ -163,6 +168,7 @@ beforeEach(() => {
|
||||
disconnect();
|
||||
localStorage.clear();
|
||||
backend.changes = [];
|
||||
backend.changesCalls = 0;
|
||||
backend.grouped = { uncommitted: [], commits: [] };
|
||||
backend.rolledBack = [];
|
||||
backend.external = [];
|
||||
@@ -1384,6 +1390,24 @@ describe("sessions from other apps", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("changes refresh", () => {
|
||||
it("re-reads the changes only when the task's own checkout changes", async () => {
|
||||
vi.useFakeTimers();
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
await act(async () => {});
|
||||
backend.changesCalls = 0;
|
||||
|
||||
// The watcher reports the main checkout and other tasks' worktrees too.
|
||||
act(() => emitLocalIdeEvent({ type: "vcsChanged", root: "/elsewhere" }));
|
||||
await act(() => vi.advanceTimersByTimeAsync(600));
|
||||
expect(backend.changesCalls).toBe(0);
|
||||
|
||||
act(() => emitLocalIdeEvent({ type: "vcsChanged", root: "/project" }));
|
||||
await act(() => vi.advanceTimersByTimeAsync(600));
|
||||
expect(backend.changesCalls).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("accept changes", () => {
|
||||
const worktreeChanges = () => {
|
||||
backend.changes = [
|
||||
|
||||
@@ -40,6 +40,7 @@ import { findOutputLinks, type OutputLink } from "@/features/run/links";
|
||||
import { openTarget, openUrl } from "@/features/run/navigate";
|
||||
import { navigateToMessage, rerunBuild, selectBuildNode, useBuildRun } from "./store";
|
||||
import styles from "./Build.module.css";
|
||||
import { WidestColumns } from "@/platform/consoleColumns";
|
||||
import { tx } from "@/platform/i18n";
|
||||
|
||||
const SEVERITY_ICON: Record<Severity, string> = { error: "error", warning: "warning", info: "info", hint: "info" };
|
||||
@@ -246,6 +247,8 @@ function BuildConsole() {
|
||||
const lines = useBuildRun((s) => s.lines);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const stick = useRef(true);
|
||||
const [widest] = useState(() => new WidestColumns());
|
||||
const columns = useMemo(() => widest.measure(lines), [widest, lines]);
|
||||
const virtualizer = useVirtualizer({
|
||||
count: lines.length,
|
||||
getScrollElement: () => ref.current,
|
||||
@@ -268,7 +271,10 @@ function BuildConsole() {
|
||||
stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < LINE_HEIGHT * 2;
|
||||
}}
|
||||
>
|
||||
<div className={styles.consoleInner} style={{ height: virtualizer.getTotalSize() }}>
|
||||
<div
|
||||
className={styles.consoleInner}
|
||||
style={{ height: virtualizer.getTotalSize(), minWidth: `calc(${columns}ch + 24px)` }}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((v) => {
|
||||
const line = lines[v.index]!;
|
||||
return (
|
||||
|
||||
@@ -387,6 +387,10 @@
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.formRow[hidden],
|
||||
.formLabel[hidden] {
|
||||
display: none;
|
||||
}
|
||||
/* Data source dialog. */
|
||||
.formSection {
|
||||
grid-column: 1 / -1;
|
||||
|
||||
@@ -34,6 +34,8 @@ import {
|
||||
} from "./model";
|
||||
import {
|
||||
nodeTarget,
|
||||
isOtherFolder,
|
||||
otherFolder,
|
||||
routineFolder,
|
||||
schemaObjectOf,
|
||||
tableFolder,
|
||||
@@ -78,9 +80,23 @@ const FOLDER_LABELS: Record<Folder, string> = {
|
||||
functions: "functions",
|
||||
packages: "packages",
|
||||
sequences: "sequences",
|
||||
stages: "stages",
|
||||
streams: "streams",
|
||||
tasks: "tasks",
|
||||
pipes: "pipes",
|
||||
fileFormats: "file formats",
|
||||
};
|
||||
|
||||
const FOLDERS: readonly Folder[] = ["tables", "views", "materializedViews", "routines", "packages", "sequences"];
|
||||
const OTHERS: readonly Folder[] = ["stages", "streams", "tasks", "pipes", "fileFormats"];
|
||||
const FOLDERS: readonly Folder[] = [
|
||||
"tables",
|
||||
"views",
|
||||
"materializedViews",
|
||||
"routines",
|
||||
"packages",
|
||||
"sequences",
|
||||
...OTHERS,
|
||||
];
|
||||
const SEPARATE_FOLDERS: readonly Folder[] = [
|
||||
"tables",
|
||||
"views",
|
||||
@@ -89,6 +105,7 @@ const SEPARATE_FOLDERS: readonly Folder[] = [
|
||||
"functions",
|
||||
"packages",
|
||||
"sequences",
|
||||
...OTHERS,
|
||||
];
|
||||
|
||||
function status<T>(key: string, l: Loaded<T> | undefined, map: (v: T) => Node[]): Node[] | undefined {
|
||||
@@ -172,8 +189,11 @@ export function DatabaseView() {
|
||||
(st.objects[tablesKey(id, schema)]?.sequences ?? []).filter(shown(id));
|
||||
const packagesOf = (id: string, schema: string) =>
|
||||
(st.objects[tablesKey(id, schema)]?.packages ?? []).filter(shown(id));
|
||||
const othersOf = (id: string, schema: string, folder: Folder) =>
|
||||
(st.objects[tablesKey(id, schema)]?.others ?? []).filter((o) => otherFolder(o.kind) === folder).filter(shown(id));
|
||||
/** Members of a schema folder, after object and quick filtering. */
|
||||
const folderSize = (id: string, schema: string, folder: Folder): number => {
|
||||
if (isOtherFolder(folder)) return othersOf(id, schema, folder).length;
|
||||
if (folder === "routines" || folder === "procedures" || folder === "functions")
|
||||
return routinesOf(id, schema).filter(
|
||||
(routine) => routineFolder(routine.kind, st.separateProceduresAndFunctions) === folder,
|
||||
@@ -262,6 +282,13 @@ export function DatabaseView() {
|
||||
package: pkg,
|
||||
...at,
|
||||
}));
|
||||
if (isOtherFolder(n.folder))
|
||||
return othersOf(n.id, n.schema, n.folder).map((object) => ({
|
||||
kind: "object",
|
||||
key: `${n.key}/${object.name}`,
|
||||
object,
|
||||
...at,
|
||||
}));
|
||||
if (n.folder === "sequences")
|
||||
return sequencesOf(n.id, n.schema).map((sequence) => ({
|
||||
kind: "sequence",
|
||||
@@ -355,6 +382,8 @@ export function DatabaseView() {
|
||||
return n.package.name;
|
||||
case "sequence":
|
||||
return n.sequence.name;
|
||||
case "object":
|
||||
return n.object.name;
|
||||
case "trigger":
|
||||
return n.trigger.name;
|
||||
case "table":
|
||||
@@ -397,6 +426,8 @@ export function DatabaseView() {
|
||||
return `(${n.routine.arguments})${n.routine.returns ? `: ${n.routine.returns}` : ""}`;
|
||||
case "sequence":
|
||||
return n.sequence.dataType;
|
||||
case "object":
|
||||
return n.object.detail || undefined;
|
||||
case "trigger":
|
||||
return `${n.trigger.timing} ${n.trigger.events}`.trim();
|
||||
case "group": {
|
||||
@@ -435,6 +466,8 @@ export function DatabaseView() {
|
||||
return "db-routine";
|
||||
case "sequence":
|
||||
return "db-sequence";
|
||||
case "object":
|
||||
return n.object.kind === "stage" ? "db-folder" : n.object.kind === "stream" ? "db-table" : "db-routine";
|
||||
case "trigger":
|
||||
return "db-trigger";
|
||||
case "column":
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Fragment, useState } from "react";
|
||||
import { call, commands, type DataSource } from "@/platform/ipc";
|
||||
import { Checkbox, Dialog, Input, lazyComponent, T } from "@/ui";
|
||||
import { sshLabel } from "./model";
|
||||
import { SourceDialog } from "./SourceDialog";
|
||||
import { SourceDialog, snowflakeSecretLabel } from "./SourceDialog";
|
||||
import { closeDialog, useDatabase } from "./store";
|
||||
import styles from "./Database.module.css";
|
||||
import { isImeKey } from "@/platform/ime";
|
||||
@@ -42,7 +42,7 @@ function PasswordDialog({ source, resolve }: { source: DataSource; resolve: (ok:
|
||||
</span>
|
||||
<span>{source.user || "(default)"}</span>
|
||||
<label className={styles.formLabel} htmlFor="db-ask-password">
|
||||
<T>Password:</T>
|
||||
{tx(source.driver === "snowflake" ? snowflakeSecretLabel(source.snowflake?.auth) : "Password:")}
|
||||
</label>
|
||||
<Input
|
||||
id="db-ask-password"
|
||||
|
||||
@@ -48,7 +48,7 @@ export interface GridColumn {
|
||||
enumValues?: readonly string[];
|
||||
}
|
||||
|
||||
const NUMERIC_TYPE = /int|num|dec|real|float|double|money|serial|^bit$/i;
|
||||
const NUMERIC_TYPE = /int|num|dec|real|float|double|money|serial|^bit$|^fixed\b/i;
|
||||
const NUMBER = /^-?\d+(\.\d+)?(e[+-]?\d+)?$/i;
|
||||
|
||||
/** Numbers are right-aligned (IDEA): by type, else when every sampled value is one. */
|
||||
|
||||
@@ -216,6 +216,22 @@ function TruncateDialog({ id, schema, table }: { id: string; schema: string; tab
|
||||
// ---- Modify Table / New Table -----------------------------------------------
|
||||
|
||||
const TYPES: Record<DbDriver, string[]> = {
|
||||
snowflake: [
|
||||
"NUMBER(38,0)",
|
||||
"NUMBER(10,2)",
|
||||
"FLOAT",
|
||||
"VARCHAR",
|
||||
"BOOLEAN",
|
||||
"DATE",
|
||||
"TIME",
|
||||
"TIMESTAMP_NTZ",
|
||||
"TIMESTAMP_LTZ",
|
||||
"TIMESTAMP_TZ",
|
||||
"VARIANT",
|
||||
"OBJECT",
|
||||
"ARRAY",
|
||||
"BINARY",
|
||||
],
|
||||
sqlite: ["INTEGER", "TEXT", "REAL", "NUMERIC", "BLOB", "BOOLEAN", "DATE", "DATETIME"],
|
||||
postgres: [
|
||||
"integer",
|
||||
|
||||
@@ -227,7 +227,7 @@ function Result({ tab }: { tab: ResultTab }) {
|
||||
const resultNames = useMemo(() => r?.columns.map((c) => c.name) ?? [], [r]);
|
||||
const eq = useMemo(
|
||||
() =>
|
||||
driver === "postgres" || driver === "oracle" || driver === "sqlServer"
|
||||
driver === "postgres" || driver === "oracle" || driver === "sqlServer" || driver === "snowflake"
|
||||
? (a: string | null | undefined, b: string | null | undefined) => !!a && a === b
|
||||
: nameMatcher(driver),
|
||||
[driver],
|
||||
|
||||
@@ -18,4 +18,27 @@ describe("new data source", () => {
|
||||
fireEvent.click(within(screen.getByRole("listbox")).getByText("Password"));
|
||||
expect(savePassword().checked).toBe(false);
|
||||
});
|
||||
|
||||
it("names the secret of a Snowflake source after its authentication method", () => {
|
||||
render(
|
||||
<SourceDialog
|
||||
initial={null}
|
||||
preset={{
|
||||
driver: "snowflake",
|
||||
host: "",
|
||||
snowflake: { warehouse: "", role: "", auth: "keyPair", privateKeyFile: "" },
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Account:")).toBeTruthy();
|
||||
expect(screen.queryByRole("tab", { name: "SSH/SSL" })).toBeNull();
|
||||
expect(screen.getByLabelText("Private key:")).toBeTruthy();
|
||||
expect((screen.getByLabelText("Passphrase:") as HTMLInputElement).type).toBe("password");
|
||||
expect(savePassword().checked).toBe(false);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Snowflake authentication" }));
|
||||
fireEvent.click(within(screen.getByRole("listbox")).getByText("Password"));
|
||||
expect(screen.queryByLabelText("Private key:")).toBeNull();
|
||||
expect((screen.getByLabelText("Password:") as HTMLInputElement).type).toBe("password");
|
||||
for (const field of ["Schema:", "Warehouse:", "Role:"]) expect(screen.getByLabelText(field)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
type DbIntrospection,
|
||||
type DbOptions,
|
||||
type DbProperty,
|
||||
type DbSnowflake,
|
||||
type DbSnowflakeAuth,
|
||||
type DbSsh,
|
||||
type SshAuth,
|
||||
type DbSslMode,
|
||||
@@ -34,7 +36,7 @@ import {
|
||||
sshTunnelReady,
|
||||
} from "./model";
|
||||
import { closeDialog, loadDatabases, loadSchemas, saveSource, useDatabase } from "./store";
|
||||
import { VENDORS, sourceUrl, vendorById, vendorOf } from "./vendors";
|
||||
import { VENDORS, blankSnowflake, sourceUrl, vendorById, vendorOf } from "./vendors";
|
||||
import styles from "./Database.module.css";
|
||||
import { isImeKey } from "@/platform/ime";
|
||||
|
||||
@@ -71,6 +73,21 @@ const SSH_AUTH: Array<SelectOption<SshAuth>> = [
|
||||
{ value: "keyPair", label: "Key pair" },
|
||||
];
|
||||
|
||||
const SNOWFLAKE_AUTH: Array<SelectOption<DbSnowflakeAuth>> = [
|
||||
{ value: "keyPair", label: "Key pair" },
|
||||
{ value: "browser", label: "Single sign-on (web browser)" },
|
||||
{ value: "password", label: "Password" },
|
||||
{ value: "token", label: "Programmatic access token" },
|
||||
{ value: "oAuth", label: "OAuth token" },
|
||||
];
|
||||
|
||||
/** What the data source's secret is, by Snowflake authentication method. */
|
||||
export function snowflakeSecretLabel(auth: DbSnowflakeAuth | null | undefined): string {
|
||||
if (auth === "password") return "Password:";
|
||||
if (auth === "token" || auth === "oAuth") return "Token:";
|
||||
return "Passphrase:";
|
||||
}
|
||||
|
||||
/** Driver property names the Advanced tab suggests, and what the backend does with them. */
|
||||
const PROPERTIES: Record<string, { names: string[]; hint: string }> = {
|
||||
clickhouse: {
|
||||
@@ -101,6 +118,17 @@ const PROPERTIES: Record<string, { names: string[]; hint: string }> = {
|
||||
names: ["connect_timeout"],
|
||||
hint: "Connection settings for SQL Server.",
|
||||
},
|
||||
snowflake: {
|
||||
names: [
|
||||
"QUERY_TAG",
|
||||
"STATEMENT_TIMEOUT_IN_SECONDS",
|
||||
"TIMEZONE",
|
||||
"WEEK_START",
|
||||
"CLIENT_SESSION_KEEP_ALIVE",
|
||||
"connect_timeout",
|
||||
],
|
||||
hint: "Session parameters set when signing in (any session parameter, e.g. QUERY_TAG). connect_timeout is in seconds.",
|
||||
},
|
||||
oracle: {
|
||||
names: ["NLS_DATE_FORMAT", "NLS_TIMESTAMP_FORMAT", "NLS_LANGUAGE", "connect_timeout"],
|
||||
hint: "Session parameters set after connecting (ALTER SESSION SET name = value). connect_timeout is in seconds.",
|
||||
@@ -385,9 +413,14 @@ export function SourceDialog({
|
||||
const sqlite = s.driver === "sqlite";
|
||||
const postgres = s.driver === "postgres";
|
||||
/** Drivers that can list every database on the host (tree level + Schemas tab). */
|
||||
const serverDatabases = postgres || s.driver === "sqlServer";
|
||||
const serverDatabases = postgres || s.driver === "sqlServer" || s.driver === "snowflake";
|
||||
const clickhouse = s.driver === "clickhouse";
|
||||
const oracle = s.driver === "oracle";
|
||||
const snowflake = s.driver === "snowflake";
|
||||
const sf: DbSnowflake = { ...blankSnowflake(), ...s.snowflake };
|
||||
const setSnowflake = (patch: Partial<DbSnowflake>) =>
|
||||
setS((x) => ({ ...x, snowflake: { ...blankSnowflake(), ...x.snowflake, ...patch } }));
|
||||
const browserSignIn = snowflake && sf.auth === "browser";
|
||||
const urlOnly = !sqlite && !!s.urlOnly;
|
||||
const parsedUrl = urlOnly ? parseSourceUrl(s.url ?? "") : null;
|
||||
const urlValid = !!parsedUrl && parsedUrl.source.driver === s.driver;
|
||||
@@ -395,10 +428,13 @@ export function SourceDialog({
|
||||
const database = urlOnly ? (parsedUrl?.source.database ?? "") : (s.database ?? "");
|
||||
const name =
|
||||
s.name?.trim() || (sqlite ? fileSourceName(s.file ?? "") : `${database || s.user || "db"}@${host || "localhost"}`);
|
||||
const sshOn = !sqlite && !!ssh.enabled;
|
||||
// Snowflake is reached over HTTPS, where a tunnel to another host name cannot pass certificate checks.
|
||||
const sshOn = !sqlite && !snowflake && !!ssh.enabled;
|
||||
const valid = sqlite
|
||||
? !!s.file?.trim()
|
||||
: (urlOnly ? urlValid : !!s.host?.trim()) && (!sshOn || sshTunnelReady(ssh, sshConfigs));
|
||||
: (urlOnly ? urlValid : !!s.host?.trim()) &&
|
||||
(!sshOn || sshTunnelReady(ssh, sshConfigs)) &&
|
||||
(!snowflake || (!!s.user?.trim() && (sf.auth !== "keyPair" || !!sf.privateKeyFile?.trim())));
|
||||
const sslOn = (s.ssl ?? "prefer") !== "disable";
|
||||
|
||||
/** What is saved / tested: a preset's port when the field was left empty. */
|
||||
@@ -416,6 +452,9 @@ export function SourceDialog({
|
||||
vendor: v.id === v.driver ? "" : v.id,
|
||||
port,
|
||||
ssl: v.ssl ?? (v.driver === "clickhouse" ? "disable" : x.ssl),
|
||||
...(v.driver === "snowflake"
|
||||
? { host: x.host === "localhost" ? "" : x.host, snowflake: { ...blankSnowflake(), ...x.snowflake } }
|
||||
: {}),
|
||||
...(v.driver === "clickhouse"
|
||||
? {
|
||||
user: x.user || "default",
|
||||
@@ -433,6 +472,8 @@ export function SourceDialog({
|
||||
const p = parseSourceUrl(text);
|
||||
if (!p || p.source.driver !== s.driver) return;
|
||||
const { host: h, port, database: db, user, ssl, sslCaFile, sslCertFile, sslKeyFile, vendor: v } = p.source;
|
||||
if (p.source.snowflake) setSnowflake(p.source.snowflake);
|
||||
if (p.source.options?.defaultSchema) setOptions({ defaultSchema: p.source.options.defaultSchema });
|
||||
set({
|
||||
host: h,
|
||||
port,
|
||||
@@ -495,12 +536,8 @@ export function SourceDialog({
|
||||
const tabs: Array<{ id: Tab; label: string }> = [
|
||||
{ id: "general", label: "General" },
|
||||
{ id: "options", label: "Options" },
|
||||
...(sqlite
|
||||
? []
|
||||
: [
|
||||
{ id: "ssh" as const, label: "SSH/SSL" },
|
||||
{ id: "schemas" as const, label: "Schemas" },
|
||||
]),
|
||||
...(sqlite ? [] : snowflake ? [] : [{ id: "ssh" as const, label: "SSH/SSL" }]),
|
||||
...(sqlite ? [] : [{ id: "schemas" as const, label: "Schemas" }]),
|
||||
{ id: "advanced", label: "Advanced" },
|
||||
];
|
||||
const current = tabs.some((t) => t.id === tab) ? tab : "general";
|
||||
@@ -537,13 +574,14 @@ export function SourceDialog({
|
||||
{!urlOnly && (
|
||||
<>
|
||||
<label className={styles.formLabel} htmlFor="db-host">
|
||||
<T>Host:</T>
|
||||
{tx(snowflake ? "Account:" : "Host:")}
|
||||
</label>
|
||||
<div className={styles.formRow}>
|
||||
<Input
|
||||
id="db-host"
|
||||
className={styles.fill}
|
||||
value={s.host ?? ""}
|
||||
placeholder={snowflake ? "myorg-myaccount or xy12345.eu-central-1" : undefined}
|
||||
onChange={(e) => set({ host: e.target.value })}
|
||||
/>
|
||||
<label className={styles.formLabel} htmlFor="db-port">
|
||||
@@ -575,16 +613,53 @@ export function SourceDialog({
|
||||
placeholder={urlOnly ? "from the URL" : ""}
|
||||
onChange={(e) => set({ user: e.target.value })}
|
||||
/>
|
||||
<label className={styles.formLabel} htmlFor="db-password">
|
||||
<T>Password:</T>
|
||||
{snowflake && (
|
||||
<>
|
||||
<span className={styles.formLabel}>
|
||||
<T>Authentication:</T>
|
||||
</span>
|
||||
<Select
|
||||
aria-label="Snowflake authentication"
|
||||
value={sf.auth ?? "keyPair"}
|
||||
options={SNOWFLAKE_AUTH}
|
||||
onChange={(auth) => setSnowflake({ auth })}
|
||||
/>
|
||||
{sf.auth === "keyPair" && (
|
||||
<FileField
|
||||
id="db-snowflake-key"
|
||||
label="Private key:"
|
||||
value={sf.privateKeyFile ?? ""}
|
||||
placeholder="~/.ssh/rsa_key.p8"
|
||||
title="Private Key File"
|
||||
onChange={(privateKeyFile) => setSnowflake({ privateKeyFile })}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{browserSignIn && (
|
||||
<>
|
||||
<span />
|
||||
<span className={styles.formHint}>
|
||||
<T>Opens your web browser to sign in with your identity provider when connecting.</T>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<label className={styles.formLabel} htmlFor="db-password" hidden={browserSignIn}>
|
||||
{tx(snowflake ? snowflakeSecretLabel(sf.auth) : "Password:")}
|
||||
</label>
|
||||
<div className={styles.formRow}>
|
||||
<div className={styles.formRow} hidden={browserSignIn}>
|
||||
<Input
|
||||
id="db-password"
|
||||
className={styles.fill}
|
||||
type="password"
|
||||
value={password ?? ""}
|
||||
placeholder={initial && password === undefined ? "<unchanged>" : ""}
|
||||
placeholder={
|
||||
initial && password === undefined
|
||||
? "<unchanged>"
|
||||
: snowflake && sf.auth === "keyPair"
|
||||
? "Only for an encrypted key"
|
||||
: ""
|
||||
}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<Checkbox
|
||||
@@ -604,6 +679,36 @@ export function SourceDialog({
|
||||
placeholder={oracle ? "Service name, e.g. ORCLPDB1" : undefined}
|
||||
onChange={(e) => set({ database: e.target.value })}
|
||||
/>
|
||||
{snowflake && (
|
||||
<>
|
||||
<label className={styles.formLabel} htmlFor="db-snowflake-schema">
|
||||
<T>Schema:</T>
|
||||
</label>
|
||||
<Input
|
||||
id="db-snowflake-schema"
|
||||
value={options.defaultSchema ?? ""}
|
||||
onChange={(e) => setOptions({ defaultSchema: e.target.value })}
|
||||
/>
|
||||
<label className={styles.formLabel} htmlFor="db-snowflake-warehouse">
|
||||
<T>Warehouse:</T>
|
||||
</label>
|
||||
<Input
|
||||
id="db-snowflake-warehouse"
|
||||
value={sf.warehouse ?? ""}
|
||||
placeholder="The user's default warehouse when empty"
|
||||
onChange={(e) => setSnowflake({ warehouse: e.target.value })}
|
||||
/>
|
||||
<label className={styles.formLabel} htmlFor="db-snowflake-role">
|
||||
<T>Role:</T>
|
||||
</label>
|
||||
<Input
|
||||
id="db-snowflake-role"
|
||||
value={sf.role ?? ""}
|
||||
placeholder="The user's default role when empty"
|
||||
onChange={(e) => setSnowflake({ role: e.target.value })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<label className={styles.formLabel} htmlFor="db-url">
|
||||
@@ -729,9 +834,11 @@ export function SourceDialog({
|
||||
? "SET max_execution_time = 30;"
|
||||
: sqlite
|
||||
? "PRAGMA foreign_keys = ON;"
|
||||
: oracle
|
||||
? "ALTER SESSION SET NLS_DATE_FORMAT = 'YYYY-MM-DD HH24:MI:SS';"
|
||||
: "SET SESSION sql_mode = 'ANSI';"
|
||||
: snowflake
|
||||
? "ALTER SESSION SET QUERY_TAG = 'ide';"
|
||||
: oracle
|
||||
? "ALTER SESSION SET NLS_DATE_FORMAT = 'YYYY-MM-DD HH24:MI:SS';"
|
||||
: "SET SESSION sql_mode = 'ANSI';"
|
||||
}
|
||||
onChange={(e) => setOptions({ startupScript: e.target.value })}
|
||||
/>
|
||||
|
||||
@@ -21,6 +21,8 @@ describe("boolean cell editing", () => {
|
||||
expect(booleanLiterals("sqlite")).toEqual({ t: "1", f: "0" });
|
||||
expect(booleanLiterals("sqlServer")).toEqual({ t: "true", f: "false" });
|
||||
expect(booleanLiterals("oracle")).toEqual({ t: "true", f: "false" });
|
||||
expect(booleanLiterals("snowflake")).toEqual({ t: "true", f: "false" });
|
||||
expect(isBooleanType("BOOLEAN", "snowflake")).toBe(true);
|
||||
expect(booleanLiterals(undefined)).toEqual({ t: "true", f: "false" });
|
||||
});
|
||||
|
||||
|
||||
@@ -17,11 +17,21 @@ import {
|
||||
type CompletionResult,
|
||||
type CompletionSource,
|
||||
} from "@codemirror/autocomplete";
|
||||
import { EditorState, Facet, Prec, RangeSet, StateEffect, StateField, type Extension } from "@codemirror/state";
|
||||
import {
|
||||
Compartment,
|
||||
EditorState,
|
||||
Facet,
|
||||
Prec,
|
||||
RangeSet,
|
||||
StateEffect,
|
||||
StateField,
|
||||
type Extension,
|
||||
} from "@codemirror/state";
|
||||
import {
|
||||
Decoration,
|
||||
EditorView,
|
||||
GutterMarker,
|
||||
ViewPlugin,
|
||||
WidgetType,
|
||||
gutter,
|
||||
showPanel,
|
||||
@@ -29,17 +39,25 @@ import {
|
||||
type Panel,
|
||||
type Tooltip,
|
||||
} from "@codemirror/view";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { EditorDocContext } from "@/editor/api";
|
||||
import { iconNode } from "@/editor/lsp/iconMarkup";
|
||||
import type { DbDriver, DbStatementOutcome } from "@/platform/ipc";
|
||||
import { call, commands, type DbDriver, type DbSessionContext, type DbStatementOutcome } from "@/platform/ipc";
|
||||
import { shortcutLabel } from "@/platform/keymap";
|
||||
import { IconButton, Select } from "@/ui";
|
||||
import { activeSchema, schemaSwitchable, sourceLabel, splitTarget, targetOf } from "./model";
|
||||
import { loadSavedCatalog, offerIntrospection } from "./introspection";
|
||||
import { analyze, templateContexts, valuesSlot } from "./sql/analyze";
|
||||
import { shouldAutoCompleteSql, sqlCandidates, type CandidateKind, type SqlCandidate } from "./sql/complete";
|
||||
import {
|
||||
shouldAutoCompleteSql,
|
||||
sqlCandidates,
|
||||
type CandidateKind,
|
||||
type SqlCandidate,
|
||||
type SqlCatalog,
|
||||
} from "./sql/complete";
|
||||
import { sqlSupport } from "./sqlSupport";
|
||||
import { snowflakeLanguage } from "./sql/snowflakeHighlight";
|
||||
import {
|
||||
bindConsole,
|
||||
cancelConsole,
|
||||
@@ -50,10 +68,13 @@ import {
|
||||
finishTransaction,
|
||||
openHistory,
|
||||
loadCompletion,
|
||||
runSql,
|
||||
listsDatabases,
|
||||
loadDatabases,
|
||||
loadSchemas,
|
||||
loadSources,
|
||||
loadTables,
|
||||
tablesKey,
|
||||
setConsoleSchema,
|
||||
setManualCommit,
|
||||
sourceById,
|
||||
@@ -216,6 +237,12 @@ export async function executeInView(view: EditorView, path: string) {
|
||||
if (outcomes.length) markOutcomes(view, outcomes);
|
||||
}
|
||||
|
||||
/** Runs every statement of `view`, in order (stops at the first that fails). */
|
||||
export async function executeScriptInView(view: EditorView, path: string) {
|
||||
const outcomes = await executeConsole(path, view.state.doc.toString(), 0, view.state.doc.length);
|
||||
if (outcomes.length) markOutcomes(view, outcomes);
|
||||
}
|
||||
|
||||
/** Explain Plan of the statement at the caret (or the first selected one) of `view`. */
|
||||
export async function explainInView(view: EditorView, path: string) {
|
||||
const sel = view.state.selection.main;
|
||||
@@ -300,6 +327,12 @@ function ConsoleBar({ path, view }: { path: string; view: EditorView }) {
|
||||
disabled={!bound || running}
|
||||
onClick={() => void executeInView(view, path)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="gutter-run-all"
|
||||
title="Execute Script"
|
||||
disabled={!bound || running}
|
||||
onClick={() => void executeScriptInView(view, path)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="db-plan"
|
||||
title="Explain Plan"
|
||||
@@ -353,6 +386,7 @@ function ConsoleBar({ path, view }: { path: string; view: EditorView }) {
|
||||
</>
|
||||
)}
|
||||
<span className={styles.spacer} />
|
||||
{bound && sourceById(boundId)?.driver === "snowflake" && <SnowflakeSession target={bound} idle={!running} />}
|
||||
{switchable && (
|
||||
<Select
|
||||
aria-label={sqlServer ? "Database" : "Schema"}
|
||||
@@ -376,6 +410,69 @@ function ConsoleBar({ path, view }: { path: string; view: EditorView }) {
|
||||
);
|
||||
}
|
||||
|
||||
const quoted = (name: string) => `"${name.replace(/"/g, '""')}"`;
|
||||
|
||||
/**
|
||||
* Warehouse and role of a Snowflake data source's session (its consoles share them). Read
|
||||
* once connected and after each run, since `USE WAREHOUSE` / `USE ROLE` in a console moves them too.
|
||||
*/
|
||||
function SnowflakeSession({ target, idle }: { target: string; idle: boolean }) {
|
||||
const connected = useDatabase((s) => s.connected.has(target));
|
||||
const [state, setState] = useState<DbSessionContext>({
|
||||
warehouse: "",
|
||||
role: "",
|
||||
warehouses: [],
|
||||
roles: [],
|
||||
});
|
||||
useEffect(() => {
|
||||
// Completion has the saved catalog before the first connect; a stale one is read again after it.
|
||||
void loadSavedCatalog(target);
|
||||
if (connected) void offerIntrospection(target);
|
||||
}, [connected, target]);
|
||||
useEffect(() => {
|
||||
if (!connected || !idle) return;
|
||||
let stale = false;
|
||||
call(commands.dbSessionContext(target))
|
||||
.then((context) => {
|
||||
if (!stale) setState(context);
|
||||
})
|
||||
// Not connected any more, or no privilege: the pickers keep what they showed.
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
stale = true;
|
||||
};
|
||||
}, [target, connected, idle]);
|
||||
if (!connected) return null;
|
||||
const use = async (what: "WAREHOUSE" | "ROLE", name: string) => {
|
||||
if (await runSql(target, [`USE ${what} ${quoted(name)}`]))
|
||||
setState((s) => (what === "WAREHOUSE" ? { ...s, warehouse: name } : { ...s, role: name }));
|
||||
};
|
||||
const options = (names: string[], current: string) =>
|
||||
[...new Set(current ? [current, ...names] : names)].map((n) => ({ value: n, label: n }));
|
||||
return (
|
||||
<>
|
||||
<Select
|
||||
aria-label="Warehouse"
|
||||
value={state.warehouse || null}
|
||||
placeholder="<warehouse>"
|
||||
options={options(state.warehouses, state.warehouse)}
|
||||
searchable={state.warehouses.length > 10}
|
||||
onChange={(v) => void use("WAREHOUSE", v)}
|
||||
disabled={!idle}
|
||||
/>
|
||||
<Select
|
||||
aria-label="Role"
|
||||
value={state.role || null}
|
||||
placeholder="<role>"
|
||||
options={options(state.roles, state.role)}
|
||||
searchable={state.roles.length > 10}
|
||||
onChange={(v) => void use("ROLE", v)}
|
||||
disabled={!idle}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function consolePanel(path: string) {
|
||||
return (view: EditorView): Panel => {
|
||||
const dom = document.createElement("div");
|
||||
@@ -458,8 +555,81 @@ function finishTemplateField(view: EditorView) {
|
||||
* Context-aware SQL completion against the bound data source's schema (see
|
||||
* `sql/analyze` and `sql/complete`); keywords and functions without one.
|
||||
*/
|
||||
/**
|
||||
* The databases of Snowflake data source `id` with their loaded schemas and tables, so
|
||||
* completion can follow `database.schema.table` beyond the console's own database.
|
||||
*/
|
||||
function catalogOf(id: string): SqlCatalog | undefined {
|
||||
const st = useDatabase.getState();
|
||||
const { id: sourceId, database: bound } = splitTarget(id);
|
||||
const listed = st.databases[sourceId];
|
||||
const dbs = listed?.status === "ok" ? listed.value : null;
|
||||
// What introspection read comes first (it is there before connecting); a database it has not
|
||||
// reached falls back to what the tree loaded.
|
||||
const read = st.catalogs[sourceId]?.databases ?? {};
|
||||
if (!dbs && !Object.keys(read).length) return undefined;
|
||||
const same = (a: string, b: string) => a.toLowerCase() === b.toLowerCase();
|
||||
const target = (database: string) => (dbs ? targetOf(sourceId, database, dbs.current) : null);
|
||||
const schemas = (database: string) => {
|
||||
if (read[database]) return read[database].map((s) => s.name);
|
||||
const at = target(database);
|
||||
const list = at ? st.schemas[at] : undefined;
|
||||
return list?.status === "ok" ? list.value : null;
|
||||
};
|
||||
const current = bound ?? dbs?.current ?? "";
|
||||
return {
|
||||
databases: dbs?.names ?? Object.keys(read),
|
||||
schemas,
|
||||
tables: (database, schema) => {
|
||||
const known = read[database]?.find((s) => same(s.name, schema));
|
||||
if (known) return known.tables.map((t) => ({ name: t.name, view: t.kind !== "table" }));
|
||||
const exact = schemas(database)?.find((s) => same(s, schema));
|
||||
const at = target(database);
|
||||
const list = exact && at ? st.tables[tablesKey(at, exact)] : undefined;
|
||||
return list?.status === "ok" ? list.value.map((t) => ({ name: t.name, view: t.kind !== "table" })) : null;
|
||||
},
|
||||
search: (prefix, limit) => {
|
||||
const wanted = prefix.toLowerCase();
|
||||
const found: Array<{ database: string; schema: string; name: string; view: boolean }> = [];
|
||||
for (const [database, list] of Object.entries(read)) {
|
||||
// The console's own database is in the schema model already.
|
||||
if (same(database, current)) continue;
|
||||
for (const schema of list)
|
||||
for (const t of schema.tables) {
|
||||
if (!t.name.toLowerCase().startsWith(wanted)) continue;
|
||||
found.push({ database, schema: schema.name, name: t.name, view: t.kind !== "table" });
|
||||
if (found.length >= limit) return found;
|
||||
}
|
||||
}
|
||||
return found;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Loads what `database.` / `database.schema.` at the caret needs before it is completed. */
|
||||
async function loadQualified(id: string, qualifier: readonly string[]): Promise<void> {
|
||||
const sourceId = splitTarget(id).id;
|
||||
// Introspection has it: nothing to load (and no reason to connect for it).
|
||||
const wanted = (qualifier[0] ?? "").toLowerCase();
|
||||
const read = useDatabase.getState().catalogs[sourceId]?.databases ?? {};
|
||||
if (Object.keys(read).some((d) => d.toLowerCase() === wanted)) return;
|
||||
if (!useDatabase.getState().databases[sourceId]) await loadDatabases(sourceId);
|
||||
const dbs = useDatabase.getState().databases[sourceId];
|
||||
if (dbs?.status !== "ok") return;
|
||||
const same = (a: string, b: string) => a.toLowerCase() === b.toLowerCase();
|
||||
const database = dbs.value.names.find((d) => same(d, qualifier[0] ?? ""));
|
||||
if (!database) return;
|
||||
if (useDatabase.getState().catalogs[sourceId]?.databases[database]) return;
|
||||
const target = targetOf(sourceId, database, dbs.value.current);
|
||||
if (!useDatabase.getState().schemas[target]) await loadSchemas(target);
|
||||
if (qualifier.length < 2) return;
|
||||
const list = useDatabase.getState().schemas[target];
|
||||
const schema = list?.status === "ok" ? list.value.find((s) => same(s, qualifier[1]!)) : undefined;
|
||||
if (schema && !useDatabase.getState().tables[tablesKey(target, schema)]) await loadTables(target, schema);
|
||||
}
|
||||
|
||||
function completionSource(path: string): CompletionSource {
|
||||
return (ctx: CompletionContext): CompletionResult | null => {
|
||||
return async (ctx: CompletionContext): Promise<CompletionResult | null> => {
|
||||
const id = consoleSource(path);
|
||||
const source = sourceById(id);
|
||||
const driver: DbDriver = source?.driver ?? "sqlite";
|
||||
@@ -476,7 +646,18 @@ function completionSource(path: string): CompletionSource {
|
||||
? analyze(doc.slice(0, sel.from) + doc.slice(sel.to), sel.from, driver)
|
||||
: analyze(doc, ctx.pos, driver);
|
||||
if (!ctx.explicit && !shouldAutoCompleteSql(analysis)) return null;
|
||||
const candidates = sqlCandidates({ model: scoped, analysis, driver, sourceName: source?.name ?? undefined });
|
||||
const cataloged = driver === "snowflake" && !!id;
|
||||
if (cataloged && analysis.qualifier?.length) {
|
||||
await loadQualified(id, analysis.qualifier).catch(() => {});
|
||||
if (ctx.aborted) return null;
|
||||
}
|
||||
const candidates = sqlCandidates({
|
||||
model: scoped,
|
||||
analysis,
|
||||
driver,
|
||||
sourceName: source?.name ?? undefined,
|
||||
catalog: cataloged ? catalogOf(id) : undefined,
|
||||
});
|
||||
if (!candidates.length) return null;
|
||||
return {
|
||||
from: analysis.prefix.from,
|
||||
@@ -501,6 +682,30 @@ function driverOf(path: string): DbDriver {
|
||||
return sourceById(consoleSource(path))?.driver ?? "sqlite";
|
||||
}
|
||||
|
||||
/**
|
||||
* Syntax colours of the bound data source's dialect, over the editor's standard SQL mode.
|
||||
* Follows the binding: a console moved to another data source is coloured for that one.
|
||||
*/
|
||||
function dialectColours(path: string): Extension {
|
||||
const slot = new Compartment();
|
||||
const colours = (driver: DbDriver): Extension =>
|
||||
driver === "snowflake" ? Prec.high(snowflakeLanguage().extension) : [];
|
||||
const initial = driverOf(path);
|
||||
return [
|
||||
slot.of(colours(initial)),
|
||||
ViewPlugin.define((view) => {
|
||||
let shown = initial;
|
||||
const stop = useDatabase.subscribe(() => {
|
||||
const driver = driverOf(path);
|
||||
if (driver === shown) return;
|
||||
shown = driver;
|
||||
view.dispatch({ effects: slot.reconfigure(colours(driver)) });
|
||||
});
|
||||
return { destroy: stop };
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
/** Live template contexts at a position (`sql-statement` / `sql-query` / `sql-code`). */
|
||||
function templateContextsAt(path: string) {
|
||||
return (state: EditorState, pos: number): readonly string[] =>
|
||||
@@ -576,6 +781,7 @@ export function consoleExtension(ctx: EditorDocContext): Extension {
|
||||
completeAfterTemplate,
|
||||
valuesInfo(ctx.path),
|
||||
sqlSupport(ctx.path),
|
||||
dialectColours(ctx.path),
|
||||
// The grammar's keyword list would duplicate ours (it is the fallback without a language server).
|
||||
Prec.highest(EditorState.languageData.of(() => [{ autocomplete: noKeywords }])),
|
||||
];
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { DataSource } from "@/platform/ipc";
|
||||
import { exportData, type ExportData } from "./export";
|
||||
import { isNumericColumn } from "./Grid";
|
||||
import {
|
||||
activeSchema,
|
||||
buildRowEdits,
|
||||
@@ -112,6 +114,23 @@ describe("export formats", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("doubles quotes and backslashes in Snowflake SQL exports", () => {
|
||||
const columns = [
|
||||
{ name: "ID", type: "NUMBER(38,0)" },
|
||||
{ name: "name", type: "VARCHAR(100)" },
|
||||
];
|
||||
expect(exportData("sql", { columns, table: "ORDERS", driver: "snowflake", rows: [["1", "C:\\new\\file's"]] })).toBe(
|
||||
`INSERT INTO ORDERS (ID, "name") VALUES (1, 'C:\\\\new\\\\file''s');`,
|
||||
);
|
||||
});
|
||||
|
||||
it("right-aligns Snowflake number types", () => {
|
||||
for (const type of ["NUMBER(38,0)", "FIXED", "DECFLOAT", "FLOAT"])
|
||||
expect(isNumericColumn(type, []), type).toBe(true);
|
||||
for (const type of ["VARCHAR(100)", "TIMESTAMP_NTZ(9)", "BOOLEAN", "VARIANT", "FixedString(10)"])
|
||||
expect(isNumericColumn(type, []), type).toBe(false);
|
||||
});
|
||||
|
||||
it("writes a Markdown table, numbers right-aligned", () => {
|
||||
expect(exportData("markdown", { ...data, rows: [["1", "a|b"]] })).toBe(
|
||||
"| id | name |\n| --: | --- |\n| 1 | a\\|b |",
|
||||
@@ -172,6 +191,62 @@ describe("connection targets", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Snowflake sources", () => {
|
||||
const settings = { warehouse: "COMPUTE_WH", role: "ANALYST", auth: "keyPair", privateKeyFile: "" } as const;
|
||||
|
||||
it("start without a host and with key-pair authentication", () => {
|
||||
expect(vendorPreset(vendorById("snowflake")!)).toEqual({
|
||||
driver: "snowflake",
|
||||
vendor: "",
|
||||
port: 443,
|
||||
host: "",
|
||||
snowflake: { warehouse: "", role: "", auth: "keyPair", privateKeyFile: "" },
|
||||
});
|
||||
});
|
||||
|
||||
it("show a JDBC URL with the account's host and the session settings", () => {
|
||||
const source = {
|
||||
driver: "snowflake",
|
||||
host: "my_org-my_account",
|
||||
database: "ANALYTICS",
|
||||
options: { defaultSchema: "PUBLIC" },
|
||||
snowflake: settings,
|
||||
} as unknown as DataSource;
|
||||
expect(sourceUrl(source)).toBe(
|
||||
"jdbc:snowflake://my-org-my-account.snowflakecomputing.com/?db=ANALYTICS&schema=PUBLIC&warehouse=COMPUTE_WH&role=ANALYST",
|
||||
);
|
||||
expect(sourceUrl({ driver: "snowflake", host: "", port: 8443 } as unknown as DataSource)).toBe(
|
||||
"jdbc:snowflake://<account>.snowflakecomputing.com:8443/",
|
||||
);
|
||||
expect(
|
||||
sourceUrl({ driver: "snowflake", host: "https://acct.snowflakecomputing.com/console" } as unknown as DataSource),
|
||||
).toBe("jdbc:snowflake://acct.snowflakecomputing.com/");
|
||||
});
|
||||
|
||||
it("read the session settings of a JDBC URL", () => {
|
||||
const parsed = parseSourceUrl(
|
||||
"jdbc:snowflake://myorg-myaccount.snowflakecomputing.com:443/?warehouse=COMPUTE_WH&db=ANALYTICS&schema=PUBLIC" +
|
||||
"&role=ANALYST&user=jane&authenticator=snowflake_jwt&private_key_file=%7E%2Fkeys%2Frsa_key.p8&QUERY_TAG=ide",
|
||||
);
|
||||
expect(parsed).toEqual({
|
||||
source: {
|
||||
driver: "snowflake",
|
||||
host: "myorg-myaccount.snowflakecomputing.com",
|
||||
port: 443,
|
||||
user: "jane",
|
||||
database: "ANALYTICS",
|
||||
options: { defaultSchema: "PUBLIC" },
|
||||
snowflake: { ...settings, privateKeyFile: "~/keys/rsa_key.p8" },
|
||||
properties: [{ name: "QUERY_TAG", value: "ide" }],
|
||||
},
|
||||
password: null,
|
||||
});
|
||||
expect(
|
||||
parseSourceUrl("snowflake://acct.snowflakecomputing.com/?authenticator=externalbrowser")?.source.snowflake,
|
||||
).toMatchObject({ auth: "browser" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSourceUrl", () => {
|
||||
it("reads PostgreSQL / MySQL URLs, plain and JDBC", () => {
|
||||
expect(parseSourceUrl("postgresql://ann:s%[email protected]:5433/shop?sslmode=require")).toEqual({
|
||||
@@ -216,7 +291,9 @@ describe("data source types", () => {
|
||||
expect(vendorPreset(vendorById("supabase")!).ssl).toBe("require");
|
||||
expect(vendorPreset(vendorById("oracle")!)).toEqual({ driver: "oracle", vendor: "", port: 1521 });
|
||||
expect(
|
||||
VENDORS.every((v) => ["postgres", "mysql", "sqlite", "sqlServer", "clickhouse", "oracle"].includes(v.driver)),
|
||||
VENDORS.every((v) =>
|
||||
["postgres", "mysql", "sqlite", "sqlServer", "clickhouse", "oracle", "snowflake"].includes(v.driver),
|
||||
),
|
||||
).toBe(true);
|
||||
const labels = VENDORS.map((v) => v.label);
|
||||
expect(labels).toEqual([...labels].sort((a, b) => a.localeCompare(b)));
|
||||
@@ -337,7 +414,7 @@ describe("active schema", () => {
|
||||
});
|
||||
|
||||
it("offers the schema dropdown to multi-schema drivers only", () => {
|
||||
for (const driver of ["postgres", "mysql", "sqlServer", "oracle"] as const)
|
||||
for (const driver of ["postgres", "mysql", "sqlServer", "oracle", "snowflake"] as const)
|
||||
expect(schemaSwitchable(driver)).toBe(true);
|
||||
for (const driver of ["sqlite", "clickhouse"] as const) expect(schemaSwitchable(driver)).toBe(false);
|
||||
expect(schemaSwitchable(undefined)).toBe(false);
|
||||
|
||||
@@ -85,7 +85,7 @@ export function exportData(
|
||||
? "NULL"
|
||||
: numeric[i] && Number.isFinite(Number(v))
|
||||
? v
|
||||
: `'${(d.driver === "clickhouse" ? v.replace(/\\/g, "\\\\") : v).replace(/'/g, "''")}'`;
|
||||
: `'${(d.driver === "clickhouse" || d.driver === "snowflake" ? 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");
|
||||
}
|
||||
|
||||
@@ -99,6 +99,7 @@ export function activate(): () => void {
|
||||
loaded: false,
|
||||
connected: new Set(),
|
||||
databases: {},
|
||||
catalogs: {},
|
||||
schemas: {},
|
||||
tables: {},
|
||||
details: {},
|
||||
@@ -202,6 +203,24 @@ export function activate(): () => void {
|
||||
await (await import("./console")).executeInView(e.view, e.ctx.path);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "db.executeScript",
|
||||
title: "Execute SQL Script",
|
||||
category: "Tools",
|
||||
icon: "gutter-run-all",
|
||||
keywords: ["run all", "whole file", "statements"],
|
||||
when: `editorTextFocus && editorLangId == ${SQL_LANGUAGE}`,
|
||||
run: async () => {
|
||||
const e = activeEditor();
|
||||
if (!e || e.ctx.language !== SQL_LANGUAGE) return;
|
||||
if (!useDatabase.getState().loaded) await loadSources();
|
||||
if (!consoleSource(e.ctx.path) && !useDatabase.getState().sources.length) {
|
||||
openSourceDialog(null);
|
||||
return;
|
||||
}
|
||||
await (await import("./console")).executeScriptInView(e.view, e.ctx.path);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "db.goToColumn",
|
||||
title: "Go to Column",
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DataSource, DbSourceCatalog } from "@/platform/ipc";
|
||||
|
||||
const backend = vi.hoisted(() => ({
|
||||
saved: vi.fn(),
|
||||
database: vi.fn(),
|
||||
retain: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/platform/ipc")>();
|
||||
return {
|
||||
...actual,
|
||||
commands: {
|
||||
...actual.commands,
|
||||
dbSavedCatalog: backend.saved,
|
||||
dbDatabaseCatalog: backend.database,
|
||||
dbRetainCatalog: backend.retain,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const ok = <T>(data: T) => ({ status: "ok" as const, data });
|
||||
const schemas = (table: string) => [{ name: "PUBLIC", tables: [{ name: table, kind: "table" as const }] }];
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
|
||||
/** A fresh copy of the module (its "asked this run" memory starts empty) over a seeded store. */
|
||||
async function setup(id: string, saved: DbSourceCatalog | null, introspection?: Partial<DataSource["introspection"]>) {
|
||||
vi.resetModules();
|
||||
const { useDatabase } = await import("./store");
|
||||
const source = {
|
||||
id,
|
||||
name: "warehouse",
|
||||
driver: "snowflake",
|
||||
introspection: { allDatabases: true, databases: [], ...introspection },
|
||||
} as unknown as DataSource;
|
||||
useDatabase.setState({
|
||||
sources: [source],
|
||||
loaded: true,
|
||||
catalogs: {},
|
||||
databases: { [id]: { status: "ok", value: { current: "", names: ["ANALYTICS", "RAW"] } } },
|
||||
});
|
||||
backend.saved.mockResolvedValue(ok(saved));
|
||||
backend.database.mockImplementation((_id: string, database: string) => Promise.resolve(ok(schemas(`${database}_T`))));
|
||||
backend.retain.mockResolvedValue(ok(null));
|
||||
return { useDatabase, ...(await import("./introspection")) };
|
||||
}
|
||||
|
||||
// Each test loads a fresh copy of the store module, which takes a while in a busy run.
|
||||
vi.setConfig({ testTimeout: 30_000 });
|
||||
|
||||
describe("Snowflake introspection", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("starts from the catalog saved on disk without reading the server", async () => {
|
||||
const saved = { databases: [{ name: "RAW", readAt: Date.now() - HOUR, schemas: schemas("EVENTS") }] };
|
||||
const { useDatabase, loadSavedCatalog, offerIntrospection } = await setup("sf", saved);
|
||||
await loadSavedCatalog("sf:RAW");
|
||||
const catalog = useDatabase.getState().catalogs.sf!;
|
||||
expect(catalog.status).toBe("done");
|
||||
expect(catalog.databases.RAW).toEqual(schemas("EVENTS"));
|
||||
// Recent enough: connecting neither asks nor reads again.
|
||||
await offerIntrospection("sf");
|
||||
expect(backend.database).not.toHaveBeenCalled();
|
||||
expect(backend.saved).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reads every shown database, saves as it goes and forgets dropped ones", async () => {
|
||||
const saved = { databases: [{ name: "GONE", readAt: Date.now() - HOUR, schemas: schemas("OLD") }] };
|
||||
const { useDatabase, introspectSource } = await setup("sf", saved);
|
||||
await introspectSource("sf");
|
||||
expect(backend.database.mock.calls.map((c) => c[1])).toEqual(["ANALYTICS", "RAW"]);
|
||||
expect(backend.retain).toHaveBeenCalledWith("sf", ["ANALYTICS", "RAW"]);
|
||||
const catalog = useDatabase.getState().catalogs.sf!;
|
||||
expect(catalog.status).toBe("done");
|
||||
expect(Object.keys(catalog.databases).sort()).toEqual(["ANALYTICS", "RAW"]);
|
||||
expect(catalog.databases.ANALYTICS).toEqual(schemas("ANALYTICS_T"));
|
||||
expect(Date.now() - catalog.readAt!).toBeLessThan(HOUR);
|
||||
});
|
||||
|
||||
it("reads again on connect only when the saved catalog is over a day old and the user agreed before", async () => {
|
||||
const old = { databases: [{ name: "RAW", readAt: Date.now() - 25 * HOUR, schemas: schemas("EVENTS") }] };
|
||||
// Not agreed yet: asks (a notification), reads nothing.
|
||||
let run = await setup("sf", old);
|
||||
await run.offerIntrospection("sf");
|
||||
expect(backend.database).not.toHaveBeenCalled();
|
||||
expect(run.useDatabase.getState().catalogs.sf!.databases.RAW).toEqual(schemas("EVENTS"));
|
||||
|
||||
// Agreed once (by running it): the next run refreshes a stale catalog by itself.
|
||||
await run.introspectSource("sf");
|
||||
vi.clearAllMocks();
|
||||
run = await setup("sf", old);
|
||||
await run.offerIntrospection("sf");
|
||||
await vi.waitFor(() => expect(run.useDatabase.getState().catalogs.sf?.status).toBe("done"));
|
||||
expect(backend.database.mock.calls.map((c) => c[1])).toEqual(["ANALYTICS", "RAW"]);
|
||||
});
|
||||
|
||||
it("keeps to the databases the Schemas tab shows, and goes on past one it may not read", async () => {
|
||||
const { useDatabase, introspectSource } = await setup("sf", null, { allDatabases: false, databases: ["RA*"] });
|
||||
backend.database.mockRejectedValueOnce(new Error("not authorized"));
|
||||
await introspectSource("sf");
|
||||
expect(backend.database.mock.calls.map((c) => c[1])).toEqual(["RAW"]);
|
||||
expect(useDatabase.getState().catalogs.sf).toMatchObject({ status: "done", done: 1, total: 1, databases: {} });
|
||||
// Nothing was read: not a catalog to rely on, so the next connect reads again.
|
||||
expect(useDatabase.getState().catalogs.sf?.readAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does nothing for other databases", async () => {
|
||||
const { useDatabase, introspectSource, loadSavedCatalog } = await setup("pg", null);
|
||||
useDatabase.setState({ sources: [{ id: "pg", name: "pg", driver: "postgres" } as unknown as DataSource] });
|
||||
await loadSavedCatalog("pg");
|
||||
await introspectSource("pg");
|
||||
expect(backend.saved).not.toHaveBeenCalled();
|
||||
expect(backend.database).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Introspection of a Snowflake data source: the schemas, tables and views of every database
|
||||
* it shows, read in the background so completion can follow `database.schema.table` without
|
||||
* the tree being opened. The backend keeps what was read on disk, so the next run starts with
|
||||
* it; it is read again when asked, or on connecting once it is older than a day.
|
||||
*/
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import { shownNames, splitTarget } from "./model";
|
||||
import { loadDatabases, sourceById, useDatabase } from "./store";
|
||||
|
||||
const ACCEPTED_KEY = "openidea.database.introspect";
|
||||
/** Data sources asked (or started) in this run, so a connect asks only once. */
|
||||
const offered = new Set<string>();
|
||||
const cancelled = new Set<string>();
|
||||
|
||||
function accepted(): string[] {
|
||||
try {
|
||||
const stored: unknown = JSON.parse(localStorage.getItem(ACCEPTED_KEY) ?? "[]");
|
||||
return Array.isArray(stored) ? stored.filter((id): id is string => typeof id === "string") : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function remember(id: string) {
|
||||
try {
|
||||
localStorage.setItem(ACCEPTED_KEY, JSON.stringify([...new Set([...accepted(), id])]));
|
||||
} catch {
|
||||
// Without storage the question comes again next time.
|
||||
}
|
||||
}
|
||||
|
||||
const notice = (id: string) => `db-introspect-${id}`;
|
||||
|
||||
/** A saved catalog older than this is read again on the next connect. */
|
||||
const STALE_MS = 24 * 60 * 60 * 1000;
|
||||
const loading = new Map<string, Promise<void>>();
|
||||
|
||||
/**
|
||||
* Brings what earlier introspection of data source `id` saved into the store (once per run).
|
||||
* Reads a file: the data source is not connected to.
|
||||
*/
|
||||
export function loadSavedCatalog(id: string): Promise<void> {
|
||||
const sourceId = splitTarget(id).id;
|
||||
if (sourceById(sourceId)?.driver !== "snowflake") return Promise.resolve();
|
||||
let pending = loading.get(sourceId);
|
||||
if (!pending) {
|
||||
pending = call(commands.dbSavedCatalog(sourceId))
|
||||
.then((saved) => {
|
||||
const databases = saved?.databases ?? [];
|
||||
// An introspection started meanwhile knows better.
|
||||
if (!databases.length || useDatabase.getState().catalogs[sourceId]) return;
|
||||
useDatabase.setState((s) => ({
|
||||
catalogs: {
|
||||
...s.catalogs,
|
||||
[sourceId]: {
|
||||
status: "done",
|
||||
done: databases.length,
|
||||
total: databases.length,
|
||||
readAt: Math.min(...databases.map((d) => d.readAt ?? 0)),
|
||||
databases: Object.fromEntries(databases.map((d) => [d.name, d.schemas])),
|
||||
},
|
||||
},
|
||||
}));
|
||||
})
|
||||
.catch(() => {});
|
||||
loading.set(sourceId, pending);
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** Stops the running introspection of data source `id` after the database it is reading. */
|
||||
export function cancelIntrospection(id: string) {
|
||||
cancelled.add(splitTarget(id).id);
|
||||
}
|
||||
|
||||
/** Reads the schemas and tables of every database data source `id` shows (see the Schemas tab). */
|
||||
export async function introspectSource(id: string): Promise<void> {
|
||||
const sourceId = splitTarget(id).id;
|
||||
const source = sourceById(sourceId);
|
||||
if (source?.driver !== "snowflake" || useDatabase.getState().catalogs[sourceId]?.status === "running") return;
|
||||
offered.add(sourceId);
|
||||
remember(sourceId);
|
||||
cancelled.delete(sourceId);
|
||||
const name = source.name ?? "";
|
||||
const update = (patch: Partial<{ status: "running" | "done" | "cancelled"; done: number; total: number }>) =>
|
||||
useDatabase.setState((s) => {
|
||||
const old = s.catalogs[sourceId] ?? { status: "running" as const, done: 0, total: 0, databases: {} };
|
||||
return { catalogs: { ...s.catalogs, [sourceId]: { ...old, ...patch } } };
|
||||
});
|
||||
await loadSavedCatalog(sourceId);
|
||||
update({ status: "running", done: 0, total: 0 });
|
||||
if (!useDatabase.getState().databases[sourceId]) await loadDatabases(sourceId);
|
||||
const listed = useDatabase.getState().databases[sourceId];
|
||||
if (listed?.status !== "ok") {
|
||||
update({ status: "cancelled" });
|
||||
return;
|
||||
}
|
||||
const filter = source.introspection;
|
||||
const databases = shownNames(listed.value.names, filter?.allDatabases, filter?.databases, listed.value.current);
|
||||
update({ total: databases.length });
|
||||
let tables = 0;
|
||||
let done = 0;
|
||||
const failed: string[] = [];
|
||||
let reason = "";
|
||||
for (const database of databases) {
|
||||
if (cancelled.has(sourceId)) break;
|
||||
const position = done + 1;
|
||||
const count = databases.length;
|
||||
notifications().notify({
|
||||
level: "info",
|
||||
key: notice(sourceId),
|
||||
title: tx`Introspecting ${name}`,
|
||||
body: tx`${database} (${position} of ${count})`,
|
||||
actions: [{ label: "Stop", run: () => cancelIntrospection(sourceId) }],
|
||||
sticky: true,
|
||||
});
|
||||
try {
|
||||
const schemas = await call(commands.dbDatabaseCatalog(sourceId, database));
|
||||
tables += schemas.reduce((n, schema) => n + schema.tables.length, 0);
|
||||
useDatabase.setState((s) => {
|
||||
const old = s.catalogs[sourceId];
|
||||
if (!old) return s;
|
||||
return {
|
||||
catalogs: { ...s.catalogs, [sourceId]: { ...old, databases: { ...old.databases, [database]: schemas } } },
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
// A database the role may not read: completion goes without it, and the summary says so.
|
||||
failed.push(database);
|
||||
reason ||= error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
done += 1;
|
||||
update({ done });
|
||||
}
|
||||
const stopped = cancelled.delete(sourceId);
|
||||
const total = tables.toLocaleString("en");
|
||||
const read = done - failed.length;
|
||||
const unread = failed.length;
|
||||
if (!stopped) {
|
||||
// Databases dropped on the server, or no longer shown, leave the catalog and its file.
|
||||
useDatabase.setState((s) => {
|
||||
const old = s.catalogs[sourceId];
|
||||
if (!old) return s;
|
||||
const kept = Object.fromEntries(Object.entries(old.databases).filter(([d]) => databases.includes(d)));
|
||||
// Nothing read at all is not a catalog to rely on for a day: the next connect tries again.
|
||||
const readAt = read > 0 || !unread ? Date.now() : old.readAt;
|
||||
return { catalogs: { ...s.catalogs, [sourceId]: { ...old, databases: kept, readAt } } };
|
||||
});
|
||||
await call(commands.dbRetainCatalog(sourceId, databases)).catch(() => {});
|
||||
}
|
||||
update({ status: stopped ? "cancelled" : "done" });
|
||||
const summary = tx`${total} tables and views in ${read} databases are ready for completion.`;
|
||||
notifications().notify({
|
||||
level: unread ? "warning" : "info",
|
||||
key: notice(sourceId),
|
||||
title: stopped ? tx`Introspection of ${name} stopped` : tx`Introspected ${name}`,
|
||||
body: unread ? `${summary} ${tx`${unread} databases could not be read: ${reason}`}` : summary,
|
||||
sticky: unread > 0,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* On connecting to Snowflake data source `id`: nothing while its saved catalog is recent; else
|
||||
* introspects it when the user agreed before, or asks once (IDEA asks the same of a data
|
||||
* source it has not introspected).
|
||||
*/
|
||||
export async function offerIntrospection(id: string): Promise<void> {
|
||||
const sourceId = splitTarget(id).id;
|
||||
const source = sourceById(sourceId);
|
||||
if (source?.driver !== "snowflake" || offered.has(sourceId)) return;
|
||||
offered.add(sourceId);
|
||||
await loadSavedCatalog(sourceId);
|
||||
const known = useDatabase.getState().catalogs[sourceId];
|
||||
if (known?.status === "running") return;
|
||||
if (known?.readAt !== undefined && Date.now() - known.readAt < STALE_MS) return;
|
||||
if (accepted().includes(sourceId)) {
|
||||
void introspectSource(sourceId);
|
||||
return;
|
||||
}
|
||||
const name = source.name ?? "";
|
||||
notifications().notify({
|
||||
level: "info",
|
||||
key: notice(sourceId),
|
||||
title: tx`Introspect ${name}?`,
|
||||
body: tx("Reads the names of schemas, tables and views of every database, so completion works for all of them."),
|
||||
actions: [
|
||||
{ label: "Introspect", run: () => void introspectSource(sourceId) },
|
||||
{ label: "Not Now", run: () => {} },
|
||||
],
|
||||
sticky: true,
|
||||
});
|
||||
}
|
||||
@@ -5,6 +5,8 @@ describe("JSON column detection", () => {
|
||||
it("matches json, jsonb and MySQL JSON types", () => {
|
||||
for (const type of ["json", "jsonb", "JSON", "Json", "MYSQL_TYPE_JSON"]) expect(isJsonColumn(type)).toBe(true);
|
||||
for (const type of ["text", "_json", "jsonb[]", "notjson", "json_config"]) expect(isJsonColumn(type)).toBe(false);
|
||||
for (const type of ["VARIANT", "OBJECT", "ARRAY", "variant"]) expect(isJsonColumn(type)).toBe(true);
|
||||
for (const type of ["Array(Int32)", "VARCHAR(100)", "int4[]"]) expect(isJsonColumn(type)).toBe(false);
|
||||
expect(isJsonColumn("integer")).toBe(false);
|
||||
expect(isJsonColumn("")).toBe(false);
|
||||
});
|
||||
|
||||
@@ -3,7 +3,8 @@ import type { Cell } from "./model";
|
||||
/** Automatic formatting and content detection are bounded on the UI thread. */
|
||||
export const JSON_INLINE_LIMIT = 256_000;
|
||||
|
||||
export const isJsonColumn = (type: string) => /^(json|jsonb|MYSQL_TYPE_JSON)$/i.test(type.trim());
|
||||
/** JSON types, Snowflake's semi-structured ones included. */
|
||||
export const isJsonColumn = (type: string) => /^(json|jsonb|MYSQL_TYPE_JSON|variant|object|array)$/i.test(type.trim());
|
||||
|
||||
export interface JsonError {
|
||||
message: string;
|
||||
|
||||
@@ -5,13 +5,14 @@ import type {
|
||||
DbDriver,
|
||||
DbProperty,
|
||||
DbRowEdit,
|
||||
DbSnowflakeAuth,
|
||||
DbSortKey,
|
||||
DbSsh,
|
||||
DbSslMode,
|
||||
DataSource,
|
||||
} from "@/platform/ipc";
|
||||
import type { SshConfigValid } from "@/platform/remoteSettings";
|
||||
import { vendorOf, vendorOfScheme } from "./vendors";
|
||||
import { blankSnowflake, vendorOf, vendorOfScheme } from "./vendors";
|
||||
|
||||
export type Cell = string | null;
|
||||
|
||||
@@ -218,7 +219,13 @@ export function passesTreeFilter(name: string, filter: string | null | undefined
|
||||
* several schemas / databases to switch between; SQLite has one database only).
|
||||
*/
|
||||
export function schemaSwitchable(driver: DbDriver | null | undefined): boolean {
|
||||
return driver === "postgres" || driver === "mysql" || driver === "sqlServer" || driver === "oracle";
|
||||
return (
|
||||
driver === "postgres" ||
|
||||
driver === "mysql" ||
|
||||
driver === "sqlServer" ||
|
||||
driver === "oracle" ||
|
||||
driver === "snowflake"
|
||||
);
|
||||
}
|
||||
|
||||
/** The schema a console's statements run in: its pick, else the connection's default. */
|
||||
@@ -353,6 +360,44 @@ const SSL_MODES: Record<string, DbSslMode> = {
|
||||
"verify-identity": "verifyFull",
|
||||
};
|
||||
|
||||
const SNOWFLAKE_AUTHENTICATORS: Record<string, DbSnowflakeAuth> = {
|
||||
snowflake_jwt: "keyPair",
|
||||
snowflake: "password",
|
||||
programmatic_access_token: "token",
|
||||
oauth: "oAuth",
|
||||
externalbrowser: "browser",
|
||||
};
|
||||
|
||||
/** Applies a parameter of a Snowflake JDBC URL that names a connection setting. */
|
||||
function snowflakeParameter(source: Partial<DataSource>, name: string, value: string): boolean {
|
||||
const settings = (source.snowflake ??= blankSnowflake());
|
||||
switch (name.toLowerCase()) {
|
||||
case "db":
|
||||
case "database":
|
||||
source.database = value;
|
||||
return true;
|
||||
case "schema":
|
||||
source.options = { ...source.options, defaultSchema: value } as DataSource["options"];
|
||||
return true;
|
||||
case "warehouse":
|
||||
settings.warehouse = value;
|
||||
return true;
|
||||
case "role":
|
||||
settings.role = value;
|
||||
return true;
|
||||
case "private_key_file":
|
||||
settings.privateKeyFile = value;
|
||||
return true;
|
||||
case "authenticator": {
|
||||
const auth = SNOWFLAKE_AUTHENTICATORS[value.toLowerCase()];
|
||||
if (auth) settings.auth = auth;
|
||||
return !!auth;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Data Source from URL (and the General tab's URL field): `postgres://user:pass@host:5432/db`,
|
||||
* `mysql://…`, `mariadb://…`, wire-compatible products (`redshift://`, `tidb://`…), the
|
||||
@@ -422,6 +467,7 @@ export function parseSourceUrl(raw: string): { source: Partial<DataSource>; pass
|
||||
source.sslKeyFile = v;
|
||||
break;
|
||||
default:
|
||||
if (vendor.driver === "snowflake" && snowflakeParameter(source, k, v)) break;
|
||||
properties.push({ name: k, value: v });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
DbObjectKind as DbSchemaObjectKind,
|
||||
DbPackageInfo,
|
||||
DbRoutineInfo,
|
||||
DbSchemaObject,
|
||||
DbSequenceInfo,
|
||||
DbTableInfo,
|
||||
DbTableKind,
|
||||
@@ -25,7 +26,26 @@ export type Folder =
|
||||
| "procedures"
|
||||
| "functions"
|
||||
| "packages"
|
||||
| "sequences";
|
||||
| "sequences"
|
||||
| OtherFolder;
|
||||
|
||||
/** Folders of object kinds only some databases have (Snowflake), by the kind the backend names. */
|
||||
export const OTHER_FOLDERS = {
|
||||
stage: "stages",
|
||||
stream: "streams",
|
||||
task: "tasks",
|
||||
pipe: "pipes",
|
||||
fileFormat: "fileFormats",
|
||||
} as const;
|
||||
|
||||
export type OtherFolder = (typeof OTHER_FOLDERS)[keyof typeof OTHER_FOLDERS];
|
||||
|
||||
export const isOtherFolder = (folder: Folder): folder is OtherFolder =>
|
||||
(Object.values(OTHER_FOLDERS) as string[]).includes(folder);
|
||||
|
||||
/** The folder an object of `kind` is listed in (unknown kinds are not shown). */
|
||||
export const otherFolder = (kind: string): OtherFolder | undefined =>
|
||||
(OTHER_FOLDERS as Record<string, OtherFolder | undefined>)[kind];
|
||||
|
||||
/** The folder a table, view or materialized view is listed in. */
|
||||
export function tableFolder(kind: DbTableKind): Folder {
|
||||
@@ -55,6 +75,8 @@ export type DbNode =
|
||||
| { kind: "routine"; key: string; id: string; schema: string; routine: DbRoutineInfo }
|
||||
| { kind: "package"; key: string; id: string; schema: string; package: DbPackageInfo }
|
||||
| { kind: "sequence"; key: string; id: string; schema: string; sequence: DbSequenceInfo }
|
||||
/** A stage, stream, task, pipe or file format: listed by name. */
|
||||
| { kind: "object"; key: string; id: string; schema: string; object: DbSchemaObject }
|
||||
| ({ kind: "group"; key: string; group: Group } & InTable)
|
||||
| ({ kind: "column"; key: string; column: DbColumnInfo; foreignKey: boolean } & InTable)
|
||||
| ({ kind: "key"; key: string; columns: string[] } & InTable)
|
||||
|
||||
@@ -239,3 +239,105 @@ describe("oracle object SQL", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("snowflake object SQL", () => {
|
||||
const emp = [
|
||||
col("ID", "NUMBER(38,0)", { primaryKey: true, nullable: false }),
|
||||
col("NAME", "VARCHAR(40)", { default: "'x'" }),
|
||||
];
|
||||
const drop = { ifExists: true, cascade: true };
|
||||
|
||||
it("keeps unquoted names that the server folds to upper case", () => {
|
||||
expect(ident("snowflake", "EMPLOYEES")).toBe("EMPLOYEES");
|
||||
expect(ident("snowflake", "EMP_2$")).toBe("EMP_2$");
|
||||
expect(ident("snowflake", "employees")).toBe('"employees"');
|
||||
expect(ident("snowflake", "My Table")).toBe('"My Table"');
|
||||
expect(ident("snowflake", 'a"b')).toBe('"a""b"');
|
||||
expect(ident("snowflake", "QUALIFY")).toBe('"QUALIFY"');
|
||||
expect(ident("snowflake", "ORDER")).toBe('"ORDER"');
|
||||
expect(quoteIdent("snowflake", "x")).toBe('"x"');
|
||||
expect(reference("snowflake", { kind: "column", schema: "HR", name: "EMPLOYEES", column: "name" })).toBe(
|
||||
'HR.EMPLOYEES."name"',
|
||||
);
|
||||
});
|
||||
|
||||
it("renames within the object's schema", () => {
|
||||
expect(renameSql("snowflake", { kind: "table", schema: "HR", name: "EMP" }, "STAFF")).toBe(
|
||||
"alter table HR.EMP rename to HR.STAFF;",
|
||||
);
|
||||
expect(renameSql("snowflake", { kind: "view", schema: "HR", name: "V_EMP" }, "V_STAFF")).toBe(
|
||||
"alter view HR.V_EMP rename to HR.V_STAFF;",
|
||||
);
|
||||
expect(renameSql("snowflake", { kind: "column", schema: "HR", name: "EMP", column: "A" }, "b")).toBe(
|
||||
'alter table HR.EMP rename column A to "b";',
|
||||
);
|
||||
});
|
||||
|
||||
it("drops and truncates, and has no indexes or triggers", () => {
|
||||
expect(dropSql("snowflake", { kind: "table", schema: "HR", name: "EMP" }, drop)).toBe(
|
||||
"drop table if exists HR.EMP;",
|
||||
);
|
||||
expect(dropSql("snowflake", { kind: "view", schema: "HR", name: "V" }, drop)).toBe("drop view if exists HR.V;");
|
||||
expect(dropSql("snowflake", { kind: "materializedView", schema: "HR", name: "MV" }, drop)).toBe(
|
||||
"drop materialized view if exists HR.MV;",
|
||||
);
|
||||
expect(dropSql("snowflake", { kind: "sequence", schema: "HR", name: "S" }, drop)).toBe(
|
||||
"drop sequence if exists HR.S;",
|
||||
);
|
||||
expect(
|
||||
dropSql("snowflake", { kind: "function", schema: "HR", name: "F", arguments: "NUMBER, VARCHAR" }, drop),
|
||||
).toBe("drop function if exists HR.F(NUMBER, VARCHAR);");
|
||||
expect(dropSql("snowflake", { kind: "procedure", schema: "HR", name: "P" }, { ...drop, ifExists: false })).toBe(
|
||||
"drop procedure HR.P();",
|
||||
);
|
||||
expect(dropSql("snowflake", { kind: "column", schema: "HR", name: "EMP", column: "NAME" }, drop)).toBe(
|
||||
"alter table HR.EMP drop column NAME;",
|
||||
);
|
||||
expect(dropSql("snowflake", { kind: "schema", schema: "", name: "HR" }, drop)).toBe("drop schema if exists HR;");
|
||||
expect(truncateSql("snowflake", "HR", "EMP", { restartIdentity: true, cascade: true })).toBe(
|
||||
"truncate table HR.EMP;",
|
||||
);
|
||||
expect(canDrop("snowflake", { kind: "index", schema: "HR", name: "I" })).toBe(false);
|
||||
expect(canDrop("snowflake", { kind: "trigger", schema: "HR", name: "T" })).toBe(false);
|
||||
expect(canDrop("snowflake", { kind: "table", schema: "HR", name: "EMP" })).toBe(true);
|
||||
expect(canModifyTable("snowflake")).toBe(true);
|
||||
});
|
||||
|
||||
it("creates tables and script templates", () => {
|
||||
const d = emp.map(draftOf);
|
||||
d.push({ ...d[1]!, key: 9, original: null, name: "note", dataType: "", default: "", notNull: true });
|
||||
expect(createTableSql("snowflake", "HR", "T", d)).toBe(
|
||||
"create table HR.T\n(\n ID NUMBER(38,0) not null,\n NAME VARCHAR(40) default 'x',\n" +
|
||||
' "note" VARCHAR not null,\n primary key (ID)\n);\n',
|
||||
);
|
||||
expect(scriptSql("snowflake", "selectFirst", "HR", "EMP", emp)).toBe("select *\nfrom HR.EMP\nlimit 10;\n");
|
||||
});
|
||||
|
||||
it("alters columns with ALTER COLUMN and never sets a default on an existing column", () => {
|
||||
const p = emp.map(draftOf);
|
||||
p[0] = { ...p[0]!, notNull: false, default: "1" };
|
||||
p[1] = { ...p[1]!, name: "FULL_NAME", dataType: "VARCHAR(80)", notNull: true, default: "" };
|
||||
p.push({
|
||||
key: 99,
|
||||
original: null,
|
||||
name: "HIRED",
|
||||
dataType: "DATE",
|
||||
notNull: false,
|
||||
default: "CURRENT_DATE",
|
||||
primaryKey: false,
|
||||
dropped: false,
|
||||
} satisfies ColumnDraft);
|
||||
expect(alterTableSql("snowflake", "HR", "EMP", "STAFF", p)).toEqual([
|
||||
"alter table HR.EMP alter column ID drop not null;",
|
||||
"alter table HR.EMP rename column NAME to FULL_NAME;",
|
||||
"alter table HR.EMP alter column FULL_NAME set data type VARCHAR(80);",
|
||||
"alter table HR.EMP alter column FULL_NAME set not null;",
|
||||
"alter table HR.EMP alter column FULL_NAME drop default;",
|
||||
"alter table HR.EMP add column HIRED DATE default CURRENT_DATE;",
|
||||
"alter table HR.EMP rename to HR.STAFF;",
|
||||
]);
|
||||
const dropped = emp.map(draftOf);
|
||||
dropped[1] = { ...dropped[1]!, dropped: true };
|
||||
expect(alterTableSql("snowflake", "HR", "EMP", "EMP", dropped)).toEqual(["alter table HR.EMP drop column NAME;"]);
|
||||
});
|
||||
});
|
||||
Loaded 100 of 159 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Заблокувати користувача