Skip to content

Commit 3a3d1a0

Browse files
committed
feat(databases): load a query result via --result
`databases load` gains a `--result <id>` flag, mutually exclusive with --file/--url/--upload-id, that loads a saved query result into a table by posting {mode, result_id} to the load endpoint — skipping the upload step entirely. Works for both the standard and database-scoped load paths, and reuses the existing spinner, auto-declare recovery, and success output.
1 parent dcdd7b9 commit 3a3d1a0

2 files changed

Lines changed: 120 additions & 25 deletions

File tree

‎src/commands/databases.rs‎

Lines changed: 116 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ pub enum DatabasesCommands {
107107
name_or_id: String,
108108
},
109109

110-
/// Load a parquet file into a managed database table
110+
/// Load a parquet file or a saved query result into a managed database table
111111
Load {
112112
/// SQL catalog alias of the target database (e.g. `--catalog airbnb`)
113113
#[arg(long)]
@@ -122,16 +122,21 @@ pub enum DatabasesCommands {
122122
table: String,
123123

124124
/// Path to a local parquet file to upload and load
125-
#[arg(long, conflicts_with_all = ["upload_id", "url"])]
125+
#[arg(long, conflicts_with_all = ["upload_id", "url", "result"])]
126126
file: Option<String>,
127127

128128
/// URL of a remote parquet file to download and load
129-
#[arg(long, conflicts_with_all = ["file", "upload_id"])]
129+
#[arg(long, conflicts_with_all = ["file", "upload_id", "result"])]
130130
url: Option<String>,
131131

132132
/// Use a previously staged upload ID from `POST /v1/uploads` instead of uploading
133-
#[arg(long, conflicts_with_all = ["file", "url"])]
133+
#[arg(long, conflicts_with_all = ["file", "url", "result"])]
134134
upload_id: Option<String>,
135+
136+
/// Load a saved query result by id (e.g. `--result rslt…`, from
137+
/// `hotdata results` or a query's `[result-id: …]` footer) instead of a file
138+
#[arg(long, conflicts_with_all = ["file", "url", "upload_id"])]
139+
result: Option<String>,
135140
},
136141

137142
/// Manage tables inside a managed database
@@ -190,7 +195,7 @@ pub enum DatabaseTablesCommands {
190195
output: String,
191196
},
192197

193-
/// Load a parquet file into a table (creates or replaces the table)
198+
/// Load a parquet file or a saved query result into a table (creates or replaces the table)
194199
Load {
195200
/// Database id or name (defaults to current database)
196201
#[arg(long)]
@@ -204,16 +209,21 @@ pub enum DatabaseTablesCommands {
204209
schema: String,
205210

206211
/// Path to a local parquet file to upload and load
207-
#[arg(long, conflicts_with_all = ["upload_id", "url"])]
212+
#[arg(long, conflicts_with_all = ["upload_id", "url", "result"])]
208213
file: Option<String>,
209214

210215
/// URL of a remote parquet file to download and load
211-
#[arg(long, conflicts_with_all = ["file", "upload_id"])]
216+
#[arg(long, conflicts_with_all = ["file", "upload_id", "result"])]
212217
url: Option<String>,
213218

214219
/// Use a previously staged upload ID from `POST /v1/uploads` instead of uploading
215-
#[arg(long, conflicts_with_all = ["file", "url"])]
220+
#[arg(long, conflicts_with_all = ["file", "url", "result"])]
216221
upload_id: Option<String>,
222+
223+
/// Load a saved query result by id (e.g. `--result rslt…`, from
224+
/// `hotdata results` or a query's `[result-id: …]` footer) instead of a file
225+
#[arg(long, conflicts_with_all = ["file", "url", "upload_id"])]
226+
result: Option<String>,
217227
},
218228

219229
/// Delete a table from a managed database
@@ -561,6 +571,13 @@ pub fn load_table_request(upload_id: &str) -> serde_json::Value {
561571
})
562572
}
563573

574+
pub fn load_table_request_from_result(result_id: &str) -> serde_json::Value {
575+
serde_json::json!({
576+
"mode": "replace",
577+
"result_id": result_id,
578+
})
579+
}
580+
564581
/// Returns true when `path` looks like a parquet file by extension.
565582
pub fn is_parquet_path(path: &str) -> bool {
566583
path.to_ascii_lowercase().ends_with(".parquet")
@@ -1332,6 +1349,7 @@ pub fn tables_list(workspace_id: &str, database: Option<&str>, schema: Option<&s
13321349
}
13331350
}
13341351

