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