Skip to content

Commit 431c195

Browse files
committed
fix: scope results and query runs to a database
1 parent 33c1113 commit 431c195

9 files changed

Lines changed: 345 additions & 70 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Changed
1111

1212
- feat: support async table loads and append mode
13+
- **Breaking:** results and query runs are now scoped to a database via the
14+
required `X-Database-Id` header. The ergonomic wrappers gain a `database_id`
15+
argument to match: `Client::get_result`, `Client::list_results`,
16+
`Client::list_query_runs`, `Client::await_result`, `Client::get_result_arrow`,
17+
`Client::stream_result_arrow`, `Client::query_to_arrow`, and the
18+
`results()` / `query_runs()` resource handles. `Client::query`'s truncation
19+
auto-follow now forwards the query's database scope (the `X-Database-Id`
20+
header, or the request-body `database_id` when no header is set) to the
21+
follow-up result and query-run fetches.
1322

1423
## [0.7.0] - 2026-06-30
1524

README.md

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -64,16 +64,22 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
6464
// .base_url("https://api.hotdata.dev") // optional
6565
.build()?;
6666

67-
// Submit a query. `query` transparently retries HTTP 429 (server overload)
67+
// Queries, results, and query runs are scoped to a database (the required
68+
// `X-Database-Id` header), so pick one first.
69+
let database_id = "your_database_id";
70+
71+
// Submit a query. `query_in` transparently retries HTTP 429 (server overload)
6872
// and auto-follows a truncated large result to its full row set. Rows come
6973
// back inline, plus a result_id persisted for later retrieval.
7074
let response = client
71-
.query(QueryRequest::new("SELECT 1 AS n".to_string()))
75+
.query_in(QueryRequest::new("SELECT 1 AS n".to_string()), database_id)
7276
.await?;
7377

7478
if let Some(result_id) = response.result_id.flatten() {
7579
// Poll the persisted result to `ready` without hand-rolling a loop.
76-
let result = client.await_result(&result_id, PollConfig::default()).await?;
80+
let result = client
81+
.await_result(&result_id, database_id, PollConfig::default())
82+
.await?;
7783
println!("result {} is {}", result.result_id, result.status);
7884
}
7985

@@ -91,7 +97,8 @@ ergonomic, workspace-scoped handles so you never pass a `Configuration` around:
9197
let connections = client.connections().list().await?;
9298
let connection = client.connections().get(&connections.connections[0].id).await?;
9399
let secrets = client.secrets().list().await?;
94-
let runs = client.query_runs().list(Some(50), None, None, None).await?;
100+
// Query runs are database-scoped: pass the database id first.
101+
let runs = client.query_runs().list(database_id, Some(50), None, None, None).await?;
95102
```
96103

97104
Handles exist for every resource — `connections`, `connection_types`,
@@ -101,7 +108,9 @@ Handles exist for every resource — `connections`, `connection_types`,
101108
operations also have flat shortcuts directly on `Client` (`query` — with
102109
`query_in` to scope to a database, `query_preview` to skip auto-follow, and
103110
`query_with` for a per-call `QueryConfig` — plus `get_result`, `list_results`,
104-
`list_query_runs`, `list_workspaces`).
111+
`list_query_runs`, `list_workspaces`). The result and query-run operations take
112+
a `database_id` argument — they are scoped to a database via the required
113+
`X-Database-Id` header.
105114

106115
For anything not yet wrapped, the full generated surface is one call away via
107116
`client.configuration()`:
@@ -120,12 +129,12 @@ them with the typed [`ResultStatus`] / [`QueryRunStatus`] enums via the
120129
```rust
121130
use hotdata::prelude::*;
122131

123-
let result = client.await_result(&result_id, PollConfig::default()).await?;
132+
let result = client.await_result(&result_id, database_id, PollConfig::default()).await?;
124133
if result.result_status().is_ready() {
125134
// ...
126135
}
127136

