Skip to main content

alopex_cli/commands/
sql.rs

1//! SQL Command - SQL query execution
2//!
3//! Supports: query execution, file-based queries
4
5use std::collections::HashSet;
6use std::fs;
7use std::io::{self, Read, Write};
8
9use alopex_embedded::Database;
10
11use crate::batch::BatchMode;
12use crate::cli::{OutputFormat, SqlCommand};
13use crate::client::http::{ClientError, HttpClient};
14use crate::error::{CliError, Result};
15use crate::models::{Column, DataType, Row, Value};
16use crate::output::formatter::{create_formatter, Formatter};
17use crate::streaming::timeout::parse_deadline;
18use crate::streaming::{CancelSignal, Deadline, StreamingWriter, WriteStatus};
19use crate::tui::{is_tty, TuiApp};
20use crate::ui::mode::UiMode;
21use futures_util::StreamExt;
22use reqwest::Response;
23
24#[doc(hidden)]
25pub struct SqlExecutionOptions<'a> {
26    pub limit: Option<usize>,
27    pub quiet: bool,
28    pub cancel: &'a CancelSignal,
29    pub deadline: &'a Deadline,
30    pub admin_launcher: Option<Box<dyn FnMut() -> Result<()> + 'a>>,
31}
32
33/// Source of formatters for SQL result output.
34///
35/// The `sql` command emits one result block per statement, so a single
36/// invocation may need more than one formatter instance.
37enum SqlOutput<'a> {
38    /// Create formatters from an output format. `OutputFormat::Json` also
39    /// wraps the output in an array of per-statement result sets.
40    Format(OutputFormat),
41    /// Obtain formatters from a caller-supplied factory (e.g. the admin TUI
42    /// capture formatter). No statement-array wrapping is applied.
43    Custom(&'a mut dyn FnMut() -> Box<dyn Formatter>),
44}
45
46impl SqlOutput<'_> {
47    /// Create a formatter for the next result block.
48    fn create_block_formatter(&mut self) -> Box<dyn Formatter> {
49        match self {
50            SqlOutput::Format(format) => create_formatter(*format),
51            SqlOutput::Custom(factory) => factory(),
52        }
53    }
54
55    /// Whether output must be wrapped in an array of per-statement result sets.
56    fn statement_array(&self) -> bool {
57        matches!(self, SqlOutput::Format(OutputFormat::Json))
58    }
59
60    /// Whether the produced formatters support streaming output.
61    fn supports_streaming(&mut self) -> bool {
62        match self {
63            SqlOutput::Format(format) => format.supports_streaming(),
64            SqlOutput::Custom(factory) => factory().supports_streaming(),
65        }
66    }
67}
68
69/// Write adapter that records whether any bytes have been written.
70///
71/// Used by the JSON statement-array wrapping to decide, on error paths, how
72/// many opened arrays must be closed to keep stdout well-formed JSON.
73struct CountingWriter<W> {
74    inner: W,
75    bytes: u64,
76}
77
78impl<W: Write> CountingWriter<W> {
79    fn new(inner: W) -> Self {
80        Self { inner, bytes: 0 }
81    }
82
83    fn bytes_written(&self) -> u64 {
84        self.bytes
85    }
86}
87
88impl<W: Write> Write for CountingWriter<W> {
89    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
90        let written = self.inner.write(buf)?;
91        self.bytes += written as u64;
92        Ok(written)
93    }
94
95    fn flush(&mut self) -> io::Result<()> {
96        self.inner.flush()
97    }
98}
99
100/// Execute a SQL command with dynamic column detection.
101///
102/// This function creates formatters internally based on the query result type,
103/// ensuring that SELECT queries use the correct column headers.
104///
105/// # Output contract
106///
107/// - `--output json` emits an array of per-statement result sets; a single
108///   statement yields a 1-element array. With `--quiet`, DDL/DML status
109///   result sets are omitted, so input consisting only of such statements
110///   yields an empty array.
111/// - Other formats emit one result block per statement, in statement order.
112///
113/// # Arguments
114///
115/// * `db` - The database instance.
116/// * `cmd` - The SQL command to execute.
117/// * `writer` - The output writer.
118/// * `output_format` - The output format to use.
119/// * `limit` - Optional row limit.
120/// * `quiet` - Whether to suppress warnings and status result sets.
121#[allow(clippy::too_many_arguments)]
122pub fn execute_with_formatter<'a, W: Write>(
123    db: &Database,
124    cmd: SqlCommand,
125    batch_mode: &BatchMode,
126    ui_mode: UiMode,
127    writer: &mut W,
128    output_format: OutputFormat,
129    admin_launcher: Option<Box<dyn FnMut() -> Result<()> + 'a>>,
130    limit: Option<usize>,
131    quiet: bool,
132) -> Result<()> {
133    let deadline = Deadline::new(parse_deadline(cmd.deadline.as_deref())?);
134    let cancel = CancelSignal::new();
135
136    execute_with_formatter_control(
137        db,
138        cmd,
139        batch_mode,
140        ui_mode,
141        writer,
142        output_format,
143        SqlExecutionOptions {
144            limit,
145            quiet,
146            cancel: &cancel,
147            deadline: &deadline,
148            admin_launcher,
149        },
150    )
151}
152
153/// Execute a SQL command using formatters supplied by `make_formatter`.
154///
155/// One formatter is created per result block (a multi-statement input emits
156/// one block per statement). No JSON statement-array wrapping is applied;
157/// this entry point is intended for callers that capture results with a
158/// custom formatter (e.g. the admin TUI).
159#[allow(clippy::too_many_arguments)]
160pub fn execute_with_formatter_factory<'a, W: Write>(
161    db: &Database,
162    cmd: SqlCommand,
163    batch_mode: &BatchMode,
164    ui_mode: UiMode,
165    writer: &mut W,
166    make_formatter: &mut dyn FnMut() -> Box<dyn Formatter>,
167    admin_launcher: Option<Box<dyn FnMut() -> Result<()> + 'a>>,
168    limit: Option<usize>,
169    quiet: bool,
170) -> Result<()> {
171    let deadline = Deadline::new(parse_deadline(cmd.deadline.as_deref())?);
172    let cancel = CancelSignal::new();
173    let mut output = SqlOutput::Custom(make_formatter);
174
175    execute_with_output_control(
176        db,
177        cmd,
178        batch_mode,
179        ui_mode,
180        writer,
181        &mut output,
182        SqlExecutionOptions {
183            limit,
184            quiet,
185            cancel: &cancel,
186            deadline: &deadline,
187            admin_launcher,
188        },
189    )
190}
191
192#[doc(hidden)]
193pub fn execute_with_formatter_control<W: Write>(
194    db: &Database,
195    cmd: SqlCommand,
196    batch_mode: &BatchMode,
197    ui_mode: UiMode,
198    writer: &mut W,
199    output_format: OutputFormat,
200    options: SqlExecutionOptions<'_>,
201) -> Result<()> {
202    let mut output = SqlOutput::Format(output_format);
203    execute_with_output_control(db, cmd, batch_mode, ui_mode, writer, &mut output, options)
204}
205
206fn execute_with_output_control<W: Write>(
207    db: &Database,
208    cmd: SqlCommand,
209    batch_mode: &BatchMode,
210    ui_mode: UiMode,
211    writer: &mut W,
212    output: &mut SqlOutput<'_>,
213    mut options: SqlExecutionOptions<'_>,
214) -> Result<()> {
215    let sql = cmd.resolve_query(batch_mode)?;
216    let effective_limit = merge_limit(options.limit, cmd.max_rows);
217    options.limit = effective_limit;
218
219    if ui_mode == UiMode::Tui {
220        return execute_tui_local_or_fallback(db, &sql, writer, output, options);
221    }
222
223    execute_sql_with_formatter(db, &sql, writer, output, &options)
224}
225
226fn is_select_query(sql: &str) -> Result<bool> {
227    use alopex_sql::{AlopexDialect, Parser, StatementKind};
228
229    let dialect = AlopexDialect;
230    let stmts = Parser::parse_sql(&dialect, sql).map_err(|e| CliError::Parse(format!("{}", e)))?;
231    Ok(stmts.len() == 1
232        && matches!(
233            stmts.first().map(|s| &s.kind),
234            Some(StatementKind::Select(_))
235        ))
236}
237
238/// Execute a SQL command against a remote server using HttpClient.
239///
240/// `--output json` emits an array of result sets (currently a single element,
241/// matching the local output contract; server-side per-statement results are
242/// tracked as issue #31).
243#[allow(clippy::too_many_arguments)]
244pub async fn execute_remote_with_formatter<'a, W: Write>(
245    client: &HttpClient,
246    cmd: &SqlCommand,
247    batch_mode: &BatchMode,
248    ui_mode: UiMode,
249    writer: &mut W,
250    output_format: OutputFormat,
251    admin_launcher: Option<Box<dyn FnMut() -> Result<()> + 'a>>,
252    limit: Option<usize>,
253    quiet: bool,
254) -> Result<()> {
255    let effective_limit = merge_limit(limit, cmd.max_rows);
256    let deadline = Deadline::new(parse_deadline(cmd.deadline.as_deref())?);
257    let cancel = CancelSignal::new();
258    let options = SqlExecutionOptions {
259        limit: effective_limit,
260        quiet,
261        cancel: &cancel,
262        deadline: &deadline,
263        admin_launcher,
264    };
265
266    execute_remote_with_formatter_control(
267        client,
268        cmd,
269        batch_mode,
270        ui_mode,
271        writer,
272        output_format,
273        options,
274    )
275    .await
276}
277
278fn sql_context_message(sql: &str) -> String {
279    let condensed = sql.split_whitespace().collect::<Vec<_>>().join(" ");
280    let max_len = 200;
281    if condensed.chars().count() > max_len {
282        let truncated: String = condensed.chars().take(max_len).collect();
283        format!("SQL: {truncated}...")
284    } else {
285        format!("SQL: {condensed}")
286    }
287}
288
289/// Execute a remote SQL command using formatters supplied by `make_formatter`.
290///
291/// See [`execute_with_formatter_factory`] for the output contract.
292#[allow(clippy::too_many_arguments)]
293pub async fn execute_remote_with_formatter_factory<'a, W: Write>(
294    client: &HttpClient,
295    cmd: &SqlCommand,
296    batch_mode: &BatchMode,
297    ui_mode: UiMode,
298    writer: &mut W,
299    make_formatter: &mut dyn FnMut() -> Box<dyn Formatter>,
300    admin_launcher: Option<Box<dyn FnMut() -> Result<()> + 'a>>,
301    limit: Option<usize>,
302    quiet: bool,
303) -> Result<()> {
304    let effective_limit = merge_limit(limit, cmd.max_rows);
305    let deadline = Deadline::new(parse_deadline(cmd.deadline.as_deref())?);
306    let cancel = CancelSignal::new();
307    let options = SqlExecutionOptions {
308        limit: effective_limit,
309        quiet,
310        cancel: &cancel,
311        deadline: &deadline,
312        admin_launcher,
313    };
314    let mut output = SqlOutput::Custom(make_formatter);
315    execute_remote_with_output_control(
316        client,
317        cmd,
318        batch_mode,
319        ui_mode,
320        writer,
321        &mut output,
322        options,
323    )
324    .await
325}
326
327#[doc(hidden)]
328pub async fn execute_remote_with_formatter_control<W: Write>(
329    client: &HttpClient,
330    cmd: &SqlCommand,
331    batch_mode: &BatchMode,
332    ui_mode: UiMode,
333    writer: &mut W,
334    output_format: OutputFormat,
335    options: SqlExecutionOptions<'_>,
336) -> Result<()> {
337    let mut output = SqlOutput::Format(output_format);
338    execute_remote_with_output_control(
339        client,
340        cmd,
341        batch_mode,
342        ui_mode,
343        writer,
344        &mut output,
345        options,
346    )
347    .await
348}
349
350async fn execute_remote_with_output_control<W: Write>(
351    client: &HttpClient,
352    cmd: &SqlCommand,
353    batch_mode: &BatchMode,
354    ui_mode: UiMode,
355    writer: &mut W,
356    output: &mut SqlOutput<'_>,
357    options: SqlExecutionOptions<'_>,
358) -> Result<()> {
359    let sql = cmd.resolve_query(batch_mode)?;
360    if ui_mode == UiMode::Tui {
361        return execute_tui_remote_or_fallback(client, &sql, cmd, writer, output, options).await;
362    }
363    execute_remote_with_formatter_impl(client, &sql, cmd, writer, output, &options).await
364}
365
366async fn execute_remote_with_formatter_impl<W: Write>(
367    client: &HttpClient,
368    sql: &str,
369    cmd: &SqlCommand,
370    writer: &mut W,
371    output: &mut SqlOutput<'_>,
372    options: &SqlExecutionOptions<'_>,
373) -> Result<()> {
374    if is_select_query(sql)? && output.supports_streaming() {
375        return execute_remote_streaming(
376            client,
377            sql,
378            writer,
379            output,
380            options,
381            cmd.fetch_size,
382            cmd.max_rows,
383        )
384        .await;
385    }
386
387    let request = RemoteSqlRequest {
388        sql: sql.to_string(),
389        streaming: false,
390        fetch_size: cmd.fetch_size,
391        max_rows: cmd.max_rows,
392    };
393    let response: RemoteSqlResponse = tokio::select! {
394        result = tokio::time::timeout(options.deadline.remaining(), client.post_json("api/sql/query", &request)) => {
395            match result {
396                Ok(value) => value.map_err(map_client_error)?,
397                Err(_) => {
398                    let _ = send_cancel_request(client).await;
399                    return Err(CliError::Timeout(format!(
400                        "deadline exceeded after {}",
401                        humantime::format_duration(options.deadline.duration())
402                    )));
403                }
404            }
405        }
406        _ = options.cancel.wait() => {
407            let _ = send_cancel_request(client).await;
408            return Err(CliError::Cancelled);
409        }
410    };
411
412    // JSON output is an array of result sets; the remote API currently
413    // returns a single result set, so the array has one element (server-side
414    // per-statement results are tracked as issue #31).
415    let json_array = output.statement_array();
416
417    if response.columns.is_empty() {
418        if options.quiet {
419            if json_array {
420                writeln!(writer, "[")?;
421                writeln!(writer, "]")?;
422            }
423            return Ok(());
424        }
425        let message = match response.affected_rows {
426            Some(count) => format!("{count} row(s) affected"),
427            None => "Operation completed successfully".to_string(),
428        };
429        if json_array {
430            writeln!(writer, "[")?;
431        }
432        let columns = sql_status_columns();
433        {
434            let mut streaming_writer = StreamingWriter::new(
435                &mut *writer,
436                output.create_block_formatter(),
437                columns,
438                options.limit,
439            )
440            .with_quiet(options.quiet);
441            streaming_writer.prepare(Some(1))?;
442            let row = Row::new(vec![Value::Text("OK".to_string()), Value::Text(message)]);
443            streaming_writer.write_row(row)?;
444            streaming_writer.finish()?;
445        }
446        if json_array {
447            writeln!(writer, "]")?;
448        }
449        return Ok(());
450    }
451
452    let columns: Vec<Column> = response
453        .columns
454        .iter()
455        .map(|col| Column::new(&col.name, data_type_from_string(&col.data_type)))
456        .collect();
457    if json_array {
458        writeln!(writer, "[")?;
459    }
460    {
461        let mut streaming_writer = StreamingWriter::new(
462            &mut *writer,
463            output.create_block_formatter(),
464            columns,
465            options.limit,
466        )
467        .with_quiet(options.quiet);
468        streaming_writer.prepare(Some(response.rows.len()))?;
469        for row in response.rows {
470            if options.cancel.is_cancelled() {
471                let _ = send_cancel_request(client).await;
472                return Err(CliError::Cancelled);
473            }
474            options.deadline.check()?;
475            let values = row.into_iter().map(remote_value_to_value).collect();
476            match streaming_writer.write_row(Row::new(values))? {
477                WriteStatus::LimitReached => break,
478                WriteStatus::Continue => {}
479            }
480        }
481        streaming_writer.finish()?;
482    }
483    if json_array {
484        writeln!(writer, "]")?;
485    }
486    Ok(())
487}
488
489fn execute_tui_local_or_fallback<'a, W: Write>(
490    db: &Database,
491    sql: &str,
492    writer: &mut W,
493    output: &mut SqlOutput<'_>,
494    mut options: SqlExecutionOptions<'a>,
495) -> Result<()> {
496    if !is_tty() {
497        if !options.quiet {
498            eprintln!("Warning: --tui requires a TTY, falling back to batch output.");
499        }
500        return execute_sql_with_formatter(db, sql, writer, output, &options);
501    }
502
503    let admin_launcher = options.admin_launcher.take();
504    match execute_tui_local(db, sql, &options, admin_launcher) {
505        Ok(()) => Ok(()),
506        Err(err) => {
507            if !options.quiet {
508                eprintln!("Warning: TUI failed ({err}); falling back to batch output.");
509            }
510            execute_sql_with_formatter(db, sql, writer, output, &options)
511        }
512    }
513}
514
515fn execute_tui_local<'a>(
516    db: &Database,
517    sql: &str,
518    options: &SqlExecutionOptions<'a>,
519    admin_launcher: Option<Box<dyn FnMut() -> Result<()> + 'a>>,
520) -> Result<()> {
521    use alopex_sql::ExecutionResult;
522
523    options.deadline.check()?;
524    let result = db.execute_sql(sql)?;
525    options.deadline.check()?;
526
527    let (columns, rows) = match result {
528        ExecutionResult::Success => {
529            let columns = sql_status_columns();
530            let row = Row::new(vec![
531                Value::Text("OK".to_string()),
532                Value::Text("Operation completed successfully".to_string()),
533            ]);
534            (columns, vec![row])
535        }
536        ExecutionResult::RowsAffected(count) => {
537            let columns = sql_status_columns();
538            let row = Row::new(vec![
539                Value::Text("OK".to_string()),
540                Value::Text(format!("{count} row(s) affected")),
541            ]);
542            (columns, vec![row])
543        }
544        ExecutionResult::Query(query_result) => {
545            let columns = query_result
546                .columns
547                .iter()
548                .map(|col| Column::new(&col.name, DataType::Text))
549                .collect::<Vec<_>>();
550            let mut rows = Vec::with_capacity(query_result.rows.len());
551            for sql_row in query_result.rows {
552                let values = sql_row.into_iter().map(sql_value_to_value).collect();
553                rows.push(Row::new(values));
554            }
555            if let Some(limit) = options.limit {
556                rows.truncate(limit);
557            }
558            (columns, rows)
559        }
560    };
561
562    let app = TuiApp::new(columns, rows, "local", false)
563        .with_context_message(Some(sql_context_message(sql)))
564        .with_admin_launcher(admin_launcher);
565    app.run()
566}
567
568async fn execute_tui_remote_or_fallback<'a, W: Write>(
569    client: &HttpClient,
570    sql: &str,
571    cmd: &SqlCommand,
572    writer: &mut W,
573    output: &mut SqlOutput<'_>,
574    mut options: SqlExecutionOptions<'a>,
575) -> Result<()> {
576    if !is_tty() {
577        if !options.quiet {
578            eprintln!("Warning: --tui requires a TTY, falling back to batch output.");
579        }
580        return execute_remote_with_formatter_impl(client, sql, cmd, writer, output, &options)
581            .await;
582    }
583
584    let admin_launcher = options.admin_launcher.take();
585    match execute_tui_remote(client, sql, cmd, &options, admin_launcher).await {
586        Ok(()) => Ok(()),
587        Err(err) => {
588            if !options.quiet {
589                eprintln!("Warning: TUI failed ({err}); falling back to batch output.");
590            }
591            execute_remote_with_formatter_impl(client, sql, cmd, writer, output, &options).await
592        }
593    }
594}
595
596async fn execute_tui_remote<'a>(
597    client: &HttpClient,
598    sql: &str,
599    cmd: &SqlCommand,
600    options: &SqlExecutionOptions<'a>,
601    admin_launcher: Option<Box<dyn FnMut() -> Result<()> + 'a>>,
602) -> Result<()> {
603    if is_select_query(sql)? {
604        let (columns, rows) =
605            collect_remote_streaming_rows(client, sql, options, cmd.fetch_size, cmd.max_rows)
606                .await?;
607        let app = TuiApp::new(columns, rows, "server", false)
608            .with_context_message(Some(sql_context_message(sql)))
609            .with_admin_launcher(admin_launcher);
610        return app.run();
611    }
612
613    let request = RemoteSqlRequest {
614        sql: sql.to_string(),
615        streaming: false,
616        fetch_size: cmd.fetch_size,
617        max_rows: cmd.max_rows,
618    };
619
620    let response: RemoteSqlResponse = tokio::select! {
621        result = tokio::time::timeout(options.deadline.remaining(), client.post_json("api/sql/query", &request)) => {
622            match result {
623                Ok(value) => value.map_err(map_client_error)?,
624                Err(_) => {
625                    let _ = send_cancel_request(client).await;
626                    return Err(CliError::Timeout(format!(
627                        "deadline exceeded after {}",
628                        humantime::format_duration(options.deadline.duration())
629                    )));
630                }
631            }
632        }
633        _ = options.cancel.wait() => {
634            let _ = send_cancel_request(client).await;
635            return Err(CliError::Cancelled);
636        }
637    };
638
639    let (columns, rows) = if response.columns.is_empty() {
640        let columns = sql_status_columns();
641        let message = match response.affected_rows {
642            Some(count) => format!("{count} row(s) affected"),
643            None => "Operation completed successfully".to_string(),
644        };
645        let row = Row::new(vec![Value::Text("OK".to_string()), Value::Text(message)]);
646        (columns, vec![row])
647    } else {
648        let columns: Vec<Column> = response
649            .columns
650            .iter()
651            .map(|col| Column::new(&col.name, data_type_from_string(&col.data_type)))
652            .collect();
653        let mut rows = response
654            .rows
655            .into_iter()
656            .map(|row| Row::new(row.into_iter().map(remote_value_to_value).collect()))
657            .collect::<Vec<_>>();
658        if let Some(limit) = options.limit {
659            rows.truncate(limit);
660        }
661        (columns, rows)
662    };
663
664    let app = TuiApp::new(columns, rows, "server", false)
665        .with_context_message(Some(sql_context_message(sql)))
666        .with_admin_launcher(admin_launcher);
667    app.run()
668}
669
670async fn collect_remote_streaming_rows(
671    client: &HttpClient,
672    sql: &str,
673    options: &SqlExecutionOptions<'_>,
674    fetch_size: Option<usize>,
675    max_rows: Option<usize>,
676) -> Result<(Vec<Column>, Vec<Row>)> {
677    let request = RemoteSqlRequest {
678        sql: sql.to_string(),
679        streaming: true,
680        fetch_size,
681        max_rows,
682    };
683
684    let response = tokio::select! {
685        result = tokio::time::timeout(options.deadline.remaining(), client.post_json_stream("api/sql/query", &request)) => {
686            match result {
687                Ok(value) => value.map_err(map_client_error)?,
688                Err(_) => {
689                    let _ = send_cancel_request(client).await;
690                    return Err(CliError::Timeout(format!(
691                        "deadline exceeded after {}",
692                        humantime::format_duration(options.deadline.duration())
693                    )));
694                }
695            }
696        }
697        _ = options.cancel.wait() => {
698            let _ = send_cancel_request(client).await;
699            return Err(CliError::Cancelled);
700        }
701    };
702
703    if let Some(content_type) = response
704        .headers()
705        .get(reqwest::header::CONTENT_TYPE)
706        .and_then(|value| value.to_str().ok())
707    {
708        if content_type.starts_with("application/jsonl") {
709            return collect_remote_jsonl_rows(client, response, options).await;
710        }
711    }
712
713    let mut stream = response.bytes_stream();
714    let mut buffer: Vec<u8> = Vec::new();
715    let mut pos: usize = 0;
716    let mut done = false;
717    let mut saw_array_start = false;
718    let mut columns: Option<Vec<String>> = None;
719    let mut column_set: Option<HashSet<String>> = None;
720    let mut rows: Vec<Row> = Vec::new();
721
722    while !done {
723        if options.cancel.is_cancelled() {
724            let _ = send_cancel_request(client).await;
725            return Err(CliError::Cancelled);
726        }
727        if let Err(err) = options.deadline.check() {
728            let _ = send_cancel_request(client).await;
729            return Err(err);
730        }
731
732        let next = tokio::select! {
733            _ = options.cancel.wait() => {
734                let _ = send_cancel_request(client).await;
735                return Err(CliError::Cancelled);
736            }
737            result = tokio::time::timeout(options.deadline.remaining(), stream.next()) => {
738                match result {
739                    Ok(value) => value,
740                    Err(_) => {
741                        let _ = send_cancel_request(client).await;
742                        return Err(CliError::Timeout(format!(
743                            "deadline exceeded after {}",
744                            humantime::format_duration(options.deadline.duration())
745                        )));
746                    }
747                }
748            }
749        };
750
751        let chunk = match next {
752            Some(chunk) => chunk,
753            None => break,
754        };
755
756        let bytes = match chunk {
757            Ok(bytes) => bytes,
758            Err(err) => return Err(CliError::ServerConnection(format!("request failed: {err}"))),
759        };
760
761        buffer.extend_from_slice(&bytes);
762
763        loop {
764            skip_whitespace(&buffer, &mut pos);
765            if pos >= buffer.len() {
766                break;
767            }
768
769            if !saw_array_start {
770                if buffer[pos] != b'[' {
771                    return Err(CliError::InvalidArgument(
772                        "Invalid streaming response: expected JSON array".into(),
773                    ));
774                }
775                pos += 1;
776                saw_array_start = true;
777                continue;
778            }
779
780            skip_whitespace(&buffer, &mut pos);
781            if pos >= buffer.len() {
782                break;
783            }
784
785            if buffer[pos] == b']' {
786                pos += 1;
787                done = true;
788                break;
789            }
790
791            let slice = &buffer[pos..];
792            let mut stream =
793                serde_json::Deserializer::from_slice(slice).into_iter::<serde_json::Value>();
794            let value = match stream.next() {
795                Some(Ok(value)) => value,
796                Some(Err(err)) if err.is_eof() => break,
797                Some(Err(err)) => return Err(CliError::Json(err)),
798                None => break,
799            };
800            pos = pos.saturating_add(stream.byte_offset());
801
802            let object = value.as_object().ok_or_else(|| {
803                CliError::InvalidArgument("Invalid streaming row: expected JSON object".into())
804            })?;
805
806            if columns.is_none() {
807                let names: Vec<String> = object.keys().cloned().collect();
808                let set: HashSet<String> = names.iter().cloned().collect();
809                if names.is_empty() {
810                    return Err(CliError::InvalidArgument(
811                        "Invalid streaming row: empty object".into(),
812                    ));
813                }
814                columns = Some(names);
815                column_set = Some(set);
816            }
817
818            let names = columns
819                .as_ref()
820                .ok_or_else(|| CliError::InvalidArgument("Missing columns".into()))?;
821            let set = column_set
822                .as_ref()
823                .ok_or_else(|| CliError::InvalidArgument("Missing column set".into()))?;
824
825            if object.len() != names.len() || !object.keys().all(|key| set.contains(key)) {
826                return Err(CliError::InvalidArgument(
827                    "Invalid streaming row: column mismatch".into(),
828                ));
829            }
830
831            let values = names
832                .iter()
833                .map(|name| {
834                    object.get(name).ok_or_else(|| {
835                        CliError::InvalidArgument(format!(
836                            "Invalid streaming row: missing column '{name}'"
837                        ))
838                    })
839                })
840                .map(|value| value.and_then(json_value_to_value))
841                .collect::<Result<Vec<_>>>()?;
842            rows.push(Row::new(values));
843
844            if let Some(limit) = options.limit {
845                if rows.len() >= limit {
846                    let _ = send_cancel_request(client).await;
847                    done = true;
848                    break;
849                }
850            }
851
852            skip_whitespace(&buffer, &mut pos);
853            if pos >= buffer.len() {
854                break;
855            }
856            match buffer[pos] {
857                b',' => {
858                    pos += 1;
859                }
860                b']' => {
861                    pos += 1;
862                    done = true;
863                    break;
864                }
865                _ => {
866                    return Err(CliError::InvalidArgument(
867                        "Invalid streaming response: expected ',' or ']'".into(),
868                    ))
869                }
870            }
871        }
872
873        if pos > 0 {
874            buffer.drain(..pos);
875            pos = 0;
876        }
877    }
878
879    if done {
880        if has_non_whitespace(&buffer) {
881            return Err(CliError::InvalidArgument(
882                "Invalid streaming response: unexpected trailing data".into(),
883            ));
884        }
885        buffer.clear();
886        loop {
887            let next = tokio::select! {
888                _ = options.cancel.wait() => {
889                    let _ = send_cancel_request(client).await;
890                    return Err(CliError::Cancelled);
891                }
892                result = tokio::time::timeout(options.deadline.remaining(), stream.next()) => {
893                    match result {
894                        Ok(value) => value,
895                        Err(_) => {
896                            let _ = send_cancel_request(client).await;
897                            return Err(CliError::Timeout(format!(
898                                "deadline exceeded after {}",
899                                humantime::format_duration(options.deadline.duration())
900                            )));
901                        }
902                    }
903                }
904            };
905
906            let chunk = match next {
907                Some(chunk) => chunk,
908                None => break,
909            };
910
911            let bytes = match chunk {
912                Ok(bytes) => bytes,
913                Err(err) => {
914                    return Err(CliError::ServerConnection(format!("request failed: {err}")))
915                }
916            };
917
918            if has_non_whitespace(&bytes) {
919                return Err(CliError::InvalidArgument(
920                    "Invalid streaming response: unexpected trailing data".into(),
921                ));
922            }
923        }
924    } else {
925        skip_whitespace(&buffer, &mut pos);
926        if pos < buffer.len() {
927            return Err(CliError::InvalidArgument(
928                "Invalid streaming response: unexpected trailing data".into(),
929            ));
930        }
931        return Err(CliError::InvalidArgument(
932            "Invalid streaming response: unexpected end of stream".into(),
933        ));
934    }
935
936    let columns = columns
937        .unwrap_or_default()
938        .into_iter()
939        .map(|name| Column::new(name, DataType::Text))
940        .collect();
941    Ok((columns, rows))
942}
943
944#[allow(dead_code)]
945async fn collect_remote_non_streaming_rows(
946    client: &HttpClient,
947    sql: &str,
948    options: &SqlExecutionOptions<'_>,
949    fetch_size: Option<usize>,
950    max_rows: Option<usize>,
951) -> Result<(Vec<Column>, Vec<Row>)> {
952    let request = RemoteSqlRequest {
953        sql: sql.to_string(),
954        streaming: false,
955        fetch_size,
956        max_rows,
957    };
958    let response: RemoteSqlResponse = tokio::select! {
959        result = tokio::time::timeout(options.deadline.remaining(), client.post_json("api/sql/query", &request)) => {
960            match result {
961                Ok(value) => value.map_err(map_client_error)?,
962                Err(_) => {
963                    let _ = send_cancel_request(client).await;
964                    return Err(CliError::Timeout(format!(
965                        "deadline exceeded after {}",
966                        humantime::format_duration(options.deadline.duration())
967                    )));
968                }
969            }
970        }
971        _ = options.cancel.wait() => {
972            let _ = send_cancel_request(client).await;
973            return Err(CliError::Cancelled);
974        }
975    };
976
977    if response.columns.is_empty() {
978        let message = match response.affected_rows {
979            Some(count) => format!("{count} row(s) affected"),
980            None => "Operation completed successfully".to_string(),
981        };
982        let columns = sql_status_columns();
983        let row = Row::new(vec![Value::Text("OK".to_string()), Value::Text(message)]);
984        return Ok((columns, vec![row]));
985    }
986
987    let columns = response
988        .columns
989        .iter()
990        .map(|col| Column::new(&col.name, data_type_from_string(&col.data_type)))
991        .collect::<Vec<_>>();
992    let mut rows = response
993        .rows
994        .into_iter()
995        .map(|row| Row::new(row.into_iter().map(remote_value_to_value).collect()))
996        .collect::<Vec<_>>();
997    if let Some(limit) = options.limit {
998        rows.truncate(limit);
999    }
1000    Ok((columns, rows))
1001}
1002
1003async fn execute_remote_streaming<W: Write>(
1004    client: &HttpClient,
1005    sql: &str,
1006    writer: &mut W,
1007    output: &mut SqlOutput<'_>,
1008    options: &SqlExecutionOptions<'_>,
1009    fetch_size: Option<usize>,
1010    max_rows: Option<usize>,
1011) -> Result<()> {
1012    let request = RemoteSqlRequest {
1013        sql: sql.to_string(),
1014        streaming: true,
1015        fetch_size,
1016        max_rows,
1017    };
1018
1019    let response = tokio::select! {
1020        result = tokio::time::timeout(options.deadline.remaining(), client.post_json_stream("api/sql/query", &request)) => {
1021            match result {
1022                Ok(value) => value.map_err(map_client_error)?,
1023                Err(_) => {
1024                    let _ = send_cancel_request(client).await;
1025                    return Err(CliError::Timeout(format!(
1026                        "deadline exceeded after {}",
1027                        humantime::format_duration(options.deadline.duration())
1028                    )));
1029                }
1030            }
1031        }
1032        _ = options.cancel.wait() => {
1033            let _ = send_cancel_request(client).await;
1034            return Err(CliError::Cancelled);
1035        }
1036    };
1037
1038    // JSON output is an array of result sets; the remote streaming API
1039    // currently yields a single result set, so the array has one element
1040    // (server-side per-statement results are tracked as issue #31).
1041    if !output.statement_array() {
1042        return stream_remote_result_set(
1043            client,
1044            response,
1045            writer,
1046            output.create_block_formatter(),
1047            options,
1048        )
1049        .await;
1050    }
1051
1052    writeln!(writer, "[")?;
1053    let mut result_set_writer = CountingWriter::new(&mut *writer);
1054    let result = stream_remote_result_set(
1055        client,
1056        response,
1057        &mut result_set_writer,
1058        output.create_block_formatter(),
1059        options,
1060    )
1061    .await;
1062    let result_set_started = result_set_writer.bytes_written() > 0;
1063    match result {
1064        Ok(()) => {
1065            writeln!(writer, "]")?;
1066            Ok(())
1067        }
1068        Err(err) => {
1069            // Keep stdout well-formed JSON on stream errors: rows are always
1070            // written as complete objects, so closing the result-set array
1071            // (when it was opened) and the statement array leaves valid JSON
1072            // alongside the non-zero exit code.
1073            if result_set_started {
1074                let _ = writeln!(writer, "]");
1075            }
1076            let _ = writeln!(writer, "]");
1077            Err(err)
1078        }
1079    }
1080}
1081
1082/// Stream a single remote result set (JSON array or JSONL body) to `writer`.
1083async fn stream_remote_result_set<W: Write>(
1084    client: &HttpClient,
1085    response: Response,
1086    writer: &mut W,
1087    formatter: Box<dyn Formatter>,
1088    options: &SqlExecutionOptions<'_>,
1089) -> Result<()> {
1090    if let Some(content_type) = response
1091        .headers()
1092        .get(reqwest::header::CONTENT_TYPE)
1093        .and_then(|value| value.to_str().ok())
1094    {
1095        if content_type.starts_with("application/jsonl") {
1096            return execute_remote_jsonl_streaming(client, response, writer, formatter, options)
1097                .await;
1098        }
1099    }
1100
1101    let mut stream = response.bytes_stream();
1102    let mut buffer: Vec<u8> = Vec::new();
1103    let mut pos: usize = 0;
1104    let mut streaming_writer: Option<StreamingWriter<&mut W>> = None;
1105    let mut formatter = Some(formatter);
1106    let mut columns: Option<Vec<String>> = None;
1107    let mut column_set: Option<HashSet<String>> = None;
1108    let mut done = false;
1109    let mut saw_array_start = false;
1110
1111    while !done {
1112        if options.cancel.is_cancelled() {
1113            let _ = send_cancel_request(client).await;
1114            return Err(CliError::Cancelled);
1115        }
1116        if let Err(err) = options.deadline.check() {
1117            let _ = send_cancel_request(client).await;
1118            return Err(err);
1119        }
1120
1121        let next = tokio::select! {
1122            _ = options.cancel.wait() => {
1123                let _ = send_cancel_request(client).await;
1124                return Err(CliError::Cancelled);
1125            }
1126            result = tokio::time::timeout(options.deadline.remaining(), stream.next()) => {
1127                match result {
1128                    Ok(value) => value,
1129                    Err(_) => {
1130                        let _ = send_cancel_request(client).await;
1131                        return Err(CliError::Timeout(format!(
1132                            "deadline exceeded after {}",
1133                            humantime::format_duration(options.deadline.duration())
1134                        )));
1135                    }
1136                }
1137            }
1138        };
1139
1140        let chunk = match next {
1141            Some(chunk) => chunk,
1142            None => break,
1143        };
1144
1145        let bytes = match chunk {
1146            Ok(bytes) => bytes,
1147            Err(err) => return Err(CliError::ServerConnection(format!("request failed: {err}"))),
1148        };
1149
1150        buffer.extend_from_slice(&bytes);
1151
1152        loop {
1153            skip_whitespace(&buffer, &mut pos);
1154            if pos >= buffer.len() {
1155                break;
1156            }
1157
1158            if !saw_array_start {
1159                if buffer[pos] != b'[' {
1160                    return Err(CliError::InvalidArgument(
1161                        "Invalid streaming response: expected JSON array".into(),
1162                    ));
1163                }
1164                pos += 1;
1165                saw_array_start = true;
1166                continue;
1167            }
1168
1169            skip_whitespace(&buffer, &mut pos);
1170            if pos >= buffer.len() {
1171                break;
1172            }
1173
1174            if buffer[pos] == b']' {
1175                pos += 1;
1176                done = true;
1177                break;
1178            }
1179
1180            let slice = &buffer[pos..];
1181            let mut stream =
1182                serde_json::Deserializer::from_slice(slice).into_iter::<serde_json::Value>();
1183            let value = match stream.next() {
1184                Some(Ok(value)) => value,
1185                Some(Err(err)) if err.is_eof() => break,
1186                Some(Err(err)) => return Err(CliError::Json(err)),
1187                None => break,
1188            };
1189            pos = pos.saturating_add(stream.byte_offset());
1190
1191            let object = value.as_object().ok_or_else(|| {
1192                CliError::InvalidArgument("Invalid streaming row: expected JSON object".into())
1193            })?;
1194
1195            if columns.is_none() {
1196                let names: Vec<String> = object.keys().cloned().collect();
1197                let set: HashSet<String> = names.iter().cloned().collect();
1198                if names.is_empty() {
1199                    return Err(CliError::InvalidArgument(
1200                        "Invalid streaming row: empty object".into(),
1201                    ));
1202                }
1203                let cols = names
1204                    .iter()
1205                    .map(|name| Column::new(name, DataType::Text))
1206                    .collect::<Vec<_>>();
1207                let formatter = formatter
1208                    .take()
1209                    .ok_or_else(|| CliError::InvalidArgument("Missing formatter".into()))?;
1210                let mut writer = StreamingWriter::new(&mut *writer, formatter, cols, options.limit)
1211                    .with_quiet(options.quiet);
1212                writer.prepare(None)?;
1213                streaming_writer = Some(writer);
1214                columns = Some(names);
1215                column_set = Some(set);
1216            }
1217
1218            let names = columns
1219                .as_ref()
1220                .ok_or_else(|| CliError::InvalidArgument("Missing columns".into()))?;
1221            let set = column_set
1222                .as_ref()
1223                .ok_or_else(|| CliError::InvalidArgument("Missing column set".into()))?;
1224
1225            if object.len() != names.len() || !object.keys().all(|key| set.contains(key)) {
1226                return Err(CliError::InvalidArgument(
1227                    "Invalid streaming row: column mismatch".into(),
1228                ));
1229            }
1230
1231            let values = names
1232                .iter()
1233                .map(|name| {
1234                    object.get(name).ok_or_else(|| {
1235                        CliError::InvalidArgument(format!(
1236                            "Invalid streaming row: missing column '{name}'"
1237                        ))
1238                    })
1239                })
1240                .map(|value| value.and_then(json_value_to_value))
1241                .collect::<Result<Vec<_>>>()?;
1242
1243            if let Some(writer) = streaming_writer.as_mut() {
1244                match writer.write_row(Row::new(values))? {
1245                    WriteStatus::LimitReached => {
1246                        let _ = send_cancel_request(client).await;
1247                        return writer.finish();
1248                    }
1249                    WriteStatus::Continue => {}
1250                }
1251            }
1252
1253            skip_whitespace(&buffer, &mut pos);
1254            if pos >= buffer.len() {
1255                break;
1256            }
1257            match buffer[pos] {
1258                b',' => {
1259                    pos += 1;
1260                }
1261                b']' => {
1262                    pos += 1;
1263                    done = true;
1264                    break;
1265                }
1266                _ => {
1267                    return Err(CliError::InvalidArgument(
1268                        "Invalid streaming response: expected ',' or ']'".into(),
1269                    ))
1270                }
1271            }
1272        }
1273
1274        if pos > 0 {
1275            buffer.drain(..pos);
1276            pos = 0;
1277        }
1278    }
1279
1280    if done {
1281        if has_non_whitespace(&buffer) {
1282            return Err(CliError::InvalidArgument(
1283                "Invalid streaming response: unexpected trailing data".into(),
1284            ));
1285        }
1286        buffer.clear();
1287        loop {
1288            let next = tokio::select! {
1289                _ = options.cancel.wait() => {
1290                    let _ = send_cancel_request(client).await;
1291                    return Err(CliError::Cancelled);
1292                }
1293                result = tokio::time::timeout(options.deadline.remaining(), stream.next()) => {
1294                    match result {
1295                        Ok(value) => value,
1296                        Err(_) => {
1297                            let _ = send_cancel_request(client).await;
1298                            return Err(CliError::Timeout(format!(
1299                                "deadline exceeded after {}",
1300                                humantime::format_duration(options.deadline.duration())
1301                            )));
1302                        }
1303                    }
1304                }
1305            };
1306
1307            let chunk = match next {
1308                Some(chunk) => chunk,
1309                None => break,
1310            };
1311
1312            let bytes = match chunk {
1313                Ok(bytes) => bytes,
1314                Err(err) => {
1315                    return Err(CliError::ServerConnection(format!("request failed: {err}")))
1316                }
1317            };
1318
1319            if has_non_whitespace(&bytes) {
1320                return Err(CliError::InvalidArgument(
1321                    "Invalid streaming response: unexpected trailing data".into(),
1322                ));
1323            }
1324        }
1325    } else {
1326        skip_whitespace(&buffer, &mut pos);
1327        if pos < buffer.len() {
1328            return Err(CliError::InvalidArgument(
1329                "Invalid streaming response: unexpected trailing data".into(),
1330            ));
1331        }
1332        return Err(CliError::InvalidArgument(
1333            "Invalid streaming response: unexpected end of stream".into(),
1334        ));
1335    }
1336
1337    if let Some(mut writer) = streaming_writer {
1338        return writer.finish();
1339    }
1340
1341    if done && saw_array_start {
1342        if let Some(formatter) = formatter.take() {
1343            let mut writer =
1344                StreamingWriter::new(&mut *writer, formatter, Vec::new(), options.limit)
1345                    .with_quiet(options.quiet);
1346            writer.prepare(None)?;
1347            return writer.finish();
1348        }
1349    }
1350
1351    Ok(())
1352}
1353
1354async fn collect_remote_jsonl_rows(
1355    client: &HttpClient,
1356    response: Response,
1357    options: &SqlExecutionOptions<'_>,
1358) -> Result<(Vec<Column>, Vec<Row>)> {
1359    let mut stream = response.bytes_stream();
1360    let mut buffer: Vec<u8> = Vec::new();
1361    let mut columns: Option<Vec<Column>> = None;
1362    let mut rows: Vec<Row> = Vec::new();
1363    let mut done = false;
1364
1365    while !done {
1366        if options.cancel.is_cancelled() {
1367            let _ = send_cancel_request(client).await;
1368            return Err(CliError::Cancelled);
1369        }
1370        if let Err(err) = options.deadline.check() {
1371            let _ = send_cancel_request(client).await;
1372            return Err(err);
1373        }
1374
1375        let next = tokio::select! {
1376            _ = options.cancel.wait() => {
1377                let _ = send_cancel_request(client).await;
1378                return Err(CliError::Cancelled);
1379            }
1380            result = tokio::time::timeout(options.deadline.remaining(), stream.next()) => {
1381                match result {
1382                    Ok(value) => value,
1383                    Err(_) => {
1384                        let _ = send_cancel_request(client).await;
1385                        return Err(CliError::Timeout(format!(
1386                            "deadline exceeded after {}",
1387                            humantime::format_duration(options.deadline.duration())
1388                        )));
1389                    }
1390                }
1391            }
1392        };
1393
1394        let chunk = match next {
1395            Some(chunk) => chunk,
1396            None => break,
1397        };
1398
1399        let bytes = match chunk {
1400            Ok(bytes) => bytes,
1401            Err(err) => return Err(CliError::ServerConnection(format!("request failed: {err}"))),
1402        };
1403
1404        buffer.extend_from_slice(&bytes);
1405
1406        while let Some(newline) = buffer.iter().position(|&b| b == b'\n') {
1407            let line = buffer.drain(..=newline).collect::<Vec<u8>>();
1408            if let Some(item) = parse_jsonl_line(&line)? {
1409                if let Some(error) = item.error {
1410                    return Err(CliError::InvalidArgument(format!(
1411                        "Server error: {}",
1412                        error.message
1413                    )));
1414                }
1415                if item.done {
1416                    done = true;
1417                    break;
1418                }
1419                let row = item.row.ok_or_else(|| {
1420                    CliError::InvalidArgument("Invalid streaming response: missing row".into())
1421                })?;
1422                if columns.is_none() {
1423                    columns = Some(default_stream_columns(row.len()));
1424                }
1425                rows.push(Row::new(
1426                    row.into_iter().map(remote_value_to_value).collect(),
1427                ));
1428                if let Some(limit) = options.limit {
1429                    if rows.len() >= limit {
1430                        let _ = send_cancel_request(client).await;
1431                        done = true;
1432                        break;
1433                    }
1434                }
1435            }
1436        }
1437    }
1438
1439    if !done {
1440        if let Some(item) = parse_jsonl_line(&buffer)? {
1441            if let Some(error) = item.error {
1442                return Err(CliError::InvalidArgument(format!(
1443                    "Server error: {}",
1444                    error.message
1445                )));
1446            }
1447            if item.done {
1448                done = true;
1449            } else if let Some(row) = item.row {
1450                if columns.is_none() {
1451                    columns = Some(default_stream_columns(row.len()));
1452                }
1453                rows.push(Row::new(
1454                    row.into_iter().map(remote_value_to_value).collect(),
1455                ));
1456                if let Some(limit) = options.limit {
1457                    if rows.len() >= limit {
1458                        let _ = send_cancel_request(client).await;
1459                        done = true;
1460                    }
1461                }
1462            } else {
1463                return Err(CliError::InvalidArgument(
1464                    "Invalid streaming response: missing row".into(),
1465                ));
1466            }
1467        }
1468    }
1469
1470    if !done {
1471        return Err(CliError::InvalidArgument(
1472            "Invalid streaming response: unexpected end of stream".into(),
1473        ));
1474    }
1475
1476    Ok((columns.unwrap_or_default(), rows))
1477}
1478
1479async fn execute_remote_jsonl_streaming<W: Write>(
1480    client: &HttpClient,
1481    response: Response,
1482    writer: &mut W,
1483    formatter: Box<dyn Formatter>,
1484    options: &SqlExecutionOptions<'_>,
1485) -> Result<()> {
1486    let mut stream = response.bytes_stream();
1487    let mut buffer: Vec<u8> = Vec::new();
1488    let mut streaming_writer: Option<StreamingWriter<&mut W>> = None;
1489    let mut formatter = Some(formatter);
1490    let mut done = false;
1491
1492    while !done {
1493        if options.cancel.is_cancelled() {
1494            let _ = send_cancel_request(client).await;
1495            return Err(CliError::Cancelled);
1496        }
1497        if let Err(err) = options.deadline.check() {
1498            let _ = send_cancel_request(client).await;
1499            return Err(err);
1500        }
1501
1502        let next = tokio::select! {
1503            _ = options.cancel.wait() => {
1504                let _ = send_cancel_request(client).await;
1505                return Err(CliError::Cancelled);
1506            }
1507            result = tokio::time::timeout(options.deadline.remaining(), stream.next()) => {
1508                match result {
1509                    Ok(value) => value,
1510                    Err(_) => {
1511                        let _ = send_cancel_request(client).await;
1512                        return Err(CliError::Timeout(format!(
1513                            "deadline exceeded after {}",
1514                            humantime::format_duration(options.deadline.duration())
1515                        )));
1516                    }
1517                }
1518            }
1519        };
1520
1521        let chunk = match next {
1522            Some(chunk) => chunk,
1523            None => break,
1524        };
1525
1526        let bytes = match chunk {
1527            Ok(bytes) => bytes,
1528            Err(err) => return Err(CliError::ServerConnection(format!("request failed: {err}"))),
1529        };
1530
1531        buffer.extend_from_slice(&bytes);
1532
1533        while let Some(newline) = buffer.iter().position(|&b| b == b'\n') {
1534            let line = buffer.drain(..=newline).collect::<Vec<u8>>();
1535            if let Some(item) = parse_jsonl_line(&line)? {
1536                if let Some(error) = item.error {
1537                    return Err(CliError::InvalidArgument(format!(
1538                        "Server error: {}",
1539                        error.message
1540                    )));
1541                }
1542                if item.done {
1543                    done = true;
1544                    break;
1545                }
1546                let row = item.row.ok_or_else(|| {
1547                    CliError::InvalidArgument("Invalid streaming response: missing row".into())
1548                })?;
1549                if streaming_writer.is_none() {
1550                    let columns = default_stream_columns(row.len());
1551                    let formatter = formatter
1552                        .take()
1553                        .ok_or_else(|| CliError::InvalidArgument("Missing formatter".into()))?;
1554                    let mut writer =
1555                        StreamingWriter::new(&mut *writer, formatter, columns, options.limit)
1556                            .with_quiet(options.quiet);
1557                    writer.prepare(None)?;
1558                    streaming_writer = Some(writer);
1559                }
1560                let values = row.into_iter().map(remote_value_to_value).collect();
1561                if let Some(writer) = streaming_writer.as_mut() {
1562                    match writer.write_row(Row::new(values))? {
1563                        WriteStatus::LimitReached => {
1564                            let _ = send_cancel_request(client).await;
1565                            done = true;
1566                            break;
1567                        }
1568                        WriteStatus::Continue => {}
1569                    }
1570                }
1571            }
1572        }
1573    }
1574
1575    if !done {
1576        if let Some(item) = parse_jsonl_line(&buffer)? {
1577            if let Some(error) = item.error {
1578                return Err(CliError::InvalidArgument(format!(
1579                    "Server error: {}",
1580                    error.message
1581                )));
1582            }
1583            if item.done {
1584                done = true;
1585            } else if let Some(row) = item.row {
1586                if streaming_writer.is_none() {
1587                    let columns = default_stream_columns(row.len());
1588                    let formatter = formatter
1589                        .take()
1590                        .ok_or_else(|| CliError::InvalidArgument("Missing formatter".into()))?;
1591                    let mut writer =
1592                        StreamingWriter::new(&mut *writer, formatter, columns, options.limit)
1593                            .with_quiet(options.quiet);
1594                    writer.prepare(None)?;
1595                    streaming_writer = Some(writer);
1596                }
1597                let values = row.into_iter().map(remote_value_to_value).collect();
1598                if let Some(writer) = streaming_writer.as_mut() {
1599                    match writer.write_row(Row::new(values))? {
1600                        WriteStatus::LimitReached => {
1601                            let _ = send_cancel_request(client).await;
1602                            done = true;
1603                        }
1604                        WriteStatus::Continue => {}
1605                    }
1606                }
1607            } else {
1608                return Err(CliError::InvalidArgument(
1609                    "Invalid streaming response: missing row".into(),
1610                ));
1611            }
1612        }
1613    }
1614
1615    if !done {
1616        return Err(CliError::InvalidArgument(
1617            "Invalid streaming response: unexpected end of stream".into(),
1618        ));
1619    }
1620
1621    if let Some(mut writer) = streaming_writer {
1622        return writer.finish();
1623    }
1624
1625    if let Some(formatter) = formatter.take() {
1626        let mut writer = StreamingWriter::new(&mut *writer, formatter, Vec::new(), options.limit)
1627            .with_quiet(options.quiet);
1628        writer.prepare(None)?;
1629        return writer.finish();
1630    }
1631
1632    Ok(())
1633}
1634
1635fn default_stream_columns(count: usize) -> Vec<Column> {
1636    (0..count)
1637        .map(|idx| Column::new(format!("col{}", idx + 1), DataType::Text))
1638        .collect()
1639}
1640
1641fn parse_jsonl_line(line: &[u8]) -> Result<Option<RemoteStreamItem>> {
1642    if line
1643        .iter()
1644        .all(|b| matches!(b, b' ' | b'\n' | b'\r' | b'\t'))
1645    {
1646        return Ok(None);
1647    }
1648    let text = std::str::from_utf8(line)
1649        .map_err(|err| CliError::InvalidArgument(format!("Invalid UTF-8: {err}")))?;
1650    let trimmed = text.trim();
1651    if trimmed.is_empty() {
1652        return Ok(None);
1653    }
1654    let item = serde_json::from_str::<RemoteStreamItem>(trimmed)?;
1655    Ok(Some(item))
1656}
1657
1658fn skip_whitespace(buffer: &[u8], pos: &mut usize) {
1659    while *pos < buffer.len() {
1660        match buffer[*pos] {
1661            b' ' | b'\n' | b'\r' | b'\t' => *pos += 1,
1662            _ => break,
1663        }
1664    }
1665}
1666
1667fn has_non_whitespace(buffer: &[u8]) -> bool {
1668    buffer
1669        .iter()
1670        .any(|byte| !matches!(byte, b' ' | b'\n' | b'\r' | b'\t'))
1671}
1672
1673fn json_value_to_value(value: &serde_json::Value) -> Result<Value> {
1674    match value {
1675        serde_json::Value::Null => Ok(Value::Null),
1676        serde_json::Value::Bool(value) => Ok(Value::Bool(*value)),
1677        serde_json::Value::Number(value) => {
1678            if let Some(value) = value.as_i64() {
1679                Ok(Value::Int(value))
1680            } else if let Some(value) = value.as_f64() {
1681                Ok(Value::Float(value))
1682            } else {
1683                Err(CliError::InvalidArgument(
1684                    "Invalid numeric value in streaming row".into(),
1685                ))
1686            }
1687        }
1688        serde_json::Value::String(value) => Ok(Value::Text(value.clone())),
1689        serde_json::Value::Array(values) => {
1690            let mut vector = Vec::with_capacity(values.len());
1691            for entry in values {
1692                let number = entry.as_f64().ok_or_else(|| {
1693                    CliError::InvalidArgument("Invalid vector value in streaming row".into())
1694                })?;
1695                vector.push(number as f32);
1696            }
1697            Ok(Value::Vector(vector))
1698        }
1699        serde_json::Value::Object(_) => Err(CliError::InvalidArgument(
1700            "Invalid streaming row: nested objects are not supported".into(),
1701        )),
1702    }
1703}
1704
1705/// Legacy execute function for backward compatibility with tests.
1706#[allow(dead_code)]
1707pub fn execute<W: Write>(
1708    db: &Database,
1709    cmd: SqlCommand,
1710    batch_mode: &BatchMode,
1711    writer: &mut StreamingWriter<W>,
1712) -> Result<()> {
1713    let sql = cmd.resolve_query(batch_mode)?;
1714
1715    execute_sql(db, &sql, writer)
1716}
1717
1718impl SqlCommand {
1719    /// Resolve the SQL query source (argument, file, or stdin).
1720    pub fn resolve_query(&self, batch_mode: &BatchMode) -> Result<String> {
1721        match (&self.query, &self.file) {
1722            (Some(query), None) => Ok(query.clone()),
1723            (None, Some(file)) => fs::read_to_string(file).map_err(|e| {
1724                CliError::InvalidArgument(format!("Failed to read SQL file '{}': {}", file, e))
1725            }),
1726            (None, None) if !batch_mode.is_tty => {
1727                let mut buf = String::new();
1728                io::stdin().read_to_string(&mut buf)?;
1729                Ok(buf)
1730            }
1731            (None, None) => Err(CliError::NoQueryProvided),
1732            (Some(_), Some(_)) => Err(CliError::InvalidArgument(
1733                "Cannot specify both query and file".to_string(),
1734            )),
1735        }
1736    }
1737}
1738
1739/// Execute SQL and write results.
1740fn execute_sql<W: Write>(db: &Database, sql: &str, writer: &mut StreamingWriter<W>) -> Result<()> {
1741    use alopex_embedded::SqlResult;
1742
1743    let result = db.execute_sql(sql)?;
1744
1745    match result {
1746        SqlResult::Success => {
1747            // DDL success - output simple status
1748            writer.prepare(Some(1))?;
1749            let row = Row::new(vec![
1750                Value::Text("OK".to_string()),
1751                Value::Text("Operation completed successfully".to_string()),
1752            ]);
1753            writer.write_row(row)?;
1754            writer.finish()?;
1755        }
1756        SqlResult::RowsAffected(count) => {
1757            // DML success - output affected rows count
1758            writer.prepare(Some(1))?;
1759            let row = Row::new(vec![
1760                Value::Text("OK".to_string()),
1761                Value::Text(format!("{} row(s) affected", count)),
1762            ]);
1763            writer.write_row(row)?;
1764            writer.finish()?;
1765        }
1766        SqlResult::Query(query_result) => {
1767            // SELECT result - output rows
1768            let row_count = query_result.rows.len();
1769            writer.prepare(Some(row_count))?;
1770
1771            for sql_row in query_result.rows {
1772                let values: Vec<Value> = sql_row.into_iter().map(sql_value_to_value).collect();
1773                let row = Row::new(values);
1774
1775                match writer.write_row(row)? {
1776                    WriteStatus::LimitReached => break,
1777                    WriteStatus::Continue => {}
1778                }
1779            }
1780
1781            writer.finish()?;
1782        }
1783    }
1784
1785    Ok(())
1786}
1787
1788/// Execute SQL locally, emitting one result block per statement.
1789///
1790/// - A single SELECT statement uses the streaming path (FR-7).
1791/// - Everything else (DDL/DML and multi-statement input) executes all
1792///   statements in one transaction via [`Database::execute_sql_multi`] and
1793///   emits each statement's result in order.
1794/// - For `--output json` the output is always an array of per-statement
1795///   result sets (a single statement yields a 1-element array).
1796///
1797/// FR-7 Compliance: Uses SQL parser to detect SELECT queries instead of heuristic.
1798/// This properly handles:
1799/// - WITH clauses (CTEs)
1800/// - Leading comments
1801/// - Complex query structures
1802fn execute_sql_with_formatter<W: Write>(
1803    db: &Database,
1804    sql: &str,
1805    writer: &mut W,
1806    output: &mut SqlOutput<'_>,
1807    options: &SqlExecutionOptions<'_>,
1808) -> Result<()> {
1809    use alopex_sql::{AlopexDialect, Parser, StatementKind};
1810
1811    // FR-7: Use parser to detect SELECT instead of starts_with("SELECT") heuristic
1812    // This correctly handles WITH clauses, leading comments, and complex query structures
1813    let dialect = AlopexDialect;
1814    let stmts = Parser::parse_sql(&dialect, sql).map_err(|e| CliError::Parse(format!("{}", e)))?;
1815
1816    let is_single_select = stmts.len() == 1
1817        && matches!(
1818            stmts.first().map(|s| &s.kind),
1819            Some(StatementKind::Select(_))
1820        );
1821
1822    if is_single_select {
1823        // Single SELECT: use streaming path (FR-7).
1824        let formatter = output.create_block_formatter();
1825        if !output.statement_array() {
1826            return execute_sql_select_streaming(db, sql, writer, formatter, options, false);
1827        }
1828        let mut result_set_writer = CountingWriter::new(&mut *writer);
1829        let result =
1830            execute_sql_select_streaming(db, sql, &mut result_set_writer, formatter, options, true);
1831        let output_started = result_set_writer.bytes_written() > 0;
1832        match result {
1833            Ok(()) => {
1834                // Close the statement-result array opened by the streaming callback.
1835                writeln!(writer, "]")?;
1836                Ok(())
1837            }
1838            Err(err) => {
1839                // Keep stdout well-formed JSON on mid-stream errors: the
1840                // callback opens the statement array and the result-set array
1841                // together before writing rows (always complete objects), so
1842                // closing both leaves valid JSON alongside the non-zero exit
1843                // code. If nothing was written (e.g. planning failed), there
1844                // is nothing to close.
1845                if output_started {
1846                    let _ = writeln!(writer, "]");
1847                    let _ = writeln!(writer, "]");
1848                }
1849                Err(err)
1850            }
1851        }
1852    } else {
1853        // DDL/DML and multi-statement input: emit one result block per statement.
1854        execute_sql_statements(db, sql, writer, output, options)
1855    }
1856}
1857
1858/// Execute SELECT query with streaming callback (FR-7).
1859///
1860/// This function uses `execute_sql_with_rows` for true streaming output.
1861/// The callback receives rows one at a time from the iterator, and the
1862/// transaction is kept alive during streaming.
1863///
1864/// When `open_statement_array` is true (JSON output), the callback writes the
1865/// opening `[` of the statement-result array before streaming; the caller is
1866/// responsible for writing the closing `]` after this function returns.
1867fn execute_sql_select_streaming<W: Write>(
1868    db: &Database,
1869    sql: &str,
1870    writer: &mut W,
1871    formatter: Box<dyn Formatter>,
1872    options: &SqlExecutionOptions<'_>,
1873    open_statement_array: bool,
1874) -> Result<()> {
1875    use alopex_embedded::StreamingQueryResult;
1876    use std::sync::atomic::{AtomicBool, Ordering};
1877    use std::sync::Arc;
1878
1879    // Helper to convert CliError to alopex_embedded::Error for callback
1880    fn cli_err_to_embedded(e: crate::error::CliError) -> alopex_embedded::Error {
1881        alopex_embedded::Error::Sql(alopex_sql::SqlError::Execution {
1882            message: e.to_string(),
1883            code: "ALOPEX-C001",
1884        })
1885    }
1886
1887    let cancelled = Arc::new(AtomicBool::new(false));
1888    let timed_out = Arc::new(AtomicBool::new(false));
1889    let cancel_flag = cancelled.clone();
1890    let timeout_flag = timed_out.clone();
1891
1892    let result = db.execute_sql_with_rows(sql, |mut rows| {
1893        // FR-7: SELECT result - stream rows directly from iterator while transaction is alive
1894        let columns = columns_from_streaming_rows(&rows);
1895        // The prefix is written here (not by the caller) so that planning
1896        // errors do not leave a dangling `[` on stdout.
1897        if open_statement_array {
1898            writeln!(writer, "[").map_err(|e| cli_err_to_embedded(CliError::Io(e)))?;
1899        }
1900        let mut streaming_writer = StreamingWriter::new(writer, formatter, columns, options.limit)
1901            .with_quiet(options.quiet);
1902
1903        // FR-7: Use None for row count hint to support true streaming output
1904        streaming_writer
1905            .prepare(None)
1906            .map_err(cli_err_to_embedded)?;
1907
1908        if let Err(err) = options.deadline.check() {
1909            timeout_flag.store(true, Ordering::SeqCst);
1910            return Err(cli_err_to_embedded(err));
1911        }
1912
1913        // Consume iterator row by row for true streaming.
1914        // Errors from `next_row` must propagate: swallowing them here would
1915        // silently truncate the result set (GitHub issue #23).
1916        while let Some(sql_row) = rows.next_row()? {
1917            if options.cancel.is_cancelled() {
1918                cancel_flag.store(true, Ordering::SeqCst);
1919                return Err(cli_err_to_embedded(CliError::Cancelled));
1920            }
1921            if let Err(err) = options.deadline.check() {
1922                timeout_flag.store(true, Ordering::SeqCst);
1923                return Err(cli_err_to_embedded(err));
1924            }
1925            let values: Vec<Value> = sql_row.into_iter().map(sql_value_to_value).collect();
1926            let row = Row::new(values);
1927
1928            match streaming_writer
1929                .write_row(row)
1930                .map_err(cli_err_to_embedded)?
1931            {
1932                WriteStatus::LimitReached => break,
1933                WriteStatus::Continue => {}
1934            }
1935        }
1936
1937        streaming_writer.finish().map_err(cli_err_to_embedded)?;
1938        Ok(())
1939    });
1940
1941    let result = match result {
1942        Ok(value) => value,
1943        Err(err) => {
1944            if cancelled.load(Ordering::SeqCst) {
1945                return Err(CliError::Cancelled);
1946            }
1947            if timed_out.load(Ordering::SeqCst) {
1948                return Err(CliError::Timeout(format!(
1949                    "deadline exceeded after {}",
1950                    humantime::format_duration(options.deadline.duration())
1951                )));
1952            }
1953            return Err(CliError::Database(err));
1954        }
1955    };
1956
1957    match result {
1958        StreamingQueryResult::QueryProcessed(()) => Ok(()),
1959        StreamingQueryResult::Success | StreamingQueryResult::RowsAffected(_) => {
1960            // Unexpected: SELECT should not return these
1961            Ok(())
1962        }
1963    }
1964}
1965
1966#[derive(serde::Serialize)]
1967struct RemoteSqlRequest {
1968    sql: String,
1969    #[serde(default)]
1970    streaming: bool,
1971    #[serde(skip_serializing_if = "Option::is_none")]
1972    fetch_size: Option<usize>,
1973    #[serde(skip_serializing_if = "Option::is_none")]
1974    max_rows: Option<usize>,
1975}
1976
1977#[derive(serde::Deserialize)]
1978struct RemoteColumnInfo {
1979    name: String,
1980    data_type: String,
1981}
1982
1983#[derive(serde::Deserialize)]
1984struct RemoteSqlResponse {
1985    columns: Vec<RemoteColumnInfo>,
1986    rows: Vec<Vec<alopex_sql::storage::SqlValue>>,
1987    affected_rows: Option<u64>,
1988}
1989
1990#[derive(serde::Deserialize)]
1991struct RemoteStreamItem {
1992    row: Option<Vec<alopex_sql::storage::SqlValue>>,
1993    error: Option<RemoteStreamError>,
1994    #[serde(default)]
1995    done: bool,
1996}
1997
1998#[derive(serde::Deserialize)]
1999struct RemoteStreamError {
2000    message: String,
2001}
2002
2003fn map_client_error(err: ClientError) -> CliError {
2004    match err {
2005        ClientError::Request { source, .. } => {
2006            CliError::ServerConnection(format!("request failed: {source}"))
2007        }
2008        ClientError::InvalidUrl(message) => CliError::InvalidArgument(message),
2009        ClientError::Build(message) => CliError::InvalidArgument(message),
2010        ClientError::Auth(err) => CliError::InvalidArgument(err.to_string()),
2011        ClientError::HttpStatus { status, body } => {
2012            CliError::InvalidArgument(format!("Server error: HTTP {} - {}", status.as_u16(), body))
2013        }
2014    }
2015}
2016
2017async fn send_cancel_request(client: &HttpClient) -> Result<()> {
2018    #[derive(serde::Serialize)]
2019    struct CancelRequest {}
2020
2021    let request = CancelRequest {};
2022    let _: serde_json::Value = client
2023        .post_json("api/sql/cancel", &request)
2024        .await
2025        .map_err(map_client_error)?;
2026    Ok(())
2027}
2028
2029fn merge_limit(limit: Option<usize>, max_rows: Option<usize>) -> Option<usize> {
2030    match (limit, max_rows) {
2031        (Some(a), Some(b)) => Some(a.min(b)),
2032        (Some(value), None) | (None, Some(value)) => Some(value),
2033        (None, None) => None,
2034    }
2035}
2036
2037/// Execute DDL/DML/multi-statement SQL and emit one result block per statement.
2038///
2039/// All statements run in a single auto-commit transaction
2040/// ([`Database::execute_sql_multi`]); if any statement fails, the whole batch
2041/// is rolled back and an error is returned (non-zero exit code).
2042///
2043/// Output contract:
2044/// - `--output json`: an array of per-statement result sets. DDL/DML
2045///   statements contribute a status result set (`status`/`message` columns);
2046///   `--quiet` omits status result sets.
2047/// - Other formats: one result block per statement, in statement order
2048///   (status blocks are suppressed by `--quiet`).
2049fn execute_sql_statements<W: Write>(
2050    db: &Database,
2051    sql: &str,
2052    writer: &mut W,
2053    output: &mut SqlOutput<'_>,
2054    options: &SqlExecutionOptions<'_>,
2055) -> Result<()> {
2056    use alopex_sql::ExecutionResult;
2057
2058    options.deadline.check()?;
2059    let results = db.execute_sql_multi(sql)?;
2060    options.deadline.check()?;
2061
2062    let json_array = output.statement_array();
2063    if json_array {
2064        writeln!(writer, "[")?;
2065    }
2066    let mut first = true;
2067    for result in results {
2068        let is_status = !matches!(result, ExecutionResult::Query(_));
2069        if is_status && options.quiet {
2070            continue;
2071        }
2072        if json_array && !first {
2073            writeln!(writer, ",")?;
2074        }
2075        first = false;
2076        emit_execution_result(result, writer, output, options)?;
2077    }
2078    if json_array {
2079        writeln!(writer, "]")?;
2080    }
2081    Ok(())
2082}
2083
2084/// Emit a single statement's execution result as one result block.
2085fn emit_execution_result<W: Write>(
2086    result: alopex_sql::ExecutionResult,
2087    writer: &mut W,
2088    output: &mut SqlOutput<'_>,
2089    options: &SqlExecutionOptions<'_>,
2090) -> Result<()> {
2091    use alopex_sql::ExecutionResult;
2092
2093    match result {
2094        ExecutionResult::Success => emit_status_result(
2095            writer,
2096            output,
2097            options,
2098            "Operation completed successfully".to_string(),
2099        ),
2100        ExecutionResult::RowsAffected(count) => {
2101            emit_status_result(writer, output, options, format!("{count} row(s) affected"))
2102        }
2103        ExecutionResult::Query(query_result) => {
2104            let columns = columns_from_query_result(&query_result);
2105            let mut streaming_writer = StreamingWriter::new(
2106                &mut *writer,
2107                output.create_block_formatter(),
2108                columns,
2109                options.limit,
2110            )
2111            .with_quiet(options.quiet);
2112            streaming_writer.prepare(Some(query_result.rows.len()))?;
2113            for sql_row in query_result.rows {
2114                let values: Vec<Value> = sql_row.into_iter().map(sql_value_to_value).collect();
2115                match streaming_writer.write_row(Row::new(values))? {
2116                    WriteStatus::LimitReached => break,
2117                    WriteStatus::Continue => {}
2118                }
2119            }
2120            streaming_writer.finish()
2121        }
2122    }
2123}
2124
2125/// Emit an `OK` status result set (`status`/`message` columns).
2126fn emit_status_result<W: Write>(
2127    writer: &mut W,
2128    output: &mut SqlOutput<'_>,
2129    options: &SqlExecutionOptions<'_>,
2130    message: String,
2131) -> Result<()> {
2132    let columns = sql_status_columns();
2133    let mut streaming_writer = StreamingWriter::new(
2134        &mut *writer,
2135        output.create_block_formatter(),
2136        columns,
2137        options.limit,
2138    )
2139    .with_quiet(options.quiet);
2140    streaming_writer.prepare(Some(1))?;
2141    streaming_writer.write_row(Row::new(vec![
2142        Value::Text("OK".to_string()),
2143        Value::Text(message),
2144    ]))?;
2145    streaming_writer.finish()
2146}
2147
2148/// Convert alopex_sql::SqlValue to our Value type.
2149fn sql_value_to_value(sql_value: alopex_sql::SqlValue) -> Value {
2150    use alopex_sql::SqlValue;
2151
2152    match sql_value {
2153        SqlValue::Null => Value::Null,
2154        SqlValue::Integer(i) => Value::Int(i as i64),
2155        SqlValue::BigInt(i) => Value::Int(i),
2156        SqlValue::Float(f) => Value::Float(f as f64),
2157        SqlValue::Double(f) => Value::Float(f),
2158        SqlValue::Text(s) => Value::Text(s),
2159        SqlValue::Blob(b) => Value::Bytes(b),
2160        SqlValue::Boolean(b) => Value::Bool(b),
2161        SqlValue::Timestamp(ts) => {
2162            // Format timestamp as ISO 8601 string
2163            Value::Text(format!("{}", ts))
2164        }
2165        SqlValue::Vector(v) => Value::Vector(v),
2166    }
2167}
2168
2169fn remote_value_to_value(sql_value: alopex_sql::storage::SqlValue) -> Value {
2170    use alopex_sql::storage::SqlValue;
2171
2172    match sql_value {
2173        SqlValue::Null => Value::Null,
2174        SqlValue::Integer(i) => Value::Int(i as i64),
2175        SqlValue::BigInt(i) => Value::Int(i),
2176        SqlValue::Float(f) => Value::Float(f as f64),
2177        SqlValue::Double(f) => Value::Float(f),
2178        SqlValue::Text(s) => Value::Text(s),
2179        SqlValue::Blob(b) => Value::Bytes(b),
2180        SqlValue::Boolean(b) => Value::Bool(b),
2181        SqlValue::Timestamp(ts) => Value::Text(ts.to_string()),
2182        SqlValue::Vector(v) => Value::Vector(v),
2183    }
2184}
2185
2186fn data_type_from_string(value: &str) -> DataType {
2187    let upper = value.to_ascii_uppercase();
2188    if upper.starts_with("INT") || upper.starts_with("BIGINT") {
2189        DataType::Int
2190    } else if upper.starts_with("FLOAT") || upper.starts_with("DOUBLE") {
2191        DataType::Float
2192    } else if upper.starts_with("BLOB") {
2193        DataType::Bytes
2194    } else if upper.starts_with("BOOLEAN") {
2195        DataType::Bool
2196    } else if upper.starts_with("VECTOR") {
2197        DataType::Vector
2198    } else {
2199        DataType::Text
2200    }
2201}
2202
2203/// Convert alopex_sql::executor::ColumnInfo to our Column type.
2204fn sql_column_to_column(col: &alopex_sql::executor::ColumnInfo) -> Column {
2205    use alopex_sql::planner::ResolvedType;
2206
2207    let data_type = match &col.data_type {
2208        ResolvedType::Integer | ResolvedType::BigInt => DataType::Int,
2209        ResolvedType::Float | ResolvedType::Double => DataType::Float,
2210        ResolvedType::Text => DataType::Text,
2211        ResolvedType::Blob => DataType::Bytes,
2212        ResolvedType::Boolean => DataType::Bool,
2213        ResolvedType::Timestamp => DataType::Text, // Display as text
2214        ResolvedType::Vector { .. } => DataType::Vector,
2215        ResolvedType::Null => DataType::Text, // Fallback
2216    };
2217
2218    Column::new(&col.name, data_type)
2219}
2220
2221/// Create columns from SQL query result.
2222fn columns_from_query_result(query_result: &alopex_sql::executor::QueryResult) -> Vec<Column> {
2223    query_result
2224        .columns
2225        .iter()
2226        .map(sql_column_to_column)
2227        .collect()
2228}
2229
2230/// Create columns from streaming query result iterator (FR-7).
2231#[allow(dead_code)] // Kept for potential future use with old streaming API
2232fn columns_from_streaming_result(
2233    query_iter: &alopex_embedded::QueryRowIterator<'_>,
2234) -> Vec<Column> {
2235    query_iter
2236        .columns()
2237        .iter()
2238        .map(sql_column_to_column)
2239        .collect()
2240}
2241
2242/// Create columns from callback-based streaming rows (FR-7).
2243fn columns_from_streaming_rows(rows: &alopex_embedded::StreamingRows<'_>) -> Vec<Column> {
2244    rows.columns().iter().map(sql_column_to_column).collect()
2245}
2246
2247/// Create columns for status output.
2248pub fn sql_status_columns() -> Vec<Column> {
2249    vec![
2250        Column::new("status", DataType::Text),
2251        Column::new("message", DataType::Text),
2252    ]
2253}
2254
2255#[cfg(test)]
2256mod tests {
2257    use super::*;
2258    use crate::batch::BatchModeSource;
2259    use crate::output::jsonl::JsonlFormatter;
2260
2261    fn create_test_db() -> Database {
2262        Database::open_in_memory().unwrap()
2263    }
2264
2265    fn create_status_writer(output: &mut Vec<u8>) -> StreamingWriter<&mut Vec<u8>> {
2266        let formatter = Box::new(JsonlFormatter::new());
2267        let columns = sql_status_columns();
2268        StreamingWriter::new(output, formatter, columns, None)
2269    }
2270
2271    fn create_query_writer(
2272        output: &mut Vec<u8>,
2273        columns: Vec<Column>,
2274    ) -> StreamingWriter<&mut Vec<u8>> {
2275        let formatter = Box::new(JsonlFormatter::new());
2276        StreamingWriter::new(output, formatter, columns, None)
2277    }
2278
2279    fn default_batch_mode() -> BatchMode {
2280        BatchMode {
2281            is_batch: false,
2282            is_tty: true,
2283            source: BatchModeSource::Default,
2284        }
2285    }
2286
2287    #[test]
2288    fn test_create_table() {
2289        let db = create_test_db();
2290
2291        let mut output = Vec::new();
2292        {
2293            let mut writer = create_status_writer(&mut output);
2294            execute_sql(
2295                &db,
2296                "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT);",
2297                &mut writer,
2298            )
2299            .unwrap();
2300        }
2301
2302        let result = String::from_utf8(output).unwrap();
2303        assert!(result.contains("OK"));
2304    }
2305
2306    #[test]
2307    fn test_insert_and_select() {
2308        let db = create_test_db();
2309
2310        // Create table
2311        {
2312            let mut output = Vec::new();
2313            let mut writer = create_status_writer(&mut output);
2314            execute_sql(
2315                &db,
2316                "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT);",
2317                &mut writer,
2318            )
2319            .unwrap();
2320        }
2321
2322        // Insert
2323        {
2324            let mut output = Vec::new();
2325            let mut writer = create_status_writer(&mut output);
2326            execute_sql(
2327                &db,
2328                "INSERT INTO users (id, name) VALUES (1, 'Alice');",
2329                &mut writer,
2330            )
2331            .unwrap();
2332            let result = String::from_utf8(output).unwrap();
2333            assert!(result.contains("row(s) affected"));
2334        }
2335
2336        // Select - we need columns from the query result
2337        {
2338            let mut output = Vec::new();
2339            let columns = vec![
2340                Column::new("id", DataType::Int),
2341                Column::new("name", DataType::Text),
2342            ];
2343            let mut writer = create_query_writer(&mut output, columns);
2344            execute_sql(&db, "SELECT id, name FROM users;", &mut writer).unwrap();
2345            let result = String::from_utf8(output).unwrap();
2346            assert!(result.contains("Alice"));
2347        }
2348    }
2349
2350    #[test]
2351    fn test_syntax_error() {
2352        let db = create_test_db();
2353
2354        let mut output = Vec::new();
2355        let mut writer = create_status_writer(&mut output);
2356        let result = execute_sql(&db, "CREATE TABEL invalid_syntax;", &mut writer);
2357        assert!(result.is_err());
2358    }
2359
2360    #[test]
2361    fn test_multiple_statements() {
2362        let db = create_test_db();
2363
2364        let mut output = Vec::new();
2365        {
2366            let mut writer = create_status_writer(&mut output);
2367            execute_sql(
2368                &db,
2369                "CREATE TABLE t (id INTEGER PRIMARY KEY); INSERT INTO t (id) VALUES (1);",
2370                &mut writer,
2371            )
2372            .unwrap();
2373        }
2374
2375        // Verify the table exists and has data
2376        {
2377            let mut output = Vec::new();
2378            let columns = vec![Column::new("id", DataType::Int)];
2379            let mut writer = create_query_writer(&mut output, columns);
2380            execute_sql(&db, "SELECT id FROM t;", &mut writer).unwrap();
2381            let result = String::from_utf8(output).unwrap();
2382            assert!(result.contains("1"));
2383        }
2384    }
2385
2386    /// Regression test for issue #23: errors surfaced by `next_row` during a
2387    /// streaming SELECT must propagate as `Err`, not be swallowed into a
2388    /// silently-empty result set with exit code 0 (the old
2389    /// `while let Ok(Some(...))` pattern did exactly that).
2390    #[test]
2391    fn streaming_select_row_error_propagates() {
2392        let db = create_test_db();
2393        db.execute_sql("CREATE TABLE t (id INTEGER PRIMARY KEY);")
2394            .unwrap();
2395        db.execute_sql("INSERT INTO t (id) VALUES (1);").unwrap();
2396
2397        let deadline = Deadline::new(parse_deadline(None).unwrap());
2398        let cancel = CancelSignal::new();
2399        let options = SqlExecutionOptions {
2400            limit: None,
2401            quiet: false,
2402            cancel: &cancel,
2403            deadline: &deadline,
2404            admin_launcher: None,
2405        };
2406
2407        let mut output = Vec::new();
2408        let formatter = Box::new(JsonlFormatter::new());
2409        // `id / 0` fails during row evaluation (division by zero), i.e. inside
2410        // the `next_row` loop of `execute_sql_select_streaming`.
2411        let result = execute_sql_select_streaming(
2412            &db,
2413            "SELECT id / 0 AS x FROM t;",
2414            &mut output,
2415            formatter,
2416            &options,
2417            false,
2418        );
2419        assert!(
2420            result.is_err(),
2421            "row evaluation errors must propagate instead of yielding an empty result"
2422        );
2423    }
2424
2425    #[test]
2426    fn test_sql_value_conversion() {
2427        use alopex_sql::SqlValue;
2428
2429        assert!(matches!(sql_value_to_value(SqlValue::Null), Value::Null));
2430        assert!(matches!(
2431            sql_value_to_value(SqlValue::Integer(42)),
2432            Value::Int(42)
2433        ));
2434        assert!(matches!(
2435            sql_value_to_value(SqlValue::BigInt(100)),
2436            Value::Int(100)
2437        ));
2438        assert!(matches!(
2439            sql_value_to_value(SqlValue::Boolean(true)),
2440            Value::Bool(true)
2441        ));
2442        assert!(
2443            matches!(sql_value_to_value(SqlValue::Text("hello".to_string())), Value::Text(s) if s == "hello")
2444        );
2445    }
2446
2447    #[test]
2448    fn resolve_query_from_argument() {
2449        let cmd = SqlCommand {
2450            query: Some("SELECT 1".to_string()),
2451            file: None,
2452            fetch_size: None,
2453            max_rows: None,
2454            deadline: None,
2455            tui: false,
2456        };
2457
2458        let sql = cmd.resolve_query(&default_batch_mode()).unwrap();
2459        assert_eq!(sql, "SELECT 1");
2460    }
2461
2462    #[test]
2463    fn resolve_query_from_file() {
2464        let mut file = tempfile::NamedTempFile::new().unwrap();
2465        writeln!(file, "SELECT * FROM users").unwrap();
2466
2467        let cmd = SqlCommand {
2468            query: None,
2469            file: Some(file.path().display().to_string()),
2470            fetch_size: None,
2471            max_rows: None,
2472            deadline: None,
2473            tui: false,
2474        };
2475
2476        let sql = cmd.resolve_query(&default_batch_mode()).unwrap();
2477        assert_eq!(sql, "SELECT * FROM users\n");
2478    }
2479
2480    #[test]
2481    fn resolve_query_returns_no_query_error() {
2482        let cmd = SqlCommand {
2483            query: None,
2484            file: None,
2485            fetch_size: None,
2486            max_rows: None,
2487            deadline: None,
2488            tui: false,
2489        };
2490
2491        let err = cmd.resolve_query(&default_batch_mode()).unwrap_err();
2492        assert!(matches!(err, CliError::NoQueryProvided));
2493    }
2494
2495    #[test]
2496    fn resolve_query_rejects_query_and_file() {
2497        let cmd = SqlCommand {
2498            query: Some("SELECT 1".to_string()),
2499            file: Some("query.sql".into()),
2500            fetch_size: None,
2501            max_rows: None,
2502            deadline: None,
2503            tui: false,
2504        };
2505
2506        let err = cmd.resolve_query(&default_batch_mode()).unwrap_err();
2507        assert!(matches!(
2508            err,
2509            CliError::InvalidArgument(msg) if msg == "Cannot specify both query and file"
2510        ));
2511    }
2512}