Skip to main content

scirs2_io/delta/
table.rs

1//! Delta Lake table read/write operations.
2//!
3//! Provides `DeltaTableWriter` for writing data files and committing transactions,
4//! and `DeltaTableReader` for reading data with time travel, schema evolution,
5//! and partition pruning support.
6
7use std::collections::HashMap;
8use std::fs;
9use std::io::{BufRead, BufReader, Write as IoWrite};
10use std::path::{Path, PathBuf};
11
12use super::log::TransactionLog;
13use super::types::{
14    ColumnSchema, DeltaAction, DeltaConfig, DeltaError, DeltaTable, DeltaTransaction, DeltaVersion,
15    FileInfo, Schema,
16};
17
18/// Writer for Delta Lake tables.
19///
20/// Writes data as CSV-like files (one file per write) and commits
21/// a transaction log entry for each write operation.
22pub struct DeltaTableWriter {
23    config: DeltaConfig,
24    log: TransactionLog,
25}
26
27impl DeltaTableWriter {
28    /// Create a new writer for the table at `config.base_path`.
29    pub fn new(config: DeltaConfig) -> Result<Self, DeltaError> {
30        let log = TransactionLog::new(&config.base_path)?;
31        // Create data directory
32        let data_dir = config.base_path.join("data");
33        fs::create_dir_all(&data_dir)?;
34        Ok(Self { config, log })
35    }
36
37    /// Write columnar data to the table and commit a transaction.
38    ///
39    /// `data` is a slice of columns, each column being a `Vec<f64>`.
40    /// `schema` describes the column names and types.
41    ///
42    /// Returns the committed version number.
43    pub fn write(&self, data: &[Vec<f64>], schema: &Schema) -> Result<u64, DeltaError> {
44        if data.is_empty() {
45            return Err(DeltaError::Other("cannot write empty data".to_string()));
46        }
47
48        // Validate column count matches schema
49        if data.len() != schema.columns.len() {
50            return Err(DeltaError::SchemaError(format!(
51                "data has {} columns but schema has {}",
52                data.len(),
53                schema.columns.len()
54            )));
55        }
56
57        let next_version = self.log.latest_version().map(|v| v + 1).unwrap_or(0);
58
59        // Write data file
60        let file_name = format!("part-{next_version:05}.csv");
61        let file_path = self.config.base_path.join("data").join(&file_name);
62
63        write_data_file(&file_path, data, schema)?;
64
65        let file_size = fs::metadata(&file_path).map(|m| m.len()).unwrap_or(0);
66
67        let now_ms = std::time::SystemTime::now()
68            .duration_since(std::time::UNIX_EPOCH)
69            .map(|d| d.as_millis() as u64)
70            .unwrap_or(0);
71
72        // Build actions
73        let mut actions = Vec::new();
74
75        // If this is the first version, include protocol and metadata
76        if next_version == 0 {
77            actions.push(DeltaAction::Protocol {
78                min_reader_version: 1,
79                min_writer_version: 1,
80            });
81            let schema_json = schema.to_json().unwrap_or_else(|_| "{}".to_string());
82            actions.push(DeltaAction::Metadata {
83                schema: schema_json,
84                partition_columns: Vec::new(),
85                description: None,
86                configuration: HashMap::new(),
87            });
88        }
89
90        actions.push(DeltaAction::Add {
91            path: format!("data/{file_name}"),
92            size: file_size,
93            modification_time: now_ms,
94            data_change: true,
95            partition_values: HashMap::new(),
96            stats_json: None,
97        });
98
99        actions.push(DeltaAction::CommitInfo {
100            timestamp: now_ms as i64,
101            operation: "WRITE".to_string(),
102            operation_parameters: HashMap::new(),
103        });
104
105        let committed = self.log.commit(actions)?;
106
107        // Auto-checkpoint if interval reached
108        if self.config.checkpoint_interval > 0
109            && committed > 0
110            && (committed as usize).is_multiple_of(self.config.checkpoint_interval)
111        {
112            // Best-effort checkpoint; do not fail the commit
113            let _ = self.log.checkpoint(committed);
114        }
115
116        Ok(committed)
117    }
118
119    /// Write partitioned data to the table.
120    ///
121    /// `partition_column` names the column whose values determine partitioning.
122    /// Data rows are grouped by their value in that column, and separate files
123    /// are written per partition.
124    pub fn write_partitioned(
125        &self,
126        data: &[Vec<f64>],
127        schema: &Schema,
128        partition_column: &str,
129    ) -> Result<u64, DeltaError> {
130        if data.is_empty() || schema.columns.is_empty() {
131            return Err(DeltaError::Other("cannot write empty data".to_string()));
132        }
133
134        // Find partition column index
135        let part_idx = schema
136            .columns
137            .iter()
138            .position(|c| c.name == partition_column)
139            .ok_or_else(|| {
140                DeltaError::SchemaError(format!(
141                    "partition column '{partition_column}' not found in schema"
142                ))
143            })?;
144
145        let num_rows = data[0].len();
146        for col in data {
147            if col.len() != num_rows {
148                return Err(DeltaError::SchemaError(
149                    "all columns must have the same number of rows".to_string(),
150                ));
151            }
152        }
153
154        // Group rows by partition value
155        let mut partitions: HashMap<String, Vec<usize>> = HashMap::new();
156        for row_idx in 0..num_rows {
157            let val = data[part_idx][row_idx];
158            let key = format!("{val}");
159            partitions.entry(key).or_default().push(row_idx);
160        }
161
162        let next_version = self.log.latest_version().map(|v| v + 1).unwrap_or(0);
163        let now_ms = std::time::SystemTime::now()
164            .duration_since(std::time::UNIX_EPOCH)
165            .map(|d| d.as_millis() as u64)
166            .unwrap_or(0);
167
168        let mut actions = Vec::new();
169
170        if next_version == 0 {
171            actions.push(DeltaAction::Protocol {
172                min_reader_version: 1,
173                min_writer_version: 1,
174            });
175            let schema_json = schema.to_json().unwrap_or_else(|_| "{}".to_string());
176            actions.push(DeltaAction::Metadata {
177                schema: schema_json,
178                partition_columns: vec![partition_column.to_string()],
179                description: None,
180                configuration: HashMap::new(),
181            });
182        }
183
184        let mut part_keys: Vec<String> = partitions.keys().cloned().collect();
185        part_keys.sort();
186
187        for (file_idx, part_val) in part_keys.iter().enumerate() {
188            let row_indices = &partitions[part_val];
189
190            // Extract partition data
191            let part_data: Vec<Vec<f64>> = data
192                .iter()
193                .map(|col| row_indices.iter().map(|&ri| col[ri]).collect())
194                .collect();
195
196            // Write partition file
197            let part_dir = self
198                .config
199                .base_path
200                .join("data")
201                .join(format!("{partition_column}={part_val}"));
202            fs::create_dir_all(&part_dir)?;
203
204            let file_name = format!("part-{next_version:05}-{file_idx:03}.csv");
205            let file_path = part_dir.join(&file_name);
206            write_data_file(&file_path, &part_data, schema)?;
207
208            let file_size = fs::metadata(&file_path).map(|m| m.len()).unwrap_or(0);
209
210            let mut pv = HashMap::new();
211            pv.insert(partition_column.to_string(), part_val.clone());
212
213            actions.push(DeltaAction::Add {
214                path: format!("data/{partition_column}={part_val}/{file_name}"),
215                size: file_size,
216                modification_time: now_ms,
217                data_change: true,
218                partition_values: pv,
219                stats_json: None,
220            });
221        }
222
223        actions.push(DeltaAction::CommitInfo {
224            timestamp: now_ms as i64,
225            operation: "WRITE".to_string(),
226            operation_parameters: HashMap::new(),
227        });
228
229        self.log.commit(actions)
230    }
231
232    /// Access the underlying transaction log.
233    pub fn transaction_log(&self) -> &TransactionLog {
234        &self.log
235    }
236}
237
238/// Reader for Delta Lake tables, supporting time travel and partition pruning.
239pub struct DeltaTableReader {
240    config: DeltaConfig,
241    log: TransactionLog,
242}
243
244impl DeltaTableReader {
245    /// Create a new reader for the table at `config.base_path`.
246    pub fn new(config: DeltaConfig) -> Result<Self, DeltaError> {
247        let log = TransactionLog::new(&config.base_path)?;
248        Ok(Self { config, log })
249    }
250
251    /// Read data from the table, optionally at a specific version (time travel).
252    ///
253    /// Returns columnar data as `Vec<Vec<f64>>`.
254    pub fn read(&self, version: Option<u64>) -> Result<Vec<Vec<f64>>, DeltaError> {
255        let files = self.log.reconstruct_files(version)?;
256        if files.is_empty() {
257            return Ok(Vec::new());
258        }
259
260        self.read_files(&files)
261    }
262
263    /// Read data with partition pruning.
264    ///
265    /// Only reads files whose partition values match the given predicates.
266    /// `partitions` maps column name -> required value.
267    pub fn read_pruned(
268        &self,
269        version: Option<u64>,
270        partitions: &HashMap<String, String>,
271    ) -> Result<Vec<Vec<f64>>, DeltaError> {
272        let all_files = self.log.reconstruct_files(version)?;
273
274        // Filter files by partition values
275        let filtered: HashMap<String, FileInfo> = all_files
276            .into_iter()
277            .filter(|(_, info)| {
278                partitions
279                    .iter()
280                    .all(|(col, val)| info.partition_values.get(col).map_or(true, |fv| fv == val))
281            })
282            .collect();
283
284        if filtered.is_empty() {
285            return Ok(Vec::new());
286        }
287
288        self.read_files(&filtered)
289    }
290
291    /// List all versions (history) of the table.
292    pub fn history(&self) -> Result<Vec<DeltaVersion>, DeltaError> {
293        self.log.history()
294    }
295
296    /// Get the latest version number.
297    pub fn latest_version(&self) -> Option<u64> {
298        self.log.latest_version()
299    }
300
301    /// Reconstruct the full table state at a given version.
302    pub fn table_state(&self, version: Option<u64>) -> Result<DeltaTable, DeltaError> {
303        let target_version = version.or_else(|| self.log.latest_version()).unwrap_or(0);
304        let actions = self.log.replay_up_to(target_version)?;
305
306        let mut active_files = HashMap::new();
307        let mut schema: Option<Schema> = None;
308        let mut partition_columns = Vec::new();
309        let mut protocol: Option<(u32, u32)> = None;
310
311        for action in &actions {
312            match action {
313                DeltaAction::Add {
314                    path,
315                    size,
316                    modification_time,
317                    partition_values,
318                    ..
319                } => {
320                    active_files.insert(
321                        path.clone(),
322                        FileInfo {
323                            path: path.clone(),
324                            size: *size,
325                            modification_time: *modification_time,
326                            partition_values: partition_values.clone(),
327                        },
328                    );
329                }
330                DeltaAction::Metadata {
331                    schema: schema_json,
332                    partition_columns: pc,
333                    ..
334                } => {
335                    schema = Schema::from_json(schema_json).ok();
336                    partition_columns = pc.clone();
337                }
338                DeltaAction::Protocol {
339                    min_reader_version,
340                    min_writer_version,
341                } => {
342                    protocol = Some((*min_reader_version, *min_writer_version));
343                }
344                _ => {}
345            }
346        }
347
348        Ok(DeltaTable {
349            config: self.config.clone(),
350            version: target_version,
351            active_files,
352            schema,
353            partition_columns,
354            protocol,
355        })
356    }
357
358    /// Read data from a specific set of files.
359    fn read_files(&self, files: &HashMap<String, FileInfo>) -> Result<Vec<Vec<f64>>, DeltaError> {
360        let mut all_columns: Vec<Vec<f64>> = Vec::new();
361        let mut first = true;
362
363        let mut sorted_paths: Vec<&String> = files.keys().collect();
364        sorted_paths.sort();
365
366        for file_path_rel in sorted_paths {
367            let abs_path = self.config.base_path.join(file_path_rel);
368            if !abs_path.exists() {
369                continue;
370            }
371
372            let file_data = read_data_file(&abs_path)?;
373            if file_data.is_empty() {
374                continue;
375            }
376
377            if first {
378                all_columns = file_data;
379                first = false;
380            } else {
381                // Append rows from each column
382                if all_columns.len() != file_data.len() {
383                    return Err(DeltaError::SchemaError(format!(
384                        "column count mismatch: {} vs {}",
385                        all_columns.len(),
386                        file_data.len()
387                    )));
388                }
389                for (i, col) in file_data.into_iter().enumerate() {
390                    all_columns[i].extend(col);
391                }
392            }
393        }
394
395        Ok(all_columns)
396    }
397
398    /// Access the underlying transaction log.
399    pub fn transaction_log(&self) -> &TransactionLog {
400        &self.log
401    }
402}
403
404// ─── Schema evolution helpers ────────────────────────────────────────────────
405
406/// Add a column to the table schema.
407///
408/// Commits a new Metadata action with the updated schema.
409/// Existing data files are not modified; the new column will have
410/// default (NaN) values when read.
411pub fn add_column(
412    log: &TransactionLog,
413    current_schema: &Schema,
414    column: ColumnSchema,
415) -> Result<u64, DeltaError> {
416    let mut new_schema = current_schema.clone();
417    if new_schema.columns.iter().any(|c| c.name == column.name) {
418        return Err(DeltaError::SchemaError(format!(
419            "column '{}' already exists",
420            column.name
421        )));
422    }
423    new_schema.columns.push(column);
424
425    let schema_json = new_schema.to_json()?;
426
427    let now_ms = std::time::SystemTime::now()
428        .duration_since(std::time::UNIX_EPOCH)
429        .map(|d| d.as_millis() as i64)
430        .unwrap_or(0);
431
432    let actions = vec![
433        DeltaAction::Metadata {
434            schema: schema_json,
435            partition_columns: Vec::new(),
436            description: Some("schema evolution: add column".to_string()),
437            configuration: HashMap::new(),
438        },
439        DeltaAction::CommitInfo {
440            timestamp: now_ms,
441            operation: "ALTER_TABLE".to_string(),
442            operation_parameters: HashMap::new(),
443        },
444    ];
445
446    log.commit(actions)
447}
448
449/// Rename a column in the table schema.
450///
451/// Commits a new Metadata action with the updated schema.
452/// Existing data files are not modified.
453pub fn rename_column(
454    log: &TransactionLog,
455    current_schema: &Schema,
456    old_name: &str,
457    new_name: &str,
458) -> Result<u64, DeltaError> {
459    let mut new_schema = current_schema.clone();
460    let col = new_schema
461        .columns
462        .iter_mut()
463        .find(|c| c.name == old_name)
464        .ok_or_else(|| DeltaError::SchemaError(format!("column '{old_name}' not found")))?;
465    col.name = new_name.to_string();
466
467    let schema_json = new_schema.to_json()?;
468
469    let now_ms = std::time::SystemTime::now()
470        .duration_since(std::time::UNIX_EPOCH)
471        .map(|d| d.as_millis() as i64)
472        .unwrap_or(0);
473
474    let actions = vec![
475        DeltaAction::Metadata {
476            schema: schema_json,
477            partition_columns: Vec::new(),
478            description: Some(format!("schema evolution: rename {old_name} -> {new_name}")),
479            configuration: HashMap::new(),
480        },
481        DeltaAction::CommitInfo {
482            timestamp: now_ms,
483            operation: "ALTER_TABLE".to_string(),
484            operation_parameters: HashMap::new(),
485        },
486    ];
487
488    log.commit(actions)
489}
490
491// ─── Data file I/O helpers ───────────────────────────────────────────────────
492
493/// Write columnar f64 data to a CSV file.
494fn write_data_file(path: &Path, data: &[Vec<f64>], schema: &Schema) -> Result<(), DeltaError> {
495    let mut file = fs::File::create(path)?;
496
497    // Write header
498    let header: Vec<&str> = schema.columns.iter().map(|c| c.name.as_str()).collect();
499    writeln!(file, "{}", header.join(","))
500        .map_err(|e| DeltaError::Io(std::io::Error::new(e.kind(), format!("write header: {e}"))))?;
501
502    // Write rows
503    let num_rows = data.first().map(|c| c.len()).unwrap_or(0);
504    for row_idx in 0..num_rows {
505        let row: Vec<String> = data
506            .iter()
507            .map(|col| {
508                if row_idx < col.len() {
509                    format!("{}", col[row_idx])
510                } else {
511                    "NaN".to_string()
512                }
513            })
514            .collect();
515        writeln!(file, "{}", row.join(",")).map_err(|e| {
516            DeltaError::Io(std::io::Error::new(e.kind(), format!("write row: {e}")))
517        })?;
518    }
519
520    file.flush()?;
521    Ok(())
522}
523
524/// Read columnar f64 data from a CSV file.
525fn read_data_file(path: &Path) -> Result<Vec<Vec<f64>>, DeltaError> {
526    let file = fs::File::open(path)?;
527    let reader = BufReader::new(file);
528
529    let mut lines = reader.lines();
530
531    // Read header to determine column count
532    let header_line = lines
533        .next()
534        .ok_or_else(|| DeltaError::Parse("empty data file".to_string()))?
535        .map_err(|e| DeltaError::Io(e))?;
536    let num_cols = header_line.split(',').count();
537
538    let mut columns: Vec<Vec<f64>> = (0..num_cols).map(|_| Vec::new()).collect();
539
540    for line_res in lines {
541        let line = line_res?;
542        let trimmed = line.trim();
543        if trimmed.is_empty() {
544            continue;
545        }
546        let fields: Vec<&str> = trimmed.split(',').collect();
547        for (i, field) in fields.iter().enumerate() {
548            if i < num_cols {
549                let val: f64 = field.trim().parse().unwrap_or(f64::NAN);
550                columns[i].push(val);
551            }
552        }
553        // If row has fewer fields than columns, pad with NaN
554        for col in columns.iter_mut().skip(fields.len()) {
555            col.push(f64::NAN);
556        }
557    }
558
559    Ok(columns)
560}