128-
let run = client.query_runs().get(&query_run_id).await?;
137+
let run = client.query_runs().get(&query_run_id, database_id).await?;
129138
if run.run_status().is_terminal() { /* ... */ }
130139
```
131140

@@ -183,16 +192,19 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
183192
.workspace_id("your_workspace_id")
184193
.build()?;
185194

195+
// Results are database-scoped (the required `X-Database-Id` header).
196+
let database_id = "your_database_id";
197+
186198
// Buffered: decodes every batch into a Vec<RecordBatch>.
187-
let result = client.get_result_arrow(&result_id, None, None).await?;
199+
let result = client.get_result_arrow(&result_id, database_id, None, None).await?;
188200
println!("schema: {:?}", result.schema);
189201
println!("total rows: {:?}", result.total_row_count);
190202
for batch in &result.batches {
191203
// work with each arrow_array::RecordBatch
192204
}
193205

194206
// Streaming: yields batches lazily without holding them all at once.
195-
let mut stream = client.stream_result_arrow(&result_id, None, None).await?;
207+
let mut stream = client.stream_result_arrow(&result_id, database_id, None, None).await?;
196208
for batch in stream.by_ref() {
197209
let batch = batch?;
198210
// ...
@@ -202,7 +214,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
202214
}
203215
```
204216

205-
Both methods accept `offset` and `limit` for pagination, and both honor the transparent JWT exchange. They return `ArrowError::NotReady` if the result is still pending or processing — poll `client.get_result(result_id)` until its status is `ready` first. `ArrowResult` also surfaces the `X-Total-Row-Count` header (`total_row_count`) and the `rel="next"` pagination `Link` (`next_link`).
217+
Both methods accept `offset` and `limit` for pagination, and both honor the transparent JWT exchange. They return `ArrowError::NotReady` if the result is still pending or processing — poll `client.get_result(result_id, database_id)` until its status is `ready` first. `ArrowResult` also surfaces the `X-Total-Row-Count` header (`total_row_count`) and the `rel="next"` pagination `Link` (`next_link`).
206218

