use bytes::Bytes;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum IndexProjection {
All,
KeysOnly,
Include(Vec<String>),
}
impl Default for IndexProjection {
fn default() -> Self {
IndexProjection::All
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LocalSecondaryIndex {
pub name: String,
pub sort_key_attribute: String,
pub projection: IndexProjection,
}
impl LocalSecondaryIndex {
pub fn new(name: impl Into<String>, sort_key_attribute: impl Into<String>) -> Self {
Self {
name: name.into(),
sort_key_attribute: sort_key_attribute.into(),
projection: IndexProjection::All,
}
}
pub fn keys_only(mut self) -> Self {
self.projection = IndexProjection::KeysOnly;
self
}
pub fn include(mut self, attributes: Vec<String>) -> Self {
self.projection = IndexProjection::Include(attributes);
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GlobalSecondaryIndex {
pub name: String,
pub partition_key_attribute: String,
pub sort_key_attribute: Option<String>,
pub projection: IndexProjection,
}
impl GlobalSecondaryIndex {
pub fn new(name: impl Into<String>, partition_key_attribute: impl Into<String>) -> Self {
Self {
name: name.into(),
partition_key_attribute: partition_key_attribute.into(),
sort_key_attribute: None,
projection: IndexProjection::All,
}
}
pub fn with_sort_key(
name: impl Into<String>,
partition_key_attribute: impl Into<String>,
sort_key_attribute: impl Into<String>,
) -> Self {
Self {
name: name.into(),
partition_key_attribute: partition_key_attribute.into(),
sort_key_attribute: Some(sort_key_attribute.into()),
projection: IndexProjection::All,
}
}
pub fn keys_only(mut self) -> Self {
self.projection = IndexProjection::KeysOnly;
self
}
pub fn include(mut self, attributes: Vec<String>) -> Self {
self.projection = IndexProjection::Include(attributes);
self
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TableSchema {
pub local_indexes: Vec<LocalSecondaryIndex>,
pub global_indexes: Vec<GlobalSecondaryIndex>,
pub ttl_attribute_name: Option<String>,
#[serde(default)]
pub stream_config: crate::stream::StreamConfig,
}
impl TableSchema {
pub fn new() -> Self {
Self::default()
}
pub fn add_local_index(mut self, index: LocalSecondaryIndex) -> Self {
self.local_indexes.push(index);
self
}
pub fn get_local_index(&self, name: &str) -> Option<&LocalSecondaryIndex> {
self.local_indexes.iter().find(|idx| idx.name == name)
}
pub fn add_global_index(mut self, index: GlobalSecondaryIndex) -> Self {
self.global_indexes.push(index);
self
}
pub fn get_global_index(&self, name: &str) -> Option<&GlobalSecondaryIndex> {
self.global_indexes.iter().find(|idx| idx.name == name)
}
pub fn with_ttl(mut self, attribute_name: impl Into<String>) -> Self {
self.ttl_attribute_name = Some(attribute_name.into());
self
}
pub fn with_stream(mut self, config: crate::stream::StreamConfig) -> Self {
self.stream_config = config;
self
}
pub fn is_expired(&self, item: &crate::Item) -> bool {
use crate::Value;
if let Some(ttl_attr) = &self.ttl_attribute_name {
if let Some(ttl_value) = item.get(ttl_attr) {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let expires_at = match ttl_value {
Value::N(n) => n.parse::<i64>().ok(),
Value::Ts(ts) => Some(ts / 1000), _ => None,
};
if let Some(expires) = expires_at {
return now > expires;
}
}
}
false
}
}
pub fn encode_index_key(index_name: &str, pk: &Bytes, index_sk: &Bytes) -> Vec<u8> {
const INDEX_MARKER: u8 = 0xFF;
let index_name_bytes = index_name.as_bytes();
let capacity = 1 + 4 + index_name_bytes.len() + 4 + pk.len() + 4 + index_sk.len();
let mut buf = Vec::with_capacity(capacity);
buf.push(INDEX_MARKER);
buf.extend_from_slice(&(index_name_bytes.len() as u32).to_le_bytes());
buf.extend_from_slice(index_name_bytes);
buf.extend_from_slice(&(pk.len() as u32).to_le_bytes());
buf.extend_from_slice(pk);
buf.extend_from_slice(&(index_sk.len() as u32).to_le_bytes());
buf.extend_from_slice(index_sk);
buf
}
pub fn decode_index_key(encoded: &[u8]) -> Option<(String, Bytes, Bytes)> {
const INDEX_MARKER: u8 = 0xFF;
if encoded.is_empty() || encoded[0] != INDEX_MARKER {
return None;
}
let mut pos = 1;
if encoded.len() < pos + 4 {
return None;
}
let name_len = u32::from_le_bytes(encoded[pos..pos + 4].try_into().ok()?) as usize;
pos += 4;
if encoded.len() < pos + name_len {
return None;
}
let index_name = String::from_utf8(encoded[pos..pos + name_len].to_vec()).ok()?;
pos += name_len;
if encoded.len() < pos + 4 {
return None;
}
let pk_len = u32::from_le_bytes(encoded[pos..pos + 4].try_into().ok()?) as usize;
pos += 4;
if encoded.len() < pos + pk_len {
return None;
}
let pk = Bytes::copy_from_slice(&encoded[pos..pos + pk_len]);
pos += pk_len;
if encoded.len() < pos + 4 {
return None;
}
let index_sk_len = u32::from_le_bytes(encoded[pos..pos + 4].try_into().ok()?) as usize;
pos += 4;
if encoded.len() < pos + index_sk_len {
return None;
}
let index_sk = Bytes::copy_from_slice(&encoded[pos..pos + index_sk_len]);
Some((index_name, pk, index_sk))
}
pub fn is_index_key(encoded: &[u8]) -> bool {
const INDEX_MARKER: u8 = 0xFF;
!encoded.is_empty() && encoded[0] == INDEX_MARKER
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_lsi_creation() {
let lsi = LocalSecondaryIndex::new("email-index", "email");
assert_eq!(lsi.name, "email-index");
assert_eq!(lsi.sort_key_attribute, "email");
assert_eq!(lsi.projection, IndexProjection::All);
}
#[test]
fn test_lsi_keys_only() {
let lsi = LocalSecondaryIndex::new("email-index", "email").keys_only();
assert_eq!(lsi.projection, IndexProjection::KeysOnly);
}
#[test]
fn test_lsi_include() {
let lsi = LocalSecondaryIndex::new("email-index", "email")
.include(vec!["name".to_string(), "age".to_string()]);
assert_eq!(
lsi.projection,
IndexProjection::Include(vec!["name".to_string(), "age".to_string()])
);
}
#[test]
fn test_table_schema() {
let schema = TableSchema::new()
.add_local_index(LocalSecondaryIndex::new("idx1", "attr1"))
.add_local_index(LocalSecondaryIndex::new("idx2", "attr2"));
assert_eq!(schema.local_indexes.len(), 2);
assert!(schema.get_local_index("idx1").is_some());
assert!(schema.get_local_index("idx3").is_none());
}
#[test]
fn test_encode_decode_index_key() {
let index_name = "email-index";
let pk = Bytes::from("user#123");
let index_sk = Bytes::from("alice@example.com");
let encoded = encode_index_key(index_name, &pk, &index_sk);
assert!(is_index_key(&encoded));
let (decoded_name, decoded_pk, decoded_sk) = decode_index_key(&encoded).unwrap();
assert_eq!(decoded_name, index_name);
assert_eq!(decoded_pk, pk);
assert_eq!(decoded_sk, index_sk);
}
#[test]
fn test_is_not_index_key() {
let base_key = vec![0, 0, 0, 4, b'u', b's', b'e', b'r'];
assert!(!is_index_key(&base_key));
}
#[test]
fn test_gsi_creation() {
let gsi = GlobalSecondaryIndex::new("status-index", "status");
assert_eq!(gsi.name, "status-index");
assert_eq!(gsi.partition_key_attribute, "status");
assert_eq!(gsi.sort_key_attribute, None);
assert_eq!(gsi.projection, IndexProjection::All);
}
#[test]
fn test_gsi_with_sort_key() {
let gsi = GlobalSecondaryIndex::with_sort_key("user-index", "userId", "timestamp");
assert_eq!(gsi.name, "user-index");
assert_eq!(gsi.partition_key_attribute, "userId");
assert_eq!(gsi.sort_key_attribute, Some("timestamp".to_string()));
}
#[test]
fn test_gsi_keys_only() {
let gsi = GlobalSecondaryIndex::new("status-index", "status").keys_only();
assert_eq!(gsi.projection, IndexProjection::KeysOnly);
}
#[test]
fn test_table_schema_with_gsi() {
let schema = TableSchema::new()
.add_local_index(LocalSecondaryIndex::new("lsi1", "attr1"))
.add_global_index(GlobalSecondaryIndex::new("gsi1", "attr2"))
.add_global_index(GlobalSecondaryIndex::with_sort_key("gsi2", "attr3", "attr4"));
assert_eq!(schema.local_indexes.len(), 1);
assert_eq!(schema.global_indexes.len(), 2);
assert!(schema.get_global_index("gsi1").is_some());
assert!(schema.get_global_index("gsi3").is_none());
}
#[test]
fn test_ttl_schema() {
let schema = TableSchema::new().with_ttl("expiresAt");
assert_eq!(schema.ttl_attribute_name, Some("expiresAt".to_string()));
}
#[test]
fn test_ttl_expired_item() {
use crate::Value;
use std::collections::HashMap;
let schema = TableSchema::new().with_ttl("ttl");
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let expired_time = now - 100;
let mut item = HashMap::new();
item.insert("name".to_string(), Value::string("test"));
item.insert("ttl".to_string(), Value::number(expired_time));
assert!(schema.is_expired(&item));
}
#[test]
fn test_ttl_not_expired_item() {
use crate::Value;
use std::collections::HashMap;
let schema = TableSchema::new().with_ttl("ttl");
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let future_time = now + 100;
let mut item = HashMap::new();
item.insert("name".to_string(), Value::string("test"));
item.insert("ttl".to_string(), Value::number(future_time));
assert!(!schema.is_expired(&item));
}
#[test]
fn test_ttl_no_ttl_attribute() {
use crate::Value;
use std::collections::HashMap;
let schema = TableSchema::new().with_ttl("ttl");
let mut item = HashMap::new();
item.insert("name".to_string(), Value::string("test"));
assert!(!schema.is_expired(&item));
}
#[test]
fn test_ttl_disabled() {
use crate::Value;
use std::collections::HashMap;
let schema = TableSchema::new();
let mut item = HashMap::new();
item.insert("name".to_string(), Value::string("test"));
item.insert("ttl".to_string(), Value::number(0));
assert!(!schema.is_expired(&item));
}
#[test]
fn test_ttl_with_timestamp_value() {
use crate::Value;
use std::collections::HashMap;
let schema = TableSchema::new().with_ttl("expiresAt");
let now_millis = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64;
let expired_millis = now_millis - 100_000;
let mut item = HashMap::new();
item.insert("name".to_string(), Value::string("test"));
item.insert("expiresAt".to_string(), Value::Ts(expired_millis));
assert!(schema.is_expired(&item));
}
}