pub(crate) mod anonymise;
pub(crate) mod config;
pub(crate) mod consistency;
pub(crate) mod keys;
pub(crate) mod notice;
pub(crate) mod parser;
pub(crate) mod schema;
use crate::{Database, ImportOptions};
use consistency::ConsistencyMap;
use indicatif::{ProgressBar, ProgressStyle};
use std::collections::HashSet;
pub use notice::{Concern, Notice};
use std::path::Path;
#[derive(Debug)]
pub enum ImportError {
Config(String),
Data(String),
Database(String),
}
impl std::fmt::Display for ImportError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ImportError::Config(msg) => write!(f, "{msg}"),
ImportError::Data(msg) => write!(f, "{msg}"),
ImportError::Database(msg) => write!(f, "{msg}"),
}
}
}
impl std::error::Error for ImportError {}
impl From<String> for ImportError {
fn from(s: String) -> Self {
ImportError::Data(s)
}
}
pub struct ImportCommand {
pub source: std::path::PathBuf,
pub output: Option<std::path::PathBuf>,
pub schema: std::path::PathBuf,
pub rules: Option<std::path::PathBuf>,
pub data_model: Option<std::path::PathBuf>,
pub tables: Option<Vec<String>>,
pub compress: bool,
pub force: bool,
pub continue_on_error: bool,
}
#[derive(Debug)]
pub struct ImportSummary {
pub tables: Vec<TableImportResult>,
pub total_items: usize,
pub total_bytes: usize,
pub total_skipped: usize,
pub notices: Vec<Notice>,
pub warnings: Vec<String>,
pub exposures: Vec<String>,
pub output_path: Option<std::path::PathBuf>,
}
impl ImportSummary {
fn notice(&mut self, notice: Notice) {
self.notices.push(notice);
}
fn finish(&mut self) {
self.warnings = self.notices.iter().map(|n| n.message.clone()).collect();
self.exposures = self
.notices
.iter()
.filter(|n| n.concern == Concern::Exposure)
.map(|n| n.message.clone())
.collect();
}
}
#[derive(Debug)]
pub struct TableImportResult {
pub table_name: String,
pub items_imported: usize,
pub bytes_imported: usize,
pub lines_skipped: usize,
}
pub fn scaffold_from_schema(db: &Database, path: &std::path::Path) -> Result<usize, ImportError> {
let (schemas, schema_json) = schema::load_schemas(path).map_err(ImportError::Config)?;
let mut created = 0;
for table_schema in &schemas {
let create_request = build_create_request(&schema_json, &table_schema.table_name)?;
match db.create_table(create_request) {
Ok(_) => created += 1,
Err(crate::errors::DynoxideError::ResourceInUseException(_)) => {} Err(e) => return Err(ImportError::Database(e.to_string())),
}
}
Ok(created)
}
fn build_create_request(
schema_json: &serde_json::Value,
table_name: &str,
) -> Result<crate::actions::create_table::CreateTableRequest, String> {
let table_json = find_table_json(schema_json, table_name)
.ok_or_else(|| format!("Schema JSON not found for table '{table_name}'"))?;
serde_json::from_value(table_json)
.map_err(|e| format!("Failed to deserialize schema for '{table_name}': {e}"))
}
pub fn run_into(db: &Database, cmd: ImportCommand) -> Result<ImportSummary, ImportError> {
let (rules, consistency_config) = if let Some(ref rules_path) = cmd.rules {
let (rules, consistency) =
config::load_and_validate(rules_path).map_err(ImportError::Config)?;
eprintln!(
"Loaded {} anonymisation rules from {}",
rules.len(),
rules_path.display()
);
(rules, consistency)
} else {
(Vec::new(), None)
};
let consistency_fields: HashSet<String> = consistency_config
.as_ref()
.map(|c| c.fields.iter().cloned().collect())
.unwrap_or_default();
let data_model = match cmd.data_model {
Some(ref path) => {
let model =
crate::schema::onetable::parse_onetable_file(path).map_err(ImportError::Config)?;
eprintln!(
"Loaded data model: {} ({} entities) from {}",
model.schema_format,
model.entities.len(),
path.display()
);
Some(model)
}
None => None,
};
let (schemas, schema_json) = schema::load_schemas(&cmd.schema)?;
eprintln!(
"Loaded {} table schemas from {}",
schemas.len(),
cmd.schema.display()
);
refuse_scopes_outside_the_schema(&rules, &schemas)?;
let table_filter = cmd.tables.as_deref();
let export_files =
parser::discover_export_files(&cmd.source, table_filter).map_err(ImportError::Config)?;
if export_files.is_empty() {
return Err(ImportError::Config(format!(
"No export files found in {}. Expected DynamoDB Export directory structure \
(<dir>/<TableName>/data/*.json.gz) or flat directory (<dir>/*.json[.gz]).",
cmd.source.display()
)));
}
if data_model.is_some() && !rules.is_empty() && export_files.len() > 1 {
let names: Vec<String> = export_files.iter().map(|(name, _)| name.clone()).collect();
return Err(ImportError::Config(format!(
"a data model describes one table, and this run covers {}. Pass --tables with \
the one the model describes",
quoted_names(&names)
)));
}
let schema_map: std::collections::HashMap<&str, &schema::TableSchema> =
schemas.iter().map(|s| (s.table_name.as_str(), s)).collect();
for (table_name, _) in &export_files {
if !schema_map.contains_key(table_name.as_str()) {
return Err(ImportError::Config(format!(
"No schema found for table '{}'. Available schemas: {}",
table_name,
schemas
.iter()
.map(|s| s.table_name.as_str())
.collect::<Vec<_>>()
.join(", ")
)));
}
let create_request = build_create_request(&schema_json, table_name)?;
db.create_table(create_request)
.map_err(|e| format!("Failed to create table '{}': {e}", table_name))?;
}
db.enable_bulk_loading()
.map_err(|e| format!("Failed to enable bulk loading: {e}"))?;
let _restore_pragmas = BulkLoading { db };
let mut summary = ImportSummary {
tables: Vec::new(),
total_items: 0,
total_bytes: 0,
total_skipped: 0,
notices: Vec::new(),
warnings: Vec::new(),
exposures: Vec::new(),
output_path: cmd.output.clone(),
};
let rules_touch_a_key = rules_touch(&rules, &schemas, extract_key_attrs);
let rebuilds_keys = !rules.is_empty() && (data_model.is_some() || rules_touch_a_key);
preflight_cautions(
&mut summary,
&rules,
&schemas,
&consistency_fields,
data_model.is_some(),
rules_touch_a_key,
);
let mut run = TableImport {
db,
rules: &rules,
consistency_fields: &consistency_fields,
data_model: data_model.as_ref(),
rebuilds_keys,
continue_on_error: cmd.continue_on_error,
consistency_map: ConsistencyMap::new(),
seen_warnings: HashSet::new(),
rule_work: vec![Default::default(); rules.len()],
};
for (table_name, files) in &export_files {
let table_schema = schema_map.get(table_name.as_str()).unwrap();
run.import_table(table_name, files, table_schema, &mut summary)?;
}
report_unused_rules(&mut summary, &rules, &run.rule_work, &export_files);
db.disable_bulk_loading()
.map_err(|e| format!("Failed to disable bulk loading: {e}"))?;
if run.consistency_map.field_count() > 0 {
eprintln!(
"Consistency map: {} fields, {} total mappings",
run.consistency_map.field_count(),
run.consistency_map.total_mappings()
);
}
summary.finish();
Ok(summary)
}
fn rules_touch(
rules: &[config::ValidatedRule],
schemas: &[schema::TableSchema],
keys_of: fn(&crate::actions::create_table::CreateTableRequest) -> Vec<String>,
) -> bool {
rules.iter().any(|rule| match rule.path.first() {
Some(crate::expressions::PathElement::Attribute(name)) => schemas
.iter()
.any(|s| rule.applies_to(&s.table_name) && keys_of(&s.create_request).contains(name)),
_ => false,
})
}
fn preflight_cautions(
summary: &mut ImportSummary,
rules: &[config::ValidatedRule],
schemas: &[schema::TableSchema],
consistency_fields: &HashSet<String>,
has_data_model: bool,
rules_touch_a_key: bool,
) {
for message in mixed_consistency_rules(rules, consistency_fields) {
summary.notice(Notice::caution(message));
}
if rules.is_empty() && has_data_model {
summary.notice(Notice::caution(
"a data model was given but no rules, so nothing was anonymised and no key was \
rebuilt. The model is only used to re-render keys after a rule has changed an \
attribute one is built from",
));
}
if !rules.is_empty() && !has_data_model {
summary.notice(Notice::caution(
"rules rewrite attributes only: a key built from an attribute (CUSTOMER#${email}) \
keeps its original value. Pass --data-model <onetable.json> to rebuild keys \
from entity templates after anonymisation",
));
}
if rules_touch(rules, schemas, index_key_attrs) && !has_data_model {
summary.notice(Notice::caution(
"a rule rewrites an index key attribute directly. No row is lost, but rows that \
an index returned together are moved apart, and without a data model nothing \
checks whether they still belong together",
));
}
if rules_touch_a_key && !has_data_model {
summary.notice(Notice::caution(
"a rule rewrites a key attribute directly, and without a data model nothing \
counts what that costs: two items whose rewritten key comes out the same land \
on one row and the later one replaces the earlier. Overwrites are only counted \
when --data-model is given, so the item count below will not show what was lost",
));
}
}
struct TableImport<'a> {
db: &'a Database,
rules: &'a [config::ValidatedRule],
consistency_fields: &'a HashSet<String>,
data_model: Option<&'a crate::schema::DataModel>,
rebuilds_keys: bool,
continue_on_error: bool,
consistency_map: ConsistencyMap,
seen_warnings: HashSet<String>,
rule_work: Vec<anonymise::RuleWork>,
}
impl TableImport<'_> {
fn import_table(
&mut self,
table_name: &str,
files: &[std::path::PathBuf],
table_schema: &schema::TableSchema,
summary: &mut ImportSummary,
) -> Result<(), ImportError> {
let rules = self.rules;
let key_attrs = extract_key_attrs(&table_schema.create_request);
let key_deriver = match self.data_model.filter(|_| !rules.is_empty()) {
Some(model) => {
let (deriver, warnings) = keys::KeyDeriver::new(
model,
&table_schema.create_request,
rules,
self.consistency_fields,
)
.map_err(|e| {
for n in &summary.notices {
eprintln!(" - {n}");
}
ImportError::Config(format!("data model: {e}"))
})?;
for notice in warnings {
summary.notice(notice.for_table(table_name));
}
Some(deriver)
}
None => None,
};
let mut landed = Landed {
key_deriver,
mask_passthroughs: std::collections::HashMap::new(),
items: 0,
bytes: 0,
};
let file_count = files.len();
eprintln!("Importing table '{}' ({} files)...", table_name, file_count);
let pb = ProgressBar::new_spinner();
pb.set_style(
ProgressStyle::with_template("{spinner:.green} [{elapsed_precise}] {msg}")
.unwrap()
.tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"),
);
pb.set_message(format!("{}: parsing...", table_name));
let mut table_skipped = 0usize;
let mut batch_error: Option<String> = None;
const BATCH_SIZE: usize = 10_000;
for file_path in files {
let mut pending = Pending::with_capacity(BATCH_SIZE, rules.len());
let stats = parser::parse_export_file_streaming(file_path, |mut item| {
if batch_error.is_some() {
return;
}
if !rules.is_empty() {
let mut warnings = Vec::new();
let plan = landed
.key_deriver
.as_ref()
.and_then(|d| d.plan(&item, &mut pending.observed, &mut warnings));
let (rule_warnings, rewritten) = anonymise::apply_rules(
&mut item,
table_name,
rules,
&mut self.consistency_map,
self.consistency_fields,
&key_attrs,
&mut anonymise::RuleTally {
mask_passthroughs: &mut pending.mask_passthroughs,
rule_work: &mut pending.rule_work,
},
);
warnings.extend(rule_warnings);
match (landed.key_deriver.as_ref(), plan) {
(Some(deriver), Some(plan)) => {
deriver.apply(
&plan,
&rewritten,
&mut item,
&mut pending.observed,
&mut warnings,
);
}
(Some(deriver), None) => {
deriver.note_primary_key(&item, &mut pending.observed)
}
(None, _) => {}
}
for notice in warnings {
pending.raise(notice.for_table(table_name), &self.seen_warnings);
}
}
pending.items.push(item);
if pending.items.len() >= BATCH_SIZE {
let full = std::mem::replace(
&mut pending,
Pending::with_capacity(BATCH_SIZE, rules.len()),
);
if let Err(msg) = self.land(table_name, full, &mut landed, summary) {
if self.continue_on_error {
summary.notice(Notice::caution(msg));
} else {
batch_error = Some(msg);
return;
}
}
pb.set_message(format!("{}: {} items", table_name, landed.items));
pb.tick();
}
})?;
if let Some(err) = batch_error.take() {
pb.abandon_with_message(format!("{}: FAILED", table_name));
return Err(ImportError::Database(err));
}
table_skipped += stats.skipped;
for warning in stats.warnings {
summary.notice(Notice::caution(warning));
}
if !pending.items.is_empty() {
if let Err(msg) = self.land(table_name, pending, &mut landed, summary) {
if self.continue_on_error {
summary.notice(Notice::caution(msg));
} else {
pb.abandon_with_message(format!("{}: FAILED", table_name));
return Err(ImportError::Database(msg));
}
}
pb.set_message(format!("{}: {} items", table_name, landed.items));
pb.tick();
}
}
if let Some(deriver) = landed.key_deriver.as_mut() {
for notice in deriver.take_notices() {
summary.notice(notice.for_table(table_name));
}
let join_breaks = deriver.join_breaks();
if let Some(first) = join_breaks.first() {
for n in &summary.notices {
eprintln!(" - {n}");
}
return Err(ImportError::Config(format!(
"table '{}': {}{}",
table_name,
first,
if join_breaks.len() > 1 {
format!(" (and {} more)", join_breaks.len() - 1)
} else {
String::new()
}
)));
}
}
let mut left_whole: Vec<(&String, &usize)> = landed.mask_passthroughs.iter().collect();
left_whole.sort();
for (path, count) in left_whole {
summary.notice(Notice::exposure(format!(
"table '{table_name}': {count} items kept '{path}' as it arrived \
because the value was no shorter than the characters the mask keeps, so \
those rows carry the original value"
)));
}
pb.finish_with_message(format!(
"{}: {} items, {} bytes{}",
table_name,
landed.items,
format_bytes(landed.bytes),
if table_skipped > 0 {
format!(", {} skipped", table_skipped)
} else {
String::new()
}
));
summary.tables.push(TableImportResult {
table_name: table_name.to_string(),
items_imported: landed.items,
bytes_imported: landed.bytes,
lines_skipped: table_skipped,
});
summary.total_items += landed.items;
summary.total_bytes += landed.bytes;
summary.total_skipped += table_skipped;
Ok(())
}
fn land(
&mut self,
table_name: &str,
pending: Pending,
landed: &mut Landed,
summary: &mut ImportSummary,
) -> Result<(), String> {
let result = insert_items(self.db, self.rebuilds_keys, table_name, pending.items)
.map_err(|e| format!("Batch import error for '{}': {e}", table_name))?;
landed.items += result.items_imported;
landed.bytes += result.bytes_imported;
if let Some(deriver) = landed.key_deriver.as_mut() {
deriver.commit(pending.observed);
}
for (path, count) in pending.mask_passthroughs {
*landed.mask_passthroughs.entry(path).or_insert(0) += count;
}
for (total, seen) in self.rule_work.iter_mut().zip(pending.rule_work) {
total.add(seen);
}
for notice in pending.notices {
if self.seen_warnings.insert(notice.message.clone()) {
summary.notice(notice);
}
}
Ok(())
}
}
struct Pending {
items: Vec<crate::types::Item>,
observed: keys::Batch,
notices: Vec<Notice>,
raised: HashSet<String>,
mask_passthroughs: std::collections::HashMap<String, usize>,
rule_work: Vec<anonymise::RuleWork>,
}
impl Pending {
fn with_capacity(items: usize, rules: usize) -> Self {
Pending {
items: Vec::with_capacity(items),
observed: keys::Batch::default(),
notices: Vec::new(),
raised: HashSet::new(),
mask_passthroughs: std::collections::HashMap::new(),
rule_work: vec![Default::default(); rules],
}
}
fn raise(&mut self, notice: Notice, already_raised: &HashSet<String>) {
if already_raised.contains(¬ice.message) {
return;
}
if self.raised.insert(notice.message.clone()) {
self.notices.push(notice);
}
}
}
struct Landed {
key_deriver: Option<keys::KeyDeriver>,
mask_passthroughs: std::collections::HashMap<String, usize>,
items: usize,
bytes: usize,
}
fn insert_items(
db: &Database,
rebuilds_keys: bool,
table: &str,
batch: Vec<crate::types::Item>,
) -> crate::Result<crate::ImportResult> {
if rebuilds_keys {
db.import_items(table, batch, ImportOptions::default())
} else {
db.import_items_fresh(table, batch, ImportOptions::default())
}
}
fn report_unused_rules(
summary: &mut ImportSummary,
rules: &[config::ValidatedRule],
rule_work: &[anonymise::RuleWork],
export_files: &[(String, Vec<std::path::PathBuf>)],
) {
for (index, work) in rule_work.iter().enumerate() {
let number = index + 1;
let path = rules
.get(index)
.map(|r| crate::expressions::format_path_for_error(&r.path))
.unwrap_or_default();
if let Some(tables) = rules.get(index).and_then(|r| r.tables.as_ref())
&& !tables
.iter()
.any(|t| export_files.iter().any(|(name, _)| name == t))
{
summary.notice(Notice::caution(format!(
"rule {number} (path '{path}') is scoped to {} and none of those tables is in \
this run, so it was not applied",
quoted_names(tables)
)));
continue;
}
if work.matched == 0 {
summary.notice(Notice::exposure(format!(
"rule {number} (path '{path}') matched no item in any table, so nothing \
was anonymised by it. Check the match expression, and check the path is \
spelled the way the export spells it"
)));
} else if work.rewrote == 0 {
summary.notice(Notice::exposure(format!(
"rule {number} matched {} items but rewrote none of them: not one carried \
the path '{path}'. The attribute it names is absent from this data",
work.matched
)));
}
}
}
pub fn run(cmd: ImportCommand) -> Result<ImportSummary, ImportError> {
let output = cmd
.output
.as_ref()
.ok_or_else(|| ImportError::Config("output path required for file-based import".into()))?;
let compress = cmd.compress;
let force = cmd.force;
let final_path = if compress {
output.with_extension("db.zst")
} else {
output.clone()
};
if final_path.exists() && !cmd.force {
return Err(ImportError::Config(format!(
"Output file '{}' already exists. Use --force to overwrite.",
final_path.display()
)));
}
let output_path = output.clone();
let output_dir = output_path.parent().unwrap_or(Path::new("."));
let tmp_file = tempfile::NamedTempFile::new_in(output_dir)
.map_err(|e| ImportError::Database(format!("Failed to create temp file: {e}")))?;
let tmp_path = tmp_file.path().to_path_buf();
let tmp_file = tmp_file.into_temp_path();
let db = Database::new(
tmp_path
.to_str()
.ok_or_else(|| ImportError::Config("Invalid temp path".to_string()))?,
)
.map_err(|e| ImportError::Database(format!("Failed to create output database: {e}")))?;
let mut summary = run_into(&db, cmd)?;
drop(db);
{
let db = Database::new(
tmp_path
.to_str()
.ok_or_else(|| ImportError::Config("Invalid temp path".to_string()))?,
)
.map_err(|e| ImportError::Database(format!("Failed to reopen database for VACUUM: {e}")))?;
db.vacuum()
.map_err(|e| ImportError::Database(format!("VACUUM failed: {e}")))?;
}
eprintln!("Database compacted.");
if compress {
let compressed_path = final_path;
let compressed_tmp = tempfile::NamedTempFile::new_in(output_dir)
.map_err(|e| ImportError::Database(format!("Failed to create temp file: {e}")))?
.into_temp_path();
let size = compress_to(&tmp_path, &compressed_tmp)?;
move_into_place(compressed_tmp, &compressed_path, force)?;
eprintln!("Compressed output: {}", format_bytes(size));
summary.output_path = Some(compressed_path);
return Ok(summary);
}
move_into_place(tmp_file, &output_path, force)?;
summary.output_path = Some(output_path);
Ok(summary)
}
fn find_table_json(schema_json: &serde_json::Value, table_name: &str) -> Option<serde_json::Value> {
let items: Vec<&serde_json::Value> = match schema_json {
serde_json::Value::Array(arr) => arr.iter().collect(),
obj @ serde_json::Value::Object(_) => vec![obj],
_ => return None,
};
for item in items {
let table = item.get("Table").unwrap_or(item);
if table.get("TableName").and_then(|v| v.as_str()) == Some(table_name) {
let mut table = table.clone();
unwrap_describe_table_shapes(&mut table);
return Some(table);
}
}
None
}
pub(super) fn unwrap_describe_table_shapes(table: &mut serde_json::Value) {
let Some(obj) = table.as_object_mut() else {
return;
};
let mut billing_mode_hoisted = false;
for (summary_key, field_key) in [
("BillingModeSummary", "BillingMode"),
("TableClassSummary", "TableClass"),
] {
if !obj.contains_key(field_key)
&& let Some(value) = obj.get(summary_key).and_then(|s| s.get(field_key))
{
let value = value.clone();
obj.insert(field_key.to_string(), value);
billing_mode_hoisted |= field_key == "BillingMode";
}
}
if billing_mode_hoisted
&& obj.get("BillingMode").and_then(|v| v.as_str()) == Some("PAY_PER_REQUEST")
{
obj.remove("ProvisionedThroughput");
if let Some(gsis) = obj
.get_mut("GlobalSecondaryIndexes")
.and_then(|v| v.as_array_mut())
{
for gsi in gsis {
if let Some(gsi) = gsi.as_object_mut() {
gsi.remove("ProvisionedThroughput");
}
}
}
}
}
fn mixed_consistency_rules(
rules: &[config::ValidatedRule],
consistency_fields: &HashSet<String>,
) -> Vec<String> {
use crate::expressions::PathElement;
let mut messages = Vec::new();
for field in consistency_fields {
let mut secrets: Vec<zeroize::Zeroizing<Vec<u8>>> = Vec::new();
let mut secret_number = |bytes: &[u8]| -> usize {
match secrets.iter().position(|known| known.as_slice() == bytes) {
Some(i) => i + 1,
None => {
secrets.push(zeroize::Zeroizing::new(bytes.to_vec()));
secrets.len()
}
}
};
let mut shapes: Vec<String> = rules
.iter()
.filter(|rule| {
matches!(rule.path.first(), Some(PathElement::Attribute(name)) if name == field)
})
.map(|rule| match &rule.action {
config::ValidatedAction::Fake {
generator,
seed: Some(seed),
} => {
let n = secret_number(seed.as_bytes());
format!("seeded fake '{generator}' (seed {n})")
}
config::ValidatedAction::Fake {
generator,
seed: None,
} => format!("unseeded fake '{generator}'"),
config::ValidatedAction::Hash { salt } => {
let n = secret_number(salt.as_bytes());
format!("hash (salt {n})")
}
other => format!("{other:?}")
.split_whitespace()
.next()
.unwrap_or("action")
.to_lowercase(),
})
.collect();
shapes.sort();
shapes.dedup();
if shapes.len() > 1 {
messages.push(format!(
"'{field}' is in [consistency] fields but its rules do not agree on how they \
generate ({}). A seeded fake derives its value and does not use the consistency \
map, so mixing it with anything else can give one input two different values",
shapes.join(", ")
));
}
}
messages.sort();
messages
}
fn refuse_scopes_outside_the_schema(
rules: &[config::ValidatedRule],
schemas: &[schema::TableSchema],
) -> Result<(), ImportError> {
for (index, rule) in rules.iter().enumerate() {
let Some(tables) = rule.tables.as_ref() else {
continue;
};
let missing: Vec<String> = tables
.iter()
.filter(|name| !schemas.iter().any(|s| &s.table_name == *name))
.cloned()
.collect();
if missing.is_empty() {
continue;
}
let known: Vec<String> = schemas.iter().map(|s| s.table_name.clone()).collect();
return Err(ImportError::Config(format!(
"rule {} (path '{}') is scoped to {}, and the schema file has no such {}. Its \
tables are {}",
index + 1,
crate::expressions::format_path_for_error(&rule.path),
quoted_names(&missing),
if missing.len() > 1 { "tables" } else { "table" },
quoted_names(&known)
)));
}
Ok(())
}
pub(crate) fn quoted_names<I, S>(names: I) -> String
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let quoted: Vec<String> = names
.into_iter()
.map(|n| format!("'{}'", n.as_ref()))
.collect();
match quoted.split_last() {
Some((last, [])) => last.clone(),
Some((last, rest)) => format!("{} and {last}", rest.join(", ")),
None => String::new(),
}
}
struct BulkLoading<'a> {
db: &'a Database,
}
impl Drop for BulkLoading<'_> {
fn drop(&mut self) {
let _ = self.db.disable_bulk_loading();
}
}
fn extract_key_attrs(request: &crate::actions::create_table::CreateTableRequest) -> Vec<String> {
request
.key_schema
.iter()
.map(|ks| ks.attribute_name.clone())
.collect()
}
fn index_key_attrs(request: &crate::actions::create_table::CreateTableRequest) -> Vec<String> {
let base = extract_key_attrs(request);
let mut names = Vec::new();
let mut push = |name: &String| {
if !base.contains(name) && !names.contains(name) {
names.push(name.clone());
}
};
for gsi in request.global_secondary_indexes.as_deref().unwrap_or(&[]) {
for ks in &gsi.key_schema {
push(&ks.attribute_name);
}
}
for lsi in request.local_secondary_indexes.as_deref().unwrap_or(&[]) {
for ks in &lsi.key_schema {
push(&ks.attribute_name);
}
}
names
}
fn move_into_place(tmp: tempfile::TempPath, dst: &Path, force: bool) -> Result<(), ImportError> {
let moved = if force {
tmp.persist(dst)
} else {
tmp.persist_noclobber(dst)
};
moved.map_err(|e| {
ImportError::Database(format!(
"Failed to move database to output path '{}': {}",
dst.display(),
e.error
))
})
}
fn compress_to(src: &Path, dst: &Path) -> Result<usize, ImportError> {
let input = std::fs::File::open(src).map_err(|e| {
ImportError::Database(format!(
"Failed to open {} for compression: {e}",
src.display()
))
})?;
let output = std::fs::File::create(dst)
.map_err(|e| ImportError::Database(format!("Failed to create {}: {e}", dst.display())))?;
let mut encoder = zstd::Encoder::new(output, 3)
.map_err(|e| ImportError::Database(format!("Failed to create zstd encoder: {e}")))?;
std::io::copy(&mut std::io::BufReader::new(input), &mut encoder)
.map_err(|e| ImportError::Database(format!("Compression failed: {e}")))?;
encoder
.finish()
.map_err(|e| ImportError::Database(format!("Failed to finalise compression: {e}")))?;
Ok(std::fs::metadata(dst)
.map(|m| m.len() as usize)
.unwrap_or(0))
}
fn format_bytes(bytes: usize) -> String {
if bytes < 1024 {
format!("{bytes} B")
} else if bytes < 1024 * 1024 {
format!("{:.1} KB", bytes as f64 / 1024.0)
} else {
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
}
}
#[cfg(all(test, feature = "import"))]
mod tests {
use super::*;
#[test]
fn the_text_views_are_the_notices_in_order_and_the_exposures_among_them() {
let mut summary = ImportSummary {
tables: Vec::new(),
total_items: 0,
total_bytes: 0,
total_skipped: 0,
notices: Vec::new(),
warnings: vec!["stale".to_string()],
exposures: vec!["stale".to_string()],
output_path: None,
};
summary.notice(Notice::caution("first, a caution"));
summary.notice(Notice::exposure("then an exposure"));
summary.notice(Notice::caution("last, a caution"));
summary.finish();
assert_eq!(
summary.warnings,
vec!["first, a caution", "then an exposure", "last, a caution"]
);
assert_eq!(summary.exposures, vec!["then an exposure"]);
}
#[test]
fn a_file_that_appeared_after_the_check_is_not_replaced_without_force() {
let dir = tempfile::tempdir().unwrap();
let dst = dir.path().join("out.db");
std::fs::write(&dst, b"theirs").unwrap();
let tmp = tempfile::NamedTempFile::new_in(dir.path()).unwrap();
std::fs::write(tmp.path(), b"ours").unwrap();
let err = move_into_place(tmp.into_temp_path(), &dst, false).unwrap_err();
assert!(err.to_string().contains("out.db"), "{err}");
assert_eq!(std::fs::read(&dst).unwrap(), b"theirs", "theirs survives");
let tmp = tempfile::NamedTempFile::new_in(dir.path()).unwrap();
std::fs::write(tmp.path(), b"ours").unwrap();
move_into_place(tmp.into_temp_path(), &dst, true).unwrap();
assert_eq!(std::fs::read(&dst).unwrap(), b"ours", "--force replaces it");
}
}