207219
To run a query and get its result as Arrow in a single call — submit, await
208220
`ready`, and decode — use `query_to_arrow`:
@@ -211,6 +223,7 @@ To run a query and get its result as Arrow in a single call — submit, await
211223
let arrow = client
212224
.query_to_arrow(
213225
QueryRequest::new("SELECT * FROM big_table".to_string()),
226+
database_id,
214227
PollConfig::default(),
215228
None, // offset
216229
None, // limit

examples/quickstart.rs

Lines changed: 64 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -92,18 +92,36 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
9292

9393
// --- 3. Submit a query -----------------------------------------------
9494
//
95+
// Queries, results, and query runs are all scoped to a database via the
96+
// `X-Database-Id` header, so resolve one first. Prefer HOTDATA_DATABASE, else
97+
// fall back to the first database in the workspace; if there are none, skip
98+
// the query portion of the tour.
99+
let database_id = match resolve_database_id(&client).await? {
100+
Some(id) => id,
101+
None => {
102+
println!(
103+
"No database available (set HOTDATA_DATABASE or create one). \
104+
Skipping the query portion of the tour."
105+
);
106+
return Ok(());
107+
}
108+
};
109+
println!("Using database {database_id}");
110+
95111
// POST /query returns rows inline *and* a result_id; persistence to the
96112
// result store then completes asynchronously.
97113
//
98-
// `query` is the enhanced default: it retries HTTP 429 (`OVERLOADED`)
99-
// transparently and, if the server truncates a large result, auto-follows it
100-
// — paging the full row set into `response.rows` — bounded by the instance
101-
// `QueryConfig` (default ceilings: 1M rows / 64 MiB). Exceed a ceiling and you
102-
// get `QueryError::Result(ResultError::TooLarge { .. })` instead of an OOM.
114+
// `query_in` is the database-scoped form of the enhanced default `query`: it
115+
// retries HTTP 429 (`OVERLOADED`) transparently and, if the server truncates
116+
// a large result, auto-follows it — paging the full row set into
117+
// `response.rows` — bounded by the instance `QueryConfig` (default ceilings:
118+
// 1M rows / 64 MiB). Exceed a ceiling and you get
119+
// `QueryError::Result(ResultError::TooLarge { .. })` instead of an OOM.
103120
let response = client
104-
.query(QueryRequest::new(
105-
"select 1 as id, 'hello' as greeting".to_string(),
106-
))
121+
.query_in(
122+
QueryRequest::new("select 1 as id, 'hello' as greeting".to_string()),
123+
&database_id,
124+
)
107125
.await?;
108126

109127
println!(
@@ -112,11 +130,15 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
112130
);
113131

114132
// Tuning the auto-follow behavior per call: clone the instance config and
115-
// override just what you need. Here we opt out of auto-follow entirely
116-
// `query_preview` is the one-call shortcut for "give me only the inline
117-
// preview" (handy when you'll page the full result yourself, e.g. as Arrow).
133+
// override just what you need. Here we opt out of auto-follow entirely, while
134+
// still scoping the query to the database via `query_with`.
135+
let preview_config = client.query_config().clone().with_auto_follow(false);
118136
let preview = client
119-
.query_preview(QueryRequest::new("select 1 as id".to_string()))
137+
.query_with(
138+
QueryRequest::new("select 1 as id".to_string()),
139+
Some(&database_id),
140+
&preview_config,
141+
)
120142
.await?;
121143
println!(
122144
"Preview: {} row(s){}",
@@ -151,24 +173,44 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
151173
// timeout polled every second.
152174
println!("Awaiting result {result_id}...");
153175
let ready = client
154-
.await_result(&result_id, PollConfig::default())
176+
.await_result(&result_id, &database_id, PollConfig::default())
155177
.await?;
156178
println!("Result status: {}", ready.status);
157179

158180
// --- 5. Fetch the result as Arrow (feature-gated) --------------------
159-
fetch_arrow(&client, &result_id).await?;
181+
fetch_arrow(&client, &database_id, &result_id).await?;
160182

161183
// --- 6. One-call query -> Arrow (feature-gated) ----------------------
162-
one_shot_arrow(&client).await?;
184+
one_shot_arrow(&client, &database_id).await?;
163185

164186
Ok(())
165187
}
166188

189+
/// Resolve a database to scope the query tour to: `HOTDATA_DATABASE` if set,
190+
/// otherwise the first database visible in the workspace (`None` if there are
191+
/// none).
192+
async fn resolve_database_id(client: &Client) -> Result<Option<String>, Box<dyn std::error::Error>> {
193+
if let Ok(id) = std::env::var("HOTDATA_DATABASE") {
194+
if !id.is_empty() {
195+
return Ok(Some(id));
196+
}
197+
}
198+
let databases = client.databases().list().await?;
199+
Ok(databases.databases.into_iter().next().map(|db| db.id))
200+
}
201+
167202
/// Fetch an already-ready result as Arrow record batches.
168203
#[cfg(feature = "arrow")]
169-
async fn fetch_arrow(client: &Client, result_id: &str) -> Result<(), Box<dyn std::error::Error>> {
204+
async fn fetch_arrow(
205+
client: &Client,
206+
database_id: &str,
207+
result_id: &str,
208+
) -> Result<(), Box<dyn std::error::Error>> {
170209
println!("Fetching result {result_id} as Arrow...");
171-
match client.get_result_arrow(result_id, None, None).await {
210+
match client
211+
.get_result_arrow(result_id, database_id, None, None)
212+
.await
213+
{
172214
Ok(arrow) => print_arrow(&arrow),
173215
// The Arrow error enum maps the result endpoint's status codes to named
174216
// variants, so callers react without string-matching on HTTP codes.
@@ -183,7 +225,10 @@ async fn fetch_arrow(client: &Client, result_id: &str) -> Result<(), Box<dyn std
183225
/// Submit a fresh query and get its result as Arrow in a single call —
184226
/// `query_to_arrow` runs the query, awaits `ready`, and decodes the stream.
185227
#[cfg(feature = "arrow")]
186-
async fn one_shot_arrow(client: &Client) -> Result<(), Box<dyn std::error::Error>> {
228+
async fn one_shot_arrow(
229+
client: &Client,
230+
database_id: &str,
231+
) -> Result<(), Box<dyn std::error::Error>> {
187232
use std::time::Duration;
188233

189234
println!("One-call query_to_arrow...");
@@ -194,6 +239,7 @@ async fn one_shot_arrow(client: &Client) -> Result<(), Box<dyn std::error::Error
194239
let arrow = client
195240
.query_to_arrow(
196241
QueryRequest::new("select 42 as answer".to_string()),
242+
database_id,
197243
poll,
198244
None,
199245
None,

src/arrow.rs

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -251,11 +251,12 @@ impl Iterator for ArrowBatchStream {
251251
pub async fn get_result_arrow(
252252
configuration: &Configuration,
253253
id: &str,
254+
x_database_id: &str,
254255
offset: Option<i64>,
255256
limit: Option<i64>,
256257
) -> Result<ArrowResult, ArrowError> {
257258
let (bytes, total_row_count, next_link) =
258-
fetch_arrow_bytes(configuration, id, offset, limit).await?;
259+
fetch_arrow_bytes(configuration, id, x_database_id, offset, limit).await?;
259260
let reader = StreamReader::try_new(Cursor::new(bytes), None)?;
260261
let schema = reader.schema();
261262
let mut batches = Vec::new();
@@ -283,11 +284,12 @@ pub async fn get_result_arrow(
283284
pub async fn stream_result_arrow(
284285
configuration: &Configuration,
285286
id: &str,
287+
x_database_id: &str,
286288
offset: Option<i64>,
287289
limit: Option<i64>,
288290
) -> Result<ArrowBatchStream, ArrowError> {
289291
let (bytes, total_row_count, next_link) =
290-
fetch_arrow_bytes(configuration, id, offset, limit).await?;
292+
fetch_arrow_bytes(configuration, id, x_database_id, offset, limit).await?;
291293
let reader = StreamReader::try_new(Cursor::new(bytes), None)?;
292294
Ok(ArrowBatchStream {
293295
reader,
@@ -326,6 +328,7 @@ fn apply_apikey_headers(
326328
async fn fetch_arrow_bytes(
327329
configuration: &Configuration,
328330
id: &str,
331+
x_database_id: &str,
329332
offset: Option<i64>,
330333
limit: Option<i64>,
331334
) -> Result<(Bytes, Option<i64>, Option<String>), ArrowError> {
@@ -336,6 +339,10 @@ async fn fetch_arrow_bytes(
336339
);
337340
let mut req_builder = configuration.client.request(reqwest::Method::GET, &uri_str);
338341

342+
// `GET /v1/results/{id}` is scoped to a database: the `X-Database-Id` header
343+
// is required (mirrors the generated `get_result`).
344+
req_builder = req_builder.header("X-Database-Id", x_database_id.to_string());
345+
339346
// format=arrow takes precedence over the Accept header server-side, but we
340347
// send both to match the generated client and be explicit on the wire.
341348
req_builder = req_builder.query(&[("format", "arrow")]);
@@ -665,6 +672,38 @@ mod tests {
665672
assert_eq!(result.total_row_count, Some(5));
666673
}
667674

675+
/// Regression (database-scoped results): the Arrow path must send the
676+
/// required `X-Database-Id` header on `GET /v1/results/{id}`. The mock
677+
/// matches only when the header is present, so a missing header 404s and the
678+
/// fetch fails.
679+
#[tokio::test]
680+
async fn fetch_arrow_sends_database_id_header() {
681+
use wiremock::matchers::{header, method, path, query_param};
682+
use wiremock::{Mock, MockServer, ResponseTemplate};
683+
684+
let (ipc, _schema) = make_ipc_stream();
685+
let server = MockServer::start().await;
686+
Mock::given(method("GET"))
687+
.and(path("/v1/results/res_1"))
688+
.and(query_param("format", "arrow"))
689+
.and(header("X-Database-Id", "db_x"))
690+
.respond_with(
691+
ResponseTemplate::new(200)
692+
.insert_header("content-type", ARROW_STREAM_MEDIA_TYPE)
693+
.set_body_bytes(ipc),
694+
)
695+
.mount(&server)
696+
.await;
697+
698+
let mut configuration = Configuration::new();
699+
configuration.base_path = server.uri();
700+
701+
let result = get_result_arrow(&configuration, "res_1", "db_x", None, None)
702+
.await
703+
.expect("arrow fetch should forward X-Database-Id and succeed");
704+
assert_eq!(result.num_rows(), 5);
705+
}
706+
668707
#[test]
669708
fn empty_stream_decodes_to_zero_batches() {
670709
let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)]));

0 commit comments

Comments
 (0)