Skip to content

Commit 6c11117

Browse files
Merge pull request #254 from hotdata-dev/feat/databases-count
feat(databases): count databases in the workspace
2 parents bef5aec + a21fab1 commit 6c11117

7 files changed

Lines changed: 154 additions & 16 deletions

File tree

‎Cargo.lock‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ path = "src/main.rs"
1616
# behind a shared multi-thread tokio runtime. The `arrow` feature backs result
1717
# decode; `upload_file` runs the presigned direct-to-storage upload flow
1818
# (see src/sdk.rs::Api::upload).
19-
hotdata = { version = "0.10.0", features = ["arrow"] }
19+
hotdata = { version = "0.11.0", features = ["arrow"] }
2020
# Shared multi-thread runtime for the sync wrapper; block_on is called
2121
# concurrently from rayon worker threads (see src/indexes.rs). The presigned
2222
# upload (src/sdk.rs::Api::upload) drives the SDK's async `upload_file` on this

‎skills/hotdata/SKILL.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ Returns workspaces with `public_id`, `name`, `active`, `favorite`, `provision_st
9595

9696
```
9797
hotdata databases list [--workspace-id <workspace_id>] [--output table|json|yaml]
98+
hotdata databases count [--workspace-id <workspace_id>] [--output table|json|yaml]
9899
hotdata databases create [--name <display_name>] [--catalog <alias>] [--table <table> ...] [--schema public] [--expires-at <duration|timestamp>] [--workspace-id <workspace_id>] [--output table|json|yaml]
99100
hotdata databases fork [<id>] [--name <display_name>] [--expires-at <duration|timestamp>] [--workspace-id <workspace_id>] [--output table|json|yaml]
100101
hotdata databases set <id>
@@ -118,6 +119,7 @@ hotdata databases tables delete <table> [--database <id>] [--schema public] [--w
118119
```
119120

120121
- `list` — all managed databases in the workspace. Active database is marked with `*` under the DEFAULT column; CREATED shows when each database was made.
122+
- `count` — the total number of managed databases in the workspace, across **all** pages (`list` shows one page). Prints a bare integer by default so it drops straight into scripts (`$(hotdata databases count)`); `--output json|yaml` render `{"count": N}` / `count: N`.
121123
- `create` — creates a new managed database. `--name` is an optional human-readable display name. `--catalog` sets the SQL alias used in queries (`SELECT … FROM <catalog>.schema.table`); must be `[a-z_][a-z0-9_]*`. `--expires-at` accepts relative durations (`24h`, `7d`, `90m`) or an RFC 3339 timestamp; omitting means no expiry. Repeat `--table` to declare tables up front.
122124
- `fork` — creates a new managed database that is an independent deep copy of an existing one (same schemas, tables, and data); the source is left unchanged and the two diverge freely afterwards. The source defaults to the active database; pass the database `<id>` to fork another. `--name` defaults to `<source>-fork` (so the two stay distinguishable in `list`); `--expires-at` accepts a relative duration or RFC 3339 timestamp, and when omitted a still-future source expiry is carried over. The fork becomes the active database on success. The fork answers to the **same catalog alias** as its source inside its own scope; catalogs attached to the source are **re-attached** to the fork, but indexes are **not** carried over. Only databases created with the current (DuckLake) storage engine can be forked — older parquet-backed databases return an error.
123125
- `set` — saves the database **id** as the active database. Subsequent `databases tables` and `context` commands use it automatically. Note that a successful `fork` also updates this: the fork becomes the active database.

‎src/commands/databases.rs‎

Lines changed: 124 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,13 @@ pub enum DatabasesCommands {
2121
output: String,
2222
},
2323

24+
/// Count managed databases in the workspace
25+
Count {
26+
/// Output format
27+
#[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])]
28+
output: String,
29+
},
30+
2431
/// Show details for a specific managed database
2532
Show {
2633
/// Database name or ID
@@ -440,9 +447,15 @@ fn list_all_databases(
440447
(true, Some(msg)) => block_with_wakeup(
441448
api,
442449
msg,
443-
api.client().databases().list(None, cursor.as_deref()),
450+
api.client()
451+
.databases()
452+
.list(None, cursor.as_deref(), None, None),
453+
)?,
454+
_ => block(
455+
api.client()
456+
.databases()
457+
.list(None, cursor.as_deref(), None, None),
444458
)?,
445-
_ => block(api.client().databases().list(None, cursor.as_deref()))?,
446459
};
447460
first = false;
448461
// Move the page rows out first (partial move), then read next_cursor —
@@ -938,7 +951,7 @@ pub fn list(workspace_id: &str, format: &str, limit: Option<u32>, cursor: Option
938951
"Loading databases...",
939952
api.client()
940953
.databases()
941-
.list(limit.map(|l| l as i32), cursor),
954+
.list(limit.map(|l| l as i32), cursor, None, None),
942955
)
943956
.unwrap_or_else(|e| e.exit());
944957
let has_more = resp.has_more.flatten().unwrap_or(false);
@@ -1003,6 +1016,54 @@ pub fn list(workspace_id: &str, format: &str, limit: Option<u32>, cursor: Option
10031016
}
10041017
}
10051018

1019+
/// Serialized shape for `databases count -o json|yaml`.
1020+
///
1021+
/// The field stays `count` even though the endpoint answers with `total`: this
1022+
/// is the CLI's own output contract, and `hotdata databases count` returning
1023+
/// `{"count": N}` is what reads naturally at the command line. The API needs
1024+
/// the other name because its list response already spends `count` on a page
1025+
/// size.
1026+
#[derive(Serialize)]
1027+
struct DatabaseCount {
1028+
count: i64,
1029+
}
1030+
1031+
/// Total number of managed databases in the workspace.
1032+
///
1033+
/// One request. The list endpoint's `count` field is a page size, not a
1034+
/// workspace total, so this used to drain every page and take the length —
1035+
/// which cost a round trip per 100 databases and threw every row away on
1036+
/// arrival. `GET /v1/databases/count` answers it in a single indexed query.
1037+
fn count_databases(api: &Api) -> Result<i64, ApiError> {
1038+
block_with_wakeup(
1039+
api,
1040+
"Counting databases...",
1041+
api.client().databases().count(None, None),
1042+
)
1043+
.map(|resp| resp.total)
1044+
}
1045+
1046+
pub fn count(workspace_id: &str, format: &str) {
1047+
let api = Api::new(Some(workspace_id));
1048+
let count = count_databases(&api).unwrap_or_else(|e| e.exit());
1049+
1050+
match format {
1051+
"json" => println!(
1052+
"{}",
1053+
serde_json::to_string_pretty(&DatabaseCount { count }).unwrap()
1054+
),
1055+
"yaml" => print!(
1056+
"{}",
1057+
serde_yaml::to_string(&DatabaseCount { count }).unwrap()
1058+
),
1059+
// Bare integer, nothing else, so $(hotdata databases count) is directly
1060+
// usable in scripts. Zero is the answer for an empty workspace — no
1061+
// "No databases found." hint like `list` has.
1062+
"table" => println!("{count}"),
1063+
_ => unreachable!(),
1064+
}
1065+
}
1066+
10061067
pub fn get(workspace_id: &str, id_or_name: &str, format: &str) {
10071068
let api = Api::new(Some(workspace_id));
10081069
let db = resolve_database(&api, id_or_name);
@@ -2381,7 +2442,7 @@ mod tests {
23812442
.with_status(200)
23822443
.with_header("content-type", "application/json")
23832444
.with_body(
2384-
r#"{"count":1,"limit":1000,"tables":[{"connection":"default","schema":"public","table":"b","synced":true,"last_sync":null}],"has_more":false,"next_cursor":null}"#,
2445+
r#"{"count":1,"limit":1000,"tables":[{"connection":"default","schema":"public","table":"b","synced":true,"partition_by":[],"sorted_by":[],"last_sync":null}],"has_more":false,"next_cursor":null}"#,
23852446
)
23862447
.create();
23872448
let page0 = server
@@ -2393,7 +2454,7 @@ mod tests {
23932454
.with_status(200)
23942455
.with_header("content-type", "application/json")
23952456
.with_body(
2396-
r#"{"count":1,"limit":1000,"tables":[{"connection":"default","schema":"public","table":"a","synced":false,"last_sync":null}],"has_more":true,"next_cursor":"cur2"}"#,
2457+
r#"{"count":1,"limit":1000,"tables":[{"connection":"default","schema":"public","table":"a","synced":false,"partition_by":[],"sorted_by":[],"last_sync":null}],"has_more":true,"next_cursor":"cur2"}"#,
23972458
)
23982459
.create();
23992460

@@ -2438,6 +2499,62 @@ mod tests {
24382499
assert_eq!(dbs[1].id, "db2");
24392500
}
24402501

2502+
#[test]
2503+
fn count_databases_reads_the_total_from_the_count_endpoint() {
2504+
let mut server = mockito::Server::new();
2505+
// The dedicated endpoint, hit exactly once — no paging. `expect(1)` is
2506+
// the assertion that matters: the old implementation drained
2507+
// /v1/databases page by page, and a regression back to that would show
2508+
// up here as the wrong request being made.
2509+
let count = server
2510+
.mock("GET", "/v1/databases/count")
2511+
.with_status(200)
2512+
.with_header("content-type", "application/json")
2513+
.with_body(r#"{"total":7}"#)
2514+
.expect(1)
2515+
.create();
2516+
// Registered but never expected to be called: counting must not touch
2517+
// the listing at all.
2518+
let listing = server
2519+
.mock("GET", "/v1/databases")
2520+
.with_status(200)
2521+
.with_header("content-type", "application/json")
2522+
.with_body(
2523+
r#"{"databases":[],"count":0,"limit":100,"has_more":false,"next_cursor":null}"#,
2524+
)
2525+
.expect(0)
2526+
.create();
2527+
2528+
let api = Api::test_new(&server.url(), "k", Some("ws"));
2529+
let n = count_databases(&api).unwrap();
2530+
count.assert();
2531+
listing.assert();
2532+
assert_eq!(n, 7);
2533+
}
2534+
2535+
#[test]
2536+
fn count_databases_empty_workspace_is_zero() {
2537+
let mut server = mockito::Server::new();
2538+
let count = server
2539+
.mock("GET", "/v1/databases/count")
2540+
.with_status(200)
2541+
.with_header("content-type", "application/json")
2542+
.with_body(r#"{"total":0}"#)
2543+
.create();
2544+
2545+
let api = Api::test_new(&server.url(), "k", Some("ws"));
2546+
let n = count_databases(&api).unwrap();
2547+
count.assert();
2548+
assert_eq!(n, 0);
2549+
}
2550+
2551+
#[test]
2552+
fn database_count_serialization_shapes() {
2553+
let c = DatabaseCount { count: 7 };
2554+
assert_eq!(serde_json::to_string(&c).unwrap(), r#"{"count":7}"#);
2555+
assert_eq!(serde_yaml::to_string(&c).unwrap(), "count: 7\n");
2556+
}
2557+
24412558
#[test]
24422559
fn collect_tables_with_limit_makes_single_request_and_returns_cursor() {
24432560
let mut server = mockito::Server::new();
@@ -2451,7 +2568,7 @@ mod tests {
24512568
.with_status(200)
24522569
.with_header("content-type", "application/json")
24532570
.with_body(
2454-
r#"{"count":1,"limit":5,"tables":[{"connection":"default","schema":"public","table":"a","synced":true,"last_sync":null}],"has_more":true,"next_cursor":"page2"}"#,
2571+
r#"{"count":1,"limit":5,"tables":[{"connection":"default","schema":"public","table":"a","synced":true,"partition_by":[],"sorted_by":[],"last_sync":null}],"has_more":true,"next_cursor":"page2"}"#,
24552572
)
24562573
.expect(1)
24572574
.create();
@@ -2477,7 +2594,7 @@ mod tests {
24772594
.with_status(200)
24782595
.with_header("content-type", "application/json")
24792596
.with_body(
2480-
r#"{"count":1,"limit":1000,"tables":[{"connection":"default","schema":"public","table":"orders","synced":true,"last_sync":null}],"has_more":false,"next_cursor":null}"#,
2597+
r#"{"count":1,"limit":1000,"tables":[{"connection":"default","schema":"public","table":"orders","synced":true,"partition_by":[],"sorted_by":[],"last_sync":null}],"has_more":false,"next_cursor":null}"#,
24812598
)
24822599
.create();
24832600

‎src/commands/indexes.rs‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -855,7 +855,7 @@ mod tests {
855855
.with_header("content-type", "application/json")
856856
.with_body(
857857
r#"{"count":1,"limit":100,"tables":[
858-
{"connection":"__db_abc","schema":"public","table":"vec_mid","synced":true}
858+
{"connection":"__db_abc","schema":"public","table":"vec_mid","synced":true,"partition_by":[],"sorted_by":[]}
859859
],"has_more":false,"next_cursor":null}"#,
860860
)
861861
.create();
@@ -937,7 +937,7 @@ mod tests {
937937
.with_header("content-type", "application/json")
938938
.with_body(
939939
r#"{"count":1,"limit":100,"tables":[
940-
{"connection":"__db_abc","schema":"public","table":"listings","synced":true}
940+
{"connection":"__db_abc","schema":"public","table":"listings","synced":true,"partition_by":[],"sorted_by":[]}
941941
],"has_more":false,"next_cursor":null}"#,
942942
)
943943
.create();
@@ -992,7 +992,7 @@ mod tests {
992992
.with_header("content-type", "application/json")
993993
.with_body(
994994
r#"{"count":1,"limit":100,"tables":[
995-
{"connection":"Warehouse","schema":"public","table":"events","synced":true}
995+
{"connection":"Warehouse","schema":"public","table":"events","synced":true,"partition_by":[],"sorted_by":[]}
996996
],"has_more":false,"next_cursor":null}"#,
997997
)
998998
.create();
@@ -1036,7 +1036,7 @@ mod tests {
10361036
.with_header("content-type", "application/json")
10371037
.with_body(
10381038
r#"{"count":1,"limit":100,"tables":[
1039-
{"connection":"__db_abc","schema":"public","table":"listings","synced":true}
1039+
{"connection":"__db_abc","schema":"public","table":"listings","synced":true,"partition_by":[],"sorted_by":[]}
10401040
],"has_more":false,"next_cursor":null}"#,
10411041
)
10421042
.create();
@@ -1082,8 +1082,8 @@ mod tests {
10821082
.with_header("content-type", "application/json")
10831083
.with_body(
10841084
r#"{"count":2,"limit":100,"tables":[
1085-
{"connection":"c1","schema":"public","table":"z","synced":true},
1086-
{"connection":"c1","schema":"public","table":"a","synced":true}
1085+
{"connection":"c1","schema":"public","table":"z","synced":true,"partition_by":[],"sorted_by":[]},
1086+
{"connection":"c1","schema":"public","table":"a","synced":true,"partition_by":[],"sorted_by":[]}
10871087
],"has_more":false,"next_cursor":null}"#,
10881088
)
10891089
.create();

‎src/main.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,9 @@ fn main() {
262262
limit,
263263
cursor,
264264
}) => databases::list(&workspace_id, &output, limit, cursor.as_deref()),
265+
Some(DatabasesCommands::Count { output }) => {
266+
databases::count(&workspace_id, &output)
267+
}
265268
Some(DatabasesCommands::Show { name_or_id, output }) => {
266269
databases::get(&workspace_id, &name_or_id, &output)
267270
}

‎tests/databases_cli.rs‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,29 @@ fn databases_help_lists_subcommands() {
1414
);
1515
let help = String::from_utf8_lossy(&output.stdout);
1616
assert!(help.contains("list"));
17+
assert!(help.contains("count"));
1718
assert!(help.contains("create"));
1819
assert!(help.contains("delete"));
1920
assert!(help.contains("tables"));
2021
assert!(help.contains("attach"));
2122
assert!(help.contains("detach"));
2223
}
2324

25+
#[test]
26+
fn databases_count_help_documents_output_flag() {
27+
let output = hotdata()
28+
.args(["databases", "count", "--help"])
29+
.output()
30+
.unwrap();
31+
assert!(
32+
output.status.success(),
33+
"stderr: {}",
34+
String::from_utf8_lossy(&output.stderr)
35+
);
36+
let help = String::from_utf8_lossy(&output.stdout);
37+
assert!(help.contains("--output"), "help: {help}");
38+
}
39+
2440
#[test]
2541
fn databases_create_help_documents_attach_flag() {
2642
let output = hotdata()

0 commit comments

Comments
 (0)