From 080d894034e41462ad82412fdd2c2e5497f75bb6 Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Fri, 22 May 2026 12:15:44 -0700 Subject: [PATCH 1/7] feat(databases): migrate to dedicated databases API --- src/command.rs | 8 +- src/databases.rs | 548 ++++++++++++++++++++++------------------- src/main.rs | 4 +- tests/databases_cli.rs | 13 +- 4 files changed, 298 insertions(+), 275 deletions(-) diff --git a/src/command.rs b/src/command.rs index a92193a..a57e2fe 100644 --- a/src/command.rs +++ b/src/command.rs @@ -71,7 +71,7 @@ pub enum Commands { /// Managed databases you create and populate with tables (parquet uploads) Databases { - /// Database name or connection ID (omit to use a subcommand) + /// Database id or description (omit to use a subcommand) name_or_id: Option, /// Workspace ID (defaults to first workspace from login) @@ -557,15 +557,15 @@ pub enum DatabasesCommands { /// Create a new managed database Create { - /// Database name (used as the connection name in SQL: `name.schema.table`) + /// Optional display label (not unique, not an identifier — databases are addressed by id) #[arg(long)] - name: String, + description: Option, /// Schema for tables declared at create time (default: public) #[arg(long, default_value = "public")] schema: String, - /// Table to declare up front (repeatable). Required before load on current API. + /// Table to declare up front (repeatable) #[arg(long = "table")] tables: Vec, diff --git a/src/databases.rs b/src/databases.rs index 1ee73da..5cc5eda 100644 --- a/src/databases.rs +++ b/src/databases.rs @@ -3,30 +3,33 @@ use indicatif::{ProgressBar, ProgressStyle}; use serde::{Deserialize, Serialize}; use std::path::Path; -const MANAGED_SOURCE_TYPE: &str = "managed"; const DEFAULT_SCHEMA: &str = "public"; +/// Summary row returned by `GET /databases` (no `default_connection_id`). #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] -pub struct Database { - pub id: String, - pub name: String, - pub source_type: String, +struct DatabaseSummary { + id: String, + description: Option, } #[derive(Deserialize)] -struct ListConnectionsResponse { - connections: Vec, +struct ListDatabasesResponse { + databases: Vec, } -#[derive(Deserialize, Serialize)] -struct DatabaseDetail { - id: String, - name: String, - source_type: String, - #[serde(default)] - table_count: u64, - #[serde(default)] - synced_table_count: u64, +/// Full record returned by `GET /databases/{id}`. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub struct Database { + pub id: String, + pub description: Option, + pub default_connection_id: String, + attachments: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +struct DatabaseAttachment { + connection_id: String, + alias: Option, } #[derive(Deserialize)] @@ -56,10 +59,10 @@ struct TableRow { } #[derive(Deserialize, Serialize)] -struct CreateConnectionResponse { +struct CreateDatabaseResponse { id: String, - name: String, - source_type: String, + description: Option, + default_connection_id: String, } #[derive(Deserialize)] @@ -73,43 +76,42 @@ struct LoadManagedTableResponse { arrow_schema_json: String, } -fn is_managed(db: &Database) -> bool { - db.source_type == MANAGED_SOURCE_TYPE +fn fetch_database(api: &ApiClient, id: &str) -> Database { + api.get(&format!("/databases/{id}")) } -pub fn try_resolve_database(api: &ApiClient, name_or_id: &str) -> Result { - let body: ListConnectionsResponse = api.get("/connections"); - let by_id = body - .connections +pub fn try_resolve_database(api: &ApiClient, id_or_description: &str) -> Result { + // Try a direct id lookup first — avoids the list round-trip for the common case. + if let Some(db) = api.get_none_if_not_found(&format!("/databases/{id_or_description}")) { + return Ok(db); + } + + // Fall back to listing and matching by description. + let body: ListDatabasesResponse = api.get("/databases"); + let desc_matches: Vec<&DatabaseSummary> = body + .databases .iter() - .find(|c| c.id == name_or_id) - .cloned(); - let found = by_id.or_else(|| { - body.connections - .iter() - .find(|c| c.name == name_or_id) - .cloned() - }); - match found { - Some(db) if is_managed(&db) => Ok(db), - Some(db) => Err(format!( - "'{}' is not a managed database (source_type: {})", - db.name, db.source_type + .filter(|d| d.description.as_deref() == Some(id_or_description)) + .collect(); + + match desc_matches.len() { + 0 => Err(format!( + "no database with id or description '{id_or_description}'" + )), + 1 => Ok(fetch_database(api, &desc_matches[0].id)), + _ => Err(format!( + "multiple databases have description '{}' — use the database id instead", + id_or_description )), - None => Err(format!("no database named or with id '{name_or_id}'")), } } -pub fn resolve_database(api: &ApiClient, name_or_id: &str) -> Database { - match try_resolve_database(api, name_or_id) { +pub fn resolve_database(api: &ApiClient, id_or_description: &str) -> Database { + match try_resolve_database(api, id_or_description) { Ok(db) => db, Err(e) => { use crossterm::style::Stylize; - if e.contains("not a managed database") { - eprintln!("{}", format!("error: {e}. Use `hotdata connections` for remote sources.").red()); - } else { - eprintln!("{}", format!("error: {e}").red()); - } + eprintln!("{}", format!("error: {e}").red()); std::process::exit(1); } } @@ -119,28 +121,33 @@ fn schema_name(schema: Option<&str>) -> &str { schema.unwrap_or(DEFAULT_SCHEMA) } -/// Build managed-connection `config` with declared schemas/tables. -pub fn build_managed_config(schema: &str, tables: &[String]) -> serde_json::Value { - if tables.is_empty() { - return serde_json::json!({}); +/// Build the request body for `POST /v1/databases`. +pub fn create_database_request( + description: Option<&str>, + schema: &str, + tables: &[String], +) -> serde_json::Value { + let mut req = serde_json::Map::new(); + + if let Some(desc) = description { + req.insert( + "description".to_string(), + serde_json::Value::String(desc.to_string()), + ); } - let table_objs: Vec = tables - .iter() - .map(|t| serde_json::json!({ "name": t })) - .collect(); - serde_json::json!({ - "schemas": [{ "name": schema, "tables": table_objs }] - }) -} -/// Request body for `POST /v1/connections` when creating a managed database. -pub fn create_connection_request(name: &str, schema: &str, tables: &[String]) -> serde_json::Value { - serde_json::json!({ - "name": name, - "source_type": MANAGED_SOURCE_TYPE, - "config": build_managed_config(schema, tables), - "skip_discovery": true, - }) + if !tables.is_empty() { + let table_objs: Vec = tables + .iter() + .map(|t| serde_json::json!({ "name": t })) + .collect(); + req.insert( + "schemas".to_string(), + serde_json::json!([{ "name": schema, "tables": table_objs }]), + ); + } + + serde_json::Value::Object(req) } pub fn managed_table_load_path(connection_id: &str, schema: &str, table: &str) -> String { @@ -164,11 +171,11 @@ pub fn is_parquet_path(path: &str) -> bool { || Path::new(path).extension().and_then(|e| e.to_str()) == Some("parquet") } -fn table_rows_for_database(db_name: &str, tables: Vec) -> Vec { +fn table_rows(tables: Vec) -> Vec { tables .into_iter() .map(|t| TableRow { - full_name: format!("{}.{}.{}", db_name, t.schema, t.table), + full_name: format!("default.{}.{}", t.schema, t.table), schema: t.schema, table: t.table, synced: t.synced, @@ -177,7 +184,12 @@ fn table_rows_for_database(db_name: &str, tables: Vec) -> Vec, pb: &ProgressBar) -> String { +fn finish_upload( + api: &ApiClient, + reader: impl std::io::Read + Send + 'static, + size: Option, + pb: &ProgressBar, +) -> String { let (status, resp_body) = api.post_body("/files", "application/octet-stream", reader, size); pb.finish_and_clear(); @@ -251,7 +263,10 @@ fn upload_parquet_url(api: &ApiClient, url: &str) -> String { }; if !resp.status().is_success() { - eprintln!("error: remote server returned {} for '{url}'", resp.status()); + eprintln!( + "error: remote server returned {} for '{url}'", + resp.status() + ); std::process::exit(1); } @@ -285,9 +300,8 @@ fn collect_tables(api: &ApiClient, connection_id: &str, schema: Option<&str>) -> let mut out = Vec::new(); let mut cursor: Option = None; loop { - let mut params: Vec<(&str, Option)> = vec![ - ("connection_id", Some(connection_id.to_string())), - ]; + let mut params: Vec<(&str, Option)> = + vec![("connection_id", Some(connection_id.to_string()))]; if let Some(s) = schema { params.push(("schema", Some(s.to_string()))); } @@ -314,73 +328,97 @@ fn collect_tables(api: &ApiClient, connection_id: &str, schema: Option<&str>) -> pub fn list(workspace_id: &str, format: &str) { let api = ApiClient::new(Some(workspace_id)); - let body: ListConnectionsResponse = api.get("/connections"); - let databases: Vec<&Database> = body - .connections - .iter() - .filter(|c| is_managed(c)) - .collect(); + let body: ListDatabasesResponse = api.get("/databases"); match format { - "json" => println!("{}", serde_json::to_string_pretty(&databases).unwrap()), - "yaml" => print!("{}", serde_yaml::to_string(&databases).unwrap()), + "json" => println!("{}", serde_json::to_string_pretty(&body.databases).unwrap()), + "yaml" => print!("{}", serde_yaml::to_string(&body.databases).unwrap()), "table" => { - if databases.is_empty() { + if body.databases.is_empty() { use crossterm::style::Stylize; eprintln!("{}", "No databases found.".dark_grey()); eprintln!( "{}", - "Create one with: hotdata databases create --name ".dark_grey() + "Create one with: hotdata databases create".dark_grey() ); } else { - let rows: Vec> = databases + let rows: Vec> = body + .databases .iter() - .map(|d| vec![d.name.clone(), d.id.clone()]) + .map(|d| { + vec![ + d.description.as_deref().unwrap_or("-").to_string(), + d.id.clone(), + ] + }) .collect(); - crate::table::print(&["NAME", "ID"], &rows); + crate::table::print(&["DESCRIPTION", "ID"], &rows); } } _ => unreachable!(), } } -pub fn get(workspace_id: &str, name_or_id: &str, format: &str) { +pub fn get(workspace_id: &str, id_or_description: &str, format: &str) { let api = ApiClient::new(Some(workspace_id)); - let db = resolve_database(&api, name_or_id); - let detail: DatabaseDetail = api.get(&format!("/connections/{}", db.id)); + let db = resolve_database(&api, id_or_description); match format { - "json" => println!("{}", serde_json::to_string_pretty(&detail).unwrap()), - "yaml" => print!("{}", serde_yaml::to_string(&detail).unwrap()), + "json" => println!("{}", serde_json::to_string_pretty(&db).unwrap()), + "yaml" => print!("{}", serde_yaml::to_string(&db).unwrap()), "table" => { use crossterm::style::Stylize; - let label = |l: &str| format!("{:<16}", l).dark_grey().to_string(); - println!("{}{}", label("name:"), detail.name.clone().white()); - println!("{}{}", label("id:"), detail.id.dark_cyan()); + let label = |l: &str| format!("{:<24}", l).dark_grey().to_string(); + println!("{}{}", label("id:"), db.id.clone().dark_cyan()); println!( - "{}{} synced / {} total", - label("tables:"), - detail.synced_table_count.to_string().cyan(), - detail.table_count.to_string().cyan(), + "{}{}", + label("description:"), + db.description.as_deref().unwrap_or("-").white() + ); + println!( + "{}{}", + label("default_connection_id:"), + db.default_connection_id.clone().dark_cyan() ); println!( "{}{}", label("sql_prefix:"), - format!("{}.{{schema}}.{{table}}", detail.name).green() + "default.{schema}.{table} (pass X-Database-Id header when querying)".green() ); + if !db.attachments.is_empty() { + println!("{}({})", label("attached catalogs:"), db.attachments.len()); + for a in &db.attachments { + let alias = a + .alias + .as_deref() + .map(|al| format!(" as {al}")) + .unwrap_or_default(); + println!( + " {}{}", + a.connection_id.clone().dark_cyan(), + alias.dark_grey() + ); + } + } } _ => unreachable!(), } } -pub fn create(workspace_id: &str, name: &str, schema: &str, tables: &[String], format: &str) { +pub fn create( + workspace_id: &str, + description: Option<&str>, + schema: &str, + tables: &[String], + format: &str, +) { use crossterm::style::Stylize; - let body = create_connection_request(name, schema, tables); + let body = create_database_request(description, schema, tables); let api = ApiClient::new(Some(workspace_id)); let spinner = (format == "table").then(|| crate::util::spinner("Creating database...")); - let (status, resp_body) = api.post_raw("/connections", &body); + let (status, resp_body) = api.post_raw("/databases", &body); if let Some(s) = &spinner { s.finish_and_clear(); } @@ -390,7 +428,7 @@ pub fn create(workspace_id: &str, name: &str, schema: &str, tables: &[String], f std::process::exit(1); } - let result: CreateConnectionResponse = match serde_json::from_str(&resp_body) { + let result: CreateDatabaseResponse = match serde_json::from_str(&resp_body) { Ok(v) => v, Err(e) => { eprintln!("error parsing response: {e}"); @@ -403,42 +441,36 @@ pub fn create(workspace_id: &str, name: &str, schema: &str, tables: &[String], f "yaml" => print!("{}", serde_yaml::to_string(&result).unwrap()), "table" => { println!("{}", "Database created".green()); - println!("name: {}", result.name); - println!("id: {}", result.id); + if let Some(desc) = &result.description { + println!("description: {desc}"); + } + println!("id: {}", result.id); } _ => unreachable!(), } } -pub fn delete(workspace_id: &str, name_or_id: &str) { +pub fn delete(workspace_id: &str, id_or_description: &str) { use crossterm::style::Stylize; let api = ApiClient::new(Some(workspace_id)); - let db = resolve_database(&api, name_or_id); - let (status, resp_body) = api.delete_raw(&format!("/connections/{}", db.id)); + let db = resolve_database(&api, id_or_description); + let (status, resp_body) = api.delete_raw(&format!("/databases/{}", db.id)); if !status.is_success() { eprintln!("{}", crate::util::api_error(resp_body).red()); std::process::exit(1); } - println!( - "{}", - format!("Database '{}' deleted.", db.name).green() - ); + println!("{}", "Database deleted.".green()); } -pub fn tables_list( - workspace_id: &str, - database: &str, - schema: Option<&str>, - format: &str, -) { +pub fn tables_list(workspace_id: &str, database: &str, schema: Option<&str>, format: &str) { let api = ApiClient::new(Some(workspace_id)); let db = resolve_database(&api, database); - let tables = collect_tables(&api, &db.id, schema); + let tables = collect_tables(&api, &db.default_connection_id, schema); - let rows = table_rows_for_database(&db.name, tables); + let rows = table_rows(tables); match format { "json" => println!("{}", serde_json::to_string_pretty(&rows).unwrap()), @@ -495,7 +527,7 @@ pub fn tables_load( _ => unreachable!(), }; - let path = managed_table_load_path(&db.id, schema, table); + let path = managed_table_load_path(&db.default_connection_id, schema, table); let body = load_table_request(&upload_id); let spinner = crate::util::spinner("Loading table..."); @@ -508,12 +540,9 @@ pub fn tables_load( eprintln!("{}", msg.red()); eprintln!( "{}", - format!( - "Declare the table when creating the database, e.g.:\n \ - hotdata databases create --name {} --table {}", - db.name, table - ) - .dark_grey() + "Declare the table when creating the database, e.g.:\n \ + hotdata databases create --table " + .dark_grey() ); } else { eprintln!("{}", msg.red()); @@ -529,25 +558,20 @@ pub fn tables_load( } }; - let full_name = format!("{}.{}.{}", db.name, result.schema_name, result.table_name); + let full_name = format!("default.{}.{}", result.schema_name, result.table_name); println!("{}", "Table loaded".green()); println!("full_name: {}", full_name.green()); println!("rows: {}", result.row_count); } -pub fn tables_delete( - workspace_id: &str, - database: &str, - table: &str, - schema: Option<&str>, -) { +pub fn tables_delete(workspace_id: &str, database: &str, table: &str, schema: Option<&str>) { use crossterm::style::Stylize; let api = ApiClient::new(Some(workspace_id)); let db = resolve_database(&api, database); let schema = schema_name(schema); - let path = managed_table_delete_path(&db.id, schema, table); + let path = managed_table_delete_path(&db.default_connection_id, schema, table); let (status, resp_body) = api.delete_raw(&path); if !status.is_success() { @@ -557,7 +581,7 @@ pub fn tables_delete( println!( "{}", - format!("Table '{}.{}.{}' deleted.", db.name, schema, table).green() + format!("Table 'default.{}.{}' deleted.", schema, table).green() ); } @@ -572,115 +596,125 @@ mod tests { } #[test] - fn build_managed_config_empty_without_tables() { - assert_eq!(build_managed_config("public", &[]), serde_json::json!({})); + fn create_database_request_empty_without_description_or_tables() { + let req = create_database_request(None, "public", &[]); + assert_eq!(req, serde_json::json!({})); } #[test] - fn build_managed_config_declares_tables() { - let cfg = build_managed_config("public", &["orders".to_string(), "customers".to_string()]); - assert_eq!( - cfg, - serde_json::json!({ - "schemas": [{ - "name": "public", - "tables": [{ "name": "orders" }, { "name": "customers" }] - }] - }) - ); + fn create_database_request_includes_description() { + let req = create_database_request(Some("my db"), "public", &[]); + assert_eq!(req["description"], "my db"); + assert!(req.get("schemas").is_none()); } #[test] - fn is_managed_only_matches_managed_type() { - let db = Database { - id: "c1".into(), - name: "sales".into(), - source_type: "managed".into(), - }; - assert!(is_managed(&db)); - let pg = Database { - id: "c2".into(), - name: "warehouse".into(), - source_type: "postgres".into(), - }; - assert!(!is_managed(&pg)); + fn create_database_request_includes_schemas_when_tables_declared() { + let req = create_database_request( + Some("sales"), + "public", + &["orders".to_string(), "customers".to_string()], + ); + assert_eq!(req["description"], "sales"); + assert_eq!(req["schemas"][0]["name"], "public"); + assert_eq!(req["schemas"][0]["tables"][0]["name"], "orders"); + assert_eq!(req["schemas"][0]["tables"][1]["name"], "customers"); } #[test] - fn resolve_database_by_name_and_id() { - let mut server = mockito::Server::new(); - let mock = server - .mock("GET", "/connections") - .with_status(200) - .with_body( - r#"{"connections":[ - {"id":"conn_abc","name":"sales","source_type":"managed"}, - {"id":"conn_xyz","name":"warehouse","source_type":"postgres"} - ]}"#, - ) - .expect(2) - .create(); + fn create_database_request_schemas_without_description() { + let req = create_database_request(None, "analytics", &["events".to_string()]); + assert!(req.get("description").is_none()); + assert_eq!(req["schemas"][0]["name"], "analytics"); + } - let api = ApiClient::test_new(&server.url(), "k", Some("ws")); - let by_name = resolve_database(&api, "sales"); - assert_eq!(by_name.id, "conn_abc"); - let by_id = resolve_database(&api, "conn_abc"); - assert_eq!(by_id.name, "sales"); - mock.assert(); + fn full_detail(id: &str, desc: &str, conn_id: &str) -> String { + format!( + r#"{{"id":"{id}","description":"{desc}","default_connection_id":"{conn_id}","attachments":[]}}"# + ) } #[test] - fn try_resolve_database_rejects_non_managed() { + fn resolve_database_by_id_and_description() { let mut server = mockito::Server::new(); - let mock = server - .mock("GET", "/connections") + // by-id path: direct GET /databases/db_abc succeeds + let by_id_mock = server + .mock("GET", "/databases/db_abc") + .with_status(200) + .with_body(full_detail("db_abc", "sales", "conn_1")) + .create(); + // by-description path: GET /databases/warehouse → 404, then list, then detail + let not_id = server + .mock("GET", "/databases/warehouse") + .with_status(404) + .with_body(r#"{"error":"not found"}"#) + .create(); + let list = server + .mock("GET", "/databases") .with_status(200) .with_body( - r#"{"connections":[{"id":"c1","name":"warehouse","source_type":"postgres"}]}"#, + r#"{"databases":[{"id":"db_abc","description":"sales"},{"id":"db_xyz","description":"warehouse"}]}"#, ) .create(); + let detail = server + .mock("GET", "/databases/db_xyz") + .with_status(200) + .with_body(full_detail("db_xyz", "warehouse", "conn_2")) + .create(); - let api = ApiClient::test_new(&server.url(), "k", None); - let err = try_resolve_database(&api, "warehouse").unwrap_err(); - assert!(err.contains("not a managed database")); - mock.assert(); + let api = ApiClient::test_new(&server.url(), "k", Some("ws")); + let by_id = resolve_database(&api, "db_abc"); + assert_eq!(by_id.default_connection_id, "conn_1"); + let by_desc = resolve_database(&api, "warehouse"); + assert_eq!(by_desc.id, "db_xyz"); + by_id_mock.assert(); + not_id.assert(); + list.assert(); + detail.assert(); } #[test] fn try_resolve_database_not_found() { let mut server = mockito::Server::new(); - let mock = server - .mock("GET", "/connections") + // Direct id lookup returns 404 + server + .mock("GET", "/databases/missing") + .with_status(404) + .with_body(r#"{"error":"not found"}"#) + .create(); + // List also returns nothing + server + .mock("GET", "/databases") .with_status(200) - .with_body(r#"{"connections":[]}"#) + .with_body(r#"{"databases":[]}"#) .create(); let api = ApiClient::test_new(&server.url(), "k", None); let err = try_resolve_database(&api, "missing").unwrap_err(); - assert!(err.contains("no database named")); - mock.assert(); + assert!(err.contains("no database with id or description")); } #[test] - fn create_connection_request_includes_declared_tables() { - let body = create_connection_request( - "sales", - "public", - &["orders".to_string(), "customers".to_string()], - ); - assert_eq!(body["name"], "sales"); - assert_eq!(body["source_type"], "managed"); - assert_eq!(body["skip_discovery"], true); - assert_eq!( - body["config"]["schemas"][0]["tables"][0]["name"], - "orders" - ); - } + fn try_resolve_database_rejects_ambiguous_description() { + let mut server = mockito::Server::new(); + // Direct id lookup returns 404 (description isn't a valid id) + server + .mock("GET", "/databases/sales") + .with_status(404) + .with_body(r#"{"error":"not found"}"#) + .create(); + // List returns two entries with the same description + server + .mock("GET", "/databases") + .with_status(200) + .with_body( + r#"{"databases":[{"id":"db_1","description":"sales"},{"id":"db_2","description":"sales"}]}"#, + ) + .create(); - #[test] - fn create_connection_request_empty_config_without_tables() { - let body = create_connection_request("sales", "public", &[]); - assert_eq!(body["config"], serde_json::json!({})); + let api = ApiClient::test_new(&server.url(), "k", None); + let err = try_resolve_database(&api, "sales").unwrap_err(); + assert!(err.contains("multiple databases")); } #[test] @@ -712,19 +746,16 @@ mod tests { } #[test] - fn table_rows_for_database_builds_full_names() { - let rows = table_rows_for_database( - "sales", - vec![InfoTable { - connection: "sales".into(), - schema: "public".into(), - table: "orders".into(), - synced: true, - last_sync: Some("2026-05-19T00:00:00Z".into()), - }], - ); + fn table_rows_uses_default_prefix() { + let rows = table_rows(vec![InfoTable { + connection: "ignored".into(), + schema: "public".into(), + table: "orders".into(), + synced: true, + last_sync: Some("2026-05-19T00:00:00Z".into()), + }]); assert_eq!(rows.len(), 1); - assert_eq!(rows[0].full_name, "sales.public.orders"); + assert_eq!(rows[0].full_name, "default.public.orders"); assert!(rows[0].synced); } @@ -739,7 +770,7 @@ mod tests { ])) .with_status(200) .with_body( - r#"{"tables":[{"connection":"sales","schema":"public","table":"b","synced":true,"last_sync":null}],"has_more":false,"next_cursor":null}"#, + r#"{"tables":[{"connection":"default","schema":"public","table":"b","synced":true,"last_sync":null}],"has_more":false,"next_cursor":null}"#, ) .create(); let page0 = server @@ -750,7 +781,7 @@ mod tests { )) .with_status(200) .with_body( - r#"{"tables":[{"connection":"sales","schema":"public","table":"a","synced":false,"last_sync":null}],"has_more":true,"next_cursor":"cur2"}"#, + r#"{"tables":[{"connection":"default","schema":"public","table":"a","synced":false,"last_sync":null}],"has_more":true,"next_cursor":"cur2"}"#, ) .create(); @@ -764,18 +795,18 @@ mod tests { } #[test] - fn create_posts_managed_connection_with_schemas() { + fn create_posts_to_databases_endpoint() { let mut server = mockito::Server::new(); let mock = server - .mock("POST", "/connections") + .mock("POST", "/databases") .match_header("X-Workspace-Id", "ws-test") .with_status(201) .with_body( - r#"{"id":"conn_new","name":"mydb","source_type":"managed","tables_discovered":1,"discovery_status":"skipped"}"#, + r#"{"id":"db_new","description":"mydb","default_connection_id":"conn_abc"}"#, ) .match_body(mockito::Matcher::JsonString( - serde_json::to_string(&create_connection_request( - "mydb", + serde_json::to_string(&create_database_request( + Some("mydb"), "public", &["gdp".to_string()], )) @@ -784,34 +815,36 @@ mod tests { .create(); let api = ApiClient::test_new(&server.url(), "k", Some("ws-test")); - let body = create_connection_request("mydb", "public", &["gdp".to_string()]); - let (status, resp_body) = api.post_raw("/connections", &body); + let body = create_database_request(Some("mydb"), "public", &["gdp".to_string()]); + let (status, resp_body) = api.post_raw("/databases", &body); assert_eq!(status.as_u16(), 201); - let parsed: CreateConnectionResponse = serde_json::from_str(&resp_body).unwrap(); - assert_eq!(parsed.name, "mydb"); - assert_eq!(parsed.source_type, "managed"); + let parsed: CreateDatabaseResponse = serde_json::from_str(&resp_body).unwrap(); + assert_eq!(parsed.description.as_deref(), Some("mydb")); + assert_eq!(parsed.default_connection_id, "conn_abc"); mock.assert(); } #[test] - fn tables_load_posts_replace_with_upload_id() { + fn tables_load_uses_default_connection_id() { let mut server = mockito::Server::new(); - let list = server - .mock("GET", "/connections") + // resolve_database resolves by id directly + let resolve = server + .mock("GET", "/databases/db_1") .with_status(200) - .with_body( - r#"{"connections":[{"id":"conn1","name":"sales","source_type":"managed"}]}"#, - ) + .with_body(full_detail("db_1", "sales", "conn_default")) .create(); let load = server - .mock("POST", "/connections/conn1/schemas/public/tables/orders/loads") + .mock( + "POST", + "/connections/conn_default/schemas/public/tables/orders/loads", + ) .match_body(mockito::Matcher::JsonString( serde_json::to_string(&load_table_request("upl_123")).unwrap(), )) .with_status(200) .with_body( r#"{ - "connection_id":"conn1", + "connection_id":"conn_default", "schema_name":"public", "table_name":"orders", "row_count":42, @@ -821,40 +854,41 @@ mod tests { .create(); let api = ApiClient::test_new(&server.url(), "k", Some("ws1")); - let db = resolve_database(&api, "sales"); - let path = managed_table_load_path(&db.id, "public", "orders"); + let db = resolve_database(&api, "db_1"); + let path = managed_table_load_path(&db.default_connection_id, "public", "orders"); let body = load_table_request("upl_123"); let (status, resp_body) = api.post_raw(&path, &body); assert!(status.is_success()); let parsed: LoadManagedTableResponse = serde_json::from_str(&resp_body).unwrap(); assert_eq!(parsed.row_count, 42); assert_eq!(parsed.table_name, "orders"); - list.assert(); + resolve.assert(); load.assert(); } #[test] - fn tables_delete_calls_managed_table_endpoint() { + fn tables_delete_uses_default_connection_id() { let mut server = mockito::Server::new(); - let list = server - .mock("GET", "/connections") + let resolve = server + .mock("GET", "/databases/db_1") .with_status(200) - .with_body( - r#"{"connections":[{"id":"conn1","name":"sales","source_type":"managed"}]}"#, - ) + .with_body(full_detail("db_1", "sales", "conn_default")) .create(); let delete = server - .mock("DELETE", "/connections/conn1/schemas/public/tables/orders") + .mock( + "DELETE", + "/connections/conn_default/schemas/public/tables/orders", + ) .with_status(204) .with_body("") .create(); let api = ApiClient::test_new(&server.url(), "k", None); - let db = resolve_database(&api, "sales"); - let path = managed_table_delete_path(&db.id, "public", "orders"); + let db = resolve_database(&api, "db_1"); + let path = managed_table_delete_path(&db.default_connection_id, "public", "orders"); let (status, _) = api.delete_raw(&path); assert_eq!(status.as_u16(), 204); - list.assert(); + resolve.assert(); delete.assert(); } diff --git a/src/main.rs b/src/main.rs index 48f4569..a084257 100644 --- a/src/main.rs +++ b/src/main.rs @@ -382,13 +382,13 @@ fn main() { databases::list(&workspace_id, &output) } Some(DatabasesCommands::Create { - name, + description, schema, tables, output, }) => databases::create( &workspace_id, - &name, + description.as_deref(), &schema, &tables, &output, diff --git a/tests/databases_cli.rs b/tests/databases_cli.rs index d9cd4ba..347243e 100644 --- a/tests/databases_cli.rs +++ b/tests/databases_cli.rs @@ -28,7 +28,7 @@ fn databases_create_help_documents_table_flag() { assert!(output.status.success()); let help = String::from_utf8_lossy(&output.stdout); assert!(help.contains("--table")); - assert!(help.contains("--name")); + assert!(help.contains("--description")); } #[test] @@ -45,17 +45,6 @@ fn databases_tables_load_help_documents_file_and_upload_id() { assert!(help.contains("parquet")); } -#[test] -fn databases_create_requires_name() { - let output = hotdata().args(["databases", "create"]).output().unwrap(); - assert!(!output.status.success()); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("--name") || stderr.contains("required"), - "stderr: {stderr}" - ); -} - #[test] fn databases_tables_load_rejects_both_file_and_upload_id_at_parse_time() { let output = hotdata() From 0ec8e773c56f1bb6c1213e2f8f4788f149058c16 Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Fri, 22 May 2026 12:37:32 -0700 Subject: [PATCH 2/7] =?UTF-8?q?feat(datasets):=20narrow=20create=20to=20sq?= =?UTF-8?q?l/query-id;=20rename=20label=E2=86=92description,=20table-name?= =?UTF-8?q?=E2=86=92name?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/command.rs | 36 ++++++++++-------------------------- src/main.rs | 44 ++++++++++++-------------------------------- 2 files changed, 22 insertions(+), 58 deletions(-) diff --git a/src/command.rs b/src/command.rs index a57e2fe..911cc0f 100644 --- a/src/command.rs +++ b/src/command.rs @@ -453,53 +453,37 @@ pub enum DatasetsCommands { output: String, }, - /// Create a new dataset from a file, piped stdin, upload ID, or SQL query + /// Create a new dataset from a SQL query or saved query Create { - /// Dataset label (derived from filename if omitted) + /// SQL table name for the dataset #[arg(long)] - label: Option, + name: String, - /// Table name (derived from label if omitted) + /// Human-readable display label #[arg(long)] - table_name: Option, - - /// Path to a file to upload (omit to read from stdin) - #[arg(long, conflicts_with_all = ["upload_id", "sql"])] - file: Option, - - /// Skip upload and use a pre-existing upload ID directly - #[arg(long, conflicts_with_all = ["file", "sql"])] - upload_id: Option, - - /// Source format when using --upload-id (csv, json, parquet) - #[arg(long, default_value = "csv", value_parser = ["csv", "json", "parquet"], requires = "upload_id")] - format: String, + description: Option, /// SQL query to create the dataset from - #[arg(long, conflicts_with_all = ["file", "upload_id", "query_id", "url"])] + #[arg(long, conflicts_with = "query_id", required_unless_present = "query_id")] sql: Option, /// Saved query ID to create the dataset from - #[arg(long, conflicts_with_all = ["file", "upload_id", "sql", "url"])] + #[arg(long, conflicts_with = "sql", required_unless_present = "sql")] query_id: Option, - - /// URL to import data from - #[arg(long, conflicts_with_all = ["file", "upload_id", "sql", "query_id"])] - url: Option, }, - /// Update a dataset's label and/or table name + /// Update a dataset's description and/or name Update { /// Dataset ID id: String, /// New display label #[arg(long)] - label: Option, + description: Option, /// New SQL table name (must be a valid identifier) #[arg(long)] - table_name: Option, + name: Option, /// Output format #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] diff --git a/src/main.rs b/src/main.rs index a084257..8d37f8f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -186,57 +186,37 @@ fn main() { output, }) => datasets::list(&workspace_id, limit, offset, &output), Some(DatasetsCommands::Create { - label, - table_name, - file, - upload_id, - format, + name, + description, sql, query_id, - url, }) => { if let Some(sql) = sql { datasets::create_from_query( &workspace_id, &sql, - label.as_deref(), - table_name.as_deref(), - ) - } else if let Some(query_id) = query_id { - datasets::create_from_saved_query( - &workspace_id, - &query_id, - label.as_deref(), - table_name.as_deref(), - ) - } else if let Some(url) = url { - datasets::create_from_url( - &workspace_id, - &url, - label.as_deref(), - table_name.as_deref(), + description.as_deref(), + Some(&name), ) } else { - datasets::create_from_upload( + datasets::create_from_saved_query( &workspace_id, - label.as_deref(), - table_name.as_deref(), - file.as_deref(), - upload_id.as_deref(), - &format, + &query_id.unwrap(), + description.as_deref(), + Some(&name), ) } } Some(DatasetsCommands::Update { id, - label, - table_name, + description, + name, output, }) => datasets::update( &id, &workspace_id, - label.as_deref(), - table_name.as_deref(), + description.as_deref(), + name.as_deref(), &output, ), Some(DatasetsCommands::Refresh { id, r#async }) => { From 67ad881087d204552555ce2cae4cfff84be66939 Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Fri, 22 May 2026 13:26:01 -0700 Subject: [PATCH 3/7] Remove dead upload code from datasets; fix create signatures After the create rework removed --file/--upload-id/--url paths, the upload infrastructure (FileType, detect_from_bytes/path, stdin_redirect_filename, make_progress_bar, do_upload, upload_from_file/stdin, create_from_upload, create_from_url) was left as dead code. Remove it. Also align create_from_query/create_from_saved_query with the new CLI: - `name: &str` (required, was Option<&str>) - `description: Option<&str>` (optional, was required label) - create_dataset builds the body with table_name always set, label only when provided Co-Authored-By: Claude Sonnet 4.6 --- src/datasets.rs | 312 ++---------------------------------------------- src/main.rs | 4 +- 2 files changed, 12 insertions(+), 304 deletions(-) diff --git a/src/datasets.rs b/src/datasets.rs index bab4604..b67e6a6 100644 --- a/src/datasets.rs +++ b/src/datasets.rs @@ -1,8 +1,6 @@ use crate::api::ApiClient; -use indicatif::{ProgressBar, ProgressStyle}; use serde::{Deserialize, Serialize}; use serde_json::json; -use std::path::Path; #[derive(Deserialize, Serialize)] struct Dataset { @@ -72,177 +70,15 @@ struct UpdateResponse { updated_at: String, } -struct FileType { - content_type: &'static str, - format: &'static str, -} - -fn detect_from_bytes(bytes: &[u8]) -> FileType { - if bytes.starts_with(b"PAR1") { - return FileType { - content_type: "application/octet-stream", - format: "parquet", - }; - } - let first = bytes.iter().find(|&&b| !b.is_ascii_whitespace()).copied(); - if matches!(first, Some(b'{') | Some(b'[')) { - return FileType { - content_type: "application/json", - format: "json", - }; - } - FileType { - content_type: "text/csv", - format: "csv", - } -} - -fn detect_from_path(path: &str) -> Option { - match Path::new(path).extension().and_then(|e| e.to_str()) { - Some("csv") => Some(FileType { - content_type: "text/csv", - format: "csv", - }), - Some("json") => Some(FileType { - content_type: "application/json", - format: "json", - }), - Some("parquet") => Some(FileType { - content_type: "application/octet-stream", - format: "parquet", - }), - _ => None, - } -} - -/// Try to resolve the filename of the file redirected into stdin. -/// Works for `cmd < file.csv` but not for pipes (`cat file.csv | cmd`). -fn stdin_redirect_filename() -> Option { - #[cfg(target_os = "linux")] - { - std::fs::read_link("/proc/self/fd/0") - .ok() - .and_then(|p| p.file_stem().map(|s| s.to_string_lossy().into_owned())) - } - #[cfg(target_os = "macos")] - { - use nix::fcntl::{FcntlArg, fcntl}; - use std::os::unix::io::AsRawFd; - let fd = std::io::stdin().as_raw_fd(); - let mut path = std::path::PathBuf::new(); - match fcntl(fd, FcntlArg::F_GETPATH(&mut path)) { - Ok(_) => path.file_stem().map(|s| s.to_string_lossy().into_owned()), - Err(_) => None, - } - } - #[cfg(not(any(target_os = "linux", target_os = "macos")))] - { - None - } -} - -fn make_progress_bar(total: u64) -> ProgressBar { - let pb = ProgressBar::new(total); - pb.set_style( - ProgressStyle::with_template( - "{spinner:.green} [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})", - ) - .unwrap() - .progress_chars("=>-"), - ); - pb -} - -fn do_upload( - api: &ApiClient, - content_type: &str, - reader: R, - pb: ProgressBar, - content_length: Option, -) -> String { - let (status, resp_body) = api.post_body("/files", content_type, reader, content_length); - - pb.finish_and_clear(); - - if !status.is_success() { - use crossterm::style::Stylize; - eprintln!("{}", crate::util::api_error(resp_body).red()); - std::process::exit(1); - } - - let body: serde_json::Value = match serde_json::from_str(&resp_body) { - Ok(v) => v, - Err(e) => { - eprintln!("error parsing upload response: {e}"); - std::process::exit(1); - } - }; - - match body["id"].as_str() { - Some(id) => id.to_string(), - None => { - eprintln!("error: upload response missing id"); - std::process::exit(1); - } - } -} - -// Returns (upload_id, format) -fn upload_from_file(api: &ApiClient, path: &str) -> (String, &'static str) { - let mut f = match std::fs::File::open(path) { - Ok(f) => f, - Err(e) => { - eprintln!("error opening file '{path}': {e}"); - std::process::exit(1); - } - }; - - let ft = detect_from_path(path).unwrap_or_else(|| { - use std::io::{Read, Seek}; - let mut probe = [0u8; 512]; - let n = f.read(&mut probe).unwrap_or(0); - let _ = f.seek(std::io::SeekFrom::Start(0)); - detect_from_bytes(&probe[..n]) - }); - - let file_size = f.metadata().map(|m| m.len()).unwrap_or(0); - let pb = make_progress_bar(file_size); - let reader = pb.wrap_read(f); - - let id = do_upload(api, ft.content_type, reader, pb, Some(file_size)); - (id, ft.format) -} - -// Returns (upload_id, format) -fn upload_from_stdin(api: &ApiClient) -> (String, &'static str) { - use std::io::Read; - let mut probe = [0u8; 512]; - let n = std::io::stdin().read(&mut probe).unwrap_or(0); - let ft = detect_from_bytes(&probe[..n]); - - let reader = std::io::Cursor::new(probe[..n].to_vec()).chain(std::io::stdin()); - - let pb = ProgressBar::new_spinner(); - pb.set_style( - ProgressStyle::with_template("{spinner:.green} {bytes} uploaded ({elapsed})").unwrap(), - ); - pb.enable_steady_tick(std::time::Duration::from_millis(80)); - let reader = pb.wrap_read(reader); - - let id = do_upload(api, ft.content_type, reader, pb, None); - (id, ft.format) -} - fn create_dataset( api: &ApiClient, - label: &str, - table_name: Option<&str>, + description: Option<&str>, + name: &str, source: serde_json::Value, - on_failure: Option>, ) { - let mut body = json!({ "label": label, "source": source }); - if let Some(tn) = table_name { - body["table_name"] = json!(tn); + let mut body = json!({ "table_name": name, "source": source }); + if let Some(desc) = description { + body["label"] = json!(desc); } let (status, resp_body) = api.post_raw("/datasets", &body); @@ -250,9 +86,6 @@ fn create_dataset( if !status.is_success() { use crossterm::style::Stylize; eprintln!("{}", crate::util::api_error(resp_body).red()); - if let Some(f) = on_failure { - f(); - } std::process::exit(1); } @@ -274,144 +107,19 @@ fn create_dataset( ); } -pub fn create_from_upload( - workspace_id: &str, - label: Option<&str>, - table_name: Option<&str>, - file: Option<&str>, - upload_id: Option<&str>, - source_format: &str, -) { - let api = ApiClient::new(Some(workspace_id)); - - let label_derived; - let label: &str = match label { - Some(l) => l, - None => match file { - Some(path) => { - label_derived = Path::new(path) - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("dataset") - .to_string(); - &label_derived - } - None => { - if upload_id.is_some() { - eprintln!("error: no label provided. Use --label to name the dataset."); - std::process::exit(1); - } - match stdin_redirect_filename() { - Some(name) => { - label_derived = name; - &label_derived - } - None => { - eprintln!("error: no label provided. Use --label to name the dataset."); - std::process::exit(1); - } - } - } - }, - }; - - let (upload_id, format, upload_id_was_uploaded): (String, &str, bool) = if let Some(id) = - upload_id - { - (id.to_string(), source_format, false) - } else { - let (id, fmt) = match file { - Some(path) => upload_from_file(&api, path), - None => { - use std::io::IsTerminal; - if std::io::stdin().is_terminal() { - eprintln!( - "error: no input data. Use --file , --upload-id , or pipe data via stdin." - ); - std::process::exit(1); - } - upload_from_stdin(&api) - } - }; - (id, fmt, true) - }; - - let source = json!({ "upload_id": upload_id, "format": format }); - - let on_failure: Option> = if upload_id_was_uploaded { - let uid = upload_id.clone(); - Some(Box::new(move || { - use crossterm::style::Stylize; - eprintln!( - "{}", - format!( - "Resume dataset creation without re-uploading by passing --upload-id {uid}" - ) - .yellow() - ); - })) - } else { - None - }; - - create_dataset(&api, label, table_name, source, on_failure); -} - -pub fn create_from_url( - workspace_id: &str, - url: &str, - label: Option<&str>, - table_name: Option<&str>, -) { - let label = match label { - Some(l) => l, - None => { - eprintln!("error: --label is required when using --url"); - std::process::exit(1); - } - }; - let api = ApiClient::new(Some(workspace_id)); - create_dataset(&api, label, table_name, json!({ "url": url }), None); -} - -pub fn create_from_query( - workspace_id: &str, - sql: &str, - label: Option<&str>, - table_name: Option<&str>, -) { - let label = match label { - Some(l) => l, - None => { - eprintln!("error: --label is required when using --sql"); - std::process::exit(1); - } - }; +pub fn create_from_query(workspace_id: &str, sql: &str, description: Option<&str>, name: &str) { let api = ApiClient::new(Some(workspace_id)); - create_dataset(&api, label, table_name, json!({ "sql": sql }), None); + create_dataset(&api, description, name, json!({ "sql": sql })); } pub fn create_from_saved_query( workspace_id: &str, query_id: &str, - label: Option<&str>, - table_name: Option<&str>, + description: Option<&str>, + name: &str, ) { - let label = match label { - Some(l) => l, - None => { - eprintln!("error: --label is required when using --query-id"); - std::process::exit(1); - } - }; let api = ApiClient::new(Some(workspace_id)); - create_dataset( - &api, - label, - table_name, - json!({ "saved_query_id": query_id }), - None, - ); + create_dataset(&api, description, name, json!({ "saved_query_id": query_id })); } pub fn list(workspace_id: &str, limit: Option, offset: Option, format: &str) { diff --git a/src/main.rs b/src/main.rs index 74a2b59..f119411 100644 --- a/src/main.rs +++ b/src/main.rs @@ -218,14 +218,14 @@ fn main() { &workspace_id, &sql, description.as_deref(), - Some(&name), + &name, ) } else { datasets::create_from_saved_query( &workspace_id, &query_id.unwrap(), description.as_deref(), - Some(&name), + &name, ) } } From e5429adea401d86c4e771678a95bd54c5dabda45 Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Fri, 22 May 2026 13:30:27 -0700 Subject: [PATCH 4/7] docs: describe datasets as derived views in help text --- src/command.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/command.rs b/src/command.rs index 5427007..f1bbdad 100644 --- a/src/command.rs +++ b/src/command.rs @@ -8,7 +8,7 @@ pub enum Commands { command: Option, }, - /// Upload and query Parquet, CSV, and JSON files + /// Derived views — virtual SQL tables built from queries over your data Datasets { /// Dataset ID to show details id: Option, @@ -453,9 +453,9 @@ pub enum DatasetsCommands { output: String, }, - /// Create a new dataset from a SQL query or saved query + /// Create a derived view from a SQL query or saved query Create { - /// SQL table name for the dataset + /// SQL table name the dataset is addressable as (e.g. default.public.my_view) #[arg(long)] name: String, From 68e4643f0e8ec116ab4638e15d002fa0d24d6387 Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Fri, 22 May 2026 13:38:52 -0700 Subject: [PATCH 5/7] fix: update error message and unwrap in datasets - datasets::update now references --description/--name (not the old --label/--table-name flags that were renamed in this PR) - replace query_id.unwrap() with unreachable!() to make the clap invariant explicit rather than silently panicking Co-Authored-By: Claude Sonnet 4.6 --- src/datasets.rs | 2 +- src/main.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/datasets.rs b/src/datasets.rs index b67e6a6..54bc1aa 100644 --- a/src/datasets.rs +++ b/src/datasets.rs @@ -215,7 +215,7 @@ pub fn update( format: &str, ) { if label.is_none() && table_name.is_none() { - eprintln!("error: provide at least one of --label or --table-name."); + eprintln!("error: provide at least one of --description or --name."); std::process::exit(1); } diff --git a/src/main.rs b/src/main.rs index f119411..8957ae7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -223,7 +223,7 @@ fn main() { } else { datasets::create_from_saved_query( &workspace_id, - &query_id.unwrap(), + query_id.as_deref().unwrap_or_else(|| unreachable!("clap enforces --sql or --query-id")), description.as_deref(), &name, ) From 8818133fcbe23066e6112c05756661a27e55443e Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Fri, 22 May 2026 13:52:59 -0700 Subject: [PATCH 6/7] fix: fall back to --name as label when --description omitted on create MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without this, omitting --description sent no label to the API, which treats label as required and would return a server-side error. Also corrects the --name help text example from a qualified default.public.my_view to just my_view — the API table_name field expects an unqualified identifier. Co-Authored-By: Claude Sonnet 4.6 --- src/command.rs | 2 +- src/datasets.rs | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/command.rs b/src/command.rs index f1bbdad..12e659d 100644 --- a/src/command.rs +++ b/src/command.rs @@ -455,7 +455,7 @@ pub enum DatasetsCommands { /// Create a derived view from a SQL query or saved query Create { - /// SQL table name the dataset is addressable as (e.g. default.public.my_view) + /// SQL table name the dataset is addressable as (e.g. my_view) #[arg(long)] name: String, diff --git a/src/datasets.rs b/src/datasets.rs index 54bc1aa..32d5254 100644 --- a/src/datasets.rs +++ b/src/datasets.rs @@ -76,10 +76,8 @@ fn create_dataset( name: &str, source: serde_json::Value, ) { - let mut body = json!({ "table_name": name, "source": source }); - if let Some(desc) = description { - body["label"] = json!(desc); - } + let label = description.unwrap_or(name); + let body = json!({ "table_name": name, "label": label, "source": source }); let (status, resp_body) = api.post_raw("/datasets", &body); From 37fe71fd258345ade162ef574301b83620bed464 Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Fri, 22 May 2026 17:20:08 -0700 Subject: [PATCH 7/7] refactor(datasets): rename update() params from label/table_name to description/name Aligns internal parameter names with the renamed CLI flags introduced in this PR. The API body keys (label, table_name) remain unchanged. Co-Authored-By: Claude Sonnet 4.6 --- src/datasets.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/datasets.rs b/src/datasets.rs index 32d5254..65219ee 100644 --- a/src/datasets.rs +++ b/src/datasets.rs @@ -208,11 +208,11 @@ pub fn get(dataset_id: &str, workspace_id: &str, format: &str) { pub fn update( dataset_id: &str, workspace_id: &str, - label: Option<&str>, - table_name: Option<&str>, + description: Option<&str>, + name: Option<&str>, format: &str, ) { - if label.is_none() && table_name.is_none() { + if description.is_none() && name.is_none() { eprintln!("error: provide at least one of --description or --name."); std::process::exit(1); } @@ -220,11 +220,11 @@ pub fn update( let api = ApiClient::new(Some(workspace_id)); let mut body = json!({}); - if let Some(l) = label { - body["label"] = json!(l); + if let Some(d) = description { + body["label"] = json!(d); } - if let Some(tn) = table_name { - body["table_name"] = json!(tn); + if let Some(n) = name { + body["table_name"] = json!(n); } let d: UpdateResponse = api.put(&format!("/datasets/{dataset_id}"), &body);