Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ API key priority (lowest to highest): config file → `HOTDATA_API_KEY` env var
| `workspaces` | `list`, `set` | Manage workspaces |
| `connections` | `list`, `create`, `refresh`, `new` | Manage connections |
| `tables` | `list` | List tables and columns |
| `datasets` | `list`, `create` | Manage uploaded datasets |
| `datasets` | `list`, `create`, `update` | Manage uploaded datasets |
| `context` | `list`, `show`, `pull`, `push` | Workspace Markdown context (e.g. data model `DATAMODEL`) via the context API |
| `query` | | Execute a SQL query |
| `queries` | `list` | Inspect query run history |
Expand Down Expand Up @@ -142,6 +142,7 @@ hotdata datasets <dataset_id> [--workspace-id <id>] [--format table|json|yaml]
hotdata datasets create --file data.csv [--label "My Dataset"] [--table-name my_dataset]
hotdata datasets create --sql "SELECT ..." --label "My Dataset"
hotdata datasets create --url "https://example.com/data.parquet" --label "My Dataset"
hotdata datasets update <dataset_id> [--label "New Label"] [--table-name new_table]
```

- Datasets are queryable as `datasets.main.<table_name>`.
Expand Down
59 changes: 43 additions & 16 deletions src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ impl ApiClient {
Ok(t) => t,
Err(e) => {
eprintln!("{}", format!("error: {e}").red());
eprintln!("Run {} to log in, or pass --api-key.", "hotdata auth".cyan());
eprintln!(
"Run {} to log in, or pass --api-key.",
"hotdata auth".cyan()
);
std::process::exit(1);
}
};
Expand Down Expand Up @@ -71,22 +74,32 @@ impl ApiClient {
}
}


/// Prints an error for a non-2xx response and exits. On 4xx, first re-probes
/// the API key: if it's actually invalid, a clear re-auth hint is shown
/// instead of whatever cryptic body the primary endpoint returned.
fn fail_response(&self, status: reqwest::StatusCode, body: String) -> ! {
let auth_status = if status.is_client_error() {
config::load("default").ok().map(|pc| auth::check_status(&pc))
config::load("default")
.ok()
.map(|pc| auth::check_status(&pc))
} else {
None
};
eprintln!("{}", format_fail_message(status, &body, auth_status.as_ref()).red());
eprintln!(
"{}",
format_fail_message(status, &body, auth_status.as_ref()).red()
);
std::process::exit(1);
}

fn build_request(&self, method: reqwest::Method, url: &str) -> reqwest::blocking::RequestBuilder {
let mut req = self.client.request(method, url)
fn build_request(
&self,
method: reqwest::Method,
url: &str,
) -> reqwest::blocking::RequestBuilder {
let mut req = self
.client
.request(method, url)
.header("Authorization", format!("Bearer {}", self.api_key));
if let Some(ref ws) = self.workspace_id {
req = req.header("X-Workspace-Id", ws);
Expand Down Expand Up @@ -128,12 +141,19 @@ impl ApiClient {

/// GET request with query parameters, returns parsed response.
/// Parameters with `None` values are omitted.
pub fn get_with_params<T: DeserializeOwned>(&self, path: &str, params: &[(&str, Option<String>)]) -> T {
let filtered: Vec<(&str, &String)> = params.iter()
pub fn get_with_params<T: DeserializeOwned>(
&self,
path: &str,
params: &[(&str, Option<String>)],
) -> T {
let filtered: Vec<(&str, &String)> = params
.iter()
.filter_map(|(k, v)| v.as_ref().map(|val| (*k, val)))
.collect();
let url = format!("{}{path}", self.api_url);
let req = self.build_request(reqwest::Method::GET, &url).query(&filtered);
let req = self
.build_request(reqwest::Method::GET, &url)
.query(&filtered);
let (status, body) = self.send(req, None);
if !status.is_success() {
self.fail_response(status, body);
Expand Down Expand Up @@ -205,6 +225,17 @@ impl ApiClient {
Self::parse_json(&resp_body)
}

/// PUT request with JSON body, returns parsed response.
pub fn put<T: DeserializeOwned>(&self, path: &str, body: &serde_json::Value) -> T {
let url = format!("{}{path}", self.api_url);
let req = self.build_request(reqwest::Method::PUT, &url).json(body);
let (status, resp_body) = self.send(req, Some(body));
if !status.is_success() {
self.fail_response(status, resp_body);
}
Self::parse_json(&resp_body)
}

/// POST with a custom request body (for file uploads). Returns raw status and body.
pub fn post_body<R: std::io::Read + Send + 'static>(
&self,
Expand All @@ -214,7 +245,8 @@ impl ApiClient {
content_length: Option<u64>,
) -> (reqwest::StatusCode, String) {
let url = format!("{}{path}", self.api_url);
let mut req = self.build_request(reqwest::Method::POST, &url)
let mut req = self
.build_request(reqwest::Method::POST, &url)
.header("Content-Type", content_type);
if let Some(len) = content_length {
req = req.header("Content-Length", len);
Expand All @@ -225,7 +257,6 @@ impl ApiClient {
// Authorization) still log.
self.send(req, None)
}

}

/// Decide what error text to print for a failed response. Pulled out as a pure
Expand Down Expand Up @@ -365,11 +396,7 @@ mod tests {
fn format_fail_message_4xx_no_probe_result_falls_through() {
// Caller couldn't load config (None) — still surface the upstream error.
let body = "plain body";
let msg = format_fail_message(
reqwest::StatusCode::NOT_FOUND,
body,
None,
);
let msg = format_fail_message(reqwest::StatusCode::NOT_FOUND, body, None);
assert!(!msg.contains("API key is invalid"));
assert_eq!(msg, "plain body");
}
Expand Down
18 changes: 18 additions & 0 deletions src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,24 @@ pub enum DatasetsCommands {
#[arg(long, conflicts_with_all = ["file", "upload_id", "sql", "query_id"])]
url: Option<String>,
},

/// Update a dataset's label and/or table name
Update {
/// Dataset ID
id: String,

/// New display label
#[arg(long)]
label: Option<String>,

/// New SQL table name (must be a valid identifier)
#[arg(long)]
table_name: Option<String>,

/// Output format
#[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])]
output: String,
},
}

#[derive(Subcommand)]
Expand Down
107 changes: 107 additions & 0 deletions src/datasets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,20 @@ struct DatasetDetail {
columns: Vec<Column>,
}

#[derive(Deserialize, Serialize)]
struct UpdateResponse {
id: String,
label: String,
#[serde(default = "default_schema")]
schema_name: String,
table_name: String,
#[serde(default)]
latest_version: Option<i32>,
#[serde(default)]
pinned_version: Option<i32>,
updated_at: String,
}

struct FileType {
content_type: &'static str,
format: &'static str,
Expand Down Expand Up @@ -480,3 +494,96 @@ pub fn get(dataset_id: &str, workspace_id: &str, format: &str) {
_ => unreachable!(),
}
}

pub fn update(
dataset_id: &str,
workspace_id: &str,
label: Option<&str>,
table_name: Option<&str>,
format: &str,
) {
if label.is_none() && table_name.is_none() {
eprintln!("error: provide at least one of --label or --table-name.");
std::process::exit(1);
}

let api = ApiClient::new(Some(workspace_id));

let mut body = json!({});
if let Some(l) = label {
body["label"] = json!(l);
}
if let Some(tn) = table_name {
body["table_name"] = json!(tn);
}

let d: UpdateResponse = api.put(&format!("/datasets/{dataset_id}"), &body);

use crossterm::style::Stylize;
eprintln!("{}", "Dataset updated".green());
Comment thread
zfarrell marked this conversation as resolved.
Comment thread
zfarrell marked this conversation as resolved.
match format {
"json" => println!("{}", serde_json::to_string_pretty(&d).unwrap()),
"yaml" => print!("{}", serde_yaml::to_string(&d).unwrap()),
"table" => {
println!("id: {}", d.id);
println!("label: {}", d.label);
println!("full_name: datasets.{}.{}", d.schema_name, d.table_name);
println!("updated_at: {}", crate::util::format_date(&d.updated_at));
}
_ => unreachable!(),
}
}

#[cfg(test)]
mod tests {
use super::*;

/// Mirrors runtimedb's `UpdateDatasetResponse` (see runtimedb/src/http/models.rs).
/// The CLI must deserialize this exact shape — schema_name, source_type,
/// created_at, and columns are NOT in the response. If runtimedb's response
/// gains or loses fields, update this fixture in lockstep.
#[test]
fn update_response_deserializes_runtimedb_payload() {
let body = serde_json::json!({
"id": "ds_abc123",
"label": "url_test",
"table_name": "url_test",
"latest_version": 3,
"updated_at": "2026-04-28T18:30:00Z",
});
let resp: UpdateResponse = serde_json::from_value(body).unwrap();
assert_eq!(resp.id, "ds_abc123");
assert_eq!(resp.label, "url_test");
assert_eq!(resp.table_name, "url_test");
assert_eq!(resp.schema_name, "main"); // defaulted
assert_eq!(resp.latest_version, Some(3));
assert!(resp.pinned_version.is_none());
}

#[test]
fn update_response_handles_pinned_version() {
let body = serde_json::json!({
"id": "ds_abc123",
"label": "x",
"table_name": "x",
"latest_version": 5,
"pinned_version": 2,
"updated_at": "2026-04-28T18:30:00Z",
});
let resp: UpdateResponse = serde_json::from_value(body).unwrap();
assert_eq!(resp.pinned_version, Some(2));
}

#[test]
fn update_response_tolerates_missing_latest_version() {
// Defensive: treat latest_version as optional in case the server omits it.
let body = serde_json::json!({
"id": "ds_abc123",
"label": "x",
"table_name": "x",
"updated_at": "2026-04-28T18:30:00Z",
});
let resp: UpdateResponse = serde_json::from_value(body).unwrap();
assert!(resp.latest_version.is_none());
}
}
12 changes: 12 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,18 @@ fn main() {
)
}
}
Some(DatasetsCommands::Update {
id,
label,
table_name,
output,
}) => datasets::update(
&id,
&workspace_id,
label.as_deref(),
table_name.as_deref(),
&output,
),
None => {
use clap::CommandFactory;
let mut cmd = Cli::command();
Expand Down
Loading