@@ -205,6 +205,45 @@ pub(crate) fn fetch_arrow_result(api: &Api, result_id: &str) -> QueryResponse {
205205 arrow_result_to_query_response ( result, result_id. to_owned ( ) )
206206}
207207
208+ /// Resolve an inline (HTTP 200) query response for display.
209+ ///
210+ /// A non-truncated response carries the whole result in `rows`, so it's shown
211+ /// as-is. A truncated one (#640) carries only a bounded preview — the full set
212+ /// is persisted under `result_id` — so follow it to the full result via Arrow,
213+ /// the same path the async (202) branch uses. Truncation rides on result *size*
214+ /// while `async_after_ms` gates on *time*, so a fast-completing but large query
215+ /// returns a truncated inline 200; without this follow the CLI would silently
216+ /// print only the preview rows.
217+ ///
218+ /// If a truncated response has no `result_id` (persistence could not be
219+ /// initiated — see the SDK's `warning` field), the full result is unfetchable,
220+ /// so fall back to the preview and surface a warning rather than failing.
221+ fn resolve_inline ( api : & Api , resp : hotdata:: models:: QueryResponse ) -> QueryResponse {
222+ if !resp. truncated {
223+ return query_response_from_sdk ( resp) ;
224+ }
225+ match resp. result_id . clone ( ) . flatten ( ) {
226+ Some ( result_id) => {
227+ // The Arrow fetch returns only schema + rows; carry the query-level
228+ // warning and execution time the inline response reported, which
229+ // `arrow_result_to_query_response` otherwise hardcodes to None.
230+ let mut full = fetch_arrow_result ( api, & result_id) ;
231+ full. warning = resp. warning . flatten ( ) ;
232+ full. execution_time_ms = Some ( resp. execution_time_ms . max ( 0 ) as u64 ) ;
233+ full
234+ }
235+ None => {
236+ let mut preview = query_response_from_sdk ( resp) ;
237+ let note = "result truncated to a preview; full result unavailable (persistence not initiated)" ;
238+ preview. warning = Some ( match preview. warning {
239+ Some ( w) => format ! ( "{w}; {note}" ) ,
240+ None => note. to_string ( ) ,
241+ } ) ;
242+ preview
243+ }
244+ }
245+ }
246+
208247pub fn execute ( sql : & str , workspace_id : & str , database : Option < & str > , format : & str ) {
209248 let api = Api :: new ( Some ( workspace_id) ) ;
210249
@@ -223,9 +262,11 @@ pub fn execute(sql: &str, workspace_id: &str, database: Option<&str>, format: &s
223262 spinner. finish_and_clear ( ) ;
224263
225264 let async_resp = match outcome {
226- // Completed within async_after_ms — inline results.
265+ // Completed within async_after_ms — inline results. A large result can
266+ // come back truncated to a preview even on this fast path, so follow it
267+ // to the full set (resolve_inline) rather than printing the preview.
227268 hotdata:: QueryOutcome :: Inline ( resp) => {
228- print_result ( & query_response_from_sdk ( resp) , format) ;
269+ print_result ( & resolve_inline ( & api , resp) , format) ;
229270 return ;
230271 }
231272 // Still running — poll the query run, then fetch the result as Arrow.
@@ -397,3 +438,151 @@ pub fn print_result(result: &QueryResponse, format: &str) {
397438 _ => unreachable ! ( ) ,
398439 }
399440}
441+
442+ #[ cfg( test) ]
443+ mod tests {
444+ use super :: * ;
445+ use crate :: sdk:: Api ;
446+ use std:: sync:: Arc ;
447+
448+ /// A truncated inline 200: one preview row standing in for a larger result.
449+ /// `result_id` uses the wire double-option (`Some(None)` = field present but
450+ /// null, i.e. persistence not initiated).
451+ fn truncated_preview ( result_id : Option < & str > ) -> hotdata:: models:: QueryResponse {
452+ let mut resp = hotdata:: models:: QueryResponse :: new (
453+ vec ! [ "id" . to_string( ) ] , // columns
454+ 5 , // execution_time_ms
455+ vec ! [ false ] , // nullable
456+ 1 , // preview_row_count
457+ "qrun_1" . to_string ( ) , // query_run_id
458+ 1 , // row_count (deprecated, == preview)
459+ vec ! [ vec![ serde_json:: json!( 1 ) ] ] , // rows (preview only)
460+ true , // truncated
461+ ) ;
462+ resp. result_id = Some ( result_id. map ( |s| s. to_string ( ) ) ) ;
463+ resp
464+ }
465+
466+ #[ test]
467+ fn resolve_inline_follows_truncated_result_to_full_arrow ( ) {
468+ use arrow:: array:: { Int64Array , RecordBatch } ;
469+ use arrow:: datatypes:: { DataType , Field , Schema } ;
470+ use arrow:: ipc:: writer:: StreamWriter ;
471+
472+ // Full result has 3 rows — more than the 1-row inline preview.
473+ let schema = Arc :: new ( Schema :: new ( vec ! [ Field :: new( "id" , DataType :: Int64 , false ) ] ) ) ;
474+ let batch = RecordBatch :: try_new (
475+ schema. clone ( ) ,
476+ vec ! [ Arc :: new( Int64Array :: from( vec![ 1 , 2 , 3 ] ) ) ] ,
477+ )
478+ . unwrap ( ) ;
479+ let mut ipc: Vec < u8 > = Vec :: new ( ) ;
480+ {
481+ let mut writer = StreamWriter :: try_new ( & mut ipc, & schema) . unwrap ( ) ;
482+ writer. write ( & batch) . unwrap ( ) ;
483+ writer. finish ( ) . unwrap ( ) ;
484+ }
485+
486+ let mut server = mockito:: Server :: new ( ) ;
487+ let m = server
488+ . mock ( "GET" , "/v1/results/res_1" )
489+ . match_query ( mockito:: Matcher :: UrlEncoded (
490+ "format" . into ( ) ,
491+ "arrow" . into ( ) ,
492+ ) )
493+ . with_status ( 200 )
494+ . with_header ( "content-type" , "application/vnd.apache.arrow.stream" )
495+ . with_body ( ipc)
496+ . create ( ) ;
497+
498+ // The inline response carries a query-level warning and execution time
499+ // (execution_time_ms=5 from `truncated_preview`) that must survive the
500+ // Arrow follow, which otherwise hardcodes them to None.
501+ let mut resp = truncated_preview ( Some ( "res_1" ) ) ;
502+ resp. warning = Some ( Some ( "approximate aggregate" . to_string ( ) ) ) ;
503+
504+ let api = Api :: test_new ( & server. url ( ) , "test-jwt" , Some ( "ws-1" ) ) ;
505+ let resolved = resolve_inline ( & api, resp) ;
506+
507+ // Followed the truncated preview to the full 3-row result.
508+ assert_eq ! ( resolved. row_count, 3 ) ;
509+ assert_eq ! ( resolved. rows. len( ) , 3 ) ;
510+ assert_eq ! ( resolved. result_id. as_deref( ) , Some ( "res_1" ) ) ;
511+ // Inline warning + timing carried through, not dropped by the fetch.
512+ assert_eq ! ( resolved. warning. as_deref( ) , Some ( "approximate aggregate" ) ) ;
513+ assert_eq ! ( resolved. execution_time_ms, Some ( 5 ) ) ;
514+ m. assert ( ) ;
515+ }
516+
517+ #[ test]
518+ fn resolve_inline_returns_untruncated_preview_without_fetching ( ) {
519+ // truncated=false short-circuits before any network call; point the Api
520+ // at a server with no mocks so an erroneous fetch would fail loudly.
521+ let server = mockito:: Server :: new ( ) ;
522+ let api = Api :: test_new ( & server. url ( ) , "test-jwt" , Some ( "ws-1" ) ) ;
523+
524+ let mut resp = hotdata:: models:: QueryResponse :: new (
525+ vec ! [ "x" . to_string( ) ] ,
526+ 5 ,
527+ vec ! [ false ] ,
528+ 2 ,
529+ "qrun_2" . to_string ( ) ,
530+ 2 ,
531+ vec ! [ vec![ serde_json:: json!( 1 ) ] , vec![ serde_json:: json!( 2 ) ] ] ,
532+ false , // not truncated
533+ ) ;
534+ resp. result_id = Some ( Some ( "res_2" . to_string ( ) ) ) ;
535+
536+ let resolved = resolve_inline ( & api, resp) ;
537+ assert_eq ! ( resolved. row_count, 2 ) ;
538+ assert_eq ! (
539+ resolved. rows,
540+ vec![ vec![ serde_json:: json!( 1 ) ] , vec![ serde_json:: json!( 2 ) ] ]
541+ ) ;
542+ assert_eq ! ( resolved. result_id. as_deref( ) , Some ( "res_2" ) ) ;
543+ }
544+
545+ #[ test]
546+ fn resolve_inline_truncated_without_result_id_warns_and_keeps_preview ( ) {
547+ // Truncated but persistence never started (result_id is null): the full
548+ // result is unfetchable, so keep the preview and surface a warning.
549+ let server = mockito:: Server :: new ( ) ;
550+ let api = Api :: test_new ( & server. url ( ) , "test-jwt" , Some ( "ws-1" ) ) ;
551+
552+ let resolved = resolve_inline ( & api, truncated_preview ( None ) ) ;
553+ assert_eq ! ( resolved. row_count, 1 ) ;
554+ assert_eq ! ( resolved. rows. len( ) , 1 ) ;
555+ assert ! (
556+ resolved
557+ . warning
558+ . as_deref( )
559+ . unwrap_or( "" )
560+ . contains( "truncated" )
561+ ) ;
562+ }
563+
564+ #[ test]
565+ fn resolve_inline_preserves_existing_warning_when_following_fails ( ) {
566+ // A truncated response with no result_id often arrives with an SDK
567+ // warning explaining why persistence didn't start. The truncation note
568+ // is appended to it, not allowed to clobber it.
569+ let server = mockito:: Server :: new ( ) ;
570+ let api = Api :: test_new ( & server. url ( ) , "test-jwt" , Some ( "ws-1" ) ) ;
571+
572+ let mut resp = truncated_preview ( None ) ;
573+ resp. warning = Some ( Some (
574+ "result persistence could not be initiated" . to_string ( ) ,
575+ ) ) ;
576+
577+ let resolved = resolve_inline ( & api, resp) ;
578+ let warning = resolved. warning . as_deref ( ) . unwrap_or ( "" ) ;
579+ assert ! (
580+ warning. contains( "result persistence could not be initiated" ) ,
581+ "original warning dropped: {warning:?}"
582+ ) ;
583+ assert ! (
584+ warning. contains( "truncated" ) ,
585+ "truncation note missing: {warning:?}"
586+ ) ;
587+ }
588+ }
0 commit comments