Skip to main content

alopex_cli/commands/
columnar.rs

1//! Columnar Command - Columnar segment operations
2//!
3//! Supports: scan, stats, list (segment-based operations)
4
5use std::hash::{Hash, Hasher};
6use std::io::Write;
7use std::path::{Path, PathBuf};
8use std::time::Instant;
9
10use alopex_core::columnar::encoding::{Column as ColumnData, LogicalType};
11use alopex_core::columnar::segment_v2::{
12    ColumnSchema, RecordBatch, Schema, SegmentConfigV2, SegmentWriterV2,
13};
14use alopex_core::storage::compression::CompressionV2;
15use alopex_core::storage::format::bincode_config;
16use alopex_embedded::{ColumnarIndexType, Database};
17use arrow_array::{
18    Array, BinaryArray, BooleanArray, Float32Array, Float64Array, Int32Array, Int64Array,
19    LargeBinaryArray, LargeStringArray, StringArray,
20};
21use arrow_schema::DataType as ArrowDataType;
22use bincode::config::Options;
23use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
24use serde::{Deserialize, Serialize};
25
26use crate::batch::BatchMode;
27use crate::cli::{ColumnarCommand, IndexCommand, OutputFormat};
28use crate::client::http::{ClientError, HttpClient};
29use crate::error::{CliError, Result};
30use crate::models::{Column, DataType, Row, Value};
31use crate::output::formatter::Formatter;
32use crate::output::RowCollector;
33use crate::progress::ProgressIndicator;
34use crate::streaming::{StreamingWriter, WriteStatus};
35use crate::tui::admin::{AdminBackend, AdminContext, AdminTarget, AuthCapabilities};
36use crate::tui::renderer::render_output;
37
38#[derive(Debug, Serialize)]
39struct RemoteColumnarScanRequest {
40    segment_id: String,
41}
42
43#[derive(Debug, Serialize)]
44struct RemoteColumnarStatsRequest {
45    segment_id: String,
46}
47
48#[derive(Debug, Serialize)]
49struct RemoteColumnarIndexCreateRequest {
50    segment_id: String,
51    column: String,
52    index_type: String,
53}
54
55#[derive(Debug, Serialize)]
56struct RemoteColumnarIndexListRequest {
57    segment_id: String,
58}
59
60#[derive(Debug, Serialize)]
61struct RemoteColumnarIndexDropRequest {
62    segment_id: String,
63    column: String,
64}
65
66#[derive(Debug, Serialize)]
67struct RemoteColumnarIngestRequest {
68    table: String,
69    compression: String,
70    segment: Vec<u8>,
71}
72
73#[derive(Debug, Deserialize)]
74struct RemoteColumnarScanResponse {
75    rows: Vec<Vec<alopex_sql::SqlValue>>,
76}
77
78#[derive(Debug, Deserialize)]
79struct RemoteColumnarStatsResponse {
80    row_count: usize,
81    column_count: usize,
82    size_bytes: u64,
83}
84
85#[derive(Debug, Deserialize)]
86struct RemoteColumnarListResponse {
87    segments: Vec<String>,
88}
89
90#[derive(Debug, Deserialize)]
91struct RemoteColumnarIndexInfo {
92    column: String,
93    index_type: String,
94}
95
96#[derive(Debug, Deserialize)]
97struct RemoteColumnarIndexListResponse {
98    indexes: Vec<RemoteColumnarIndexInfo>,
99}
100
101#[derive(Debug, Deserialize)]
102struct RemoteColumnarIngestResponse {
103    row_count: u64,
104    segment_id: String,
105    size_bytes: u64,
106    compression: String,
107    elapsed_ms: u64,
108}
109
110#[derive(Debug, Deserialize)]
111struct RemoteColumnarStatusResponse {
112    success: bool,
113}
114
115/// Execute a Columnar command with segment-based operations.
116///
117/// # Arguments
118///
119/// * `db` - The database instance.
120/// * `cmd` - The Columnar subcommand to execute.
121/// * `writer` - The output writer.
122/// * `formatter` - The formatter to use.
123/// * `limit` - Optional row limit.
124/// * `quiet` - Whether to suppress informational output.
125pub fn execute_with_formatter<W: Write>(
126    db: &Database,
127    cmd: ColumnarCommand,
128    batch_mode: &BatchMode,
129    writer: &mut W,
130    formatter: Box<dyn Formatter>,
131    limit: Option<usize>,
132    quiet: bool,
133) -> Result<()> {
134    match cmd {
135        ColumnarCommand::Scan { segment, progress } => {
136            let columns = columnar_scan_columns();
137            let mut streaming_writer =
138                StreamingWriter::new(writer, formatter, columns, limit).with_quiet(quiet);
139            execute_scan(db, &segment, progress, batch_mode, &mut streaming_writer)
140        }
141        ColumnarCommand::Stats { segment } => {
142            let columns = columnar_stats_columns();
143            let mut streaming_writer =
144                StreamingWriter::new(writer, formatter, columns, limit).with_quiet(quiet);
145            execute_stats(db, &segment, &mut streaming_writer)
146        }
147        ColumnarCommand::List => {
148            let columns = columnar_list_columns();
149            let mut streaming_writer =
150                StreamingWriter::new(writer, formatter, columns, limit).with_quiet(quiet);
151            execute_list(db, &mut streaming_writer)
152        }
153        ColumnarCommand::Ingest {
154            file,
155            table,
156            delimiter,
157            header,
158            compression,
159            row_group_size,
160        } => {
161            let columns = columnar_ingest_columns();
162            let mut streaming_writer =
163                StreamingWriter::new(writer, formatter, columns, limit).with_quiet(quiet);
164            let options = IngestOptions {
165                file: &file,
166                table: &table,
167                delimiter,
168                header,
169                compression: compression.as_str(),
170                row_group_size,
171            };
172            execute_ingest(db, options, &mut streaming_writer)
173        }
174        ColumnarCommand::Index(command) => {
175            let columns = match &command {
176                IndexCommand::List { .. } => columnar_index_list_columns(),
177                IndexCommand::Create { .. } | IndexCommand::Drop { .. } => {
178                    columnar_status_columns()
179                }
180            };
181            let mut streaming_writer =
182                StreamingWriter::new(writer, formatter, columns, limit).with_quiet(quiet);
183            execute_index_command(db, command, &mut streaming_writer)
184        }
185    }
186}
187
188pub fn columnar_command_columns(cmd: &ColumnarCommand) -> Vec<Column> {
189    match cmd {
190        ColumnarCommand::Scan { .. } => columnar_scan_columns(),
191        ColumnarCommand::Stats { .. } => columnar_stats_columns(),
192        ColumnarCommand::List => columnar_list_columns(),
193        ColumnarCommand::Ingest { .. } => columnar_ingest_columns(),
194        ColumnarCommand::Index(command) => match command {
195            IndexCommand::List { .. } => columnar_index_list_columns(),
196            IndexCommand::Create { .. } | IndexCommand::Drop { .. } => columnar_status_columns(),
197        },
198    }
199}
200
201#[allow(clippy::too_many_arguments)]
202pub fn execute_tui(
203    db: &Database,
204    cmd: ColumnarCommand,
205    batch_mode: &BatchMode,
206    output_format: OutputFormat,
207    limit: Option<usize>,
208    quiet: bool,
209    connection_label: impl Into<String>,
210    data_dir: Option<PathBuf>,
211) -> Result<()> {
212    let connection_label = connection_label.into();
213    let context_message = Some(columnar_command_context(&cmd));
214    let admin_label = connection_label.clone();
215    let admin_data_dir = data_dir.clone();
216    let admin_launcher: Option<Box<dyn FnMut() -> Result<()> + '_>> = Some(Box::new(move || {
217        let connection_label = admin_label.clone();
218        let data_dir = admin_data_dir.clone();
219        crate::tui::admin::run_admin_ui(AdminContext {
220            connection_label,
221            auth: AuthCapabilities::full(),
222            backend: AdminBackend::Local {
223                db,
224                batch_mode,
225                output_format,
226                limit,
227                quiet,
228                data_dir,
229            },
230            initial_target: Some(AdminTarget::Columnar),
231        })
232    }));
233    let columns = columnar_command_columns(&cmd);
234    let collector = RowCollector::new();
235    let formatter = Box::new(collector.formatter());
236    let mut sink = std::io::sink();
237    execute_with_formatter(db, cmd, batch_mode, &mut sink, formatter, limit, quiet)?;
238    let warning = collector.truncation_warning();
239    render_output(
240        columns,
241        collector.rows(),
242        connection_label,
243        context_message,
244        true,
245        warning,
246        output_format,
247        admin_launcher,
248    )
249}
250
251async fn execute_remote_ingest<W: Write>(
252    client: &HttpClient,
253    options: IngestOptions<'_>,
254    writer: &mut StreamingWriter<W>,
255) -> Result<()> {
256    let extension = options
257        .file
258        .extension()
259        .and_then(|ext| ext.to_str())
260        .unwrap_or("")
261        .to_ascii_lowercase();
262
263    let batch = match extension.as_str() {
264        "csv" => parse_csv(options.file, options.delimiter, options.header)?,
265        "parquet" | "pq" => parse_parquet(options.file)?,
266        _ => {
267            return Err(CliError::InvalidArgument(format!(
268                "Unsupported file format: {}",
269                options.file.display()
270            )))
271        }
272    };
273
274    let compression_type = parse_compression_arg(options.compression)?;
275    let mut config = SegmentConfigV2::default();
276    if let Some(size) = options.row_group_size {
277        config.row_group_size = size as u64;
278    }
279    config.compression = map_compression(compression_type);
280
281    let mut segment_writer = SegmentWriterV2::new(config);
282    segment_writer
283        .write_batch(batch)
284        .map_err(|err| CliError::InvalidArgument(err.to_string()))?;
285    let segment = segment_writer
286        .finish()
287        .map_err(|err| CliError::InvalidArgument(err.to_string()))?;
288    let segment_bytes = bincode_config()
289        .serialize(&segment)
290        .map_err(|err| CliError::InvalidArgument(err.to_string()))?;
291
292    let request = RemoteColumnarIngestRequest {
293        table: options.table.to_string(),
294        compression: compression_as_str(compression_type).to_string(),
295        segment: segment_bytes,
296    };
297    let response: RemoteColumnarIngestResponse = client
298        .post_json("columnar/ingest", &request)
299        .await
300        .map_err(map_client_error)?;
301
302    writer.prepare(Some(1))?;
303    let row = Row::new(vec![
304        Value::Int(response.row_count as i64),
305        Value::Text(response.segment_id),
306        Value::Int(response.size_bytes as i64),
307        Value::Text(response.compression),
308        Value::Int(response.elapsed_ms as i64),
309    ]);
310    writer.write_row(row)?;
311    writer.finish()?;
312    Ok(())
313}
314
315/// Execute a Columnar command against a remote server.
316pub async fn execute_remote_with_formatter<W: Write>(
317    client: &HttpClient,
318    cmd: &ColumnarCommand,
319    batch_mode: &BatchMode,
320    writer: &mut W,
321    formatter: Box<dyn Formatter>,
322    limit: Option<usize>,
323    quiet: bool,
324) -> Result<()> {
325    match cmd {
326        ColumnarCommand::Scan { segment, progress } => {
327            let columns = columnar_scan_columns();
328            let mut streaming_writer =
329                StreamingWriter::new(writer, formatter, columns, limit).with_quiet(quiet);
330            execute_remote_scan(
331                client,
332                segment,
333                *progress,
334                batch_mode,
335                &mut streaming_writer,
336            )
337            .await
338        }
339        ColumnarCommand::Stats { segment } => {
340            let columns = columnar_stats_columns();
341            let mut streaming_writer =
342                StreamingWriter::new(writer, formatter, columns, limit).with_quiet(quiet);
343            execute_remote_stats(client, segment, &mut streaming_writer).await
344        }
345        ColumnarCommand::List => {
346            let columns = columnar_list_columns();
347            let mut streaming_writer =
348                StreamingWriter::new(writer, formatter, columns, limit).with_quiet(quiet);
349            execute_remote_list(client, &mut streaming_writer).await
350        }
351        ColumnarCommand::Ingest {
352            file,
353            table,
354            delimiter,
355            header,
356            compression,
357            row_group_size,
358        } => {
359            let columns = columnar_ingest_columns();
360            let mut streaming_writer =
361                StreamingWriter::new(writer, formatter, columns, limit).with_quiet(quiet);
362            let options = IngestOptions {
363                file,
364                table,
365                delimiter: *delimiter,
366                header: *header,
367                compression: compression.as_str(),
368                row_group_size: *row_group_size,
369            };
370            execute_remote_ingest(client, options, &mut streaming_writer).await
371        }
372        ColumnarCommand::Index(command) => {
373            let columns = match &command {
374                IndexCommand::List { .. } => columnar_index_list_columns(),
375                IndexCommand::Create { .. } | IndexCommand::Drop { .. } => {
376                    columnar_status_columns()
377                }
378            };
379            let mut streaming_writer =
380                StreamingWriter::new(writer, formatter, columns, limit).with_quiet(quiet);
381            execute_remote_index_command(client, command, &mut streaming_writer).await
382        }
383    }
384}
385
386#[allow(clippy::too_many_arguments)]
387pub async fn execute_remote_tui(
388    client: &HttpClient,
389    cmd: &ColumnarCommand,
390    batch_mode: &BatchMode,
391    output_format: OutputFormat,
392    limit: Option<usize>,
393    quiet: bool,
394    connection_label: impl Into<String>,
395    admin_launcher: Option<Box<dyn FnMut() -> Result<()> + '_>>,
396) -> Result<()> {
397    let columns = columnar_command_columns(cmd);
398    let collector = RowCollector::new();
399    let formatter = Box::new(collector.formatter());
400    let mut sink = std::io::sink();
401    execute_remote_with_formatter(client, cmd, batch_mode, &mut sink, formatter, limit, quiet)
402        .await?;
403    let warning = collector.truncation_warning();
404    render_output(
405        columns,
406        collector.rows(),
407        connection_label,
408        Some(columnar_command_context(cmd)),
409        true,
410        warning,
411        output_format,
412        admin_launcher,
413    )
414}
415
416async fn execute_remote_scan<W: Write>(
417    client: &HttpClient,
418    segment_id: &str,
419    progress: bool,
420    batch_mode: &BatchMode,
421    writer: &mut StreamingWriter<W>,
422) -> Result<()> {
423    let mut progress_indicator = ProgressIndicator::new(
424        batch_mode,
425        progress,
426        writer.is_quiet(),
427        format!("Scanning segment '{}'...", segment_id),
428    );
429    let request = RemoteColumnarScanRequest {
430        segment_id: segment_id.to_string(),
431    };
432    let response: RemoteColumnarScanResponse = client
433        .post_json("columnar/scan", &request)
434        .await
435        .map_err(map_client_error)?;
436    writer.prepare(Some(response.rows.len()))?;
437    let mut row_count = 0usize;
438    for row in response.rows {
439        let values: Vec<Value> = row.into_iter().map(sql_value_to_value).collect();
440        let row = Row::new(values);
441        match writer.write_row(row)? {
442            WriteStatus::LimitReached => break,
443            WriteStatus::Continue => {}
444        }
445        row_count += 1;
446    }
447    writer.finish()?;
448    progress_indicator.finish_with_message(format!("done ({} rows).", row_count));
449    Ok(())
450}
451
452async fn execute_remote_stats<W: Write>(
453    client: &HttpClient,
454    segment_id: &str,
455    writer: &mut StreamingWriter<W>,
456) -> Result<()> {
457    let request = RemoteColumnarStatsRequest {
458        segment_id: segment_id.to_string(),
459    };
460    let response: RemoteColumnarStatsResponse = client
461        .post_json("columnar/stats", &request)
462        .await
463        .map_err(map_client_error)?;
464    writer.prepare(Some(4))?;
465    let stats_rows = vec![
466        ("segment_id", Value::Text(segment_id.to_string())),
467        ("row_count", Value::Int(response.row_count as i64)),
468        ("column_count", Value::Int(response.column_count as i64)),
469        ("size_bytes", Value::Int(response.size_bytes as i64)),
470    ];
471    for (key, value) in stats_rows {
472        let row = Row::new(vec![Value::Text(key.to_string()), value]);
473        writer.write_row(row)?;
474    }
475    writer.finish()?;
476    Ok(())
477}
478
479async fn execute_remote_list<W: Write>(
480    client: &HttpClient,
481    writer: &mut StreamingWriter<W>,
482) -> Result<()> {
483    let response: RemoteColumnarListResponse = client
484        .post_json("columnar/list", &serde_json::json!({}))
485        .await
486        .map_err(map_client_error)?;
487    writer.prepare(Some(response.segments.len()))?;
488    for segment_id in response.segments {
489        let row = Row::new(vec![Value::Text(segment_id)]);
490        match writer.write_row(row)? {
491            WriteStatus::LimitReached => break,
492            WriteStatus::Continue => {}
493        }
494    }
495    writer.finish()?;
496    Ok(())
497}
498
499async fn execute_remote_index_command<W: Write>(
500    client: &HttpClient,
501    command: &IndexCommand,
502    writer: &mut StreamingWriter<W>,
503) -> Result<()> {
504    match command {
505        IndexCommand::Create {
506            segment,
507            column,
508            index_type,
509        } => {
510            let parsed = parse_index_type_arg(index_type)?;
511            let request = RemoteColumnarIndexCreateRequest {
512                segment_id: segment.clone(),
513                column: column.clone(),
514                index_type: parsed.as_str().to_string(),
515            };
516            let response: RemoteColumnarStatusResponse = client
517                .post_json("columnar/index/create", &request)
518                .await
519                .map_err(map_client_error)?;
520            if response.success {
521                write_status_if_needed(
522                    writer,
523                    &format!("Created columnar index: {}:{}", segment, column),
524                )
525            } else {
526                Err(CliError::InvalidArgument(
527                    "Failed to create columnar index".to_string(),
528                ))
529            }
530        }
531        IndexCommand::List { segment } => {
532            let request = RemoteColumnarIndexListRequest {
533                segment_id: segment.clone(),
534            };
535            let response: RemoteColumnarIndexListResponse = client
536                .post_json("columnar/index/list", &request)
537                .await
538                .map_err(map_client_error)?;
539            writer.prepare(Some(response.indexes.len()))?;
540            for entry in response.indexes {
541                let row = Row::new(vec![
542                    Value::Text(entry.column),
543                    Value::Text(entry.index_type),
544                ]);
545                match writer.write_row(row)? {
546                    WriteStatus::LimitReached => break,
547                    WriteStatus::Continue => {}
548                }
549            }
550            writer.finish()?;
551            Ok(())
552        }
553        IndexCommand::Drop { segment, column } => {
554            let request = RemoteColumnarIndexDropRequest {
555                segment_id: segment.clone(),
556                column: column.clone(),
557            };
558            let response: RemoteColumnarStatusResponse = client
559                .post_json("columnar/index/drop", &request)
560                .await
561                .map_err(map_client_error)?;
562            if response.success {
563                write_status_if_needed(
564                    writer,
565                    &format!("Dropped columnar index: {}:{}", segment, column),
566                )
567            } else {
568                Err(CliError::InvalidArgument(
569                    "Failed to drop columnar index".to_string(),
570                ))
571            }
572        }
573    }
574}
575
576fn map_client_error(err: ClientError) -> CliError {
577    match err {
578        ClientError::Request { source, .. } => {
579            CliError::ServerConnection(format!("request failed: {source}"))
580        }
581        ClientError::InvalidUrl(message) => CliError::InvalidArgument(message),
582        ClientError::Build(message) => CliError::InvalidArgument(message),
583        ClientError::Auth(err) => CliError::InvalidArgument(err.to_string()),
584        ClientError::HttpStatus { status, body } => {
585            CliError::InvalidArgument(format!("Server error: HTTP {} - {}", status.as_u16(), body))
586        }
587    }
588}
589
590fn columnar_command_context(cmd: &ColumnarCommand) -> String {
591    match cmd {
592        ColumnarCommand::Scan { segment, .. } => format!("columnar scan --segment {segment}"),
593        ColumnarCommand::Stats { segment } => format!("columnar stats --segment {segment}"),
594        ColumnarCommand::List => "columnar list".to_string(),
595        ColumnarCommand::Ingest { table, file, .. } => {
596            format!("columnar ingest --table {table} --file {}", file.display())
597        }
598        ColumnarCommand::Index(command) => match command {
599            IndexCommand::Create {
600                segment,
601                column,
602                index_type,
603            } => format!(
604                "columnar index create --segment {segment} --column {column} --type {index_type}"
605            ),
606            IndexCommand::List { segment } => {
607                format!("columnar index list --segment {segment}")
608            }
609            IndexCommand::Drop { segment, column } => {
610                format!("columnar index drop --segment {segment} --column {column}")
611            }
612        },
613    }
614}
615
616/// Execute a columnar scan command on a specific segment.
617///
618/// FR-7 Compliance: Uses streaming API to avoid materializing all rows upfront.
619/// Rows are yielded one at a time from `ColumnarRowIterator`.
620fn execute_scan<W: Write>(
621    db: &Database,
622    segment_id: &str,
623    progress: bool,
624    batch_mode: &BatchMode,
625    writer: &mut StreamingWriter<W>,
626) -> Result<()> {
627    let mut progress_indicator = ProgressIndicator::new(
628        batch_mode,
629        progress,
630        writer.is_quiet(),
631        format!("Scanning segment '{}'...", segment_id),
632    );
633
634    // FR-7: Use streaming API to avoid materializing all rows upfront
635    let iter = db.scan_columnar_segment_streaming(segment_id)?;
636
637    // FR-7: Use None for row count hint to support true streaming output
638    writer.prepare(None)?;
639
640    // FR-7: Stream rows one at a time, counting as we go
641    let mut row_count = 0usize;
642    for row_data in iter {
643        let values: Vec<Value> = row_data.into_iter().map(sql_value_to_value).collect();
644        let row = Row::new(values);
645
646        match writer.write_row(row)? {
647            WriteStatus::LimitReached => break,
648            WriteStatus::Continue => {}
649        }
650        row_count += 1;
651    }
652
653    writer.finish()?;
654
655    progress_indicator.finish_with_message(format!("done ({} rows).", row_count));
656
657    Ok(())
658}
659
660/// Execute a columnar stats command for a specific segment.
661fn execute_stats<W: Write>(
662    db: &Database,
663    segment_id: &str,
664    writer: &mut StreamingWriter<W>,
665) -> Result<()> {
666    // Try to get segment statistics
667    let stats = db.get_columnar_segment_stats(segment_id)?;
668
669    writer.prepare(Some(4))?;
670
671    // Output stats as rows
672    let stats_rows = vec![
673        ("segment_id", Value::Text(segment_id.to_string())),
674        ("row_count", Value::Int(stats.row_count as i64)),
675        ("column_count", Value::Int(stats.column_count as i64)),
676        ("size_bytes", Value::Int(stats.size_bytes as i64)),
677    ];
678
679    for (key, value) in stats_rows {
680        let row = Row::new(vec![Value::Text(key.to_string()), value]);
681        writer.write_row(row)?;
682    }
683
684    writer.finish()?;
685    Ok(())
686}
687
688/// Execute a columnar list command to list all segments.
689fn execute_list<W: Write>(db: &Database, writer: &mut StreamingWriter<W>) -> Result<()> {
690    // List all columnar segments
691    let segments = db.list_columnar_segments()?;
692
693    writer.prepare(Some(segments.len()))?;
694
695    for segment_id in segments {
696        let row = Row::new(vec![Value::Text(segment_id)]);
697
698        match writer.write_row(row)? {
699            WriteStatus::LimitReached => break,
700            WriteStatus::Continue => {}
701        }
702    }
703
704    writer.finish()?;
705    Ok(())
706}
707
708#[derive(Debug, Serialize)]
709struct IngestResult {
710    row_count: usize,
711    segment_id: String,
712    size_bytes: u64,
713    compression: String,
714    elapsed_ms: u64,
715}
716
717struct IngestOptions<'a> {
718    file: &'a Path,
719    table: &'a str,
720    delimiter: char,
721    header: bool,
722    compression: &'a str,
723    row_group_size: Option<usize>,
724}
725
726#[derive(Debug, Clone, Copy, PartialEq, Eq)]
727enum CompressionType {
728    Lz4,
729    Zstd,
730    None,
731}
732
733enum ColumnBuilder {
734    Int64(Vec<i64>),
735    Float32(Vec<f32>),
736    Float64(Vec<f64>),
737    Bool(Vec<bool>),
738    Binary(Vec<Vec<u8>>),
739}
740
741impl ColumnBuilder {
742    fn from_arrow_type(dt: &ArrowDataType) -> Result<(Self, LogicalType)> {
743        match dt {
744            ArrowDataType::Int32 | ArrowDataType::Int64 => {
745                Ok((Self::Int64(Vec::new()), LogicalType::Int64))
746            }
747            ArrowDataType::Float32 => Ok((Self::Float32(Vec::new()), LogicalType::Float32)),
748            ArrowDataType::Float64 => Ok((Self::Float64(Vec::new()), LogicalType::Float64)),
749            ArrowDataType::Boolean => Ok((Self::Bool(Vec::new()), LogicalType::Bool)),
750            ArrowDataType::Binary
751            | ArrowDataType::LargeBinary
752            | ArrowDataType::Utf8
753            | ArrowDataType::LargeUtf8 => Ok((Self::Binary(Vec::new()), LogicalType::Binary)),
754            other => Err(CliError::InvalidArgument(format!(
755                "Unsupported Parquet type: {other:?}"
756            ))),
757        }
758    }
759
760    fn append_array(&mut self, array: &dyn Array, dt: &ArrowDataType) -> Result<()> {
761        match (self, dt) {
762            (ColumnBuilder::Int64(values), ArrowDataType::Int32) => {
763                let arr = array.as_any().downcast_ref::<Int32Array>().ok_or_else(|| {
764                    CliError::InvalidArgument("Failed to read Int32 column".to_string())
765                })?;
766                for idx in 0..arr.len() {
767                    if arr.is_null(idx) {
768                        values.push(0);
769                    } else {
770                        values.push(arr.value(idx) as i64);
771                    }
772                }
773            }
774            (ColumnBuilder::Int64(values), ArrowDataType::Int64) => {
775                let arr = array.as_any().downcast_ref::<Int64Array>().ok_or_else(|| {
776                    CliError::InvalidArgument("Failed to read Int64 column".to_string())
777                })?;
778                for idx in 0..arr.len() {
779                    if arr.is_null(idx) {
780                        values.push(0);
781                    } else {
782                        values.push(arr.value(idx));
783                    }
784                }
785            }
786            (ColumnBuilder::Float32(values), ArrowDataType::Float32) => {
787                let arr = array
788                    .as_any()
789                    .downcast_ref::<Float32Array>()
790                    .ok_or_else(|| {
791                        CliError::InvalidArgument("Failed to read Float32 column".to_string())
792                    })?;
793                for idx in 0..arr.len() {
794                    if arr.is_null(idx) {
795                        values.push(0.0);
796                    } else {
797                        values.push(arr.value(idx));
798                    }
799                }
800            }
801            (ColumnBuilder::Float64(values), ArrowDataType::Float64) => {
802                let arr = array
803                    .as_any()
804                    .downcast_ref::<Float64Array>()
805                    .ok_or_else(|| {
806                        CliError::InvalidArgument("Failed to read Float64 column".to_string())
807                    })?;
808                for idx in 0..arr.len() {
809                    if arr.is_null(idx) {
810                        values.push(0.0);
811                    } else {
812                        values.push(arr.value(idx));
813                    }
814                }
815            }
816            (ColumnBuilder::Bool(values), ArrowDataType::Boolean) => {
817                let arr = array
818                    .as_any()
819                    .downcast_ref::<BooleanArray>()
820                    .ok_or_else(|| {
821                        CliError::InvalidArgument("Failed to read Boolean column".to_string())
822                    })?;
823                for idx in 0..arr.len() {
824                    if arr.is_null(idx) {
825                        values.push(false);
826                    } else {
827                        values.push(arr.value(idx));
828                    }
829                }
830            }
831            (ColumnBuilder::Binary(values), ArrowDataType::Binary) => {
832                let arr = array
833                    .as_any()
834                    .downcast_ref::<BinaryArray>()
835                    .ok_or_else(|| {
836                        CliError::InvalidArgument("Failed to read Binary column".to_string())
837                    })?;
838                for idx in 0..arr.len() {
839                    if arr.is_null(idx) {
840                        values.push(Vec::new());
841                    } else {
842                        values.push(arr.value(idx).to_vec());
843                    }
844                }
845            }
846            (ColumnBuilder::Binary(values), ArrowDataType::LargeBinary) => {
847                let arr = array
848                    .as_any()
849                    .downcast_ref::<LargeBinaryArray>()
850                    .ok_or_else(|| {
851                        CliError::InvalidArgument("Failed to read LargeBinary column".to_string())
852                    })?;
853                for idx in 0..arr.len() {
854                    if arr.is_null(idx) {
855                        values.push(Vec::new());
856                    } else {
857                        values.push(arr.value(idx).to_vec());
858                    }
859                }
860            }
861            (ColumnBuilder::Binary(values), ArrowDataType::Utf8) => {
862                let arr = array
863                    .as_any()
864                    .downcast_ref::<StringArray>()
865                    .ok_or_else(|| {
866                        CliError::InvalidArgument("Failed to read Utf8 column".to_string())
867                    })?;
868                for idx in 0..arr.len() {
869                    if arr.is_null(idx) {
870                        values.push(Vec::new());
871                    } else {
872                        values.push(arr.value(idx).as_bytes().to_vec());
873                    }
874                }
875            }
876            (ColumnBuilder::Binary(values), ArrowDataType::LargeUtf8) => {
877                let arr = array
878                    .as_any()
879                    .downcast_ref::<LargeStringArray>()
880                    .ok_or_else(|| {
881                        CliError::InvalidArgument("Failed to read LargeUtf8 column".to_string())
882                    })?;
883                for idx in 0..arr.len() {
884                    if arr.is_null(idx) {
885                        values.push(Vec::new());
886                    } else {
887                        values.push(arr.value(idx).as_bytes().to_vec());
888                    }
889                }
890            }
891            _ => {
892                return Err(CliError::InvalidArgument(
893                    "Parquet schema mismatch detected".to_string(),
894                ))
895            }
896        }
897        Ok(())
898    }
899
900    fn finish(self) -> ColumnData {
901        match self {
902            ColumnBuilder::Int64(values) => ColumnData::Int64(values),
903            ColumnBuilder::Float32(values) => ColumnData::Float32(values),
904            ColumnBuilder::Float64(values) => ColumnData::Float64(values),
905            ColumnBuilder::Bool(values) => ColumnData::Bool(values),
906            ColumnBuilder::Binary(values) => ColumnData::Binary(values),
907        }
908    }
909}
910
911fn execute_ingest<W: Write>(
912    db: &Database,
913    options: IngestOptions<'_>,
914    writer: &mut StreamingWriter<W>,
915) -> Result<()> {
916    let start = Instant::now();
917    let extension = options
918        .file
919        .extension()
920        .and_then(|ext| ext.to_str())
921        .unwrap_or("")
922        .to_ascii_lowercase();
923
924    let batch = match extension.as_str() {
925        "csv" => parse_csv(options.file, options.delimiter, options.header)?,
926        "parquet" | "pq" => parse_parquet(options.file)?,
927        _ => {
928            return Err(CliError::InvalidArgument(format!(
929                "Unsupported file format: {}",
930                options.file.display()
931            )))
932        }
933    };
934
935    let compression_type = parse_compression_arg(options.compression)?;
936    let row_count = batch.num_rows();
937    let mut config = SegmentConfigV2::default();
938    if let Some(size) = options.row_group_size {
939        config.row_group_size = size as u64;
940    }
941    config.compression = map_compression(compression_type);
942
943    let seg_id = db.write_columnar_segment_with_config(options.table, batch, config)?;
944    let segment_id = format!("{}:{}", table_id(options.table)?, seg_id);
945    let stats = db.get_columnar_segment_stats(&segment_id)?;
946    let result = IngestResult {
947        row_count,
948        segment_id,
949        size_bytes: stats.size_bytes as u64,
950        compression: compression_as_str(compression_type).to_string(),
951        elapsed_ms: start.elapsed().as_millis() as u64,
952    };
953
954    writer.prepare(Some(1))?;
955    let row = Row::new(vec![
956        Value::Int(result.row_count as i64),
957        Value::Text(result.segment_id),
958        Value::Int(result.size_bytes as i64),
959        Value::Text(result.compression),
960        Value::Int(result.elapsed_ms as i64),
961    ]);
962    writer.write_row(row)?;
963    writer.finish()?;
964    Ok(())
965}
966
967fn execute_index_command<W: Write>(
968    db: &Database,
969    cmd: IndexCommand,
970    writer: &mut StreamingWriter<W>,
971) -> Result<()> {
972    match cmd {
973        IndexCommand::Create {
974            segment,
975            column,
976            index_type,
977        } => {
978            let index_type = parse_index_type_arg(&index_type)?;
979            db.create_columnar_index(&segment, &column, index_type)?;
980            write_status_if_needed(
981                writer,
982                &format!("Created index {} on {}", index_type.as_str(), column),
983            )
984        }
985        IndexCommand::List { segment } => {
986            let entries = db.list_columnar_indexes(&segment)?;
987
988            writer.prepare(Some(entries.len()))?;
989            for entry in entries {
990                let row = Row::new(vec![
991                    Value::Text(entry.column),
992                    Value::Text(entry.index_type.as_str().to_string()),
993                ]);
994                match writer.write_row(row)? {
995                    WriteStatus::LimitReached => break,
996                    WriteStatus::Continue => {}
997                }
998            }
999            writer.finish()?;
1000            Ok(())
1001        }
1002        IndexCommand::Drop { segment, column } => {
1003            db.drop_columnar_index(&segment, &column)?;
1004            write_status_if_needed(writer, &format!("Dropped index on {}", column))
1005        }
1006    }
1007}
1008
1009fn parse_csv(path: &Path, delimiter: char, has_header: bool) -> Result<RecordBatch> {
1010    let mut reader = csv::ReaderBuilder::new()
1011        .delimiter(delimiter as u8)
1012        .has_headers(has_header)
1013        .from_path(path)
1014        .map_err(|err| {
1015            CliError::InvalidArgument(format!(
1016                "Failed to open CSV file '{}': {}",
1017                path.display(),
1018                err
1019            ))
1020        })?;
1021
1022    let mut column_names: Vec<String> = if has_header {
1023        reader
1024            .headers()
1025            .map_err(|err| {
1026                CliError::InvalidArgument(format!(
1027                    "Failed to read CSV header '{}': {}",
1028                    path.display(),
1029                    err
1030                ))
1031            })?
1032            .iter()
1033            .map(|s| s.to_string())
1034            .collect()
1035    } else {
1036        Vec::new()
1037    };
1038
1039    let mut columns: Vec<Vec<Vec<u8>>> = Vec::new();
1040
1041    for record in reader.records() {
1042        let record = record.map_err(|err| {
1043            CliError::InvalidArgument(format!(
1044                "Failed to read CSV record '{}': {}",
1045                path.display(),
1046                err
1047            ))
1048        })?;
1049        if columns.is_empty() {
1050            if !has_header {
1051                column_names = (0..record.len()).map(|i| format!("col_{}", i)).collect();
1052            }
1053            columns = vec![Vec::new(); column_names.len()];
1054        }
1055        if record.len() != columns.len() {
1056            return Err(CliError::InvalidArgument(format!(
1057                "CSV column count mismatch: expected {}, got {}",
1058                columns.len(),
1059                record.len()
1060            )));
1061        }
1062        for (idx, field) in record.iter().enumerate() {
1063            columns[idx].push(field.as_bytes().to_vec());
1064        }
1065    }
1066
1067    if columns.is_empty() {
1068        if has_header && !column_names.is_empty() {
1069            columns = vec![Vec::new(); column_names.len()];
1070        } else {
1071            return Err(CliError::InvalidArgument(format!(
1072                "CSV file '{}' is empty",
1073                path.display()
1074            )));
1075        }
1076    }
1077
1078    let schema = Schema {
1079        columns: column_names
1080            .into_iter()
1081            .map(|name| ColumnSchema {
1082                name,
1083                logical_type: LogicalType::Binary,
1084                nullable: false,
1085                fixed_len: None,
1086            })
1087            .collect(),
1088    };
1089    let columns = columns
1090        .into_iter()
1091        .map(ColumnData::Binary)
1092        .collect::<Vec<_>>();
1093    let null_bitmaps = vec![None; columns.len()];
1094    Ok(RecordBatch::new(schema, columns, null_bitmaps))
1095}
1096
1097fn parse_parquet(path: &Path) -> Result<RecordBatch> {
1098    let file = std::fs::File::open(path).map_err(|err| {
1099        CliError::InvalidArgument(format!(
1100            "Failed to open Parquet file '{}': {}",
1101            path.display(),
1102            err
1103        ))
1104    })?;
1105    let builder = ParquetRecordBatchReaderBuilder::try_new(file).map_err(|err| {
1106        CliError::InvalidArgument(format!(
1107            "Failed to read Parquet metadata '{}': {}",
1108            path.display(),
1109            err
1110        ))
1111    })?;
1112
1113    let arrow_schema = builder.schema().clone();
1114    let mut schemas = Vec::with_capacity(arrow_schema.fields().len());
1115    let mut builders = Vec::with_capacity(arrow_schema.fields().len());
1116    for field in arrow_schema.fields() {
1117        let (builder, logical_type) = ColumnBuilder::from_arrow_type(field.data_type())?;
1118        schemas.push(ColumnSchema {
1119            name: field.name().clone(),
1120            logical_type,
1121            nullable: field.is_nullable(),
1122            fixed_len: None,
1123        });
1124        builders.push(builder);
1125    }
1126
1127    let reader = builder.with_batch_size(1024).build().map_err(|err| {
1128        CliError::InvalidArgument(format!(
1129            "Failed to create Parquet reader '{}': {}",
1130            path.display(),
1131            err
1132        ))
1133    })?;
1134
1135    for batch in reader {
1136        let batch = batch.map_err(|err| {
1137            CliError::InvalidArgument(format!(
1138                "Failed to read Parquet batch '{}': {}",
1139                path.display(),
1140                err
1141            ))
1142        })?;
1143        for (col_idx, array) in batch.columns().iter().enumerate() {
1144            let dt = arrow_schema.field(col_idx).data_type();
1145            builders[col_idx].append_array(array.as_ref(), dt)?;
1146        }
1147    }
1148
1149    let columns = builders.into_iter().map(|b| b.finish()).collect::<Vec<_>>();
1150    let null_bitmaps = vec![None; columns.len()];
1151    let schema = Schema { columns: schemas };
1152    Ok(RecordBatch::new(schema, columns, null_bitmaps))
1153}
1154
1155fn parse_compression_arg(raw: &str) -> Result<CompressionType> {
1156    match raw {
1157        "lz4" => Ok(CompressionType::Lz4),
1158        "zstd" => Ok(CompressionType::Zstd),
1159        "none" => Ok(CompressionType::None),
1160        other => Err(CliError::UnknownCompressionType(other.to_string())),
1161    }
1162}
1163
1164fn parse_index_type_arg(raw: &str) -> Result<ColumnarIndexType> {
1165    match raw {
1166        "minmax" => Ok(ColumnarIndexType::Minmax),
1167        "bloom" => Ok(ColumnarIndexType::Bloom),
1168        other => Err(CliError::UnknownIndexType(other.to_string())),
1169    }
1170}
1171
1172fn map_compression(compression: CompressionType) -> CompressionV2 {
1173    match compression {
1174        CompressionType::Lz4 => CompressionV2::Lz4,
1175        CompressionType::Zstd => CompressionV2::Zstd { level: 3 },
1176        CompressionType::None => CompressionV2::None,
1177    }
1178}
1179
1180fn compression_as_str(compression: CompressionType) -> &'static str {
1181    match compression {
1182        CompressionType::Lz4 => "lz4",
1183        CompressionType::Zstd => "zstd",
1184        CompressionType::None => "none",
1185    }
1186}
1187
1188fn table_id(table: &str) -> Result<u32> {
1189    if table.is_empty() {
1190        return Err(CliError::InvalidArgument(
1191            "Table name is required".to_string(),
1192        ));
1193    }
1194    let mut hasher = std::collections::hash_map::DefaultHasher::new();
1195    table.hash(&mut hasher);
1196    Ok((hasher.finish() & 0xffff_ffff) as u32)
1197}
1198
1199fn write_status_if_needed<W: Write>(writer: &mut StreamingWriter<W>, message: &str) -> Result<()> {
1200    if writer.is_quiet() {
1201        return Ok(());
1202    }
1203
1204    writer.prepare(Some(1))?;
1205    let row = Row::new(vec![
1206        Value::Text("OK".to_string()),
1207        Value::Text(message.to_string()),
1208    ]);
1209    writer.write_row(row)?;
1210    writer.finish()?;
1211    Ok(())
1212}
1213
1214/// Convert alopex_sql::SqlValue to our Value type.
1215fn sql_value_to_value(sql_value: alopex_sql::SqlValue) -> Value {
1216    use alopex_sql::SqlValue;
1217
1218    match sql_value {
1219        SqlValue::Null => Value::Null,
1220        SqlValue::Integer(i) => Value::Int(i as i64),
1221        SqlValue::BigInt(i) => Value::Int(i),
1222        SqlValue::Float(f) => Value::Float(f as f64),
1223        SqlValue::Double(f) => Value::Float(f),
1224        SqlValue::Text(s) => Value::Text(s),
1225        SqlValue::Blob(b) => Value::Bytes(b),
1226        SqlValue::Boolean(b) => Value::Bool(b),
1227        SqlValue::Timestamp(ts) => Value::Text(format!("{}", ts)),
1228        SqlValue::Vector(v) => Value::Vector(v),
1229    }
1230}
1231
1232/// Create columns for columnar scan output.
1233pub fn columnar_scan_columns() -> Vec<Column> {
1234    // Generic columns - actual columns will depend on segment schema
1235    vec![
1236        Column::new("column1", DataType::Text),
1237        Column::new("column2", DataType::Text),
1238    ]
1239}
1240
1241/// Create columns for columnar stats output.
1242pub fn columnar_stats_columns() -> Vec<Column> {
1243    vec![
1244        Column::new("property", DataType::Text),
1245        Column::new("value", DataType::Text),
1246    ]
1247}
1248
1249/// Create columns for columnar list output.
1250pub fn columnar_list_columns() -> Vec<Column> {
1251    vec![Column::new("segment_id", DataType::Text)]
1252}
1253
1254/// Create columns for columnar ingest output.
1255pub fn columnar_ingest_columns() -> Vec<Column> {
1256    vec![
1257        Column::new("row_count", DataType::Int),
1258        Column::new("segment_id", DataType::Text),
1259        Column::new("size_bytes", DataType::Int),
1260        Column::new("compression", DataType::Text),
1261        Column::new("elapsed_ms", DataType::Int),
1262    ]
1263}
1264
1265/// Create columns for columnar index list output.
1266pub fn columnar_index_list_columns() -> Vec<Column> {
1267    vec![
1268        Column::new("column", DataType::Text),
1269        Column::new("index_type", DataType::Text),
1270    ]
1271}
1272
1273/// Create columns for columnar status output.
1274pub fn columnar_status_columns() -> Vec<Column> {
1275    vec![
1276        Column::new("status", DataType::Text),
1277        Column::new("message", DataType::Text),
1278    ]
1279}
1280
1281#[cfg(test)]
1282mod tests {
1283    use super::*;
1284    use crate::output::jsonl::JsonlFormatter;
1285
1286    fn create_test_db() -> Database {
1287        Database::open_in_memory().unwrap()
1288    }
1289
1290    fn create_stats_writer(output: &mut Vec<u8>) -> StreamingWriter<&mut Vec<u8>> {
1291        let formatter = Box::new(JsonlFormatter::new());
1292        let columns = columnar_stats_columns();
1293        StreamingWriter::new(output, formatter, columns, None)
1294    }
1295
1296    fn create_list_writer(output: &mut Vec<u8>) -> StreamingWriter<&mut Vec<u8>> {
1297        let formatter = Box::new(JsonlFormatter::new());
1298        let columns = columnar_list_columns();
1299        StreamingWriter::new(output, formatter, columns, None)
1300    }
1301
1302    #[test]
1303    fn test_columnar_list_empty() {
1304        let db = create_test_db();
1305
1306        let mut output = Vec::new();
1307        {
1308            let mut writer = create_list_writer(&mut output);
1309            // This may return an error or empty list depending on implementation
1310            let _ = execute_list(&db, &mut writer);
1311        }
1312    }
1313
1314    #[test]
1315    fn test_columnar_stats_nonexistent() {
1316        let db = create_test_db();
1317
1318        let mut output = Vec::new();
1319        {
1320            let mut writer = create_stats_writer(&mut output);
1321            let result = execute_stats(&db, "nonexistent_segment", &mut writer);
1322            // Should return an error for nonexistent segment
1323            assert!(result.is_err());
1324        }
1325    }
1326
1327    #[test]
1328    fn test_sql_value_conversion() {
1329        use alopex_sql::SqlValue;
1330
1331        assert!(matches!(sql_value_to_value(SqlValue::Null), Value::Null));
1332        assert!(matches!(
1333            sql_value_to_value(SqlValue::Integer(42)),
1334            Value::Int(42)
1335        ));
1336        assert!(matches!(
1337            sql_value_to_value(SqlValue::Boolean(true)),
1338            Value::Bool(true)
1339        ));
1340        assert!(matches!(
1341            sql_value_to_value(SqlValue::Text("hello".to_string())),
1342            Value::Text(s) if s == "hello"
1343        ));
1344    }
1345
1346    #[test]
1347    fn test_parse_compression_arg_valid() {
1348        assert!(matches!(
1349            parse_compression_arg("lz4").unwrap(),
1350            CompressionType::Lz4
1351        ));
1352        assert!(matches!(
1353            parse_compression_arg("zstd").unwrap(),
1354            CompressionType::Zstd
1355        ));
1356        assert!(matches!(
1357            parse_compression_arg("none").unwrap(),
1358            CompressionType::None
1359        ));
1360    }
1361
1362    #[test]
1363    fn test_parse_compression_arg_invalid() {
1364        let err = parse_compression_arg("snappy").unwrap_err();
1365        assert!(matches!(
1366            err,
1367            CliError::UnknownCompressionType(value) if value == "snappy"
1368        ));
1369    }
1370
1371    #[test]
1372    fn test_parse_index_type_arg_valid() {
1373        assert!(matches!(
1374            parse_index_type_arg("minmax").unwrap(),
1375            ColumnarIndexType::Minmax
1376        ));
1377        assert!(matches!(
1378            parse_index_type_arg("bloom").unwrap(),
1379            ColumnarIndexType::Bloom
1380        ));
1381    }
1382
1383    #[test]
1384    fn test_parse_index_type_arg_invalid() {
1385        let err = parse_index_type_arg("bitmap").unwrap_err();
1386        assert!(matches!(
1387            err,
1388            CliError::UnknownIndexType(value) if value == "bitmap"
1389        ));
1390    }
1391}