Skip to main content

dynoxide/import/
mod.rs

1//! Import CLI for DynamoDB Export data.
2//!
3//! Parses DynamoDB Export JSON Lines files, optionally applies anonymisation
4//! rules, and imports the data into a Dynoxide SQLite database.
5//!
6//! ## Pipeline
7//!
8//! 1. Parse TOML config (validate all rules upfront)
9//! 2. Source table schemas from `--schema <file>`
10//! 3. Create tables in output SQLite database
11//! 4. For each table: read JSON Lines → parse → anonymise → batch insert
12//! 5. VACUUM (compact the SQLite file)
13//! 6. Optionally compress with zstd
14
15pub(crate) mod anonymise;
16pub(crate) mod config;
17pub(crate) mod consistency;
18pub(crate) mod parser;
19pub(crate) mod schema;
20
21use crate::{Database, ImportOptions};
22use consistency::ConsistencyMap;
23use indicatif::{ProgressBar, ProgressStyle};
24use std::collections::HashSet;
25use std::path::Path;
26
27/// Errors from the import pipeline.
28#[derive(Debug)]
29pub enum ImportError {
30    /// Configuration or validation error (e.g., invalid TOML, missing schema).
31    Config(String),
32    /// I/O or parsing error during data import.
33    Data(String),
34    /// Database error during table creation or item insertion.
35    Database(String),
36}
37
38impl std::fmt::Display for ImportError {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        match self {
41            ImportError::Config(msg) => write!(f, "{msg}"),
42            ImportError::Data(msg) => write!(f, "{msg}"),
43            ImportError::Database(msg) => write!(f, "{msg}"),
44        }
45    }
46}
47
48impl std::error::Error for ImportError {}
49
50impl From<String> for ImportError {
51    fn from(s: String) -> Self {
52        ImportError::Data(s)
53    }
54}
55
56/// Configuration for the import operation.
57pub struct ImportCommand {
58    /// Source directory containing export files.
59    pub source: std::path::PathBuf,
60    /// Output SQLite database path (required for file-based import, None for in-memory).
61    pub output: Option<std::path::PathBuf>,
62    /// Schema file path (DescribeTable JSON format).
63    pub schema: std::path::PathBuf,
64    /// Optional anonymisation rules TOML file.
65    pub rules: Option<std::path::PathBuf>,
66    /// Optional table name filter (comma-separated).
67    pub tables: Option<Vec<String>>,
68    /// Optional zstd compression of output (only valid with file output).
69    pub compress: bool,
70    /// Overwrite existing output file without prompting.
71    pub force: bool,
72    /// Continue importing when a batch fails (default: fail-fast).
73    /// When true, batch errors are recorded as warnings and import continues.
74    /// When false (default), the first batch error aborts the import.
75    pub continue_on_error: bool,
76}
77
78/// Result of an import operation.
79#[derive(Debug)]
80pub struct ImportSummary {
81    /// Per-table import statistics.
82    pub tables: Vec<TableImportResult>,
83    /// Total items imported across all tables.
84    pub total_items: usize,
85    /// Total bytes imported.
86    pub total_bytes: usize,
87    /// Total lines skipped due to parse errors.
88    pub total_skipped: usize,
89    /// Warnings generated during import.
90    pub warnings: Vec<String>,
91    /// Output file path (may differ from input if compressed). None for in-memory imports.
92    pub output_path: Option<std::path::PathBuf>,
93}
94
95/// Per-table import result.
96#[derive(Debug)]
97pub struct TableImportResult {
98    pub table_name: String,
99    pub items_imported: usize,
100    pub bytes_imported: usize,
101    pub lines_skipped: usize,
102}
103
104/// Scaffold empty tables from a DynamoDB DescribeTable JSON schema file.
105///
106/// Reads the schema file, creates each table defined in it, and skips any
107/// tables that already exist. Returns the number of tables created.
108///
109/// The schema file format is identical to `import --schema`: a JSON file
110/// containing a single `aws dynamodb describe-table` response or an array
111/// of them.
112pub fn scaffold_from_schema(db: &Database, path: &std::path::Path) -> Result<usize, ImportError> {
113    let (schemas, schema_json) = schema::load_schemas(path).map_err(ImportError::Config)?;
114    let mut created = 0;
115    for table_schema in &schemas {
116        let create_request = build_create_request(&schema_json, &table_schema.table_name)?;
117        match db.create_table(create_request) {
118            Ok(_) => created += 1,
119            Err(crate::errors::DynoxideError::ResourceInUseException(_)) => {} // already exists
120            Err(e) => return Err(ImportError::Database(e.to_string())),
121        }
122    }
123    Ok(created)
124}
125
126/// Build a `CreateTableRequest` for `table_name` from raw schema JSON.
127///
128/// Deserializes through `CreateTableRequest`'s `Deserialize` impl (rather than
129/// building the struct by hand) so GlobalSecondaryIndexes and
130/// LocalSecondaryIndexes are picked up from the schema. Shared by `run_into`
131/// and `scaffold_from_schema` so the two can't drift apart on which fields a
132/// schema-sourced table ends up with.
133fn build_create_request(
134    schema_json: &serde_json::Value,
135    table_name: &str,
136) -> Result<crate::actions::create_table::CreateTableRequest, String> {
137    let table_json = find_table_json(schema_json, table_name)
138        .ok_or_else(|| format!("Schema JSON not found for table '{table_name}'"))?;
139
140    serde_json::from_value(table_json)
141        .map_err(|e| format!("Failed to deserialize schema for '{table_name}': {e}"))
142}
143
144/// Execute the import pipeline into a caller-provided database.
145///
146/// This is the core import logic — database-agnostic. The caller is
147/// responsible for creating the database and any post-import steps
148/// (VACUUM, compression). This makes import usable with both file-backed
149/// and in-memory databases.
150pub fn run_into(db: &Database, cmd: ImportCommand) -> Result<ImportSummary, ImportError> {
151    // 1. Load and validate anonymisation rules (if provided)
152    let (rules, consistency_config) = if let Some(ref rules_path) = cmd.rules {
153        let (rules, consistency) =
154            config::load_and_validate(rules_path).map_err(ImportError::Config)?;
155        eprintln!(
156            "Loaded {} anonymisation rules from {}",
157            rules.len(),
158            rules_path.display()
159        );
160        (rules, consistency)
161    } else {
162        (Vec::new(), None)
163    };
164
165    let consistency_fields: std::collections::HashSet<String> = consistency_config
166        .as_ref()
167        .map(|c| c.fields.iter().cloned().collect())
168        .unwrap_or_default();
169    let mut consistency_map = ConsistencyMap::new();
170
171    // 2. Load table schemas (returns both parsed schemas and raw JSON)
172    let (schemas, schema_json) = schema::load_schemas(&cmd.schema)?;
173    eprintln!(
174        "Loaded {} table schemas from {}",
175        schemas.len(),
176        cmd.schema.display()
177    );
178
179    // 3. Discover export files
180    let table_filter = cmd.tables.as_deref();
181    let export_files = parser::discover_export_files(&cmd.source, table_filter)?;
182
183    if export_files.is_empty() {
184        return Err(ImportError::Config(format!(
185            "No export files found in {}. Expected DynamoDB Export directory structure \
186             (<dir>/<TableName>/data/*.json.gz) or flat directory (<dir>/*.json[.gz]).",
187            cmd.source.display()
188        )));
189    }
190
191    // Build a schema lookup map
192    let schema_map: std::collections::HashMap<&str, &schema::TableSchema> =
193        schemas.iter().map(|s| (s.table_name.as_str(), s)).collect();
194
195    // 4. Create tables from schemas
196    for (table_name, _) in &export_files {
197        if !schema_map.contains_key(table_name.as_str()) {
198            return Err(ImportError::Config(format!(
199                "No schema found for table '{}'. Available schemas: {}",
200                table_name,
201                schemas
202                    .iter()
203                    .map(|s| s.table_name.as_str())
204                    .collect::<Vec<_>>()
205                    .join(", ")
206            )));
207        }
208
209        let create_request = build_create_request(&schema_json, table_name)?;
210
211        db.create_table(create_request)
212            .map_err(|e| format!("Failed to create table '{}': {e}", table_name))?;
213    }
214
215    // 5. Enable bulk-loading PRAGMAs (safe: fresh DB, can re-import on crash)
216    db.enable_bulk_loading()
217        .map_err(|e| format!("Failed to enable bulk loading: {e}"))?;
218
219    // 6. Import data for each table
220    let mut summary = ImportSummary {
221        tables: Vec::new(),
222        total_items: 0,
223        total_bytes: 0,
224        total_skipped: 0,
225        warnings: Vec::new(),
226        output_path: cmd.output.clone(),
227    };
228
229    let mut seen_warnings: HashSet<String> = HashSet::new();
230
231    for (table_name, files) in &export_files {
232        let table_schema = schema_map.get(table_name.as_str()).unwrap();
233        let key_attrs = extract_key_attrs(&table_schema.create_request);
234
235        let file_count = files.len();
236        eprintln!("Importing table '{}' ({} files)...", table_name, file_count);
237
238        let pb = ProgressBar::new_spinner();
239        pb.set_style(
240            ProgressStyle::with_template("{spinner:.green} [{elapsed_precise}] {msg}")
241                .unwrap()
242                .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"),
243        );
244        pb.set_message(format!("{}: parsing...", table_name));
245
246        let mut table_items = 0usize;
247        let mut table_bytes = 0usize;
248        let mut table_skipped = 0usize;
249        let mut batch_error: Option<String> = None;
250
251        const BATCH_SIZE: usize = 10_000;
252
253        for file_path in files {
254            let mut batch: Vec<crate::types::Item> = Vec::with_capacity(BATCH_SIZE);
255
256            let stats = parser::parse_export_file_streaming(file_path, |mut item| {
257                // Skip processing if we've already hit a fatal batch error
258                if batch_error.is_some() {
259                    return;
260                }
261
262                // Apply anonymisation rules
263                if !rules.is_empty() {
264                    let warnings = anonymise::apply_rules(
265                        &mut item,
266                        &rules,
267                        &mut consistency_map,
268                        &consistency_fields,
269                        &key_attrs,
270                    );
271                    for w in warnings {
272                        if !seen_warnings.contains(&w) {
273                            seen_warnings.insert(w.clone());
274                            summary.warnings.push(w);
275                        }
276                    }
277                }
278                batch.push(item);
279
280                // Flush batch when full
281                if batch.len() >= BATCH_SIZE {
282                    let chunk = std::mem::replace(&mut batch, Vec::with_capacity(BATCH_SIZE));
283                    match db.import_items_fresh(table_name, chunk, ImportOptions::default()) {
284                        Ok(result) => {
285                            table_items += result.items_imported;
286                            table_bytes += result.bytes_imported;
287                        }
288                        Err(e) => {
289                            let msg = format!("Batch import error for '{}': {e}", table_name);
290                            if cmd.continue_on_error {
291                                summary.warnings.push(msg);
292                            } else {
293                                batch_error = Some(msg);
294                                return;
295                            }
296                        }
297                    }
298                    pb.set_message(format!("{}: {} items", table_name, table_items));
299                    pb.tick();
300                }
301            })?;
302
303            // Propagate batch error after the streaming callback completes
304            if let Some(err) = batch_error.take() {
305                pb.abandon_with_message(format!("{}: FAILED", table_name));
306                return Err(ImportError::Database(err));
307            }
308
309            table_skipped += stats.skipped;
310            for warning in stats.warnings {
311                summary.warnings.push(warning);
312            }
313
314            // Flush remaining items
315            if !batch.is_empty() {
316                let import_result = db
317                    .import_items_fresh(table_name, batch, ImportOptions::default())
318                    .map_err(|e| format!("Failed to import items into '{}': {e}", table_name))?;
319                table_items += import_result.items_imported;
320                table_bytes += import_result.bytes_imported;
321                pb.set_message(format!("{}: {} items", table_name, table_items));
322                pb.tick();
323            }
324        }
325
326        pb.finish_with_message(format!(
327            "{}: {} items, {} bytes{}",
328            table_name,
329            table_items,
330            format_bytes(table_bytes),
331            if table_skipped > 0 {
332                format!(", {} skipped", table_skipped)
333            } else {
334                String::new()
335            }
336        ));
337
338        summary.tables.push(TableImportResult {
339            table_name: table_name.clone(),
340            items_imported: table_items,
341            bytes_imported: table_bytes,
342            lines_skipped: table_skipped,
343        });
344        summary.total_items += table_items;
345        summary.total_bytes += table_bytes;
346        summary.total_skipped += table_skipped;
347    }
348
349    // 7. Restore normal PRAGMAs (important if DB will be served after import)
350    db.disable_bulk_loading()
351        .map_err(|e| format!("Failed to disable bulk loading: {e}"))?;
352
353    // Report consistency map stats
354    if consistency_map.field_count() > 0 {
355        eprintln!(
356            "Consistency map: {} fields, {} total mappings",
357            consistency_map.field_count(),
358            consistency_map.total_mappings()
359        );
360    }
361
362    Ok(summary)
363}
364
365/// Execute the import pipeline with file-based output.
366///
367/// Creates a new database at a temporary path, imports data, VACUUMs,
368/// then atomically renames to the final output path. If the import fails
369/// at any point, the temp file is cleaned up automatically and any
370/// existing output file is preserved.
371pub fn run(cmd: ImportCommand) -> Result<ImportSummary, ImportError> {
372    let output = cmd
373        .output
374        .as_ref()
375        .ok_or_else(|| ImportError::Config("output path required for file-based import".into()))?;
376
377    // Check for existing output file
378    if output.exists() && !cmd.force {
379        return Err(ImportError::Config(format!(
380            "Output file '{}' already exists. Use --force to overwrite.",
381            output.display()
382        )));
383    }
384
385    let output_path = output.clone();
386    let compress = cmd.compress;
387
388    // Write to a temp file in the same directory as the output so that
389    // persist() can do an atomic rename (same filesystem). On failure,
390    // NamedTempFile's Drop cleans up automatically.
391    let output_dir = output_path.parent().unwrap_or(Path::new("."));
392    let tmp_file = tempfile::NamedTempFile::new_in(output_dir)
393        .map_err(|e| ImportError::Database(format!("Failed to create temp file: {e}")))?;
394    let tmp_path = tmp_file.path().to_path_buf();
395
396    // Close the temp file handle — Database::new will open it by path.
397    // Keep the NamedTempFile alive so it cleans up on error.
398    let tmp_file = tmp_file.into_temp_path();
399
400    let db = Database::new(
401        tmp_path
402            .to_str()
403            .ok_or_else(|| ImportError::Config("Invalid temp path".to_string()))?,
404    )
405    .map_err(|e| ImportError::Database(format!("Failed to create output database: {e}")))?;
406
407    let mut summary = run_into(&db, cmd)?;
408
409    // VACUUM for compact output.
410    // Drop the db and reopen to release any in-process state before compacting.
411    drop(db);
412    {
413        let db = Database::new(
414            tmp_path
415                .to_str()
416                .ok_or_else(|| ImportError::Config("Invalid temp path".to_string()))?,
417        )
418        .map_err(|e| ImportError::Database(format!("Failed to reopen database for VACUUM: {e}")))?;
419        db.vacuum()
420            .map_err(|e| ImportError::Database(format!("VACUUM failed: {e}")))?;
421    }
422    eprintln!("Database compacted.");
423
424    // Atomically move the temp file to the final output path.
425    // This overwrites any existing file (--force was already checked above).
426    tmp_file.persist(&output_path).map_err(|e| {
427        ImportError::Database(format!("Failed to move database to output path: {e}"))
428    })?;
429
430    summary.output_path = Some(output_path.clone());
431
432    // Optionally compress with zstd
433    if compress {
434        let compressed_path = compress_output(&output_path)?;
435        summary.output_path = Some(compressed_path);
436    }
437
438    Ok(summary)
439}
440
441/// Find the raw JSON for a specific table in the schema file.
442/// Converts from DescribeTable format (with "Table" wrapper) to CreateTableRequest format.
443fn find_table_json(schema_json: &serde_json::Value, table_name: &str) -> Option<serde_json::Value> {
444    let items: Vec<&serde_json::Value> = match schema_json {
445        serde_json::Value::Array(arr) => arr.iter().collect(),
446        obj @ serde_json::Value::Object(_) => vec![obj],
447        _ => return None,
448    };
449
450    for item in items {
451        let table = item.get("Table").unwrap_or(item);
452        if table.get("TableName").and_then(|v| v.as_str()) == Some(table_name) {
453            // Convert from DescribeTable format to CreateTableRequest format:
454            // strip the "Table" wrapper, then translate the fields whose shape
455            // differs between the two.
456            let mut table = table.clone();
457            unwrap_describe_table_shapes(&mut table);
458            return Some(table);
459        }
460    }
461    None
462}
463
464/// Translate DescribeTable-only shapes into their CreateTableRequest
465/// equivalents. DescribeTable wraps billing mode and table class in summary
466/// objects (`BillingModeSummary`, `TableClassSummary`), which CreateTable
467/// never reads, so both would otherwise fall back to their defaults. It also
468/// reports zeroed `ProvisionedThroughput` blocks on an on-demand table and
469/// its GSIs, which CreateTable rejects, so those are dropped too. The drop
470/// is gated on the billing mode having come from the summary: a schema
471/// already in CreateTable shape passes through untouched, so an inconsistent
472/// one still fails validation exactly as it would on the CreateTable API.
473fn unwrap_describe_table_shapes(table: &mut serde_json::Value) {
474    let Some(obj) = table.as_object_mut() else {
475        return;
476    };
477
478    let mut billing_mode_hoisted = false;
479    for (summary_key, field_key) in [
480        ("BillingModeSummary", "BillingMode"),
481        ("TableClassSummary", "TableClass"),
482    ] {
483        if !obj.contains_key(field_key) {
484            if let Some(value) = obj.get(summary_key).and_then(|s| s.get(field_key)) {
485                let value = value.clone();
486                obj.insert(field_key.to_string(), value);
487                billing_mode_hoisted |= field_key == "BillingMode";
488            }
489        }
490    }
491
492    if billing_mode_hoisted
493        && obj.get("BillingMode").and_then(|v| v.as_str()) == Some("PAY_PER_REQUEST")
494    {
495        obj.remove("ProvisionedThroughput");
496        if let Some(gsis) = obj
497            .get_mut("GlobalSecondaryIndexes")
498            .and_then(|v| v.as_array_mut())
499        {
500            for gsi in gsis {
501                if let Some(gsi) = gsi.as_object_mut() {
502                    gsi.remove("ProvisionedThroughput");
503                }
504            }
505        }
506    }
507}
508
509/// Extract key attribute names from a CreateTableRequest.
510fn extract_key_attrs(request: &crate::actions::create_table::CreateTableRequest) -> Vec<String> {
511    request
512        .key_schema
513        .iter()
514        .map(|ks| ks.attribute_name.clone())
515        .collect()
516}
517
518/// Compress a file with zstd, removing the original.
519fn compress_output(path: &Path) -> Result<std::path::PathBuf, String> {
520    let compressed_path = path.with_extension("db.zst");
521    eprintln!("Compressing to {}...", compressed_path.display());
522
523    let input = std::fs::File::open(path)
524        .map_err(|e| format!("Failed to open {} for compression: {e}", path.display()))?;
525
526    let output = std::fs::File::create(&compressed_path)
527        .map_err(|e| format!("Failed to create {}: {e}", compressed_path.display()))?;
528
529    let mut encoder =
530        zstd::Encoder::new(output, 3).map_err(|e| format!("Failed to create zstd encoder: {e}"))?;
531
532    std::io::copy(&mut std::io::BufReader::new(input), &mut encoder)
533        .map_err(|e| format!("Compression failed: {e}"))?;
534
535    encoder
536        .finish()
537        .map_err(|e| format!("Failed to finalize compression: {e}"))?;
538
539    // Remove the uncompressed file
540    std::fs::remove_file(path).map_err(|e| format!("Failed to remove uncompressed file: {e}"))?;
541
542    let compressed_size = std::fs::metadata(&compressed_path)
543        .map(|m| m.len())
544        .unwrap_or(0);
545    eprintln!(
546        "Compressed output: {}",
547        format_bytes(compressed_size as usize)
548    );
549
550    Ok(compressed_path)
551}
552
553/// Format bytes as human-readable.
554fn format_bytes(bytes: usize) -> String {
555    if bytes < 1024 {
556        format!("{bytes} B")
557    } else if bytes < 1024 * 1024 {
558        format!("{:.1} KB", bytes as f64 / 1024.0)
559    } else {
560        format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
561    }
562}