1pub(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#[derive(Debug)]
29pub enum ImportError {
30 Config(String),
32 Data(String),
34 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
56pub struct ImportCommand {
58 pub source: std::path::PathBuf,
60 pub output: Option<std::path::PathBuf>,
62 pub schema: std::path::PathBuf,
64 pub rules: Option<std::path::PathBuf>,
66 pub tables: Option<Vec<String>>,
68 pub compress: bool,
70 pub force: bool,
72 pub continue_on_error: bool,
76}
77
78#[derive(Debug)]
80pub struct ImportSummary {
81 pub tables: Vec<TableImportResult>,
83 pub total_items: usize,
85 pub total_bytes: usize,
87 pub total_skipped: usize,
89 pub warnings: Vec<String>,
91 pub output_path: Option<std::path::PathBuf>,
93}
94
95#[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
104pub 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(_)) => {} Err(e) => return Err(ImportError::Database(e.to_string())),
121 }
122 }
123 Ok(created)
124}
125
126fn 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
144pub fn run_into(db: &Database, cmd: ImportCommand) -> Result<ImportSummary, ImportError> {
151 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 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 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 let schema_map: std::collections::HashMap<&str, &schema::TableSchema> =
193 schemas.iter().map(|s| (s.table_name.as_str(), s)).collect();
194
195 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 db.enable_bulk_loading()
217 .map_err(|e| format!("Failed to enable bulk loading: {e}"))?;
218
219 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 if batch_error.is_some() {
259 return;
260 }
261
262 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 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 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 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 db.disable_bulk_loading()
351 .map_err(|e| format!("Failed to disable bulk loading: {e}"))?;
352
353 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
365pub 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 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 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 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 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 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 if compress {
434 let compressed_path = compress_output(&output_path)?;
435 summary.output_path = Some(compressed_path);
436 }
437
438 Ok(summary)
439}
440
441fn 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 let mut table = table.clone();
457 unwrap_describe_table_shapes(&mut table);
458 return Some(table);
459 }
460 }
461 None
462}
463
464fn 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
509fn 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
518fn 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 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
553fn 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}