use crate::errors::{DynoxideError, Result};
use crate::storage::TableMetadata;
use crate::storage_backend::{IndexWriteOp, StorageBackend};
use crate::types::{AttributeValue, GlobalSecondaryIndex, Item, KeyType, ProjectionType};
use std::collections::HashMap;
pub struct IndexWrite<'a> {
pub table_name: &'a str,
pub pk: &'a str,
pub sk: &'a str,
pub pk_attr: &'a str,
pub sk_attr: Option<&'a str>,
pub old_item: Option<&'a Item>,
pub capacity_mode: Option<&'a str>,
}
pub struct IndexDef {
pub index_name: String,
pub pk_attr: String,
pub sk_attr: Option<String>,
pub hash_attrs: Vec<String>,
pub range_attrs: Vec<String>,
pub projection_type: ProjectionType,
pub non_key_attributes: Option<Vec<String>>,
}
pub type GsiDef = IndexDef;
impl IndexDef {
pub fn projects(&self, attr: &str, table_pk: &str, table_sk: Option<&str>) -> bool {
if self.is_key_attr(attr) || attr == table_pk || table_sk == Some(attr) {
return true;
}
match self.projection_type {
ProjectionType::ALL => true,
ProjectionType::KEYS_ONLY => false,
ProjectionType::INCLUDE => self
.non_key_attributes
.as_ref()
.is_some_and(|names| names.iter().any(|n| n == attr)),
}
}
pub fn is_key_attr(&self, attr: &str) -> bool {
self.hash_attrs.iter().any(|a| a == attr) || self.range_attrs.iter().any(|a| a == attr)
}
pub fn index_key_strings(&self, item: &Item) -> Option<(String, String)> {
let values = |attrs: &[String]| -> Option<Vec<&AttributeValue>> {
attrs.iter().map(|a| item.get(a)).collect()
};
let pk = composite_key_string(&values(&self.hash_attrs)?)?;
let sk = if self.range_attrs.is_empty() {
String::new()
} else {
composite_key_string(&values(&self.range_attrs)?)?
};
Some((pk, sk))
}
}
pub(crate) const KEY_PART_END: &str = "\u{1}\u{1}";
pub fn composite_key_string(values: &[&AttributeValue]) -> Option<String> {
if let [only] = values {
return only.to_key_string();
}
let mut out = String::new();
for value in values {
push_key_part(&mut out, &value.to_key_string()?);
}
Some(out)
}
pub(crate) fn push_key_part(out: &mut String, key_string: &str) {
push_escaped_key(out, key_string);
out.push_str(KEY_PART_END);
}
pub(crate) fn push_escaped_key(out: &mut String, key_string: &str) {
for c in key_string.chars() {
match c {
'\u{0}' => out.push_str("\u{1}\u{2}"),
'\u{1}' => out.push_str("\u{1}\u{3}"),
c => out.push(c),
}
}
}
pub fn gsi_to_def(gsi: &GlobalSecondaryIndex) -> Result<GsiDef> {
let pk_attr = gsi
.key_schema
.iter()
.find(|k| k.key_type == KeyType::HASH)
.map(|k| k.attribute_name.clone())
.ok_or_else(|| DynoxideError::InternalServerError("GSI missing HASH key".to_string()))?;
let sk_attr = gsi
.key_schema
.iter()
.find(|k| k.key_type == KeyType::RANGE)
.map(|k| k.attribute_name.clone());
let (hash_attrs, range_attrs) = key_attrs_by_type(&gsi.key_schema);
Ok(GsiDef {
index_name: gsi.index_name.clone(),
pk_attr,
sk_attr,
hash_attrs,
range_attrs,
projection_type: gsi.projection.projection_type.clone().unwrap_or_default(),
non_key_attributes: gsi.projection.non_key_attributes.clone(),
})
}
pub fn key_attrs_by_type(
key_schema: &[crate::types::KeySchemaElement],
) -> (Vec<String>, Vec<String>) {
let of = |key_type: KeyType| {
key_schema
.iter()
.filter(|k| k.key_type == key_type)
.map(|k| k.attribute_name.clone())
.collect()
};
(of(KeyType::HASH), of(KeyType::RANGE))
}
pub fn parse_gsi_defs(meta: &TableMetadata) -> Result<Vec<GsiDef>> {
if meta.gsi_definitions.is_some() {
crate::bench_counters::record(&crate::bench_counters::INDEX_DEFS_PARSES);
}
let gsis: Vec<GlobalSecondaryIndex> = match meta.gsi_definitions.as_ref() {
Some(json) => serde_json::from_str(json)
.map_err(|e| DynoxideError::InternalServerError(format!("Bad GSI JSON: {e}")))?,
None => return Ok(Vec::new()),
};
gsis.iter().map(gsi_to_def).collect()
}
pub fn build_index_item(
item: &Item,
index: &IndexDef,
table_pk: &str,
table_sk: Option<&str>,
) -> Item {
crate::bench_counters::record(&crate::bench_counters::INDEX_ENTRIES_BUILT);
if index.projection_type == ProjectionType::ALL {
return item.clone();
}
item.iter()
.filter(|(name, _)| index.projects(name, table_pk, table_sk))
.map(|(name, value)| (name.clone(), value.clone()))
.collect()
}
pub async fn maintain_gsis_after_write<S: StorageBackend>(
storage: &S,
meta: &TableMetadata,
target: &IndexWrite<'_>,
item: &Item,
) -> Result<HashMap<String, f64>> {
let gsi_defs = parse_gsi_defs(meta)?;
maintain_gsis_after_write_with_defs(storage, &gsi_defs, target, item).await
}
pub async fn maintain_gsis_after_write_with_defs<S: StorageBackend>(
storage: &S,
gsi_defs: &[GsiDef],
target: &IndexWrite<'_>,
item: &Item,
) -> Result<HashMap<String, f64>> {
let want_capacity = crate::types::capacity_wanted(target.capacity_mode);
let mut gsi_units: HashMap<String, f64> = HashMap::new();
let mut ops: Vec<IndexWriteOp> = Vec::new();
for gsi in gsi_defs {
ops.push(IndexWriteOp::DeleteGsi {
table_name: target.table_name.to_string(),
index_name: gsi.index_name.clone(),
table_pk: target.pk.to_string(),
table_sk: target.sk.to_string(),
});
let entry = super::index_capacity::entry_for(item, gsi, target.pk_attr, target.sk_attr);
if let Some(ref entry) = entry {
let (gsi_pk, gsi_sk) = entry.key.clone();
let item_json = serde_json::to_string(&entry.projected)
.map_err(|e| DynoxideError::InternalServerError(e.to_string()))?;
ops.push(IndexWriteOp::InsertGsi {
table_name: target.table_name.to_string(),
index_name: gsi.index_name.clone(),
gsi_pk,
gsi_sk,
table_pk: target.pk.to_string(),
table_sk: target.sk.to_string(),
item_json,
});
}
if want_capacity
&& let Some(units) = super::index_capacity::index_write_units_for(
target.old_item,
entry,
gsi,
target.pk_attr,
target.sk_attr,
)
{
gsi_units.insert(gsi.index_name.clone(), units);
}
}
storage.apply_index_writes(&ops).await?;
Ok(gsi_units)
}
pub async fn maintain_gsis_after_delete<S: StorageBackend>(
storage: &S,
meta: &TableMetadata,
target: &IndexWrite<'_>,
) -> Result<HashMap<String, f64>> {
let gsi_defs = parse_gsi_defs(meta)?;
maintain_gsis_after_delete_with_defs(storage, &gsi_defs, target).await
}
pub async fn maintain_gsis_after_delete_with_defs<S: StorageBackend>(
storage: &S,
gsi_defs: &[GsiDef],
target: &IndexWrite<'_>,
) -> Result<HashMap<String, f64>> {
let want_capacity = crate::types::capacity_wanted(target.capacity_mode);
let mut gsi_units: HashMap<String, f64> = HashMap::new();
let mut ops: Vec<IndexWriteOp> = Vec::new();
for gsi in gsi_defs {
ops.push(IndexWriteOp::DeleteGsi {
table_name: target.table_name.to_string(),
index_name: gsi.index_name.clone(),
table_pk: target.pk.to_string(),
table_sk: target.sk.to_string(),
});
if want_capacity
&& let Some(units) = super::index_capacity::index_write_units(
target.old_item,
None,
gsi,
target.pk_attr,
target.sk_attr,
)
{
gsi_units.insert(gsi.index_name.clone(), units);
}
}
storage.apply_index_writes(&ops).await?;
Ok(gsi_units)
}
pub fn parse_gsi_key_attrs(
meta: &TableMetadata,
index_name: &str,
) -> Result<(Vec<String>, Vec<String>)> {
let gsi = parse_gsi_defs(meta)?
.into_iter()
.find(|g| g.index_name == index_name)
.ok_or_else(|| {
DynoxideError::ValidationException(format!(
"The table does not have the specified index: {index_name}"
))
})?;
Ok((gsi.hash_attrs, gsi.range_attrs))
}
pub(crate) async fn rebuild_multi_attribute_gsis<S: StorageBackend>(storage: &S) -> Result<usize> {
let mut rebuilt = 0;
for table_name in storage.list_table_names().await? {
let Some(meta) = storage.get_table_metadata(&table_name).await? else {
continue;
};
let key_schema = super::helpers::parse_key_schema(&meta)?;
for def in parse_gsi_defs(&meta)? {
if def.hash_attrs.len() <= 1 && def.range_attrs.len() <= 1 {
continue;
}
storage.drop_gsi_table(&table_name, &def.index_name).await?;
storage
.create_gsi_table(&table_name, &def.index_name)
.await?;
backfill_gsi(storage, &table_name, &key_schema, &def).await?;
rebuilt += 1;
}
}
Ok(rebuilt)
}
pub fn parse_gsi_key_schema(
meta: &TableMetadata,
index_name: &str,
) -> Result<(String, Option<String>)> {
let gsi_defs = parse_gsi_defs(meta)?;
let gsi = gsi_defs
.into_iter()
.find(|g| g.index_name == index_name)
.ok_or_else(|| {
DynoxideError::ValidationException(format!(
"The table does not have the specified index: {index_name}"
))
})?;
Ok((gsi.pk_attr, gsi.sk_attr))
}
pub(crate) async fn backfill_gsi<S: StorageBackend>(
storage: &S,
table_name: &str,
key_schema: &super::helpers::KeySchema,
gsi_def: &GsiDef,
) -> Result<()> {
const BATCH_SIZE: usize = 1000;
let mut last_pk: Option<String> = None;
let mut last_sk: Option<String> = None;
loop {
let items = storage
.scan_items(
table_name,
&crate::storage::ScanParams {
limit: Some(BATCH_SIZE),
exclusive_start_pk: last_pk.as_deref(),
exclusive_start_sk: last_sk.as_deref(),
..Default::default()
},
)
.await?;
if items.is_empty() {
break;
}
let mut rows: Vec<crate::storage_backend::GsiItemRow> = Vec::new();
for (pk, sk, item_json) in &items {
let item: crate::types::Item = serde_json::from_str(item_json)
.map_err(|e| DynoxideError::InternalServerError(format!("Bad item JSON: {e}")))?;
if let Some((gsi_pk, gsi_sk)) = gsi_def.index_key_strings(&item) {
let projected = build_index_item(
&item,
gsi_def,
&key_schema.partition_key,
key_schema.sort_key.as_deref(),
);
let projected_json = serde_json::to_string(&projected)
.map_err(|e| DynoxideError::InternalServerError(e.to_string()))?;
rows.push(crate::storage_backend::GsiItemRow {
gsi_pk,
gsi_sk,
table_pk: pk.clone(),
table_sk: sk.clone(),
item_json: projected_json,
});
}
}
storage
.insert_gsi_items(table_name, &gsi_def.index_name, &rows)
.await?;
let last = &items[items.len() - 1];
last_pk = Some(last.0.clone());
last_sk = Some(last.1.clone());
if items.len() < BATCH_SIZE {
break;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn key(parts: &[&str]) -> String {
let values: Vec<AttributeValue> = parts
.iter()
.map(|p| AttributeValue::S(p.to_string()))
.collect();
let refs: Vec<&AttributeValue> = values.iter().collect();
composite_key_string(&refs).unwrap()
}
#[test]
fn a_composite_key_orders_part_by_part() {
let ascending = [
key(&["a", "z"]),
key(&["a\u{0}", "a"]),
key(&["a\u{1}", "a"]),
key(&["a\u{2}", "a"]),
key(&["ab", "a"]),
key(&["ab", "b"]),
key(&["b", "a"]),
];
for pair in ascending.windows(2) {
assert!(
pair[0] < pair[1],
"{:?} should sort before {:?}",
pair[0],
pair[1]
);
}
}
#[test]
fn a_single_attribute_key_is_stored_as_before() {
let n = AttributeValue::N("-2.5".into());
assert_eq!(composite_key_string(&[&n]), n.to_key_string());
}
}