1use 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
115pub 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
315pub 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
616fn 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 let iter = db.scan_columnar_segment_streaming(segment_id)?;
636
637 writer.prepare(None)?;
639
640 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
660fn execute_stats<W: Write>(
662 db: &Database,
663 segment_id: &str,
664 writer: &mut StreamingWriter<W>,
665) -> Result<()> {
666 let stats = db.get_columnar_segment_stats(segment_id)?;
668
669 writer.prepare(Some(4))?;
670
671 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
688fn execute_list<W: Write>(db: &Database, writer: &mut StreamingWriter<W>) -> Result<()> {
690 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
1214fn 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
1232pub fn columnar_scan_columns() -> Vec<Column> {
1234 vec![
1236 Column::new("column1", DataType::Text),
1237 Column::new("column2", DataType::Text),
1238 ]
1239}
1240
1241pub fn columnar_stats_columns() -> Vec<Column> {
1243 vec![
1244 Column::new("property", DataType::Text),
1245 Column::new("value", DataType::Text),
1246 ]
1247}
1248
1249pub fn columnar_list_columns() -> Vec<Column> {
1251 vec![Column::new("segment_id", DataType::Text)]
1252}
1253
1254pub 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
1265pub 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
1273pub 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 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 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}