1352+
#[allow(clippy::too_many_arguments)]
13351353
pub fn tables_load(
13361354
workspace_id: &str,
13371355
database: Option<&str>,
@@ -1340,6 +1358,7 @@ pub fn tables_load(
13401358
file: Option<&str>,
13411359
url: Option<&str>,
13421360
upload_id: Option<&str>,
1361+
result: Option<&str>,
13431362
) {
13441363
use crossterm::style::Stylize;
13451364

@@ -1350,7 +1369,16 @@ pub fn tables_load(
13501369
&& crate::client::credentials::api_key_jwt_source(&profile).as_deref()
13511370
== Some("database_api_token")
13521371
{
1353-
tables_load_database_scoped(workspace_id, database, table, schema, file, url, upload_id);
1372+
tables_load_database_scoped(
1373+
workspace_id,
1374+
database,
1375+
table,
1376+
schema,
1377+
file,
1378+
url,
1379+
upload_id,
1380+
result,
1381+
);
13541382
return;
13551383
}
13561384

@@ -1378,20 +1406,24 @@ pub fn tables_load(
13781406
let db = resolve_database(&api, &lookup_key);
13791407
let schema = schema_name(schema);
13801408

1381-
// clap enforces mutual exclusion; only one of these is ever Some.
1382-
let upload_id = match (upload_id, file, url) {
1383-
(Some(id), None, None) => id.to_string(),
1384-
(None, Some(path), None) => upload_parquet_file(&api, path),
1385-
(None, None, Some(u)) => upload_parquet_url(&api, u),
1386-
(None, None, None) => {
1387-
eprintln!("error: --file <path>, --url <url>, or --upload-id <id> is required");
1409+
// clap enforces mutual exclusion; only one source is ever Some. A file/URL
1410+
// is uploaded first and loaded by upload id; a result is loaded by reference
1411+
// with no upload step.
1412+
let body = match (result, upload_id, file, url) {
1413+
(Some(result_id), None, None, None) => load_table_request_from_result(result_id),
1414+
(None, Some(id), None, None) => load_table_request(id),
1415+
(None, None, Some(path), None) => load_table_request(&upload_parquet_file(&api, path)),
1416+
(None, None, None, Some(u)) => load_table_request(&upload_parquet_url(&api, u)),
1417+
(None, None, None, None) => {
1418+
eprintln!(
1419+
"error: one of --file <path>, --url <url>, --upload-id <id>, or --result <result-id> is required"
1420+
);
13881421
std::process::exit(1);
13891422
}
13901423
_ => unreachable!(),
13911424
};
13921425

13931426
let path = managed_table_load_path(&db.default_connection_id, schema, table);
1394-
let body = load_table_request(&upload_id);
13951427

13961428
let spinner = crate::util::spinner("Loading table...");
13971429
let (status, resp_body) = api.post_raw(&path, &body).unwrap_or_else(|e| {
@@ -1565,6 +1597,7 @@ pub fn tables_load(
15651597
/// Load path for a database API token: addresses the database by id and uses
15661598
/// the database-scoped endpoints (the connection-scoped managed paths and the
15671599
/// name/catalog resolve used by the standard flow are denied for this token).
1600+
#[allow(clippy::too_many_arguments)]
15681601
fn tables_load_database_scoped(
15691602
workspace_id: &str,
15701603
database: Option<&str>,
@@ -1573,6 +1606,7 @@ fn tables_load_database_scoped(
15731606
file: Option<&str>,
15741607
url: Option<&str>,
15751608
upload_id: Option<&str>,
1609+
result: Option<&str>,
15761610
) {
15771611
use crossterm::style::Stylize;
15781612

@@ -1596,20 +1630,24 @@ fn tables_load_database_scoped(
15961630
std::process::exit(1);
15971631
});
15981632

1599-
// clap enforces mutual exclusion; only one of these is ever Some.
1600-
let upload_id = match (upload_id, file, url) {
1601-
(Some(id), None, None) => id.to_string(),
1602-
(None, Some(path), None) => upload_parquet_file(&api, path),
1603-
(None, None, Some(u)) => upload_parquet_url(&api, u),
1604-
(None, None, None) => {
1605-
eprintln!("error: --file <path>, --url <url>, or --upload-id <id> is required");
1633+
// clap enforces mutual exclusion; only one source is ever Some. A file/URL
1634+
// is uploaded first and loaded by upload id; a result is loaded by reference
1635+
// with no upload step.
1636+
let body = match (result, upload_id, file, url) {
1637+
(Some(result_id), None, None, None) => load_table_request_from_result(result_id),
1638+
(None, Some(id), None, None) => load_table_request(id),
1639+
(None, None, Some(path), None) => load_table_request(&upload_parquet_file(&api, path)),
1640+
(None, None, None, Some(u)) => load_table_request(&upload_parquet_url(&api, u)),
1641+
(None, None, None, None) => {
1642+
eprintln!(
1643+
"error: one of --file <path>, --url <url>, --upload-id <id>, or --result <result-id> is required"
1644+
);
16061645
std::process::exit(1);
16071646
}
16081647
_ => unreachable!(),
16091648
};
16101649

16111650
let load_path = database_table_load_path(&db_id, schema, table);
1612-
let body = load_table_request(&upload_id);
16131651

16141652
let load = || {
16151653
let spinner = crate::util::spinner("Loading table...");
@@ -1960,6 +1998,15 @@ mod tests {
19601998
assert_eq!(body["upload_id"], "upl_abc");
19611999
}
19622000

2001+
#[test]
2002+
fn load_table_request_from_result_is_replace_mode() {
2003+
let body = load_table_request_from_result("rslt_abc");
2004+
assert_eq!(body["mode"], "replace");
2005+
assert_eq!(body["result_id"], "rslt_abc");
2006+
// A result load must not send an upload_id (the server rejects both).
2007+
assert!(body.get("upload_id").is_none());
2008+
}
2009+
19632010
#[test]
19642011
fn is_parquet_path_by_extension() {
19652012
assert!(is_parquet_path("/data/orders.parquet"));
@@ -2097,6 +2144,50 @@ mod tests {
20972144
load.assert();
20982145
}
20992146

2147+
#[test]
2148+
fn tables_load_from_result_posts_result_id() {
2149+
let mut server = mockito::Server::new();
2150+
let resolve = server
2151+
.mock("GET", "/v1/databases/db_1")
2152+
.with_status(200)
2153+
.with_header("content-type", "application/json")
2154+
.with_body(full_detail("db_1", "sales", "conn_default"))
2155+
.create();
2156+
// A result load hits the same loads endpoint but with a result_id body
2157+
// and no upload step (no POST /v1/uploads mock is registered).
2158+
let load = server
2159+
.mock(
2160+
"POST",
2161+
"/v1/connections/conn_default/schemas/public/tables/orders/loads",
2162+
)
2163+
.match_body(mockito::Matcher::JsonString(
2164+
serde_json::to_string(&load_table_request_from_result("rslt_123")).unwrap(),
2165+
))
2166+
.with_status(200)
2167+
.with_body(
2168+
r#"{
2169+
"connection_id":"conn_default",
2170+
"schema_name":"public",
2171+
"table_name":"orders",
2172+
"row_count":42,
2173+
"arrow_schema_json":"{}"
2174+
}"#,
2175+
)
2176+
.create();
2177+
2178+
let api = Api::test_new(&server.url(), "k", Some("ws1"));
2179+
let db = resolve_database(&api, "db_1");
2180+
let path = managed_table_load_path(&db.default_connection_id, "public", "orders");
2181+
let body = load_table_request_from_result("rslt_123");
2182+
let (status, resp_body) = api.post_raw(&path, &body).unwrap();
2183+
assert!(status.is_success());
2184+
let parsed: LoadManagedTableResponse = serde_json::from_str(&resp_body).unwrap();
2185+
assert_eq!(parsed.row_count, 42);
2186+
assert_eq!(parsed.table_name, "orders");
2187+
resolve.assert();
2188+
load.assert();
2189+
}
2190+
21002191
#[test]
21012192
fn tables_delete_uses_default_connection_id() {
21022193
let mut server = mockito::Server::new();

‎src/main.rs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,7 @@ fn main() {
399399
file,
400400
url,
401401
upload_id,
402+
result,
402403
}) => databases::tables_load(
403404
&workspace_id,
404405
Some(catalog.as_str()),
@@ -407,6 +408,7 @@ fn main() {
407408
file.as_deref(),
408409
url.as_deref(),
409410
upload_id.as_deref(),
411+
result.as_deref(),
410412
),
411413
Some(DatabasesCommands::Tables { database, command }) => match command {
412414
Some(DatabaseTablesCommands::List {
@@ -426,6 +428,7 @@ fn main() {
426428
file,
427429
url,
428430
upload_id,
431+
result,
429432
}) => databases::tables_load(
430433
&workspace_id,
431434
db_flag.as_deref().or(database.as_deref()),
@@ -434,6 +437,7 @@ fn main() {
434437
file.as_deref(),
435438
url.as_deref(),
436439
upload_id.as_deref(),
440+
result.as_deref(),
437441
),
438442
Some(DatabaseTablesCommands::Delete {
439443
database: db_flag,

0 commit comments

Comments
 (0)