use std::collections::{BTreeMap, HashMap, HashSet};
use std::hash::{Hash, Hasher};
use crate::actions::create_table::CreateTableRequest;
use crate::expressions::{PathElement, resolve_path};
use crate::schema::{DataModel, EntityDefinition};
use crate::types::{AttributeValue, GlobalSecondaryIndex, Item};
use crate::validation::{partition_key_name, sort_key_name};
use super::anonymise::{Rewritten, covers, path_label};
use super::config::{ValidatedAction, ValidatedRule, parse_path};
use super::notice::Notice;
pub(super) const MAX_TRACKED_KEYS: usize = 1_000_000;
const MAX_PAD_LENGTH: usize = 2048;
#[derive(Debug, Clone, PartialEq)]
enum Segment {
Literal(String),
Var {
path: Vec<PathElement>,
pad: Option<Padding>,
},
}
#[derive(Debug, Clone, PartialEq)]
struct Padding {
length: usize,
fill: String,
}
impl Padding {
fn apply(&self, value: &str) -> String {
if self.fill.is_empty() {
return value.to_string();
}
let have = value.encode_utf16().count();
if have >= self.length {
return value.to_string();
}
let shortfall = self.length - have;
let mut out = String::new();
let mut units = 0usize;
for c in self.fill.chars().cycle() {
let width = c.len_utf16();
if units + width > shortfall {
break;
}
out.push(c);
units += width;
}
out.push_str(value);
out
}
}
fn parse_template(template: &str) -> Result<Vec<Segment>, String> {
let mut segments = Vec::new();
let mut rest = template;
while let Some(start) = rest.find("${") {
let Some(len) = rest[start..].find('}') else {
return Err(format!("template '{template}' has an unclosed '${{'"));
};
if start > 0 {
segments.push(Segment::Literal(rest[..start].to_string()));
}
let var = &rest[start + 2..start + len];
let mut parts = var.split(':');
let name = parts.next().unwrap_or(var);
let pad = match parts.next() {
Some(length) => {
let length: usize = length.trim().parse().map_err(|_| {
format!(
"template '{template}': '{length}' in '${{{var}}}' is not a length; \
the form is '${{name:length:pad}}'"
)
})?;
if length > MAX_PAD_LENGTH {
return Err(format!(
"template '{template}': a pad length of {length} is beyond the {MAX_PAD_LENGTH} \
a key can usefully hold"
));
}
let fill = match parts.next() {
Some("") | None => "0".to_string(),
Some(fill) => fill.to_string(),
};
Some(Padding { length, fill })
}
None => None,
};
let path = parse_path(name)
.map_err(|e| format!("template '{template}' has an invalid reference '{name}': {e}"))?;
segments.push(Segment::Var { path, pad });
rest = &rest[start + len + 1..];
}
if !rest.is_empty() {
segments.push(Segment::Literal(rest.to_string()));
}
Ok(segments)
}
fn render(segments: &[Segment], item: &Item) -> Option<String> {
let mut out = String::new();
for segment in segments {
match segment {
Segment::Literal(s) => out.push_str(s),
Segment::Var { path, pad } => {
let rendered = match resolve_path(item, path)? {
AttributeValue::S(s) => s,
AttributeValue::N(n) => n,
AttributeValue::BOOL(b) => if b { "true" } else { "false" }.to_string(),
_ => return None,
};
match pad {
Some(pad) => out.push_str(&pad.apply(&rendered)),
None => out.push_str(&rendered),
}
}
}
}
Some(out)
}
#[derive(Debug, Clone)]
struct TemplatedKey {
attribute: String,
template: String,
segments: Vec<Segment>,
}
impl TemplatedKey {
fn source_paths(&self) -> impl Iterator<Item = &Vec<PathElement>> {
self.segments.iter().filter_map(|s| match s {
Segment::Var { path, .. } => Some(path),
Segment::Literal(_) => None,
})
}
fn sources(&self) -> impl Iterator<Item = &str> {
self.source_paths().filter_map(|path| match path.first() {
Some(PathElement::Attribute(name)) => Some(name.as_str()),
_ => None,
})
}
}
#[derive(Debug, Clone)]
struct EntityKeys {
name: String,
type_attribute: String,
keys: Vec<TemplatedKey>,
match_keys: Vec<TemplatedKey>,
}
#[derive(Debug)]
struct AtRisk {
key_attribute: String,
grouped_by: Vec<String>,
consistency_roots: Vec<String>,
entities: Vec<usize>,
observers: Vec<usize>,
seen_keys: std::collections::HashMap<u64, Vec<Outcome>>,
diverged: HashSet<usize>,
split_alone: HashSet<usize>,
unchecked: usize,
}
#[derive(Debug, Clone, Copy)]
struct Outcome {
entity: usize,
first: u64,
multiple: bool,
}
#[derive(Debug)]
pub struct Rederivation {
entity: usize,
keys: Vec<usize>,
rule_owned: Vec<usize>,
originals: Vec<(String, String)>,
at_risk_originals: Vec<(usize, u64)>,
}
#[derive(Debug, Default)]
pub struct Batch {
unmatched: usize,
ambiguous: HashSet<Vec<usize>>,
mismatch: HashMap<(usize, usize), usize>,
unrenderable: HashMap<(usize, usize), usize>,
rule_wins: HashSet<(usize, usize)>,
rule_skipped: HashMap<(usize, usize), usize>,
untemplated_index_keys: BTreeMap<String, usize>,
survived: HashMap<(usize, String), usize>,
primary_keys: Vec<u64>,
at_risk_results: Vec<(usize, usize, u64, u64)>,
}
fn first_sighting<K: Eq + Hash>(
committed: &HashMap<K, usize>,
batch: &mut HashMap<K, usize>,
key: K,
) -> bool {
let known = committed.contains_key(&key);
let seen = batch.entry(key).or_insert(0);
*seen += 1;
*seen == 1 && !known
}
type KeyShape = (String, String, Vec<Vec<PathElement>>);
type SharedGroups = Vec<(String, (Vec<usize>, Vec<String>, Vec<usize>))>;
struct BuiltEntities {
entities: Vec<EntityKeys>,
key_shapes: Vec<Vec<KeyShape>>,
unmatched_indexes: Vec<String>,
}
#[derive(Debug)]
pub struct KeyDeriver {
entities: Vec<EntityKeys>,
type_attributes: Vec<String>,
hash_attribute: Option<String>,
range_attribute: Option<String>,
warned_mismatch: HashMap<(usize, usize), usize>,
rule_targets: HashSet<String>,
schema_keys: Vec<String>,
rule_paths: Vec<Vec<PathElement>>,
survived: HashMap<(usize, String), usize>,
warned_unrenderable: HashMap<(usize, usize), usize>,
unmatched: usize,
warned_rule_wins: HashSet<(usize, usize)>,
warned_rule_skipped: HashMap<(usize, usize), usize>,
unmatched_model_indexes: Vec<String>,
unnamed_index_keys: Vec<String>,
untemplated_index_keys: BTreeMap<String, usize>,
warned_ambiguous: HashSet<Vec<usize>>,
at_risk: Vec<AtRisk>,
rebuilt_keys: HashSet<u64>,
collisions: usize,
collision_check_capped: bool,
collision_cap_reported: bool,
}
impl KeyDeriver {
pub fn new(
model: &DataModel,
request: &CreateTableRequest,
rules: &[ValidatedRule],
consistency_fields: &HashSet<String>,
) -> Result<(Self, Vec<Notice>), String> {
let hash = partition_key_name(&request.key_schema);
let range = sort_key_name(&request.key_schema);
let gsis = request.global_secondary_indexes.as_deref().unwrap_or(&[]);
let schema_keys = Self::schema_keys(request);
let unnamed_index_keys = Self::unnamed_index_keys(model, gsis);
let grouped_by = Self::grouped_by(hash, range, gsis);
let rules: Vec<&ValidatedRule> = rules
.iter()
.filter(|rule| rule.applies_to(&request.table_name))
.collect();
let rule_targets: HashSet<&str> = rules.iter().filter_map(|r| rule_target(r)).collect();
let rule_paths: Vec<Vec<PathElement>> = rules.iter().map(|r| r.path.clone()).collect();
let mut warnings: Vec<Notice> = Vec::new();
warnings.extend(Self::lsi_notice(request));
let built = Self::build_entities(
model,
gsis,
hash,
range,
&rules,
&rule_targets,
&mut warnings,
)?;
let shared = Self::shared_key_groups(&built.key_shapes, &rule_targets);
let at_risk = Self::at_risk_groups(
shared,
&built.entities,
&grouped_by,
consistency_fields,
&mut warnings,
);
let mut type_attributes: Vec<String> = built
.entities
.iter()
.map(|e| e.type_attribute.clone())
.collect();
type_attributes.sort();
type_attributes.dedup();
Ok((
Self {
entities: built.entities,
type_attributes,
hash_attribute: hash.map(String::from),
range_attribute: range.map(String::from),
warned_mismatch: HashMap::new(),
rule_targets: rule_targets.iter().map(|a| (*a).to_string()).collect(),
schema_keys,
rule_paths,
survived: HashMap::new(),
warned_unrenderable: HashMap::new(),
warned_rule_wins: HashSet::new(),
warned_ambiguous: HashSet::new(),
unmatched: 0,
at_risk,
rebuilt_keys: HashSet::new(),
collisions: 0,
collision_check_capped: false,
collision_cap_reported: false,
warned_rule_skipped: HashMap::new(),
unmatched_model_indexes: built.unmatched_indexes,
unnamed_index_keys,
untemplated_index_keys: BTreeMap::new(),
},
warnings,
))
}
fn schema_keys(request: &CreateTableRequest) -> Vec<String> {
let mut schema_keys: Vec<String> = Vec::new();
for name in partition_key_name(&request.key_schema)
.into_iter()
.chain(sort_key_name(&request.key_schema))
{
schema_keys.push(name.to_string());
}
let gsis = request.global_secondary_indexes.as_deref().unwrap_or(&[]);
let lsis = request.local_secondary_indexes.as_deref().unwrap_or(&[]);
for key_schema in gsis
.iter()
.map(|index| &index.key_schema)
.chain(lsis.iter().map(|index| &index.key_schema))
{
for ks in key_schema {
if !schema_keys.contains(&ks.attribute_name) {
schema_keys.push(ks.attribute_name.clone());
}
}
}
schema_keys
}
fn unnamed_index_keys(model: &DataModel, gsis: &[GlobalSecondaryIndex]) -> Vec<String> {
let named_indexes: HashSet<&str> = model
.entities
.iter()
.flat_map(|e| e.gsi_mappings.iter().map(|m| m.index_name.as_str()))
.collect();
let mut unnamed_index_keys: Vec<String> = Vec::new();
for index in gsis
.iter()
.filter(|g| !named_indexes.contains(g.index_name.as_str()))
{
for ks in &index.key_schema {
if !unnamed_index_keys.contains(&ks.attribute_name) {
unnamed_index_keys.push(ks.attribute_name.clone());
}
}
}
unnamed_index_keys
}
fn grouped_by<'a>(
hash: Option<&'a str>,
range: Option<&'a str>,
gsis: &'a [GlobalSecondaryIndex],
) -> HashMap<&'a str, Vec<&'a str>> {
let mut partition_keys: HashSet<&str> = HashSet::new();
partition_keys.extend(hash);
for gsi in gsis {
partition_keys.extend(partition_key_name(&gsi.key_schema));
}
let mut grouped_by: HashMap<&str, Vec<&str>> = HashMap::new();
if let (Some(range), Some(hash)) = (range, hash) {
grouped_by.entry(range).or_default().push(hash);
}
for gsi in gsis {
if let (Some(sort), Some(part)) = (
sort_key_name(&gsi.key_schema),
partition_key_name(&gsi.key_schema),
) {
let partitions = grouped_by.entry(sort).or_default();
if !partitions.contains(&part) {
partitions.push(part);
}
}
}
grouped_by.retain(|attribute, _| !partition_keys.contains(attribute));
grouped_by
}
fn lsi_notice(request: &CreateTableRequest) -> Option<Notice> {
let lsis = request.local_secondary_indexes.as_deref()?;
if lsis.is_empty() {
return None;
}
Some(Notice::caution(format!(
"has {} local secondary index(es); their sort keys are not rebuilt from \
templates and keep the values they arrive with",
lsis.len()
)))
}
fn build_entities(
model: &DataModel,
gsis: &[GlobalSecondaryIndex],
hash: Option<&str>,
range: Option<&str>,
rules: &[&ValidatedRule],
rule_targets: &HashSet<&str>,
warnings: &mut Vec<Notice>,
) -> Result<BuiltEntities, String> {
let mut unmatched_indexes: HashSet<String> = HashSet::new();
let mut entities = Vec::with_capacity(model.entities.len());
let mut key_shapes: Vec<Vec<KeyShape>> = Vec::with_capacity(model.entities.len());
for entity in &model.entities {
let mut match_keys = Vec::new();
push_key(&mut match_keys, entity, hash, Some(&entity.pk_template))?;
push_key(
&mut match_keys,
entity,
range,
entity.sk_template.as_deref(),
)?;
for mapping in &entity.gsi_mappings {
let Some(gsi) = gsis.iter().find(|g| g.index_name == mapping.index_name) else {
if unmatched_indexes.insert(mapping.index_name.clone()) {
warnings.push(Notice::caution(format!(
"the data model has an index '{}' that the table does not, so \
nothing rebuilds its keys. Set the OneTable index's \"name\" to \
the DynamoDB index name",
mapping.index_name
)));
}
continue;
};
push_key(
&mut match_keys,
entity,
partition_key_name(&gsi.key_schema),
Some(&mapping.pk_template),
)?;
push_key(
&mut match_keys,
entity,
sort_key_name(&gsi.key_schema),
mapping.sk_template.as_deref(),
)?;
}
let keys: Vec<TemplatedKey> = match_keys
.iter()
.filter(|key| {
key.segments
.iter()
.any(|s| matches!(s, Segment::Var { .. }))
|| rule_targets.contains(key.attribute.as_str())
})
.cloned()
.collect();
for key in &keys {
for source in key.sources() {
if keys
.iter()
.any(|other| other.attribute == source && other.attribute != key.attribute)
{
return Err(format!(
"entity '{}': template '{}' for {} reads key attribute '{}', which is \
itself built from a template; import cannot order those safely, so \
build both keys from plain attributes instead",
entity.name, key.template, key.attribute, source
));
}
}
}
key_shapes.push(
keys.iter()
.map(|key| {
(
key.attribute.clone(),
key.template.clone(),
key.source_paths().cloned().collect(),
)
})
.collect(),
);
for key in &keys {
if rule_targets.contains(key.attribute.as_str()) {
warnings.push(Notice::caution(format!(
"a rule targets key attribute '{}' directly, so on any item that rule \
matches it is not rebuilt from entity '{}' template '{}' and the \
rule's value is kept as written",
key.attribute, entity.name, key.template
)));
}
}
for key in &keys {
for rule in rules {
let Some(target) = rule_target(rule) else {
continue;
};
if !key.sources().any(|source| source == target) {
continue;
}
let consequence = match rule.action {
ValidatedAction::Null => format!(
"no item of that entity can rebuild {}, so every one of them keeps \
the key value it arrived with. Use fake or hash instead",
key.attribute
),
ValidatedAction::Redact => format!(
"every item of that entity renders the same {} and overwrites the \
last, leaving fewer rows than the export. Use fake or hash instead",
key.attribute
),
ValidatedAction::Mask { .. } => format!(
"items of that entity whose '{target}' masks to the same text render \
the same {} and overwrite each other. Use fake or hash instead",
key.attribute
),
ValidatedAction::Fake { .. } | ValidatedAction::Hash { .. } => continue,
};
warnings.push(Notice::caution(format!(
"a rule rewrites '{target}', which entity '{}' builds {} from via \
template '{}': {consequence}",
entity.name, key.attribute, key.template
)));
}
}
entities.push(EntityKeys {
name: entity.name.clone(),
type_attribute: type_attribute(model, entity),
keys,
match_keys,
});
}
let mut unmatched_indexes: Vec<String> = unmatched_indexes.into_iter().collect();
unmatched_indexes.sort();
Ok(BuiltEntities {
entities,
key_shapes,
unmatched_indexes,
})
}
fn shared_key_groups(
key_shapes: &[Vec<KeyShape>],
rule_targets: &HashSet<&str>,
) -> SharedGroups {
let mut shared: SharedGroups = Vec::new();
for (idx, shape) in key_shapes.iter().enumerate() {
for (attribute, _template, sources) in shape {
let roots: Vec<String> = sources
.iter()
.filter_map(|source| match source.first() {
Some(PathElement::Attribute(root)) => Some(root.clone()),
_ => None,
})
.filter(|root| rule_targets.contains(root.as_str()))
.collect();
let group = match shared.iter_mut().find(|(k, _)| k == attribute) {
Some((_, group)) => group,
None => {
shared.push((attribute.clone(), (Vec::new(), Vec::new(), Vec::new())));
&mut shared.last_mut().expect("just pushed").1
}
};
let (users, known_roots, observers) = group;
let rewrites_key_directly = rule_targets.contains(attribute.as_str());
if roots.is_empty() && !rewrites_key_directly {
if !observers.contains(&idx) {
observers.push(idx);
}
continue;
}
if !users.contains(&idx) {
users.push(idx);
}
for root in roots {
if !known_roots.contains(&root) {
known_roots.push(root);
}
}
}
}
for (_, (users, roots, observers)) in shared.iter_mut() {
users.sort();
roots.sort();
observers.sort();
observers.retain(|o| !users.contains(o));
}
shared.retain(|(_, (users, _, _))| !users.is_empty());
shared.sort_by(|a, b| a.0.cmp(&b.0));
shared
}
fn at_risk_groups(
shared: SharedGroups,
entities: &[EntityKeys],
grouped_by: &HashMap<&str, Vec<&str>>,
consistency_fields: &HashSet<String>,
warnings: &mut Vec<Notice>,
) -> Vec<AtRisk> {
shared
.into_iter()
.map(|(key_attribute, (entities_using, roots, observers))| {
let unlisted: Vec<String> = roots
.iter()
.filter(|root| !consistency_fields.contains(*root))
.cloned()
.collect();
if !unlisted.is_empty() && entities_using.len() > 1 {
warnings.push(Notice::caution(format!(
"{} both build {} from an attribute outside [consistency] fields ({}): \
if they both appear in this import their keys may not agree and the \
entities will not join",
entity_list(entities, &entities_using),
key_attribute,
super::quoted_names(&unlisted)
)));
}
AtRisk {
grouped_by: grouped_by
.get(key_attribute.as_str())
.map(|parts| parts.iter().map(|a| (*a).to_string()).collect())
.unwrap_or_default(),
key_attribute,
consistency_roots: unlisted,
entities: entities_using,
observers,
seen_keys: std::collections::HashMap::new(),
diverged: HashSet::new(),
split_alone: HashSet::new(),
unchecked: 0,
}
})
.collect()
}
pub fn plan(
&self,
item: &Item,
batch: &mut Batch,
warnings: &mut Vec<Notice>,
) -> Option<Rederivation> {
let (entity_idx, also) = match self.resolve_entity(item) {
Some(found) => found,
None => {
batch.unmatched += 1;
return None;
}
};
if !also.is_empty() {
let mut group = vec![entity_idx];
group.extend(also.iter().copied());
if !self.warned_ambiguous.contains(&group) && batch.ambiguous.insert(group.clone()) {
warnings.push(Notice::caution(format!(
"{} all reproduce the keys of at least one item that carries no type \
attribute, so it was taken as entity '{}', the first in the model. Give \
items a type attribute if that is not the entity they belong to",
entity_list(&self.entities, &group),
self.entities[entity_idx].name
)));
}
}
let entity = &self.entities[entity_idx];
let mut keys = Vec::new();
let mut rule_owned = Vec::new();
let mut rewritten_directly: Vec<&str> = Vec::new();
for (idx, key) in entity.keys.iter().enumerate() {
let Some(current) = item.get(&key.attribute) else {
continue;
};
let reproduced = match current {
AttributeValue::S(s) => render(&key.segments, item).as_deref() == Some(s.as_str()),
_ => false,
};
if reproduced {
keys.push(idx);
} else if self.rule_targets.contains(&key.attribute) {
rewritten_directly.push(key.attribute.as_str());
rule_owned.push(idx);
} else {
if first_sighting(
&self.warned_mismatch,
&mut batch.mismatch,
(entity_idx, idx),
) {
warnings.push(Notice::caution(format!(
"entity '{}': template '{}' does not reproduce {} (the attributes it \
names are missing, not scalars, or the key was built differently); \
such keys are left unchanged, and the count is reported once the \
table has been read",
entity.name, key.template, key.attribute
)));
}
}
}
let mut changing: Vec<&str> = keys
.iter()
.map(|idx| entity.keys[*idx].attribute.as_str())
.collect();
changing.extend(rewritten_directly);
let at_risk_originals = self.at_risk_originals(entity_idx, item, &changing);
let originals: Vec<(String, String)> = self
.rule_paths
.iter()
.filter_map(|path| {
let label = path_label(path);
let mut leaves = Vec::new();
scalar_leaves(resolve_path(item, path)?, &mut leaves);
Some(leaves.into_iter().map(move |leaf| (label.clone(), leaf)))
})
.flatten()
.collect();
Some(Rederivation {
entity: entity_idx,
keys,
rule_owned,
originals,
at_risk_originals,
})
}
fn at_risk_originals(
&self,
entity_idx: usize,
item: &Item,
rebuilt: &[&str],
) -> Vec<(usize, u64)> {
self.at_risk
.iter()
.enumerate()
.filter(|(_, risk)| {
(risk.entities.contains(&entity_idx) || risk.observers.contains(&entity_idx))
&& rebuilt.iter().any(|a| *a == risk.key_attribute)
})
.flat_map(|(idx, risk)| {
let Some(key) = item.get(&risk.key_attribute) else {
return Vec::new();
};
if risk.grouped_by.is_empty() {
return group_hash(None, key)
.map(|h| (idx, h))
.into_iter()
.collect();
}
risk.grouped_by
.iter()
.filter_map(|attribute| {
let value = item.get(attribute)?;
group_hash(Some((attribute.as_str(), value)), key).map(|h| (idx, h))
})
.collect()
})
.collect()
}
fn note_at_risk_results(&self, plan: &Rederivation, item: &Item, batch: &mut Batch) {
for (risk_idx, original) in &plan.at_risk_originals {
let risk = &self.at_risk[*risk_idx];
let Some(now) = item.get(&risk.key_attribute).and_then(scalar_hash) else {
continue;
};
batch
.at_risk_results
.push((plan.entity, *risk_idx, *original, now));
}
}
fn record_at_risk_result(&mut self, entity: usize, risk_idx: usize, original: u64, now: u64) {
let risk = &mut self.at_risk[risk_idx];
if !risk.seen_keys.contains_key(&original) {
if risk.seen_keys.len() >= MAX_TRACKED_KEYS {
risk.unchecked += 1;
return;
}
risk.seen_keys.insert(original, Vec::new());
}
let groups_rows = risk.grouped_by.is_empty();
let outcomes = risk.seen_keys.get_mut(&original).expect("just inserted");
match outcomes.iter_mut().find(|o| o.entity == entity) {
Some(mine) => mine.multiple |= mine.first != now,
None => outcomes.push(Outcome {
entity,
first: now,
multiple: false,
}),
}
let mut diverged: Vec<usize> = Vec::new();
let mut split_alone: Vec<usize> = Vec::new();
for (i, a) in outcomes.iter().enumerate() {
if a.multiple {
if groups_rows {
diverged.push(a.entity);
} else {
split_alone.push(a.entity);
}
}
for b in outcomes.iter().skip(i + 1) {
if a.first != b.first || a.multiple || b.multiple {
diverged.push(a.entity);
diverged.push(b.entity);
}
}
}
risk.diverged.extend(diverged);
risk.split_alone.extend(split_alone);
}
pub fn apply(
&self,
plan: &Rederivation,
rewritten: &Rewritten,
item: &mut Item,
batch: &mut Batch,
warnings: &mut Vec<Notice>,
) {
let entity = &self.entities[plan.entity];
let mut rule_wins = Vec::new();
for &idx in &plan.keys {
let key = &entity.keys[idx];
if rewritten.attributes.contains(&key.attribute) {
rule_wins.push(idx);
continue;
}
match render(&key.segments, item) {
Some(value) => {
item.insert(key.attribute.clone(), AttributeValue::S(value));
}
None => {
if first_sighting(
&self.warned_unrenderable,
&mut batch.unrenderable,
(plan.entity, idx),
) {
warnings.push(Notice::caution(format!(
"entity '{}': cannot rebuild {} from template '{}' after the rules \
ran (an attribute it needs is no longer a string or number); \
the original key value is left in place, and the count is \
reported once the table has been read",
entity.name, key.attribute, key.template
)));
}
}
}
}
for idx in rule_wins {
if !self.warned_rule_wins.contains(&(plan.entity, idx))
&& batch.rule_wins.insert((plan.entity, idx))
{
let key = &self.entities[plan.entity].keys[idx];
warnings.push(Notice::caution(format!(
"entity '{}': a rule rewrote {} directly, so it was not rebuilt from \
template '{}'",
self.entities[plan.entity].name, key.attribute, key.template
)));
}
}
for &idx in &plan.rule_owned {
let key = &self.entities[plan.entity].keys[idx];
if rewritten.attributes.contains(&key.attribute) {
continue;
}
if first_sighting(
&self.warned_rule_skipped,
&mut batch.rule_skipped,
(plan.entity, idx),
) {
warnings.push(Notice::caution(format!(
"entity '{}': a rule names {} but its condition passed at least one item \
by, and template '{}' does not reproduce that key; such keys are left \
unchanged, and the count is reported once the table has been read",
self.entities[plan.entity].name, key.attribute, key.template
)));
}
}
let entity = &self.entities[plan.entity];
let untemplated: Vec<&String> = self
.schema_keys
.iter()
.filter(|key| !entity.match_keys.iter().any(|k| &k.attribute == *key))
.filter(|key| item.contains_key(key.as_str()))
.collect();
if !self.unmatched_model_indexes.is_empty() {
for key in &untemplated {
if self.unnamed_index_keys.contains(key)
&& !rewritten.attributes.contains(key.as_str())
{
*batch
.untemplated_index_keys
.entry((*key).clone())
.or_insert(0) += 1;
}
}
}
let survivals: Vec<String> = untemplated
.into_iter()
.filter(|key| match item.get(key.as_str()) {
Some(AttributeValue::S(value)) => plan.originals.iter().any(|(recorded, old)| {
rewritten.paths.iter().any(|fired| covers(fired, recorded))
&& holds_as_component(value, old)
}),
_ => false,
})
.cloned()
.collect();
for key in survivals {
if first_sighting(
&self.survived,
&mut batch.survived,
(plan.entity, key.clone()),
) {
warnings.push(Notice::caution(format!(
"entity '{}': {key} still holds a value a rule replaced, and the model gives \
this entity no template for {key}, so nothing rebuilt it; the count is \
reported once the table has been read",
self.entities[plan.entity].name
)));
}
}
self.note_primary_key(item, batch);
self.note_at_risk_results(plan, item, batch);
}
pub fn commit(&mut self, batch: Batch) {
self.unmatched += batch.unmatched;
self.warned_ambiguous.extend(batch.ambiguous);
for (id, count) in batch.mismatch {
*self.warned_mismatch.entry(id).or_insert(0) += count;
}
for (id, count) in batch.unrenderable {
*self.warned_unrenderable.entry(id).or_insert(0) += count;
}
self.warned_rule_wins.extend(batch.rule_wins);
for (id, count) in batch.rule_skipped {
*self.warned_rule_skipped.entry(id).or_insert(0) += count;
}
for (key, count) in batch.untemplated_index_keys {
*self.untemplated_index_keys.entry(key).or_insert(0) += count;
}
for (id, count) in batch.survived {
*self.survived.entry(id).or_insert(0) += count;
}
for key in batch.primary_keys {
self.record_primary_key(key);
}
for (entity, risk_idx, original, now) in batch.at_risk_results {
self.record_at_risk_result(entity, risk_idx, original, now);
}
}
pub fn take_notices(&mut self) -> Vec<Notice> {
let mut out = Vec::new();
let unmatched = self.take_unmatched();
if unmatched > 0 {
out.push(Notice::exposure(format!(
"{unmatched} items matched no entity in the data model (no type attribute, \
and no entity's key templates reproduce their keys); their keys were not \
rebuilt"
)));
}
for (entity, key, count) in self.take_key_survivals() {
out.push(Notice::exposure(format!(
"entity '{entity}' carries {key} on {count} items with a value a rule \
replaced still inside it. The model gives that entity no template for \
{key}, so nothing rebuilt it"
)));
}
for (entity, key, count) in self.take_unrebuilt() {
out.push(Notice::exposure(format!(
"entity '{entity}' kept the original {key} on {count} items, because its \
template could not rebuild them. Those keys still hold the values the \
export arrived with"
)));
}
if !self.unmatched_model_indexes.is_empty() {
let indexes = super::quoted_names(&self.unmatched_model_indexes);
for (key, count) in std::mem::take(&mut self.untemplated_index_keys) {
out.push(Notice::exposure(format!(
"{count} items carry {key}, a key of an index no entity in the data model \
names, while the model declares index {indexes} that the table does not \
have; those keys were not rebuilt and hold the values they arrived with"
)));
}
}
for message in self.sort_key_splits() {
out.push(Notice::caution(message));
}
let unchecked = self.unchecked_join_keys();
if unchecked > 0 {
out.push(Notice::caution(format!(
"the join check stopped taking on new keys once a group reached {}, so \
{unchecked} key comparisons were skipped; a broken join among those would \
not have been caught. Keys seen before the cap were still compared",
MAX_TRACKED_KEYS
)));
}
let (collisions, capped) = self.take_collisions();
if collisions > 0 {
out.push(Notice::caution(format!(
"{collisions} items rendered the same primary key as an earlier item and \
overwrote it, so the output holds fewer rows than the export. Either a rule \
is replacing an attribute a key is built from with a constant, or a fake \
generator is drawing the same value twice: every generator except \
safe_email draws from a pool small enough that repeats are ordinary at a \
few hundred items, so prefer hash or a seeded safe_email for an attribute \
a key is built from"
)));
}
if capped {
out.push(Notice::caution(format!(
"the rebuilt-key collision check stopped after {} keys; later collisions in \
this table were not counted",
MAX_TRACKED_KEYS
)));
}
out
}
fn take_key_survivals(&mut self) -> Vec<(String, String, usize)> {
let mut out: Vec<(String, String, usize)> = self
.survived
.drain()
.map(|((entity, key), count)| (self.entities[entity].name.clone(), key, count))
.collect();
out.sort();
out
}
pub(super) fn note_primary_key(&self, item: &Item, batch: &mut Batch) {
if self.collision_check_capped {
return;
}
let mut hasher = std::collections::hash_map::DefaultHasher::new();
for attribute in [&self.hash_attribute, &self.range_attribute]
.into_iter()
.flatten()
{
match item.get(attribute) {
Some(AttributeValue::S(s)) => {
1u8.hash(&mut hasher);
s.hash(&mut hasher);
}
Some(AttributeValue::N(n)) => {
2u8.hash(&mut hasher);
match crate::types::validate_dynamo_number(n) {
Ok(()) => crate::types::normalize_dynamo_number(n).hash(&mut hasher),
Err(_) => n.hash(&mut hasher),
}
}
Some(AttributeValue::B(b)) => {
3u8.hash(&mut hasher);
b.hash(&mut hasher);
}
_ => 0u8.hash(&mut hasher),
}
}
batch.primary_keys.push(hasher.finish());
}
fn record_primary_key(&mut self, key: u64) {
if self.collision_check_capped {
return;
}
if self.rebuilt_keys.len() >= MAX_TRACKED_KEYS {
self.collision_check_capped = true;
return;
}
if !self.rebuilt_keys.insert(key) {
self.collisions += 1;
}
}
pub fn join_breaks(&self) -> Vec<String> {
self.at_risk
.iter()
.filter(|risk| !risk.diverged.is_empty())
.map(|risk| {
let mut seen: Vec<usize> = risk.diverged.iter().copied().collect();
seen.sort();
let repair = if risk.consistency_roots.is_empty() {
"Use a deterministic action such as hash, so one original key always \
renders one new key"
.to_string()
} else {
format!(
"Add {} to [consistency] fields, or use a deterministic action such \
as hash",
super::quoted_names(&risk.consistency_roots)
)
};
if seen.len() == 1 {
return format!(
"{} took one original {} to more than one value, so rows that shared a \
partition no longer share one and a query for them comes back with \
part of what it held. {repair}",
entity_list(&self.entities, &seen),
risk.key_attribute,
);
}
format!(
"{} took the same original {} to different values, so their keys no longer \
agree and they will not join. Give both entities the same attribute name \
for that value. {repair}",
entity_list(&self.entities, &seen),
risk.key_attribute,
)
})
.collect()
}
fn sort_key_splits(&mut self) -> Vec<String> {
let entities = &self.entities;
let mut out: Vec<String> = self
.at_risk
.iter_mut()
.filter(|risk| !risk.split_alone.is_empty())
.map(|risk| {
let mut seen: Vec<usize> = risk.split_alone.drain().collect();
seen.sort();
format!(
"{} took one original {} to more than one value. Those rows stay in their \
partitions, so nothing is lost from a query on the partition, but a query \
matching that {} exactly used to return them together and will not now",
entity_list(entities, &seen),
risk.key_attribute,
risk.key_attribute
)
})
.collect();
out.sort();
out
}
fn take_unrebuilt(&mut self) -> Vec<(String, String, usize)> {
let mut totals: HashMap<(usize, usize), usize> = HashMap::new();
for (id, count) in self
.warned_mismatch
.iter()
.chain(self.warned_unrenderable.iter())
.chain(self.warned_rule_skipped.iter())
{
*totals.entry(*id).or_insert(0) += count;
}
let mut out: Vec<(String, String, usize)> = totals
.into_iter()
.map(|((entity, key), count)| {
let entity = &self.entities[entity];
(
entity.name.clone(),
entity.keys[key].attribute.clone(),
count,
)
})
.collect();
out.sort();
self.warned_mismatch.clear();
self.warned_unrenderable.clear();
self.warned_rule_skipped.clear();
out
}
fn unchecked_join_keys(&mut self) -> usize {
self.at_risk
.iter_mut()
.map(|risk| std::mem::take(&mut risk.unchecked))
.sum()
}
fn take_unmatched(&mut self) -> usize {
std::mem::take(&mut self.unmatched)
}
fn take_collisions(&mut self) -> (usize, bool) {
let cap_to_report = self.collision_check_capped && !self.collision_cap_reported;
self.collision_cap_reported |= cap_to_report;
(std::mem::take(&mut self.collisions), cap_to_report)
}
fn resolve_entity(&self, item: &Item) -> Option<(usize, Vec<usize>)> {
for type_attribute in &self.type_attributes {
let Some(AttributeValue::S(type_value)) = item.get(type_attribute) else {
continue;
};
let by_type = self
.entities
.iter()
.position(|e| e.name == *type_value && e.type_attribute == *type_attribute);
if let Some(idx) = by_type {
return Some((idx, Vec::new()));
}
}
let mut matches = self.entities.iter().enumerate().filter_map(|(idx, e)| {
let mut present = 0;
let all_match = e
.match_keys
.iter()
.all(|key| match item.get(&key.attribute) {
None => true,
Some(AttributeValue::S(s)) => {
present += 1;
render(&key.segments, item).as_deref() == Some(s.as_str())
}
Some(_) => false,
});
(all_match && present > 0).then_some(idx)
});
let first = matches.next()?;
Some((first, matches.collect()))
}
}
fn holds_as_component(key: &str, value: &str) -> bool {
if value.is_empty() {
return false;
}
bounded_in(key, value) || bounded_in(&key.to_lowercase(), &value.to_lowercase())
}
fn bounded_in(key: &str, value: &str) -> bool {
let mut from = 0;
while let Some(offset) = key[from..].find(value) {
let start = from + offset;
let before = key[..start].chars().next_back();
let after = key[start + value.len()..].chars().next();
if !before.is_some_and(char::is_alphanumeric) && !after.is_some_and(char::is_alphanumeric) {
return true;
}
from = start + key[start..].chars().next().map_or(1, char::len_utf8);
}
false
}
fn scalar_leaves(value: AttributeValue, out: &mut Vec<String>) {
match value {
AttributeValue::S(s) | AttributeValue::N(s) => out.push(s),
AttributeValue::SS(set) | AttributeValue::NS(set) => out.extend(set),
AttributeValue::L(list) => {
for element in list {
scalar_leaves(element, out);
}
}
AttributeValue::M(map) => {
for element in map.into_values() {
scalar_leaves(element, out);
}
}
AttributeValue::B(_)
| AttributeValue::BS(_)
| AttributeValue::BOOL(_)
| AttributeValue::NULL(_) => {}
}
}
fn group_hash(grouping: Option<(&str, &AttributeValue)>, key: &AttributeValue) -> Option<u64> {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
if let Some((attribute, value)) = grouping {
attribute.hash(&mut hasher);
scalar_hash(value)?.hash(&mut hasher);
}
scalar_hash(key)?.hash(&mut hasher);
Some(hasher.finish())
}
fn scalar_hash(value: &AttributeValue) -> Option<u64> {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
match value {
AttributeValue::S(s) => s.hash(&mut hasher),
AttributeValue::N(n) => match crate::types::validate_dynamo_number(n) {
Ok(()) => crate::types::normalize_dynamo_number(n).hash(&mut hasher),
Err(_) => n.hash(&mut hasher),
},
AttributeValue::B(b) => b.hash(&mut hasher),
_ => return None,
}
Some(hasher.finish())
}
fn entity_list(entities: &[EntityKeys], indices: &[usize]) -> String {
let names: Vec<String> = indices
.iter()
.map(|i| format!("entity '{}'", entities[*i].name))
.collect();
match names.split_last() {
Some((last, [])) => last.clone(),
Some((last, rest)) => format!("{} and {last}", rest.join(", ")),
None => String::new(),
}
}
fn rule_target(rule: &ValidatedRule) -> Option<&str> {
match rule.path.first() {
Some(PathElement::Attribute(name)) => Some(name.as_str()),
_ => None,
}
}
fn push_key(
keys: &mut Vec<TemplatedKey>,
entity: &EntityDefinition,
attribute: Option<&str>,
template: Option<&str>,
) -> Result<(), String> {
let (Some(attribute), Some(template)) = (attribute, template) else {
return Ok(());
};
if template.is_empty() {
return Ok(());
}
let segments = parse_template(template)
.map_err(|e| format!("entity '{}', {attribute}: {e}", entity.name))?;
keys.push(TemplatedKey {
attribute: attribute.to_string(),
template: template.to_string(),
segments,
});
Ok(())
}
fn type_attribute(model: &DataModel, entity: &EntityDefinition) -> String {
entity
.type_attribute
.clone()
.unwrap_or_else(|| model.type_attribute.clone())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::expressions::condition;
use crate::schema::GsiMapping;
use crate::types::{KeySchemaElement, KeyType};
fn item(pairs: &[(&str, &str)]) -> Item {
pairs
.iter()
.map(|(k, v)| (k.to_string(), AttributeValue::S(v.to_string())))
.collect()
}
fn entity(
name: &str,
pk: &str,
sk: Option<&str>,
gsi: Option<(&str, Option<&str>)>,
) -> EntityDefinition {
EntityDefinition {
name: name.to_string(),
pk_template: pk.to_string(),
sk_template: sk.map(String::from),
type_attribute: None,
gsi_mappings: gsi
.map(|(pk, sk)| {
vec![GsiMapping {
index_name: "GSI1".to_string(),
pk_template: pk.to_string(),
sk_template: sk.map(String::from),
}]
})
.unwrap_or_default(),
description: None,
}
}
fn model() -> DataModel {
DataModel {
schema_format: "onetable:1.1.0".to_string(),
type_attribute: "_type".to_string(),
entities: vec![
entity("Account", "account#${id}", Some("account#"), None),
entity(
"User",
"account#${accountId}",
Some("user#${email}"),
Some(("user#${email}", Some("user#"))),
),
],
}
}
fn model_with(entities: Vec<EntityDefinition>) -> DataModel {
DataModel {
schema_format: "onetable:1.1.0".to_string(),
type_attribute: "_type".to_string(),
entities,
}
}
fn request() -> CreateTableRequest {
serde_json::from_value(serde_json::json!({
"TableName": "App",
"KeySchema": [
{"AttributeName": "pk", "KeyType": "HASH"},
{"AttributeName": "sk", "KeyType": "RANGE"}
],
"AttributeDefinitions": [
{"AttributeName": "pk", "AttributeType": "S"},
{"AttributeName": "sk", "AttributeType": "S"},
{"AttributeName": "gs1pk", "AttributeType": "S"},
{"AttributeName": "gs1sk", "AttributeType": "S"}
],
"GlobalSecondaryIndexes": [{
"IndexName": "GSI1",
"KeySchema": [
{"AttributeName": "gs1pk", "KeyType": "HASH"},
{"AttributeName": "gs1sk", "KeyType": "RANGE"}
],
"Projection": {"ProjectionType": "ALL"}
}]
}))
.unwrap()
}
fn two_index_request() -> CreateTableRequest {
serde_json::from_value(serde_json::json!({
"TableName": "App",
"KeySchema": [
{"AttributeName": "pk", "KeyType": "HASH"},
{"AttributeName": "sk", "KeyType": "RANGE"}
],
"AttributeDefinitions": [
{"AttributeName": "pk", "AttributeType": "S"},
{"AttributeName": "sk", "AttributeType": "S"},
{"AttributeName": "gs1pk", "AttributeType": "S"},
{"AttributeName": "gs1sk", "AttributeType": "S"},
{"AttributeName": "gs2pk", "AttributeType": "S"}
],
"GlobalSecondaryIndexes": [
{
"IndexName": "GSI1",
"KeySchema": [
{"AttributeName": "gs1pk", "KeyType": "HASH"},
{"AttributeName": "gs1sk", "KeyType": "RANGE"}
],
"Projection": {"ProjectionType": "ALL"}
},
{
"IndexName": "GSI2",
"KeySchema": [
{"AttributeName": "gs2pk", "KeyType": "HASH"},
{"AttributeName": "gs1sk", "KeyType": "RANGE"}
],
"Projection": {"ProjectionType": "ALL"}
}
]
}))
.unwrap()
}
fn key_schema(name: &str, kind: KeyType) -> KeySchemaElement {
KeySchemaElement {
attribute_name: name.to_string(),
key_type: kind,
}
}
fn rule(path: &str, action: ValidatedAction) -> ValidatedRule {
ValidatedRule {
tables: None,
condition: condition::parse("attribute_exists(pk)").unwrap(),
names: None,
values: None,
path: parse_path(path).unwrap(),
action,
}
}
fn no_consistency() -> HashSet<String> {
HashSet::new()
}
fn no_rewrites() -> Rewritten {
Rewritten::default()
}
fn rewrote(paths: &[&str]) -> Rewritten {
let mut rewritten = Rewritten::default();
for path in paths {
rewritten.paths.insert((*path).to_string());
let root = path.split(['.', '[']).next().unwrap_or(path);
rewritten.attributes.insert(root.to_string());
}
rewritten
}
fn plan_one(
d: &mut KeyDeriver,
item: &Item,
warnings: &mut Vec<Notice>,
) -> Option<Rederivation> {
let mut batch = Batch::default();
let plan = d.plan(item, &mut batch, warnings);
d.commit(batch);
plan
}
fn apply_one(
d: &mut KeyDeriver,
plan: &Rederivation,
rewritten: &Rewritten,
item: &mut Item,
warnings: &mut Vec<Notice>,
) {
let mut batch = Batch::default();
d.apply(plan, rewritten, item, &mut batch, warnings);
d.commit(batch);
}
fn note_one(d: &mut KeyDeriver, item: &Item) {
let mut batch = Batch::default();
d.note_primary_key(item, &mut batch);
d.commit(batch);
}
fn deriver() -> KeyDeriver {
let (deriver, warnings) =
KeyDeriver::new(&model(), &request(), &[], &no_consistency()).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
deriver
}
fn tracked(deriver: &KeyDeriver, entity: usize) -> Vec<&str> {
deriver.entities[entity]
.keys
.iter()
.map(|k| k.attribute.as_str())
.collect()
}
fn user() -> Item {
item(&[
("_type", "User"),
("pk", "account#acc1"),
("sk", "user#alice@example.com"),
("gs1pk", "user#alice@example.com"),
("gs1sk", "user#"),
("accountId", "acc1"),
("email", "alice@example.com"),
])
}
#[test]
fn template_splits_into_literals_and_variables() {
let var = |p: &str| Segment::Var {
path: parse_path(p).unwrap(),
pad: None,
};
assert_eq!(
parse_template("project#${status}#${name}").unwrap(),
vec![
Segment::Literal("project#".to_string()),
var("status"),
Segment::Literal("#".to_string()),
var("name"),
]
);
assert_eq!(
parse_template("account#").unwrap(),
vec![Segment::Literal("account#".to_string())]
);
assert_eq!(parse_template("${id}").unwrap(), vec![var("id")]);
assert_eq!(
parse_template("city#${address.city}").unwrap(),
vec![Segment::Literal("city#".to_string()), var("address.city")]
);
}
#[test]
fn template_rejects_an_unclosed_reference() {
let err = parse_template("user#${email").unwrap_err();
assert!(err.contains("unclosed"), "{err}");
let err = KeyDeriver::new(
&model_with(vec![entity("Bad", "x#${id", None, None)]),
&request(),
&[],
&no_consistency(),
)
.unwrap_err();
assert!(err.contains("entity 'Bad', pk"), "{err}");
}
#[test]
fn template_pads_a_value_the_way_onetable_does() {
let segments = parse_template("order#${orderNo:5}").unwrap();
assert_eq!(
render(&segments, &item(&[("orderNo", "42")])).as_deref(),
Some("order#00042")
);
let segments = parse_template("order#${orderNo:4:x}").unwrap();
assert_eq!(
render(&segments, &item(&[("orderNo", "42")])).as_deref(),
Some("order#xx42")
);
let segments = parse_template("order#${orderNo:2}").unwrap();
assert_eq!(
render(&segments, &item(&[("orderNo", "12345")])).as_deref(),
Some("order#12345")
);
let mut numeric = Item::new();
numeric.insert("n".to_string(), AttributeValue::N("7".to_string()));
let segments = parse_template("${n:3}").unwrap();
assert_eq!(render(&segments, &numeric).as_deref(), Some("007"));
let err = parse_template("order#${orderNo:wide}").unwrap_err();
assert!(err.contains("is not a length"), "{err}");
}
#[test]
fn a_padded_key_rebuilds_from_its_anonymised_source() {
let model = model_with(vec![entity(
"Order",
"order#${customerId}",
Some("order#${orderNo:5}"),
None,
)]);
let (mut d, warnings) =
KeyDeriver::new(&model, &request(), &[], &no_consistency()).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
let mut item_warnings = Vec::new();
let mut order = item(&[
("_type", "Order"),
("pk", "order#cust1"),
("sk", "order#00042"),
("customerId", "cust1"),
("orderNo", "42"),
]);
let plan = plan_one(&mut d, &order, &mut item_warnings).unwrap();
assert!(item_warnings.is_empty(), "{item_warnings:?}");
assert_eq!(plan.keys.len(), 2);
order.insert("orderNo".to_string(), AttributeValue::S("7".to_string()));
apply_one(
&mut d,
&plan,
&no_rewrites(),
&mut order,
&mut item_warnings,
);
assert_eq!(order["sk"], AttributeValue::S("order#00007".to_string()));
}
#[test]
fn render_uses_scalars_and_fails_on_anything_else() {
let segments = parse_template("user#${email}").unwrap();
assert_eq!(
render(&segments, &item(&[("email", "a@b.c")])).as_deref(),
Some("user#a@b.c")
);
assert_eq!(render(&segments, &item(&[])), None);
let mut nulled = Item::new();
nulled.insert("email".to_string(), AttributeValue::NULL(true));
assert_eq!(render(&segments, &nulled), None);
}
#[test]
fn render_reaches_into_nested_maps() {
let segments = parse_template("city#${address.city}").unwrap();
let mut address = std::collections::HashMap::new();
address.insert("city".to_string(), AttributeValue::S("Leeds".to_string()));
let mut it = Item::new();
it.insert("address".to_string(), AttributeValue::M(address));
assert_eq!(render(&segments, &it).as_deref(), Some("city#Leeds"));
}
#[test]
fn templated_keys_resolve_against_the_table_key_schema() {
let d = deriver();
assert_eq!(tracked(&d, 1), vec!["pk", "sk", "gs1pk"]);
assert_eq!(tracked(&d, 0), vec!["pk"]);
}
#[test]
fn gsi_mapping_without_a_matching_index_is_skipped() {
let mut request = request();
request.global_secondary_indexes = None;
let (d, _) = KeyDeriver::new(&model(), &request, &[], &no_consistency()).unwrap();
assert_eq!(tracked(&d, 1), vec!["pk", "sk"]);
}
#[test]
fn rebuilds_keys_from_the_anonymised_attribute() {
let mut d = deriver();
let mut warnings = Vec::new();
let mut user = user();
let plan = plan_one(&mut d, &user, &mut warnings).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(plan.keys.len(), 3);
user.insert(
"email".to_string(),
AttributeValue::S("fake@example.org".to_string()),
);
apply_one(&mut d, &plan, &no_rewrites(), &mut user, &mut warnings);
assert_eq!(user["pk"], AttributeValue::S("account#acc1".to_string()));
assert_eq!(
user["sk"],
AttributeValue::S("user#fake@example.org".to_string())
);
assert_eq!(
user["gs1pk"],
AttributeValue::S("user#fake@example.org".to_string())
);
assert_eq!(user["gs1sk"], AttributeValue::S("user#".to_string()));
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(d.take_collisions(), (0, false));
}
#[test]
fn a_key_the_template_does_not_reproduce_is_left_and_reported_once_without_its_value() {
let mut d = deriver();
let mut warnings = Vec::new();
for n in 0..3 {
let mut user = item(&[
("_type", "User"),
("pk", "account#acc1"),
("sk", "legacy-profile"),
("accountId", "acc1"),
("email", "alice@example.com"),
]);
let plan = plan_one(&mut d, &user, &mut warnings).unwrap();
assert_eq!(plan.keys.len(), 1, "only pk reproduces");
user.insert(
"email".to_string(),
AttributeValue::S(format!("fake{n}@example.org")),
);
apply_one(&mut d, &plan, &no_rewrites(), &mut user, &mut warnings);
assert_eq!(user["sk"], AttributeValue::S("legacy-profile".to_string()));
}
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(warnings[0].message.contains("entity 'User'"));
assert!(warnings[0].message.contains("does not reproduce sk"));
assert!(
!warnings[0].message.contains("legacy-profile"),
"a warning must not quote the key value: {}",
warnings[0]
);
}
#[test]
fn a_sparse_index_key_is_not_a_mismatch() {
let mut d = deriver();
let mut warnings = Vec::new();
let mut user = user();
user.remove("gs1pk");
user.remove("gs1sk");
let plan = plan_one(&mut d, &user, &mut warnings).unwrap();
assert_eq!(plan.keys.len(), 2);
assert!(warnings.is_empty(), "{warnings:?}");
}
#[test]
fn a_key_that_stops_rendering_after_the_rules_is_reported_even_after_a_mismatch() {
let mut d = deriver();
let mut warnings = Vec::new();
let legacy = item(&[
("_type", "User"),
("pk", "account#acc1"),
("sk", "legacy-profile"),
("accountId", "acc1"),
("email", "alice@example.com"),
]);
plan_one(&mut d, &legacy, &mut warnings).unwrap();
assert_eq!(warnings.len(), 1);
let mut user = user();
let plan = plan_one(&mut d, &user, &mut warnings).unwrap();
user.insert("email".to_string(), AttributeValue::NULL(true));
apply_one(&mut d, &plan, &no_rewrites(), &mut user, &mut warnings);
assert_eq!(
user["sk"],
AttributeValue::S("user#alice@example.com".to_string())
);
assert_eq!(warnings.len(), 3, "{warnings:?}");
assert!(
warnings[1].message.contains("cannot rebuild sk"),
"{warnings:?}"
);
assert!(
warnings[2].message.contains("cannot rebuild gs1pk"),
"{warnings:?}"
);
}
#[test]
fn entity_falls_back_to_template_shape_without_a_type_attribute() {
let mut d = deriver();
let mut warnings = Vec::new();
let user = item(&[
("pk", "account#acc1"),
("sk", "user#alice@example.com"),
("accountId", "acc1"),
("email", "alice@example.com"),
]);
let plan = plan_one(&mut d, &user, &mut warnings).unwrap();
assert_eq!(d.entities[plan.entity].name, "User");
let account = item(&[("pk", "account#acc1"), ("sk", "account#"), ("id", "acc1")]);
let plan = plan_one(&mut d, &account, &mut warnings).unwrap();
assert_eq!(d.entities[plan.entity].name, "Account");
let stranger = item(&[("pk", "thing#1"), ("sk", "meta")]);
assert!(plan_one(&mut d, &stranger, &mut warnings).is_none());
assert_eq!(d.take_unmatched(), 1);
assert_eq!(d.take_unmatched(), 0);
assert!(warnings.is_empty(), "{warnings:?}");
}
#[test]
fn primary_key_names_come_from_the_key_schema_not_the_model() {
let mut request = request();
request.key_schema = vec![
key_schema("PK", KeyType::HASH),
key_schema("SK", KeyType::RANGE),
];
let (d, _) = KeyDeriver::new(&model(), &request, &[], &no_consistency()).unwrap();
assert_eq!(tracked(&d, 1), vec!["PK", "SK", "gs1pk"]);
}
#[test]
fn a_rule_on_a_key_attribute_wins_on_the_items_it_matches() {
let rules = [rule("sk", ValidatedAction::Redact)];
let (mut d, warnings) =
KeyDeriver::new(&model(), &request(), &rules, &no_consistency()).unwrap();
assert_eq!(tracked(&d, 1), vec!["pk", "sk", "gs1pk"]);
assert_eq!(warnings.len(), 2, "{warnings:?}");
assert!(
warnings
.iter()
.all(|w| w.message.contains("targets key attribute 'sk' directly")),
"{warnings:?}"
);
assert!(
warnings.iter().any(|w| w.message.contains("'Account'")),
"the constant template must be named too: {warnings:?}"
);
let mut item_warnings = Vec::new();
let mut user = user();
let plan = plan_one(&mut d, &user, &mut item_warnings).unwrap();
user.insert(
"sk".to_string(),
AttributeValue::S("[REDACTED]".to_string()),
);
let rewritten = rewrote(&["sk"]);
apply_one(&mut d, &plan, &rewritten, &mut user, &mut item_warnings);
assert_eq!(user["sk"], AttributeValue::S("[REDACTED]".to_string()));
assert!(
item_warnings
.iter()
.any(|w| w.message.contains("a rule rewrote sk")),
"{item_warnings:?}"
);
}
#[test]
fn a_rule_on_a_key_that_does_not_match_this_item_leaves_the_rebuild_alone() {
let rules = [
ValidatedRule {
tables: None,
condition: condition::parse("attribute_exists(accountName)").unwrap(),
names: None,
values: None,
path: parse_path("sk").unwrap(),
action: ValidatedAction::Redact,
},
rule(
"email",
ValidatedAction::Fake {
generator: "safe_email".into(),
seed: None,
},
),
];
let (mut d, _) = KeyDeriver::new(&model(), &request(), &rules, &no_consistency()).unwrap();
let mut warnings = Vec::new();
let mut user = user();
assert!(!user.contains_key("accountName"), "the rule must not match");
let plan = plan_one(&mut d, &user, &mut warnings).unwrap();
user.insert(
"email".to_string(),
AttributeValue::S("fake@example.org".to_string()),
);
apply_one(&mut d, &plan, &no_rewrites(), &mut user, &mut warnings);
assert_eq!(
user["sk"],
AttributeValue::S("user#fake@example.org".to_string()),
"sk must be rebuilt: the sk rule never matched this item"
);
}
#[test]
fn a_constant_action_on_a_key_source_is_reported_up_front() {
let rules = [rule("email", ValidatedAction::Redact)];
let (_, warnings) =
KeyDeriver::new(&model(), &request(), &rules, &no_consistency()).unwrap();
assert_eq!(warnings.len(), 2, "{warnings:?}");
assert!(
warnings[0].message.contains("overwrites the last"),
"{warnings:?}"
);
assert!(warnings[0].message.contains("'email'"));
let rules = [rule(
"email",
ValidatedAction::Fake {
generator: "safe_email".into(),
seed: None,
},
)];
let (_, warnings) =
KeyDeriver::new(&model(), &request(), &rules, &no_consistency()).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
}
fn shared_key_model() -> DataModel {
model_with(vec![
entity("Customer", "CUSTOMER#${email}", Some("PROFILE"), None),
entity("Order", "CUSTOMER#${email}", Some("ORDER#${orderId}"), None),
])
}
fn email_rule() -> [ValidatedRule; 1] {
[rule(
"email",
ValidatedAction::Fake {
generator: "safe_email".into(),
seed: None,
},
)]
}
#[test]
fn a_shared_key_attribute_outside_consistency_warns_up_front() {
let (deriver, warnings) = KeyDeriver::new(
&shared_key_model(),
&request(),
&email_rule(),
&no_consistency(),
)
.unwrap();
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(
warnings[0]
.message
.contains("entity 'Customer' and entity 'Order'")
);
assert!(warnings[0].message.contains("'email'"));
assert!(warnings[0].message.contains("will not join"));
assert!(deriver.join_breaks().is_empty());
}
#[test]
fn listing_the_attribute_in_consistency_silences_it() {
let consistency: HashSet<String> = ["email".to_string()].into_iter().collect();
let (_, warnings) =
KeyDeriver::new(&shared_key_model(), &request(), &email_rule(), &consistency).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
}
#[test]
fn an_attribute_no_rule_rewrites_is_not_at_risk() {
let (_, warnings) =
KeyDeriver::new(&shared_key_model(), &request(), &[], &no_consistency()).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
}
#[test]
fn entities_reusing_an_attribute_name_on_different_keys_are_not_at_risk() {
let model = model_with(vec![
entity("Account", "account#${id}", Some("account#"), None),
entity("Task", "task#${projectId}", Some("task#${id}"), None),
]);
let rules = [rule(
"id",
ValidatedAction::Fake {
generator: "word".into(),
seed: None,
},
)];
let (_, warnings) = KeyDeriver::new(&model, &request(), &rules, &no_consistency()).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
}
#[test]
fn one_entity_alone_is_not_a_broken_join() {
let (mut d, _) = KeyDeriver::new(
&shared_key_model(),
&request(),
&email_rule(),
&no_consistency(),
)
.unwrap();
let mut warnings = Vec::new();
for n in 0..3 {
let customer = item(&[
("_type", "Customer"),
("pk", &format!("CUSTOMER#c{n}@x.co")),
("sk", "PROFILE"),
("email", &format!("c{n}@x.co")),
]);
plan_one(&mut d, &customer, &mut warnings).unwrap();
}
assert!(
d.join_breaks().is_empty(),
"a slice holding one entity has no join to lose: {:?}",
d.join_breaks()
);
}
#[test]
fn a_constant_key_template_still_tells_two_entities_apart() {
let (mut d, _) = KeyDeriver::new(
&shared_key_model(),
&request(),
&email_rule(),
&no_consistency(),
)
.unwrap();
let mut warnings = Vec::new();
let mut order = item(&[
("pk", "CUSTOMER#a@x.co"),
("sk", "ORDER#ref-a@x.co"),
("email", "a@x.co"),
("orderId", "ref-a@x.co"),
]);
assert!(
!order.contains_key("_type"),
"no discriminator to fall back on"
);
let plan = plan_one(&mut d, &order, &mut warnings).unwrap();
assert_eq!(d.entities[plan.entity].name, "Order");
order.insert("orderId".to_string(), AttributeValue::S("anon".to_string()));
apply_one(&mut d, &plan, &no_rewrites(), &mut order, &mut warnings);
assert_eq!(
order["sk"],
AttributeValue::S("ORDER#anon".to_string()),
"the Order's own sk must be rebuilt"
);
}
#[test]
fn a_key_built_from_another_templated_key_is_refused() {
let model = model_with(vec![entity("User", "${sk}", Some("user#${email}"), None)]);
let err =
KeyDeriver::new(&model, &request(), &email_rule(), &no_consistency()).unwrap_err();
assert!(err.contains("reads key attribute 'sk'"), "{err}");
assert!(err.contains("itself built from a template"), "{err}");
let model = model_with(vec![entity("User", "user#${email}", Some("profile"), None)]);
assert!(KeyDeriver::new(&model, &request(), &email_rule(), &no_consistency()).is_ok());
}
#[test]
fn a_gsi_sort_key_is_rebuilt_when_its_hash_key_is_a_plain_attribute() {
let model = model_with(vec![EntityDefinition {
name: "User".to_string(),
pk_template: "user#${id}".to_string(),
sk_template: Some("user#".to_string()),
type_attribute: None,
gsi_mappings: vec![GsiMapping {
index_name: "GSI1".to_string(),
pk_template: String::new(),
sk_template: Some("user#${email}".to_string()),
}],
description: None,
}]);
let (mut d, _) = KeyDeriver::new(&model, &request(), &[], &no_consistency()).unwrap();
assert!(
tracked(&d, 0).contains(&"gs1sk"),
"the GSI sort key must be tracked: {:?}",
tracked(&d, 0)
);
let mut warnings = Vec::new();
let mut user = item(&[
("pk", "user#u1"),
("sk", "user#"),
("gs1sk", "user#alice@real.co.uk"),
("id", "u1"),
("email", "alice@real.co.uk"),
]);
let plan = plan_one(&mut d, &user, &mut warnings).unwrap();
user.insert(
"email".to_string(),
AttributeValue::S("fake@example.org".to_string()),
);
apply_one(&mut d, &plan, &no_rewrites(), &mut user, &mut warnings);
assert_eq!(
user["gs1sk"],
AttributeValue::S("user#fake@example.org".to_string())
);
}
#[test]
fn unrelated_pairs_reading_the_same_attribute_name_never_shared_a_key() {
let model = model_with(vec![
entity("Account", "account#${id}", Some("account#"), None),
entity("Project", "project#${id}", Some("project#"), None),
entity("Team", "account#${id}", Some("team#"), None),
entity("Task", "project#${id}", Some("task#"), None),
]);
let rules = [rule(
"id",
ValidatedAction::Fake {
generator: "word".into(),
seed: None,
},
)];
let (mut d, warnings) =
KeyDeriver::new(&model, &request(), &rules, &no_consistency()).unwrap();
assert_eq!(warnings.len(), 1, "{warnings:?}");
let mut run = |name: &str, pk: &str, sk: &str, becomes: &str| {
let mut it = item(&[("_type", name), ("pk", pk), ("sk", sk), ("id", "shared")]);
let mut w = Vec::new();
let plan = plan_one(&mut d, &it, &mut w).unwrap();
it.insert("id".to_string(), AttributeValue::S(becomes.to_string()));
apply_one(&mut d, &plan, &no_rewrites(), &mut it, &mut w);
};
run("Account", "account#shared", "account#", "anon1");
run("Project", "project#shared", "project#", "anon2");
assert!(
d.join_breaks().is_empty(),
"different groups never joined: {:?}",
d.join_breaks()
);
}
#[test]
fn a_nested_template_source_is_tracked_for_join_breaks() {
let model = model_with(vec![
entity(
"Customer",
"CUSTOMER#${contact.email}",
Some("PROFILE"),
None,
),
entity(
"Order",
"CUSTOMER#${contact.email}",
Some("ORDER#${id}"),
None,
),
]);
let rules = [rule(
"contact",
ValidatedAction::Fake {
generator: "safe_email".into(),
seed: None,
},
)];
let (mut d, warnings) =
KeyDeriver::new(&model, &request(), &rules, &no_consistency()).unwrap();
assert!(
warnings.iter().any(|w| w.message.contains("('contact')")),
"the root is what [consistency] honours: {warnings:?}"
);
let contact = |email: &str| {
let mut map = std::collections::HashMap::new();
map.insert("email".to_string(), AttributeValue::S(email.to_string()));
AttributeValue::M(map)
};
let mut run = |name: &str, sk: &str, becomes: &str| {
let mut it = item(&[("_type", name), ("pk", "CUSTOMER#a@x.co"), ("sk", sk)]);
it.insert("contact".to_string(), contact("a@x.co"));
let mut w = Vec::new();
let plan = plan_one(&mut d, &it, &mut w).unwrap();
it.insert("contact".to_string(), contact(becomes));
apply_one(&mut d, &plan, &no_rewrites(), &mut it, &mut w);
};
run("Customer", "PROFILE", "fake1@example.com");
run("Order", "ORDER#1", "fake2@example.org");
let breaks = d.join_breaks();
assert_eq!(breaks.len(), 1, "{breaks:?}");
assert!(
breaks[0].contains("Add 'contact' to [consistency] fields"),
"must name the root, which is what the consistency map keys on: {breaks:?}"
);
}
#[test]
fn a_numeric_partition_key_is_part_of_the_collision_fingerprint() {
let model = model_with(vec![entity("Row", "${n}", Some("user#${email}"), None)]);
let (mut d, _) = KeyDeriver::new(&model, &request(), &[], &no_consistency()).unwrap();
let mut warnings = Vec::new();
for n in ["1", "2"] {
let mut it = Item::new();
it.insert("_type".to_string(), AttributeValue::S("Row".to_string()));
it.insert("pk".to_string(), AttributeValue::N(n.to_string()));
it.insert("n".to_string(), AttributeValue::N(n.to_string()));
it.insert(
"sk".to_string(),
AttributeValue::S("user#a@x.co".to_string()),
);
it.insert("email".to_string(), AttributeValue::S("a@x.co".to_string()));
let plan = plan_one(&mut d, &it, &mut warnings).unwrap();
apply_one(&mut d, &plan, &no_rewrites(), &mut it, &mut warnings);
}
assert_eq!(d.take_collisions(), (0, false));
}
fn run_item(d: &mut KeyDeriver, mut item: Item, becomes: &str) {
let mut warnings = Vec::new();
let plan = plan_one(d, &item, &mut warnings).unwrap();
item.insert("email".to_string(), AttributeValue::S(becomes.to_string()));
apply_one(d, &plan, &no_rewrites(), &mut item, &mut warnings);
}
fn code_model() -> DataModel {
let entity = |name: &str, prefix: &str| EntityDefinition {
name: name.to_string(),
pk_template: format!("{prefix}#${{id}}"),
sk_template: Some(format!("{prefix}#")),
type_attribute: None,
gsi_mappings: vec![GsiMapping {
index_name: "GSI1".to_string(),
pk_template: "TENANT#${tenant}".to_string(),
sk_template: Some("CODE#${code}".to_string()),
}],
description: None,
};
model_with(vec![entity("Event", "EVENT"), entity("Alert", "ALERT")])
}
fn code_rule() -> [ValidatedRule; 1] {
[rule(
"code",
ValidatedAction::Fake {
generator: "safe_email".into(),
seed: None,
},
)]
}
fn code_item(prefix: &str, id: &str, tenant: &str, code: &str) -> Item {
item(&[
("pk", &format!("{prefix}#{id}")),
("sk", &format!("{prefix}#")),
("gs1pk", &format!("TENANT#{tenant}")),
("gs1sk", &format!("CODE#{code}")),
("id", id),
("tenant", tenant),
("code", code),
])
}
fn lone_entity_model() -> DataModel {
model_with(vec![EntityDefinition {
name: "Order".to_string(),
pk_template: "ORDER#${id}".to_string(),
sk_template: Some("ORDER#".to_string()),
type_attribute: None,
gsi_mappings: vec![GsiMapping {
index_name: "GSI1".to_string(),
pk_template: "CUSTOMER#${email}".to_string(),
sk_template: Some("ORDER#${id}".to_string()),
}],
description: None,
}])
}
fn lone_item(id: &str, email: &str) -> Item {
item(&[
("pk", &format!("ORDER#{id}")),
("sk", "ORDER#"),
("gs1pk", &format!("CUSTOMER#{email}")),
("gs1sk", &format!("ORDER#{id}")),
("id", id),
("email", email),
])
}
#[test]
fn the_only_entity_that_templates_a_key_is_still_checked() {
let (mut d, _) = KeyDeriver::new(
&lone_entity_model(),
&request(),
&email_rule(),
&no_consistency(),
)
.unwrap();
run_item_on(
&mut d,
lone_item("o1", "a@x.co"),
"email",
"fake1@example.org",
);
run_item_on(
&mut d,
lone_item("o2", "a@x.co"),
"email",
"fake2@example.org",
);
let breaks = d.join_breaks();
assert_eq!(
breaks.len(),
1,
"two orders arrived in one customer partition and left in two: {breaks:?}"
);
assert!(breaks[0].contains("entity 'Order'"), "{breaks:?}");
assert!(breaks[0].contains("took one original gs1pk"), "{breaks:?}");
}
#[test]
fn a_lone_entity_whose_rows_never_shared_a_key_is_left_alone() {
let (mut d, _) = KeyDeriver::new(
&lone_entity_model(),
&request(),
&email_rule(),
&no_consistency(),
)
.unwrap();
run_item_on(
&mut d,
lone_item("o1", "a@x.co"),
"email",
"fake1@example.org",
);
run_item_on(
&mut d,
lone_item("o2", "b@x.co"),
"email",
"fake2@example.org",
);
assert!(
d.join_breaks().is_empty(),
"different customers never shared a partition: {:?}",
d.join_breaks()
);
}
fn lone_code_model() -> DataModel {
model_with(vec![EntityDefinition {
name: "Event".to_string(),
pk_template: "EVENT#${id}".to_string(),
sk_template: Some("EVENT#".to_string()),
type_attribute: None,
gsi_mappings: vec![GsiMapping {
index_name: "GSI1".to_string(),
pk_template: "TENANT#${tenant}".to_string(),
sk_template: Some("CODE#${code}".to_string()),
}],
description: None,
}])
}
#[test]
fn one_entity_splitting_a_gsi_sort_key_inside_one_partition_is_a_break() {
let (mut d, _) = KeyDeriver::new(
&lone_code_model(),
&request(),
&code_rule(),
&no_consistency(),
)
.unwrap();
run_item_on(
&mut d,
code_item("EVENT", "e1", "t1", "c1"),
"code",
"fake1",
);
run_item_on(
&mut d,
code_item("EVENT", "e2", "t1", "c1"),
"code",
"fake2",
);
assert!(
d.join_breaks().is_empty(),
"a sort key split is not a broken partition: {:?}",
d.join_breaks()
);
let splits = d.sort_key_splits();
assert_eq!(splits.len(), 1, "but it is worth saying: {splits:?}");
assert!(splits[0].contains("took one original gs1sk"), "{splits:?}");
assert!(splits[0].contains("entity 'Event'"), "{splits:?}");
}
#[test]
fn one_entity_splitting_a_sort_key_across_partitions_is_not_a_break() {
let (mut d, _) = KeyDeriver::new(
&lone_code_model(),
&request(),
&code_rule(),
&no_consistency(),
)
.unwrap();
run_item_on(
&mut d,
code_item("EVENT", "e1", "t1", "c1"),
"code",
"fake1",
);
run_item_on(
&mut d,
code_item("EVENT", "e2", "t2", "c1"),
"code",
"fake2",
);
assert!(
d.join_breaks().is_empty(),
"different partitions never shared a group: {:?}",
d.join_breaks()
);
assert!(
d.sort_key_splits().is_empty(),
"and nothing was split, so there is nothing to report: {:?}",
d.sort_key_splits()
);
}
#[test]
fn two_spellings_of_one_numeric_key_are_one_row() {
let mut request = request();
request.key_schema = vec![key_schema("id", KeyType::HASH)];
request.attribute_definitions = vec![crate::types::AttributeDefinition {
attribute_name: "id".to_string(),
attribute_type: crate::types::ScalarAttributeType::N,
}];
let (mut d, _) = KeyDeriver::new(&model(), &request, &[], &no_consistency()).unwrap();
let mut a = Item::new();
a.insert("id".to_string(), AttributeValue::N("1".to_string()));
let mut b = Item::new();
b.insert("id".to_string(), AttributeValue::N("1.0".to_string()));
note_one(&mut d, &a);
note_one(&mut d, &b);
assert_eq!(
d.take_collisions().0,
1,
"one row written twice is one collision"
);
}
#[test]
fn an_item_several_entities_could_own_is_reported() {
let twin = |name: &str| EntityDefinition {
name: name.to_string(),
pk_template: "thing#${id}".to_string(),
sk_template: Some("thing#".to_string()),
type_attribute: None,
gsi_mappings: vec![],
description: None,
};
let model = model_with(vec![twin("Left"), twin("Right")]);
let (mut d, _) = KeyDeriver::new(&model, &request(), &[], &no_consistency()).unwrap();
let it = item(&[("pk", "thing#1"), ("sk", "thing#"), ("id", "1")]);
let mut warnings = Vec::new();
let plan = plan_one(&mut d, &it, &mut warnings).expect("the first entity is taken");
assert_eq!(plan.entity, 0);
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(
warnings[0]
.message
.contains("entity 'Left' and entity 'Right'"),
"{warnings:?}"
);
assert!(
warnings[0].message.contains("taken as entity 'Left'"),
"{warnings:?}"
);
let mut again = Vec::new();
plan_one(&mut d, &it, &mut again);
assert!(again.is_empty(), "{again:?}");
}
#[test]
fn take_notices_decides_each_concern_in_one_place() {
use super::super::notice::Concern;
let (mut d, _) = KeyDeriver::new(&model(), &request(), &[], &no_consistency()).unwrap();
let mut w = Vec::new();
let _ = plan_one(
&mut d,
&item(&[
("_type", "User"),
("pk", "somethingelse#1"),
("sk", "user#a@x.co"),
("email", "a@x.co"),
]),
&mut w,
);
let notices = d.take_notices();
let kept = notices
.iter()
.find(|n| n.message.contains("kept the original"))
.unwrap_or_else(|| panic!("{notices:?}"));
assert_eq!(kept.concern, Concern::Exposure);
assert!(
!kept.message.contains("table '"),
"the table is named once, where the notice is filed: {kept:?}"
);
let (mut d, _) = KeyDeriver::new(
&lone_code_model(),
&request(),
&code_rule(),
&no_consistency(),
)
.unwrap();
run_item_on(
&mut d,
code_item("EVENT", "e1", "t1", "c1"),
"code",
"fake1",
);
run_item_on(
&mut d,
code_item("EVENT", "e2", "t1", "c1"),
"code",
"fake2",
);
let notices = d.take_notices();
let split = notices
.iter()
.find(|n| n.message.contains("took one original gs1sk"))
.unwrap_or_else(|| panic!("{notices:?}"));
assert_eq!(split.concern, Concern::Caution);
assert!(d.take_notices().is_empty(), "taking clears");
d.collision_check_capped = true;
let notices = d.take_notices();
assert!(
notices
.iter()
.any(|n| n.message.contains("collision check stopped")),
"{notices:?}"
);
assert!(d.take_notices().is_empty(), "the cap is said once");
assert!(d.collision_check_capped, "and the check stays capped");
}
#[test]
fn unrebuilt_keys_are_counted_per_entity_and_key() {
let (mut d, _) = KeyDeriver::new(&model(), &request(), &[], &no_consistency()).unwrap();
for n in 0..3 {
let mut warnings = Vec::new();
let it = item(&[
("_type", "User"),
("pk", &format!("somethingelse#{n}")),
("sk", "user#"),
("email", "a@x.co"),
]);
let _ = plan_one(&mut d, &it, &mut warnings);
}
let mut u = user();
let mut w = Vec::new();
let plan = plan_one(&mut d, &u, &mut w).expect("the template reproduces this one");
u.insert(
"email".to_string(),
AttributeValue::M(std::collections::HashMap::new()),
);
apply_one(&mut d, &plan, &no_rewrites(), &mut u, &mut w);
let counts = d.take_unrebuilt();
assert!(!counts.is_empty(), "the keys were not rebuilt");
let sk_row = counts
.iter()
.find(|(e, k, _)| e == "User" && k == "sk")
.expect("a row for User/sk");
assert_eq!(
sk_row.2, 4,
"three mismatches and one unrenderable are one fact: {counts:?}"
);
let mut pairs: Vec<(&String, &String)> = counts.iter().map(|(e, k, _)| (e, k)).collect();
let before = pairs.len();
pairs.sort();
pairs.dedup();
assert_eq!(
pairs.len(),
before,
"one row per entity and key: {counts:?}"
);
assert!(
d.take_unrebuilt().is_empty(),
"taking clears, so a second table does not re-report the first"
);
}
#[test]
fn two_people_who_merely_share_a_sort_value_do_not_fail_the_import() {
let (mut d, _) = KeyDeriver::new(
&lone_code_model(),
&request(),
&code_rule(),
&no_consistency(),
)
.unwrap();
run_item_on(
&mut d,
code_item("EVENT", "e1", "t1", "same"),
"code",
"fake1",
);
run_item_on(
&mut d,
code_item("EVENT", "e2", "t1", "same"),
"code",
"fake2",
);
assert!(
d.join_breaks().is_empty(),
"a shared sort value is not a shared partition: {:?}",
d.join_breaks()
);
}
#[test]
fn two_indexes_holding_the_same_partition_string_are_separate_groups() {
let model = model_with(vec![EntityDefinition {
name: "Event".to_string(),
pk_template: "EVENT#${id}".to_string(),
sk_template: Some("EVENT#".to_string()),
type_attribute: None,
gsi_mappings: vec![
GsiMapping {
index_name: "GSI1".to_string(),
pk_template: "T#${tenant}".to_string(),
sk_template: Some("CODE#${code}".to_string()),
},
GsiMapping {
index_name: "GSI2".to_string(),
pk_template: "T#${region}".to_string(),
sk_template: Some("CODE#${code}".to_string()),
},
],
description: None,
}]);
let (d, _) = KeyDeriver::new(
&model,
&two_index_request(),
&code_rule(),
&no_consistency(),
)
.unwrap();
let groupings = d
.at_risk
.iter()
.find(|r| r.key_attribute == "gs1sk")
.map(|r| r.grouped_by.clone())
.unwrap_or_default();
assert_eq!(
groupings.len(),
2,
"a sort key shared by two indexes has two groupings: {groupings:?}"
);
let mut d = d;
let row = |id: &str, tenant: &str, region: &str| {
item(&[
("pk", &format!("EVENT#{id}")),
("sk", "EVENT#"),
("gs1pk", &format!("T#{tenant}")),
("gs1sk", "CODE#c1"),
("gs2pk", &format!("T#{region}")),
("id", id),
("tenant", tenant),
("region", region),
("code", "c1"),
])
};
run_item_on(&mut d, row("e1", "a", "b"), "code", "fake1");
run_item_on(&mut d, row("e2", "b", "a"), "code", "fake2");
assert!(
d.join_breaks().is_empty(),
"different groups in every index: {:?}",
d.join_breaks()
);
assert!(
d.sort_key_splits().is_empty(),
"and no split to report either: {:?}",
d.sort_key_splits()
);
}
#[test]
fn a_local_secondary_index_is_a_warning_not_an_exposure() {
let request: CreateTableRequest = serde_json::from_value(serde_json::json!({
"TableName": "App",
"KeySchema": [
{"AttributeName": "pk", "KeyType": "HASH"},
{"AttributeName": "sk", "KeyType": "RANGE"}
],
"AttributeDefinitions": [
{"AttributeName": "pk", "AttributeType": "S"},
{"AttributeName": "sk", "AttributeType": "S"},
{"AttributeName": "lsisk", "AttributeType": "S"}
],
"LocalSecondaryIndexes": [{
"IndexName": "LSI1",
"KeySchema": [
{"AttributeName": "pk", "KeyType": "HASH"},
{"AttributeName": "lsisk", "KeyType": "RANGE"}
],
"Projection": {"ProjectionType": "ALL"}
}]
}))
.unwrap();
let model = model_with(vec![EntityDefinition {
name: "User".to_string(),
pk_template: "user#${id}".to_string(),
sk_template: Some("user#${email}".to_string()),
type_attribute: None,
gsi_mappings: vec![],
description: None,
}]);
let (d, warnings) =
KeyDeriver::new(&model, &request, &email_rule(), &no_consistency()).unwrap();
assert!(
warnings
.iter()
.any(|w| w.message.contains("local secondary index")),
"it is said: {warnings:?}"
);
assert!(
warnings
.iter()
.all(|w| w.concern != super::super::notice::Concern::Exposure),
"but it is not an observed exposure: {warnings:?}"
);
let _ = d;
}
#[test]
fn a_nested_rule_target_still_finds_its_value_in_a_key() {
let model = model_with(vec![EntityDefinition {
name: "Order".to_string(),
pk_template: "ORDER#${id}".to_string(),
sk_template: Some("ORDER#".to_string()),
type_attribute: None,
gsi_mappings: vec![],
description: None,
}]);
let rules = [rule(
"profile.email",
ValidatedAction::Fake {
generator: "safe_email".into(),
seed: None,
},
)];
let (mut d, _) = KeyDeriver::new(&model, &request(), &rules, &no_consistency()).unwrap();
let mut profile = std::collections::HashMap::new();
profile.insert(
"email".to_string(),
AttributeValue::S("alice@real.co.uk".to_string()),
);
let mut it = item(&[
("pk", "ORDER#o1"),
("sk", "ORDER#"),
("gs1pk", "CUSTOMER#alice@real.co.uk"),
("id", "o1"),
]);
it.insert("profile".to_string(), AttributeValue::M(profile));
let mut warnings = Vec::new();
let plan = plan_one(&mut d, &it, &mut warnings).unwrap();
let mut replaced = std::collections::HashMap::new();
replaced.insert(
"email".to_string(),
AttributeValue::S("fake@example.org".to_string()),
);
it.insert("profile".to_string(), AttributeValue::M(replaced));
let rewritten = rewrote(&["profile.email"]);
apply_one(&mut d, &plan, &rewritten, &mut it, &mut warnings);
assert_eq!(
d.take_key_survivals(),
vec![("Order".to_string(), "gs1pk".to_string(), 1)]
);
}
#[test]
fn a_redacted_map_still_finds_its_leaves_in_a_key() {
let model = model_with(vec![EntityDefinition {
name: "Order".to_string(),
pk_template: "ORDER#${id}".to_string(),
sk_template: Some("ORDER#".to_string()),
type_attribute: None,
gsi_mappings: vec![],
description: None,
}]);
let rules = [rule("profile", ValidatedAction::Redact)];
let (mut d, _) = KeyDeriver::new(&model, &request(), &rules, &no_consistency()).unwrap();
let mut profile = std::collections::HashMap::new();
profile.insert(
"email".to_string(),
AttributeValue::S("alice@x.co".to_string()),
);
let mut it = item(&[
("pk", "ORDER#o1"),
("sk", "ORDER#"),
("gs1pk", "EMAIL#alice@x.co"),
("id", "o1"),
]);
it.insert("profile".to_string(), AttributeValue::M(profile));
let mut warnings = Vec::new();
let plan = plan_one(&mut d, &it, &mut warnings).unwrap();
it.insert(
"profile".to_string(),
AttributeValue::M(std::collections::HashMap::new()),
);
let rewritten = rewrote(&["profile"]);
apply_one(&mut d, &plan, &rewritten, &mut it, &mut warnings);
assert_eq!(
d.take_key_survivals(),
vec![("Order".to_string(), "gs1pk".to_string(), 1)]
);
}
#[test]
fn a_redacted_list_still_finds_its_elements_in_a_key() {
let model = model_with(vec![EntityDefinition {
name: "Order".to_string(),
pk_template: "ORDER#${id}".to_string(),
sk_template: Some("ORDER#".to_string()),
type_attribute: None,
gsi_mappings: vec![],
description: None,
}]);
let rules = [rule("emails", ValidatedAction::Redact)];
let (mut d, _) = KeyDeriver::new(&model, &request(), &rules, &no_consistency()).unwrap();
let mut it = item(&[
("pk", "ORDER#o1"),
("sk", "ORDER#"),
("gs1pk", "EMAIL#bob@x.co"),
("id", "o1"),
]);
it.insert(
"emails".to_string(),
AttributeValue::L(vec![
AttributeValue::S("alice@x.co".to_string()),
AttributeValue::S("bob@x.co".to_string()),
]),
);
let mut warnings = Vec::new();
let plan = plan_one(&mut d, &it, &mut warnings).unwrap();
it.insert("emails".to_string(), AttributeValue::L(Vec::new()));
let rewritten = rewrote(&["emails"]);
apply_one(&mut d, &plan, &rewritten, &mut it, &mut warnings);
assert_eq!(
d.take_key_survivals(),
vec![("Order".to_string(), "gs1pk".to_string(), 1)]
);
}
#[test]
fn a_value_has_to_sit_in_the_key_as_a_component_to_count() {
assert!(!holds_as_component("ORDER#1042", "42"));
assert!(!holds_as_component("ID42", "42"));
assert!(holds_as_component("STATUS#42", "42"));
assert!(holds_as_component("CUSTOMER#alice@x", "alice@x"));
assert!(holds_as_component("CUSTOMER|alice@x", "alice@x"));
assert!(holds_as_component("CUSTOMER:alice@x", "alice@x"));
assert!(holds_as_component("CUSTOMER_alice@x", "alice@x"));
assert!(holds_as_component("alice@x", "alice@x"));
assert!(holds_as_component("TENANT#a#b#PROFILE", "a#b"));
assert!(holds_as_component("xa#a#a", "a#a"));
assert!(!holds_as_component("xa#a#ax", "a#a"));
assert!(!holds_as_component("CUSTOMER#alice@x", ""));
}
#[test]
fn a_name_that_folds_differently_inside_a_key_still_counts() {
assert!(holds_as_component("USER#ΝΙΚΟΣ'ACCOUNT", "ΝΙΚΟΣ"));
assert!(holds_as_component("user#νικος'account", "ΝΙΚΟΣ"));
}
#[test]
fn a_case_folded_copy_of_a_replaced_value_still_counts() {
assert!(holds_as_component(
"CUSTOMER#alice@real.co.uk",
"Alice@Real.co.uk"
));
assert!(holds_as_component(
"CUSTOMER#ALICE@REAL.CO.UK",
"alice@real.co.uk"
));
assert!(holds_as_component(
"CUSTOMER#alice@real.co.uk",
"alice@real.co.uk"
));
assert!(!holds_as_component("ORDER#1042", "42"));
assert!(!holds_as_component("ID42", "42"));
assert!(!holds_as_component(
"CUSTOMER#malice@real.co.uk",
"Alice@Real.co.uk"
));
}
#[test]
fn a_key_joined_with_another_delimiter_still_counts_as_a_survival() {
let model = model_with(vec![EntityDefinition {
name: "Order".to_string(),
pk_template: "ORDER#${id}".to_string(),
sk_template: Some("ORDER#".to_string()),
type_attribute: None,
gsi_mappings: vec![],
description: None,
}]);
let (mut d, _) =
KeyDeriver::new(&model, &request(), &email_rule(), &no_consistency()).unwrap();
let rewritten = rewrote(&["email"]);
let mut it = item(&[
("pk", "ORDER#o1"),
("sk", "ORDER#"),
("gs1pk", "CUSTOMER|alice@real.co.uk"),
("id", "o1"),
("email", "alice@real.co.uk"),
]);
let mut warnings = Vec::new();
let plan = plan_one(&mut d, &it, &mut warnings).unwrap();
it.insert(
"email".to_string(),
AttributeValue::S("fake@example.org".to_string()),
);
apply_one(&mut d, &plan, &rewritten, &mut it, &mut warnings);
assert_eq!(
d.take_key_survivals(),
vec![("Order".to_string(), "gs1pk".to_string(), 1)]
);
let mut it = item(&[
("pk", "ORDER#o1"),
("sk", "ORDER#"),
("gs1pk", "ORDER#o10"),
("id", "o1"),
("email", "alice@real.co.uk"),
]);
let (mut d, _) = KeyDeriver::new(
&model,
&request(),
&[rule("id", ValidatedAction::Redact)],
&no_consistency(),
)
.unwrap();
let plan = plan_one(&mut d, &it, &mut warnings).unwrap();
it.insert(
"id".to_string(),
AttributeValue::S("[REDACTED]".to_string()),
);
let rewritten = rewrote(&["id"]);
apply_one(&mut d, &plan, &rewritten, &mut it, &mut warnings);
assert!(d.take_key_survivals().is_empty());
}
#[test]
fn a_rule_owned_key_the_rule_passed_by_is_counted_as_unrebuilt() {
let rules = [rule("pk", ValidatedAction::Redact)];
let (mut d, _) = KeyDeriver::new(&model(), &request(), &rules, &no_consistency()).unwrap();
let it = item(&[
("_type", "User"),
("pk", "legacy#a@x.co"),
("sk", "user#a@x.co"),
("email", "a@x.co"),
]);
let mut w = Vec::new();
let plan = plan_one(&mut d, &it, &mut w).unwrap();
let mut fired = it.clone();
fired.insert(
"pk".to_string(),
AttributeValue::S("[REDACTED]".to_string()),
);
let rewritten = rewrote(&["pk"]);
apply_one(&mut d, &plan, &rewritten, &mut fired, &mut w);
assert!(
d.take_notices()
.iter()
.all(|n| !n.message.contains("kept the original pk")),
"a key the rule rewrote is not unrebuilt"
);
let plan = plan_one(&mut d, &it, &mut w).unwrap();
let mut skipped = it.clone();
apply_one(&mut d, &plan, &no_rewrites(), &mut skipped, &mut w);
assert!(
w.iter().any(|n| n
.message
.contains("a rule names pk but its condition passed")),
"{w:?}"
);
let notices = d.take_notices();
let kept = notices
.iter()
.find(|n| n.message.contains("kept the original pk on 1 items"))
.unwrap_or_else(|| panic!("{notices:?}"));
assert_eq!(kept.concern, super::super::notice::Concern::Exposure);
}
#[test]
fn a_rule_for_another_table_does_not_shape_this_tables_keys() {
let mut scoped = rule("pk", ValidatedAction::Redact);
scoped.tables = Some(vec!["Orders".to_string()]);
let (mut d, _) =
KeyDeriver::new(&model(), &request(), &[scoped], &no_consistency()).unwrap();
let it = item(&[
("_type", "User"),
("pk", "somethingelse#1"),
("sk", "user#a@x.co"),
("email", "a@x.co"),
]);
let mut warnings = Vec::new();
let _ = plan_one(&mut d, &it, &mut warnings);
assert!(
d.take_unrebuilt()
.iter()
.any(|(e, k, _)| e == "User" && k == "pk"),
"pk did not reproduce and no applicable rule owns it"
);
}
#[test]
fn a_direct_key_rule_on_a_key_no_template_reproduces_is_still_join_checked() {
let rules = [rule(
"pk",
ValidatedAction::Fake {
generator: "word".into(),
seed: None,
},
)];
let (mut d, _) =
KeyDeriver::new(&shared_key_model(), &request(), &rules, &no_consistency()).unwrap();
let rewritten = rewrote(&["pk"]);
for (ty, sk, becomes) in [
("Customer", "PROFILE", "fake1"),
("Order", "ORDER#1", "fake2"),
] {
let mut it = item(&[
("_type", ty),
("pk", "legacy#same"),
("sk", sk),
("email", "a@x.co"),
]);
let mut w = Vec::new();
let plan = plan_one(&mut d, &it, &mut w).unwrap();
it.insert("pk".to_string(), AttributeValue::S(becomes.to_string()));
apply_one(&mut d, &plan, &rewritten, &mut it, &mut w);
}
let breaks = d.join_breaks();
assert_eq!(breaks.len(), 1, "the shared key split: {breaks:?}");
}
#[test]
fn a_key_the_model_never_templates_that_still_holds_a_replaced_value_is_counted() {
let model = model_with(vec![EntityDefinition {
name: "Order".to_string(),
pk_template: "ORDER#${id}".to_string(),
sk_template: Some("ORDER#".to_string()),
type_attribute: None,
gsi_mappings: vec![],
description: None,
}]);
let (mut d, _) =
KeyDeriver::new(&model, &request(), &email_rule(), &no_consistency()).unwrap();
let mut it = item(&[
("pk", "ORDER#o1"),
("sk", "ORDER#"),
("gs1pk", "CUSTOMER#alice@real.co.uk"),
("id", "o1"),
("email", "alice@real.co.uk"),
]);
let mut warnings = Vec::new();
let plan = plan_one(&mut d, &it, &mut warnings).unwrap();
it.insert(
"email".to_string(),
AttributeValue::S("fake@example.org".to_string()),
);
let rewritten = rewrote(&["email"]);
apply_one(&mut d, &plan, &rewritten, &mut it, &mut warnings);
assert!(
warnings.iter().any(|w| w
.message
.contains("gs1pk still holds a value a rule replaced")),
"{warnings:?}"
);
assert_eq!(
d.take_key_survivals(),
vec![("Order".to_string(), "gs1pk".to_string(), 1)]
);
let mut clean = item(&[
("pk", "ORDER#o2"),
("sk", "ORDER#"),
("gs1pk", "STATUS#open"),
("id", "o2"),
("email", "bob@real.co.uk"),
]);
let plan = plan_one(&mut d, &clean, &mut warnings).unwrap();
clean.insert(
"email".to_string(),
AttributeValue::S("fake2@example.org".to_string()),
);
apply_one(&mut d, &plan, &rewritten, &mut clean, &mut warnings);
assert!(d.take_key_survivals().is_empty());
}
#[test]
fn a_leaf_of_a_rule_that_did_not_fire_is_not_a_survival() {
let model = model_with(vec![EntityDefinition {
name: "Order".to_string(),
pk_template: "ORDER#${id}".to_string(),
sk_template: Some("ORDER#".to_string()),
type_attribute: None,
gsi_mappings: vec![],
description: None,
}]);
let mut customers_only = rule("profile", ValidatedAction::Redact);
customers_only.condition = condition::parse("attribute_exists(customerOnly)").unwrap();
let rules = [
customers_only,
rule("profile.email", ValidatedAction::Redact),
];
let (mut d, _) = KeyDeriver::new(&model, &request(), &rules, &no_consistency()).unwrap();
let order = |email: &str, status: &str| {
let mut profile = std::collections::HashMap::new();
profile.insert("email".to_string(), AttributeValue::S(email.to_string()));
profile.insert("status".to_string(), AttributeValue::S(status.to_string()));
let mut it = item(&[
("pk", "ORDER#o1"),
("sk", "ORDER#"),
("gs1sk", "STATUS#ACTIVE"),
("id", "o1"),
]);
it.insert("profile".to_string(), AttributeValue::M(profile));
it
};
let mut it = order("alice@real.co.uk", "ACTIVE");
let mut warnings = Vec::new();
let plan = plan_one(&mut d, &it, &mut warnings).unwrap();
let mut replaced = std::collections::HashMap::new();
replaced.insert(
"email".to_string(),
AttributeValue::S("[REDACTED]".to_string()),
);
replaced.insert(
"status".to_string(),
AttributeValue::S("ACTIVE".to_string()),
);
it.insert("profile".to_string(), AttributeValue::M(replaced));
apply_one(
&mut d,
&plan,
&rewrote(&["profile.email"]),
&mut it,
&mut warnings,
);
assert!(
d.take_key_survivals().is_empty(),
"the status was never a value a rule replaced: {warnings:?}"
);
let mut it = order("bob@real.co.uk", "ACTIVE");
it.insert("customerOnly".to_string(), AttributeValue::BOOL(true));
let plan = plan_one(&mut d, &it, &mut warnings).unwrap();
it.insert(
"profile".to_string(),
AttributeValue::M(std::collections::HashMap::new()),
);
apply_one(
&mut d,
&plan,
&rewrote(&["profile", "profile.email"]),
&mut it,
&mut warnings,
);
assert_eq!(
d.take_key_survivals(),
vec![("Order".to_string(), "gs1sk".to_string(), 1)]
);
}
#[test]
fn two_entities_sharing_a_gsi_partition_and_sort_value_still_break() {
let (mut d, _) =
KeyDeriver::new(&code_model(), &request(), &code_rule(), &no_consistency()).unwrap();
run_item_on(
&mut d,
code_item("EVENT", "e1", "t1", "c1"),
"code",
"fake1",
);
run_item_on(
&mut d,
code_item("ALERT", "a1", "t1", "c1"),
"code",
"fake2",
);
let breaks = d.join_breaks();
assert_eq!(breaks.len(), 1, "{breaks:?}");
assert!(
breaks[0].contains("entity 'Alert' and entity 'Event'")
|| breaks[0].contains("entity 'Event' and entity 'Alert'"),
"{breaks:?}"
);
}
#[test]
fn padding_matches_javascript_pad_start() {
let cases = [
("7", 4, "0", "0007"),
("7", 4, "00", "0007"),
("xx", 5, "ab", "abaxx"),
("42", 6, "-*", "-*-*42"),
("abc", 2, "0", "abc"),
("", 3, "xyz", "xyz"),
("日本", 4, "0", "00日本"),
("\u{1F600}", 4, "0", "00\u{1F600}"),
("a\u{1F600}", 4, "0", "0a\u{1F600}"),
];
for (value, length, fill, want) in cases {
let pad = Padding {
length,
fill: fill.to_string(),
};
assert_eq!(
pad.apply(value),
want,
"padStart({value:?}, {length}, {fill:?})"
);
}
}
fn run_item_on(d: &mut KeyDeriver, mut item: Item, attribute: &str, becomes: &str) {
let mut warnings = Vec::new();
let plan = plan_one(d, &item, &mut warnings).unwrap();
item.insert(
attribute.to_string(),
AttributeValue::S(becomes.to_string()),
);
apply_one(d, &plan, &no_rewrites(), &mut item, &mut warnings);
}
fn shared_key_deriver() -> KeyDeriver {
KeyDeriver::new(
&shared_key_model(),
&request(),
&email_rule(),
&no_consistency(),
)
.unwrap()
.0
}
fn customer_item(email: &str) -> Item {
item(&[
("_type", "Customer"),
("pk", &format!("CUSTOMER#{email}")),
("sk", "PROFILE"),
("email", email),
])
}
fn order_item(email: &str) -> Item {
item(&[
("_type", "Order"),
("pk", &format!("CUSTOMER#{email}")),
("sk", "ORDER#1"),
("email", email),
("orderId", "1"),
])
}
#[test]
fn a_batch_that_is_dropped_leaves_nothing_behind() {
let mut d = shared_key_deriver();
let mut w = Vec::new();
let mut dropped = Batch::default();
for (mut it, becomes) in [
(customer_item("a@x.co"), "fake1@example.com"),
(order_item("a@x.co"), "fake2@example.org"),
] {
let plan = d.plan(&it, &mut dropped, &mut w).unwrap();
it.insert("email".to_string(), AttributeValue::S(becomes.to_string()));
d.apply(&plan, &no_rewrites(), &mut it, &mut dropped, &mut w);
}
drop(dropped);
assert!(d.join_breaks().is_empty(), "{:?}", d.join_breaks());
run_item(&mut d, order_item("a@x.co"), "fake2@example.org");
assert!(
d.join_breaks().is_empty(),
"the customer never landed: {:?}",
d.join_breaks()
);
run_item(&mut d, customer_item("a@x.co"), "fake1@example.com");
assert_eq!(d.join_breaks().len(), 1, "{:?}", d.join_breaks());
}
#[test]
fn a_collision_is_counted_when_the_batch_lands_not_when_it_is_seen() {
let mut d = deriver();
let mut dropped = Batch::default();
d.note_primary_key(&user(), &mut dropped);
d.note_primary_key(&user(), &mut dropped);
drop(dropped);
assert_eq!(d.take_collisions().0, 0, "neither row landed");
let mut landed = Batch::default();
d.note_primary_key(&user(), &mut landed);
d.note_primary_key(&user(), &mut landed);
d.commit(landed);
assert_eq!(
d.take_collisions().0,
1,
"the same two rows, landed, are one overwrite"
);
}
#[test]
fn a_caution_first_raised_in_a_dropped_batch_is_raised_again_by_the_next() {
let mut d = deriver();
let mut off = user();
off.insert(
"pk".to_string(),
AttributeValue::S("somethingelse#1".to_string()),
);
let mut dropped = Batch::default();
let mut first = Vec::new();
let _ = d.plan(&off, &mut dropped, &mut first);
assert_eq!(first.len(), 1, "{first:?}");
drop(dropped);
let mut landed = Batch::default();
let mut again = Vec::new();
let _ = d.plan(&off, &mut landed, &mut again);
let _ = d.plan(&off, &mut landed, &mut again);
assert_eq!(again.len(), 1, "once more, and once only: {again:?}");
d.commit(landed);
let mut later = Batch::default();
let mut quiet = Vec::new();
let _ = d.plan(&off, &mut later, &mut quiet);
assert!(quiet.is_empty(), "{quiet:?}");
d.commit(later);
assert_eq!(
d.take_unrebuilt(),
vec![("User".to_string(), "pk".to_string(), 3)],
"three rows landed with the key left as it was"
);
}
#[test]
fn two_entities_anonymising_a_shared_value_differently_is_a_broken_join() {
let mut d = shared_key_deriver();
run_item(&mut d, customer_item("a@x.co"), "fake1@example.com");
assert!(d.join_breaks().is_empty(), "one entity so far");
run_item(&mut d, order_item("a@x.co"), "fake2@example.org");
let breaks = d.join_breaks();
assert_eq!(breaks.len(), 1, "{breaks:?}");
assert!(breaks[0].contains("entity 'Customer' and entity 'Order'"));
assert!(breaks[0].contains("Add 'email' to [consistency] fields"));
}
#[test]
fn a_break_is_found_whatever_order_the_export_is_in() {
let mut d = shared_key_deriver();
run_item(&mut d, order_item("a@x.co"), "a@x.co"); run_item(&mut d, order_item("a@x.co"), "fake@example.org"); run_item(&mut d, customer_item("a@x.co"), "a@x.co");
let breaks = d.join_breaks();
assert_eq!(
breaks.len(),
1,
"the rewritten order lost its customer: {breaks:?}"
);
}
#[test]
fn two_composite_keys_sharing_only_a_component_never_joined() {
let model = model_with(vec![
entity(
"Customer",
"TENANT#${tenantId}#${email}",
Some("PROFILE"),
None,
),
entity(
"Order",
"TENANT#${tenantId}#${email}",
Some("ORDER#${id}"),
None,
),
]);
let (mut d, _) =
KeyDeriver::new(&model, &request(), &email_rule(), &no_consistency()).unwrap();
let mut run = |name: &str, tenant: &str, sk: &str, becomes: &str| {
let mut it = item(&[
("_type", name),
("pk", &format!("TENANT#{tenant}#a@x.co")),
("sk", sk),
("tenantId", tenant),
("email", "a@x.co"),
("id", "1"),
]);
let mut w = Vec::new();
let plan = plan_one(&mut d, &it, &mut w).unwrap();
it.insert("email".to_string(), AttributeValue::S(becomes.to_string()));
apply_one(&mut d, &plan, &no_rewrites(), &mut it, &mut w);
};
run("Customer", "a", "PROFILE", "fake1@example.com");
run("Order", "b", "ORDER#1", "fake2@example.org");
assert!(
d.join_breaks().is_empty(),
"different tenants never shared a key: {:?}",
d.join_breaks()
);
}
#[test]
fn one_entity_repeating_a_key_keeps_a_bounded_summary() {
let mut d = shared_key_deriver();
for n in 0..50 {
run_item(
&mut d,
order_item("a@x.co"),
&format!("fake{n}@example.org"),
);
}
let outcomes: usize = d
.at_risk
.iter()
.map(|r| r.seen_keys.values().map(Vec::len).sum::<usize>())
.sum();
assert_eq!(outcomes, 1, "one row per entity per key, not per item");
let breaks = d.join_breaks();
assert_eq!(
breaks.len(),
1,
"an entity that splits its own partition has broken a join: {breaks:?}"
);
assert!(
breaks[0].contains("took one original"),
"the message should name the split rather than a disagreement \
between two entities: {breaks:?}"
);
run_item(&mut d, customer_item("a@x.co"), "different@example.com");
assert_eq!(d.join_breaks().len(), 1);
}
#[test]
fn keys_seen_before_the_cap_are_still_checked_after_it() {
let mut d = shared_key_deriver();
run_item(&mut d, customer_item("a@x.co"), "fake1@example.com");
for risk in &mut d.at_risk {
while risk.seen_keys.len() < MAX_TRACKED_KEYS {
let filler = risk.seen_keys.len() as u64 + 1_000_000;
risk.seen_keys.insert(filler, Vec::new());
}
}
run_item(&mut d, order_item("b@y.co"), "fake2@example.org");
assert!(d.unchecked_join_keys() > 0, "the new key went unchecked");
run_item(&mut d, order_item("a@x.co"), "different@example.org");
assert_eq!(
d.join_breaks().len(),
1,
"a key seen before the cap must still be checked: {:?}",
d.join_breaks()
);
}
#[test]
fn a_join_across_two_different_templates_is_still_seen() {
let model = model_with(vec![
entity("Customer", "CUSTOMER#${email}", Some("PROFILE"), None),
entity(
"Order",
"CUSTOMER#${customerEmail}",
Some("ORDER#${id}"),
None,
),
]);
let rules = [
rule(
"email",
ValidatedAction::Fake {
generator: "safe_email".into(),
seed: None,
},
),
rule(
"customerEmail",
ValidatedAction::Fake {
generator: "safe_email".into(),
seed: None,
},
),
];
let (mut d, warnings) =
KeyDeriver::new(&model, &request(), &rules, &no_consistency()).unwrap();
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(
warnings[0]
.message
.contains("entity 'Customer' and entity 'Order'")
);
let mut run = |name: &str, source: &str, sk: &str, becomes: &str| {
let mut it = item(&[
("_type", name),
("pk", "CUSTOMER#a@x.co"),
("sk", sk),
(source, "a@x.co"),
("id", "1"),
]);
let mut w = Vec::new();
let plan = plan_one(&mut d, &it, &mut w).unwrap();
it.insert(source.to_string(), AttributeValue::S(becomes.to_string()));
apply_one(&mut d, &plan, &no_rewrites(), &mut it, &mut w);
};
run("Customer", "email", "PROFILE", "fake1@example.com");
run("Order", "customerEmail", "ORDER#1", "fake2@example.org");
let breaks = d.join_breaks();
assert_eq!(breaks.len(), 1, "{breaks:?}");
assert!(breaks[0].contains("same attribute name"), "{breaks:?}");
}
#[test]
fn a_sibling_whose_source_no_rule_rewrites_still_breaks_the_join() {
let model = model_with(vec![
entity("Customer", "CUSTOMER#${email}", Some("PROFILE"), None),
entity(
"Order",
"CUSTOMER#${customerEmail}",
Some("ORDER#${id}"),
None,
),
]);
let (mut d, warnings) =
KeyDeriver::new(&model, &request(), &email_rule(), &no_consistency()).unwrap();
assert!(
warnings.is_empty(),
"one entity at risk has nothing to list up front: {warnings:?}"
);
let mut run = |name: &str, source: &str, sk: &str, becomes: Option<&str>| {
let mut it = item(&[
("_type", name),
("pk", "CUSTOMER#a@x.co"),
("sk", sk),
(source, "a@x.co"),
("id", "1"),
]);
let mut w = Vec::new();
let plan = plan_one(&mut d, &it, &mut w).unwrap();
if let Some(becomes) = becomes {
it.insert(source.to_string(), AttributeValue::S(becomes.to_string()));
}
apply_one(&mut d, &plan, &no_rewrites(), &mut it, &mut w);
};
run("Customer", "email", "PROFILE", Some("fake1@example.com"));
run("Order", "customerEmail", "ORDER#1", None);
let breaks = d.join_breaks();
assert_eq!(breaks.len(), 1, "{breaks:?}");
assert!(
breaks[0].contains("entity 'Customer' and entity 'Order'"),
"{breaks:?}"
);
}
#[test]
fn a_key_left_unrebuilt_is_not_reported_as_a_broken_join() {
let mut d = shared_key_deriver();
run_item(&mut d, customer_item("a@x.co"), "fake1@example.com");
let mut order = item(&[
("_type", "Order"),
("pk", "CUSTOMER#a@x.co"),
("sk", "ORDER#1"),
("orderId", "1"),
]);
assert!(!order.contains_key("email"), "nothing to rebuild pk from");
let mut warnings = Vec::new();
let plan = plan_one(&mut d, &order, &mut warnings).unwrap();
apply_one(&mut d, &plan, &no_rewrites(), &mut order, &mut warnings);
assert!(
warnings
.iter()
.any(|w| w.message.contains("does not reproduce pk")),
"the accurate diagnostic still fires: {warnings:?}"
);
assert!(
d.join_breaks().is_empty(),
"an unrebuilt key is not a divergence: {:?}",
d.join_breaks()
);
assert_eq!(
order["pk"],
AttributeValue::S("CUSTOMER#a@x.co".to_string())
);
}
#[test]
fn an_index_the_table_does_not_have_is_reported() {
let model = model_with(vec![EntityDefinition {
name: "User".to_string(),
pk_template: "user#${id}".to_string(),
sk_template: Some("user#".to_string()),
type_attribute: None,
gsi_mappings: vec![GsiMapping {
index_name: "gs1".to_string(),
pk_template: String::new(),
sk_template: Some("user#${email}".to_string()),
}],
description: None,
}]);
let (mut d, warnings) =
KeyDeriver::new(&model, &request(), &[], &no_consistency()).unwrap();
let unmatched = warnings
.iter()
.find(|w| w.message.contains("index 'gs1' that the table does not"))
.unwrap_or_else(|| panic!("{warnings:?}"));
assert_eq!(unmatched.concern, super::super::notice::Concern::Caution);
assert!(
d.take_notices().is_empty(),
"nothing observed on an empty table"
);
let mut it = item(&[
("pk", "user#1"),
("sk", "user#"),
("id", "1"),
("gs1sk", "user#a@x.co"),
("email", "a@x.co"),
]);
let mut w = Vec::new();
let plan = plan_one(&mut d, &it, &mut w).unwrap();
apply_one(&mut d, &plan, &no_rewrites(), &mut it, &mut w);
let notices = d.take_notices();
let seen = notices
.iter()
.find(|n| n.message.contains("1 items carry gs1sk"))
.unwrap_or_else(|| panic!("{notices:?}"));
assert_eq!(seen.concern, super::super::notice::Concern::Exposure);
assert!(seen.message.contains("index 'gs1'"), "{seen:?}");
assert!(d.take_notices().is_empty(), "taking clears");
}
#[test]
fn only_a_key_of_an_index_the_model_never_names_counts_for_a_missing_index() {
use super::super::notice::Concern;
let model = model_with(vec![EntityDefinition {
name: "User".to_string(),
pk_template: "user#${id}".to_string(),
sk_template: Some("user#".to_string()),
type_attribute: None,
gsi_mappings: vec![GsiMapping {
index_name: "gs1".to_string(),
pk_template: String::new(),
sk_template: Some("user#${email}".to_string()),
}],
description: None,
}]);
let mut request = request();
request.local_secondary_indexes = Some(
serde_json::from_value(serde_json::json!([{
"IndexName": "LSI1",
"KeySchema": [
{"AttributeName": "pk", "KeyType": "HASH"},
{"AttributeName": "ls1sk", "KeyType": "RANGE"}
],
"Projection": {"ProjectionType": "ALL"}
}]))
.unwrap(),
);
let rules = [rule("gs1pk", ValidatedAction::Redact)];
let (mut d, _) = KeyDeriver::new(&model, &request, &rules, &no_consistency()).unwrap();
let mut it = item(&[
("pk", "user#1"),
("sk", "user#"),
("id", "1"),
("gs1pk", "tenant#t1"),
("gs1sk", "user#a@x.co"),
("ls1sk", "2024-01-01"),
("email", "a@x.co"),
]);
let mut w = Vec::new();
let plan = plan_one(&mut d, &it, &mut w).unwrap();
it.insert(
"gs1pk".to_string(),
AttributeValue::S("[REDACTED]".to_string()),
);
let rewritten = rewrote(&["gs1pk"]);
apply_one(&mut d, &plan, &rewritten, &mut it, &mut w);
let notices = d.take_notices();
let exposures: Vec<&Notice> = notices
.iter()
.filter(|n| n.concern == Concern::Exposure)
.collect();
assert_eq!(exposures.len(), 1, "{notices:?}");
assert!(
exposures[0].message.contains("1 items carry gs1sk"),
"{notices:?}"
);
let model = model_with(vec![EntityDefinition {
name: "User".to_string(),
pk_template: "user#${id}".to_string(),
sk_template: Some("user#".to_string()),
type_attribute: None,
gsi_mappings: vec![
GsiMapping {
index_name: "GSI1".to_string(),
pk_template: String::new(),
sk_template: Some("user#${email}".to_string()),
},
GsiMapping {
index_name: "gs2".to_string(),
pk_template: "tenant#${tenant}".to_string(),
sk_template: None,
},
],
description: None,
}]);
let (mut d, _) =
KeyDeriver::new(&model, &two_index_request(), &[], &no_consistency()).unwrap();
let mut it = item(&[
("pk", "user#1"),
("sk", "user#"),
("id", "1"),
("gs1pk", "t1"),
("gs1sk", "user#a@x.co"),
("gs2pk", "tenant#t1"),
("tenant", "t1"),
("email", "a@x.co"),
]);
let mut w = Vec::new();
let plan = plan_one(&mut d, &it, &mut w).unwrap();
apply_one(&mut d, &plan, &no_rewrites(), &mut it, &mut w);
let notices = d.take_notices();
let exposures: Vec<&Notice> = notices
.iter()
.filter(|n| n.concern == Concern::Exposure)
.collect();
assert_eq!(exposures.len(), 1, "{notices:?}");
assert!(
exposures[0].message.contains("1 items carry gs2pk"),
"gs1pk is a plain key of an index the model names: {notices:?}"
);
}
#[test]
fn a_local_secondary_index_is_reported_as_not_rebuilt() {
let mut request = request();
request.local_secondary_indexes = Some(
serde_json::from_value(serde_json::json!([{
"IndexName": "LSI1",
"KeySchema": [
{"AttributeName": "pk", "KeyType": "HASH"},
{"AttributeName": "ls1sk", "KeyType": "RANGE"}
],
"Projection": {"ProjectionType": "ALL"}
}]))
.unwrap(),
);
let (_, warnings) = KeyDeriver::new(&model(), &request, &[], &no_consistency()).unwrap();
assert!(
warnings
.iter()
.any(|w| w.message.contains("local secondary index")
&& w.message.contains("not rebuilt")),
"{warnings:?}"
);
}
#[test]
fn null_on_a_key_source_is_reported_as_retaining_not_collapsing() {
let rules = [rule("email", ValidatedAction::Null)];
let (_, warnings) =
KeyDeriver::new(&model(), &request(), &rules, &no_consistency()).unwrap();
assert!(!warnings.is_empty());
for w in &warnings {
assert!(
w.message.contains("keeps the key value it arrived with"),
"null retains, it does not collapse: {w}"
);
assert!(!w.message.contains("overwrites the last"), "{w}");
}
}
#[test]
fn a_rebuilt_key_landing_on_an_untouched_row_is_a_collision() {
let mut d = deriver();
let mut warnings = Vec::new();
let stranger = item(&[("pk", "account#acc1"), ("sk", "user#[REDACTED]")]);
assert!(
plan_one(&mut d, &stranger, &mut warnings).is_none(),
"matches nothing"
);
note_one(&mut d, &stranger);
let mut user = user();
let plan = plan_one(&mut d, &user, &mut warnings).unwrap();
user.insert(
"email".to_string(),
AttributeValue::S("[REDACTED]".to_string()),
);
apply_one(&mut d, &plan, &no_rewrites(), &mut user, &mut warnings);
assert_eq!(
user["sk"],
AttributeValue::S("user#[REDACTED]".to_string()),
"the rebuilt key lands on the stranger's"
);
assert_eq!(d.take_collisions().0, 1);
}
#[test]
fn a_shared_value_anonymised_the_same_way_still_joins() {
let mut d = shared_key_deriver();
run_item(&mut d, customer_item("a@x.co"), "same@example.com");
run_item(&mut d, order_item("a@x.co"), "same@example.com");
assert!(
d.join_breaks().is_empty(),
"both agreed, so the join survives: {:?}",
d.join_breaks()
);
}
#[test]
fn both_entities_with_no_value_in_common_is_not_a_broken_join() {
let mut d = shared_key_deriver();
run_item(&mut d, customer_item("a@x.co"), "fake1@example.com");
run_item(&mut d, order_item("b@y.co"), "fake2@example.org");
assert!(
d.join_breaks().is_empty(),
"both entities present but no shared value: {:?}",
d.join_breaks()
);
}
#[test]
fn rebuilt_primary_keys_that_repeat_are_counted_as_collisions() {
let mut d = deriver();
let mut warnings = Vec::new();
for n in 0..3 {
let mut user = item(&[
("_type", "User"),
("pk", "account#acc1"),
("sk", &format!("user#u{n}@example.com")),
("accountId", "acc1"),
("email", &format!("u{n}@example.com")),
]);
let plan = plan_one(&mut d, &user, &mut warnings).unwrap();
user.insert(
"email".to_string(),
AttributeValue::S("[REDACTED]".to_string()),
);
apply_one(&mut d, &plan, &no_rewrites(), &mut user, &mut warnings);
assert_eq!(user["sk"], AttributeValue::S("user#[REDACTED]".to_string()));
}
assert_eq!(d.take_collisions(), (2, false));
assert_eq!(d.take_collisions(), (0, false));
}
}