Skip to content

Commit ae8ec55

Browse files
authored
Merge pull request #65 from hotdata-dev/feat/datasets-update-cli
feat(datasets): add update subcommand to rename label or table_name
2 parents c318258 + 10096cc commit ae8ec55

5 files changed

Lines changed: 218 additions & 17 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ API key priority (lowest to highest): config file → `HOTDATA_API_KEY` env var
6666
| `workspaces` | `list`, `set` | Manage workspaces |
6767
| `connections` | `list`, `create`, `refresh`, `new` | Manage connections |
6868
| `tables` | `list` | List tables and columns |
69-
| `datasets` | `list`, `create` | Manage uploaded datasets |
69+
| `datasets` | `list`, `create`, `update` | Manage uploaded datasets |
7070
| `context` | `list`, `show`, `pull`, `push` | Workspace Markdown context (e.g. data model `DATAMODEL`) via the context API |
7171
| `query` | | Execute a SQL query |
7272
| `queries` | `list` | Inspect query run history |
@@ -142,6 +142,7 @@ hotdata datasets <dataset_id> [--workspace-id <id>] [--format table|json|yaml]
142142
hotdata datasets create --file data.csv [--label "My Dataset"] [--table-name my_dataset]
143143
hotdata datasets create --sql "SELECT ..." --label "My Dataset"
144144
hotdata datasets create --url "https://example.com/data.parquet" --label "My Dataset"
145+
hotdata datasets update <dataset_id> [--label "New Label"] [--table-name new_table]
145146
```
146147

147148
- Datasets are queryable as `datasets.main.<table_name>`.

src/api.rs

Lines changed: 43 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,10 @@ impl ApiClient {
3939
Ok(t) => t,
4040
Err(e) => {
4141
eprintln!("{}", format!("error: {e}").red());
42-
eprintln!("Run {} to log in, or pass --api-key.", "hotdata auth".cyan());
42+
eprintln!(
43+
"Run {} to log in, or pass --api-key.",
44+
"hotdata auth".cyan()
45+
);
4346
std::process::exit(1);
4447
}
4548
};
@@ -71,22 +74,32 @@ impl ApiClient {
7174
}
7275
}
7376

74-
7577
/// Prints an error for a non-2xx response and exits. On 4xx, first re-probes
7678
/// the API key: if it's actually invalid, a clear re-auth hint is shown
7779
/// instead of whatever cryptic body the primary endpoint returned.
7880
fn fail_response(&self, status: reqwest::StatusCode, body: String) -> ! {
7981
let auth_status = if status.is_client_error() {
80-
config::load("default").ok().map(|pc| auth::check_status(&pc))
82+
config::load("default")
83+
.ok()
84+
.map(|pc| auth::check_status(&pc))
8185
} else {
8286
None
8387
};
84-
eprintln!("{}", format_fail_message(status, &body, auth_status.as_ref()).red());
88+
eprintln!(
89+
"{}",
90+
format_fail_message(status, &body, auth_status.as_ref()).red()
91+
);
8592
std::process::exit(1);
8693
}
8794

88-
fn build_request(&self, method: reqwest::Method, url: &str) -> reqwest::blocking::RequestBuilder {
89-
let mut req = self.client.request(method, url)
95+
fn build_request(
96+
&self,
97+
method: reqwest::Method,
98+
url: &str,
99+
) -> reqwest::blocking::RequestBuilder {
100+
let mut req = self
101+
.client
102+
.request(method, url)
90103
.header("Authorization", format!("Bearer {}", self.api_key));
91104
if let Some(ref ws) = self.workspace_id {
92105
req = req.header("X-Workspace-Id", ws);
@@ -128,12 +141,19 @@ impl ApiClient {
128141

129142
/// GET request with query parameters, returns parsed response.
130143
/// Parameters with `None` values are omitted.
131-
pub fn get_with_params<T: DeserializeOwned>(&self, path: &str, params: &[(&str, Option<String>)]) -> T {
132-
let filtered: Vec<(&str, &String)> = params.iter()
144+
pub fn get_with_params<T: DeserializeOwned>(
145+
&self,
146+
path: &str,
147+
params: &[(&str, Option<String>)],
148+
) -> T {
149+
let filtered: Vec<(&str, &String)> = params
150+
.iter()
133151
.filter_map(|(k, v)| v.as_ref().map(|val| (*k, val)))
134152
.collect();
135153
let url = format!("{}{path}", self.api_url);
136-
let req = self.build_request(reqwest::Method::GET, &url).query(&filtered);
154+
let req = self
155+
.build_request(reqwest::Method::GET, &url)
156+
.query(&filtered);
137157
let (status, body) = self.send(req, None);
138158
if !status.is_success() {
139159
self.fail_response(status, body);
@@ -205,6 +225,17 @@ impl ApiClient {
205225
Self::parse_json(&resp_body)
206226
}
207227

228+
/// PUT request with JSON body, returns parsed response.
229+
pub fn put<T: DeserializeOwned>(&self, path: &str, body: &serde_json::Value) -> T {
230+
let url = format!("{}{path}", self.api_url);
231+
let req = self.build_request(reqwest::Method::PUT, &url).json(body);
232+
let (status, resp_body) = self.send(req, Some(body));
233+
if !status.is_success() {
234+
self.fail_response(status, resp_body);
235+
}
236+
Self::parse_json(&resp_body)
237+
}
238+
208239
/// POST with a custom request body (for file uploads). Returns raw status and body.
209240
pub fn post_body<R: std::io::Read + Send + 'static>(
210241
&self,
@@ -214,7 +245,8 @@ impl ApiClient {
214245
content_length: Option<u64>,
215246
) -> (reqwest::StatusCode, String) {
216247
let url = format!("{}{path}", self.api_url);
217-
let mut req = self.build_request(reqwest::Method::POST, &url)
248+
let mut req = self
249+
.build_request(reqwest::Method::POST, &url)
218250
.header("Content-Type", content_type);
219251
if let Some(len) = content_length {
220252
req = req.header("Content-Length", len);
@@ -225,7 +257,6 @@ impl ApiClient {
225257
// Authorization) still log.
226258
self.send(req, None)
227259
}
228-
229260
}
230261

231262
/// Decide what error text to print for a failed response. Pulled out as a pure
@@ -365,11 +396,7 @@ mod tests {
365396
fn format_fail_message_4xx_no_probe_result_falls_through() {
366397
// Caller couldn't load config (None) — still surface the upstream error.
367398
let body = "plain body";
368-
let msg = format_fail_message(
369-
reqwest::StatusCode::NOT_FOUND,
370-
body,
371-
None,
372-
);
399+
let msg = format_fail_message(reqwest::StatusCode::NOT_FOUND, body, None);
373400
assert!(!msg.contains("API key is invalid"));
374401
assert_eq!(msg, "plain body");
375402
}

src/command.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,24 @@ pub enum DatasetsCommands {
384384
#[arg(long, conflicts_with_all = ["file", "upload_id", "sql", "query_id"])]
385385
url: Option<String>,
386386
},
387+
388+
/// Update a dataset's label and/or table name
389+
Update {
390+
/// Dataset ID
391+
id: String,
392+
393+
/// New display label
394+
#[arg(long)]
395+
label: Option<String>,
396+
397+
/// New SQL table name (must be a valid identifier)
398+
#[arg(long)]
399+
table_name: Option<String>,
400+
401+
/// Output format
402+
#[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])]
403+
output: String,
404+
},
387405
}
388406

389407
#[derive(Subcommand)]

src/datasets.rs

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,24 @@ struct DatasetDetail {
5454
columns: Vec<Column>,
5555
}
5656

57+
#[derive(Deserialize, Serialize)]
58+
struct UpdateResponse {
59+
id: String,
60+
label: String,
61+
// Not currently in runtimedb's UpdateDatasetResponse; kept Optional so we
62+
// print `full_name` only when the server actually returns the schema.
63+
// Synthesizing "main" is wrong for sandbox-scoped datasets where
64+
// schema_name == sandbox_id.
65+
#[serde(default)]
66+
schema_name: Option<String>,
67+
table_name: String,
68+
#[serde(default)]
69+
latest_version: Option<i32>,
70+
#[serde(default)]
71+
pinned_version: Option<i32>,
72+
updated_at: String,
73+
}
74+
5775
struct FileType {
5876
content_type: &'static str,
5977
format: &'static str,
@@ -480,3 +498,128 @@ pub fn get(dataset_id: &str, workspace_id: &str, format: &str) {
480498
_ => unreachable!(),
481499
}
482500
}
501+
502+
pub fn update(
503+
dataset_id: &str,
504+
workspace_id: &str,
505+
label: Option<&str>,
506+
table_name: Option<&str>,
507+
format: &str,
508+
) {
509+
if label.is_none() && table_name.is_none() {
510+
eprintln!("error: provide at least one of --label or --table-name.");
511+
std::process::exit(1);
512+
}
513+
514+
let api = ApiClient::new(Some(workspace_id));
515+
516+
let mut body = json!({});
517+
if let Some(l) = label {
518+
body["label"] = json!(l);
519+
}
520+
if let Some(tn) = table_name {
521+
body["table_name"] = json!(tn);
522+
}
523+
524+
let d: UpdateResponse = api.put(&format!("/datasets/{dataset_id}"), &body);
525+
526+
use crossterm::style::Stylize;
527+
eprintln!("{}", "Dataset updated".green());
528+
match format {
529+
"json" => println!("{}", serde_json::to_string_pretty(&d).unwrap()),
530+
"yaml" => print!("{}", serde_yaml::to_string(&d).unwrap()),
531+
"table" => {
532+
println!("id: {}", d.id);
533+
println!("label: {}", d.label);
534+
match &d.schema_name {
535+
Some(schema) => {
536+
println!("full_name: datasets.{}.{}", schema, d.table_name);
537+
}
538+
None => {
539+
println!("table_name: {}", d.table_name);
540+
eprintln!(
541+
"{}",
542+
format!(
543+
"(run `hotdata datasets {}` to see the qualified name)",
544+
d.id
545+
)
546+
.dark_grey()
547+
);
548+
}
549+
}
550+
println!("updated_at: {}", crate::util::format_date(&d.updated_at));
551+
}
552+
_ => unreachable!(),
553+
}
554+
}
555+
556+
#[cfg(test)]
557+
mod tests {
558+
use super::*;
559+
560+
/// Mirrors runtimedb's `UpdateDatasetResponse` (see runtimedb/src/http/models.rs).
561+
/// The CLI must deserialize this exact shape — schema_name, source_type,
562+
/// created_at, and columns are NOT in the response. If runtimedb's response
563+
/// gains or loses fields, update this fixture in lockstep.
564+
#[test]
565+
fn update_response_deserializes_runtimedb_payload() {
566+
let body = serde_json::json!({
567+
"id": "ds_abc123",
568+
"label": "url_test",
569+
"table_name": "url_test",
570+
"latest_version": 3,
571+
"updated_at": "2026-04-28T18:30:00Z",
572+
});
573+
let resp: UpdateResponse = serde_json::from_value(body).unwrap();
574+
assert_eq!(resp.id, "ds_abc123");
575+
assert_eq!(resp.label, "url_test");
576+
assert_eq!(resp.table_name, "url_test");
577+
// The server doesn't currently send schema_name, so we don't synthesize
578+
// one — sandbox-scoped datasets live under datasets.<sandbox_id>.<table>,
579+
// not datasets.main.*, and a fabricated "main" would mislead users.
580+
assert!(resp.schema_name.is_none());
581+
assert_eq!(resp.latest_version, Some(3));
582+
assert!(resp.pinned_version.is_none());
583+
}
584+
585+
#[test]
586+
fn update_response_uses_schema_name_when_server_supplies_it() {
587+
// Forward-compat: if runtimedb later includes schema_name, we use it.
588+
let body = serde_json::json!({
589+
"id": "ds_abc123",
590+
"label": "x",
591+
"schema_name": "sandbox_xyz",
592+
"table_name": "x",
593+
"updated_at": "2026-04-28T18:30:00Z",
594+
});
595+
let resp: UpdateResponse = serde_json::from_value(body).unwrap();
596+
assert_eq!(resp.schema_name.as_deref(), Some("sandbox_xyz"));
597+
}
598+
599+
#[test]
600+
fn update_response_handles_pinned_version() {
601+
let body = serde_json::json!({
602+
"id": "ds_abc123",
603+
"label": "x",
604+
"table_name": "x",
605+
"latest_version": 5,
606+
"pinned_version": 2,
607+
"updated_at": "2026-04-28T18:30:00Z",
608+
});
609+
let resp: UpdateResponse = serde_json::from_value(body).unwrap();
610+
assert_eq!(resp.pinned_version, Some(2));
611+
}
612+
613+
#[test]
614+
fn update_response_tolerates_missing_latest_version() {
615+
// Defensive: treat latest_version as optional in case the server omits it.
616+
let body = serde_json::json!({
617+
"id": "ds_abc123",
618+
"label": "x",
619+
"table_name": "x",
620+
"updated_at": "2026-04-28T18:30:00Z",
621+
});
622+
let resp: UpdateResponse = serde_json::from_value(body).unwrap();
623+
assert!(resp.latest_version.is_none());
624+
}
625+
}

src/main.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,18 @@ fn main() {
161161
)
162162
}
163163
}
164+
Some(DatasetsCommands::Update {
165+
id,
166+
label,
167+
table_name,
168+
output,
169+
}) => datasets::update(
170+
&id,
171+
&workspace_id,
172+
label.as_deref(),
173+
table_name.as_deref(),
174+
&output,
175+
),
164176
None => {
165177
use clap::CommandFactory;
166178
let mut cmd = Cli::command();

0 commit comments

Comments
 (0)