Skip to main content

dynamo_table/
lib.rs

1//! # DynamoDB Table Abstraction
2//!
3//! A high-level, type-safe DynamoDB table abstraction for Rust with support for:
4//! - Batch operations (get, write, delete)
5//! - Pagination and streaming
6//! - Global Secondary Indexes (GSI)
7//! - Conditional expressions
8//! - Optimistic locking
9//! - Automatic retry with exponential backoff
10//!
11//! ## Features
12//!
13//! - **Type-safe**: Leverage Rust's type system with `serde` for automatic serialization
14//! - **Async-first**: Built on `tokio` and `aws-sdk-dynamodb`
15//! - **Batch operations**: Efficiently process multiple items with automatic batching
16//! - **Streaming**: Handle large result sets with async streams
17//! - **Reserved word validation**: Debug-mode checks for DynamoDB reserved words
18//! - **GSI support**: Query and scan Global Secondary Indexes
19//! - **Consumed capacity tracking**: Global atomic counters for every operation, with `tracing`
20//!   logging and automatic shutdown reporting via [`CapacityStatsGuard`] (feature `consumed_capacity_stats`, on by default)
21//!
22//! ## Quick Start
23//!
24//! ```rust,no_run
25//! use dynamo_table::{DynamoTable, Error};
26//! use serde::{Deserialize, Serialize};
27//!
28//! #[derive(Debug, Clone, Serialize, Deserialize)]
29//! struct User {
30//!     user_id: String,
31//!     email: String,
32//!     name: String,
33//! }
34//!
35//! impl DynamoTable for User {
36//!     type PK = String;
37//!     type SK = String;
38//!
39//!     const TABLE: &'static str = "users";
40//!     const PARTITION_KEY: &'static str = "user_id";
41//!     const SORT_KEY: Option<&'static str> = None;
42//!
43//!     fn partition_key(&self) -> Self::PK {
44//!         self.user_id.clone()
45//!     }
46//! }
47//!
48//! #[tokio::main]
49//! async fn main() -> Result<(), Error> {
50//!     // Initialize the global DynamoDB client
51//!     let config = aws_config::defaults(aws_config::BehaviorVersion::latest()).load().await;
52//!     dynamo_table::init(&config).await;
53//!
54//!     // Put an item
55//!     let user = User {
56//!         user_id: "123".to_string(),
57//!         email: "user@example.com".to_string(),
58//!         name: "John Doe".to_string(),
59//!     };
60//!     user.add_item().await?;
61//!
62//!     // Get an item
63//!     let retrieved = User::get_item(&"123".to_string(), None).await?;
64//!
65//!     // Query items
66//!     let result = User::query_items(&"123".to_string(), None, None, None).await?;
67//!
68//!     Ok(())
69//! }
70//! ```
71#![deny(
72    warnings,
73    bad_style,
74    dead_code,
75    improper_ctypes,
76    non_shorthand_field_patterns,
77    no_mangle_generic_items,
78    overflowing_literals,
79    path_statements,
80    patterns_in_fns_without_body,
81    unconditional_recursion,
82    unused,
83    unused_allocation,
84    unused_comparisons,
85    unused_parens,
86    while_true,
87    missing_debug_implementations,
88    missing_docs,
89    trivial_casts,
90    trivial_numeric_casts,
91    unreachable_pub,
92    unused_extern_crates,
93    unused_import_braces,
94    unused_qualifications,
95    unused_results,
96    deprecated,
97    unknown_lints,
98    unreachable_code,
99    unused_mut
100)]
101
102mod error;
103pub use error::Error;
104
105/// Consumed capacity tracking via global atomic counters (requires `consumed_capacity_stats` feature).
106#[cfg(feature = "consumed_capacity_stats")]
107pub mod consumed_capacity;
108
109#[cfg(feature = "consumed_capacity_stats")]
110pub use consumed_capacity::stats::{
111    CapacityStats, CapacityStatsGuard, log_stats, operation_count, read_capacity_units, record,
112    record_from_option, record_read, record_write, stats as consumed_capacity_stats,
113    total_capacity_units, write_capacity_units,
114};
115
116/// Generic table module
117pub mod table;
118
119/// Methods of Generic table
120pub mod methods;
121
122/// Table setup utilities for testing
123pub mod setup;
124
125// Re-export main types for convenience
126pub use methods::DynamoTableMethods;
127pub use table::{CompositeKey, DynamoTable, GSITable};
128
129// Re-export aws-config types for configuration
130pub use aws_config::{
131    BehaviorVersion, Region, SdkConfig, defaults,
132    meta::region::{ProvideRegion, RegionProviderChain},
133    retry::{RetryConfig, RetryMode},
134    timeout::TimeoutConfig,
135};
136
137// Re-export aws-types for advanced configuration
138pub use aws_types::sdk_config::Builder as SdkConfigBuilder;
139
140// Re-export DynamoDB types
141pub use aws_sdk_dynamodb;
142
143// Re-export serde_dynamo for easy serialization/deserialization of DynamoDB items
144pub use serde_dynamo;
145
146use aws_sdk_dynamodb::Client as DynamoDbClient;
147use aws_smithy_http_client::{
148    Builder as SmithyHttpClientBuilder,
149    tls::{self, TlsContext, TrustStore, rustls_provider::CryptoMode},
150};
151use tokio::sync::OnceCell;
152
153/// Global DynamoDB client instance
154static GLOBAL_CLIENT: OnceCell<DynamoDbClient> = OnceCell::const_new();
155
156/// Initialize the global DynamoDB client with default sensible settings
157///
158/// This is called automatically by `dynamodb_client()` if not already initialized.
159/// It configures:
160/// - Adaptive retry mode with 3 max attempts
161/// - Exponential backoff starting at 1 second
162/// - Connect timeout: 3 seconds
163/// - Read timeout: 20 seconds
164/// - Operation timeout: 60 seconds
165/// - LocalStack support via AWS_PROFILE=localstack
166///
167/// Note: This function is internal. Use `init()` or `init_with_client()` for
168/// custom configuration, or let `dynamodb_client()` auto-initialize with defaults.
169async fn aws_config_defaults() -> SdkConfig {
170    use aws_config::BehaviorVersion;
171    use aws_types::sdk_config::{RetryConfig, TimeoutConfig};
172    use std::time::Duration;
173
174    let timeout_config = TimeoutConfig::builder()
175        .connect_timeout(Duration::from_secs(3))
176        .read_timeout(Duration::from_secs(20))
177        .operation_timeout(Duration::from_secs(60))
178        .build();
179
180    let mut loader = defaults(BehaviorVersion::latest())
181        .retry_config(
182            RetryConfig::adaptive()
183                .with_max_attempts(3)
184                .with_initial_backoff(Duration::from_secs(1)),
185        )
186        .timeout_config(timeout_config);
187
188    // Support LocalStack via AWS_PROFILE=localstack
189    if std::env::var("AWS_PROFILE").unwrap_or_default() == "localstack" {
190        loader = loader
191            .endpoint_url("http://127.0.0.1:4566")
192            .http_client(localstack_http_client());
193    }
194
195    loader.load().await
196}
197
198fn localstack_http_client() -> aws_smithy_runtime_api::client::http::SharedHttpClient {
199    // LocalStack test runs use plain HTTP, so native TLS roots are unnecessary.
200    // Disabling them avoids rustls startup panics on machines with unreadable or invalid
201    // system trust stores while preserving the same HTTP behavior for the local endpoint.
202    let tls_context = TlsContext::builder()
203        .with_trust_store(TrustStore::empty())
204        .build()
205        .expect("valid LocalStack TLS context");
206
207    SmithyHttpClientBuilder::new()
208        .tls_provider(tls::Provider::Rustls(CryptoMode::AwsLc))
209        .tls_context(tls_context)
210        .build_https()
211}
212
213/// Initialize the global DynamoDB client with a custom AWS config
214///
215/// Use this when you need custom AWS configuration beyond the defaults.
216///
217/// # Example
218///
219/// ```rust,no_run
220/// #[tokio::main]
221/// async fn main() {
222///     let config = aws_config::defaults(aws_config::BehaviorVersion::latest())
223///         .region(aws_config::Region::new("us-west-2"))
224///         .load()
225///         .await;
226///     dynamo_table::init(&config).await;
227///
228///     // Now you can use table operations
229/// }
230/// ```
231pub async fn init(config: &SdkConfig) {
232    let _ = GLOBAL_CLIENT
233        .get_or_init(|| async { DynamoDbClient::new(config) })
234        .await;
235}
236
237/// Initialize the global DynamoDB client with a custom client instance
238///
239/// Useful for testing or when you need fine-grained control over client configuration.
240///
241/// # Example
242///
243/// ```rust,no_run
244/// use aws_sdk_dynamodb::Client;
245///
246/// #[tokio::main]
247/// async fn main() {
248///     let config = aws_config::load_from_env().await;
249///     let client = Client::new(&config);
250///     dynamo_table::init_with_client(client).await;
251/// }
252/// ```
253pub async fn init_with_client(client: DynamoDbClient) {
254    let _ = GLOBAL_CLIENT.get_or_init(|| async { client }).await;
255}
256
257/// Get a reference to the global DynamoDB client
258///
259/// Automatically initializes the client with sensible defaults if not already initialized.
260/// For custom configuration, call [`init`] or [`init_with_client`] before using this function.
261///
262/// # Auto-Initialization
263///
264/// If not explicitly initialized, this function will automatically configure:
265/// - Adaptive retry mode with 3 max attempts
266/// - Exponential backoff starting at 1 second
267/// - Connect timeout: 3 seconds
268/// - Read timeout: 20 seconds
269/// - Operation timeout: 60 seconds
270/// - LocalStack support via AWS_PROFILE=localstack
271///
272/// # Example
273///
274/// ```rust,no_run
275/// # async fn example() {
276/// // Client auto-initializes with defaults on first use
277/// let client = dynamo_table::dynamodb_client().await;
278/// // Use client for custom operations
279/// # }
280/// ```
281///
282/// # Custom Configuration Example
283///
284/// ```rust,no_run
285/// # async fn example() {
286/// // Initialize with custom config before first use
287/// let config = dynamo_table::defaults(dynamo_table::BehaviorVersion::latest())
288///     .region(dynamo_table::Region::new("us-west-2"))
289///     .load()
290///     .await;
291/// dynamo_table::init(&config).await;
292///
293/// // Now uses custom configuration
294/// let client = dynamo_table::dynamodb_client().await;
295/// # }
296/// ```
297pub async fn dynamodb_client() -> &'static DynamoDbClient {
298    // Safe to unwrap because init_defaults() always sets it
299    GLOBAL_CLIENT
300        .get_or_init(|| async {
301            let config = aws_config_defaults().await;
302            DynamoDbClient::new(&config)
303        })
304        .await
305}
306
307fn _assert_not_reserved_key(key: &str) {
308    // https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/ReservedWords.html
309    #[rustfmt::skip]
310    const KEYS: [&str; 573] = [
311"abort", "absolute", "action", "add", "after", "agent", "aggregate", "all", "allocate", "alter", "analyze", "and", "any", "archive", "are", "array", "as", "asc", "ascii", "asensitive", "assertion", "asymmetric", "at", "atomic", "attach", "attribute", "auth", "authorization", "authorize", "auto", "avg", "back", "backup", "base", "batch", "before", "begin", "between", "bigint", "binary", "bit", "blob", "block", "boolean", "both", "breadth", "bucket", "bulk", "by", "byte", "call", "called", "calling", "capacity", "cascade", "cascaded", "case", "cast", "catalog", "char", "character", "check", "class", "clob", "close", "cluster", "clustered", "clustering", "clusters", "coalesce", "collate", "collation", "collection", "column", "columns", "combine", "comment", "commit", "compact", "compile", "compress", "condition", "conflict", "connect", "connection", "consistency", "consistent", "constraint", "constraints", "constructor", "consumed", "continue", "convert", "copy", "corresponding", "count", "counter", "create", "cross", "cube", "current", "cursor", "cycle", "data", "database", "date", "datetime", "day", "deallocate", "dec", "decimal", "declare", "default", "deferrable", "deferred", "define", "defined", "definition", "delete", "delimited", "depth", "deref", "desc", "describe", "descriptor", "detach", "deterministic", "diagnostics", "directories", "disable", "disconnect", "distinct", "distribute", "do", "domain", "double", "drop", "dump", "duration", "dynamic", "each", "element", "else", "elseif", "empty", "enable", "end", "equal", "equals", "error", "escape", "escaped", "eval", "evaluate", "exceeded", "except", "exception", "exceptions", "exclusive", "exec", "execute", "exists", "exit", "explain", "explode", "export", "expression", "extended", "external", "extract", "fail", "false", "family", "fetch", "fields", "file", "filter", "filtering", "final", "finish", "first", "fixed", "flattern", "float", "for", "force", "foreign", "format", "forward", "found", "free", "from", "full", "function", "functions", "general", "generate", "get", "glob", "global", "go", "goto", "grant", "greater", "group", "grouping", "handler", "hash", "have", "having", "heap", "hidden", "hold", "hour", "identified", "identity", "if", "ignore", "immediate", "import", "in", "including", "inclusive", "increment", "incremental", "index", "indexed", "indexes", "indicator", "infinite", "initially", "inline", "inner", "innter", "inout", "input", "insensitive", "insert", "instead", "int", "integer", "intersect", "interval", "into", "invalidate", "is", "isolation", "item", "items", "iterate", "join", "key", "keys", "lag", "language", "large", "last", "lateral", "lead", "leading", "leave", "left", "length", "less", "level", "like", "limit", "limited", "lines", "list", "load", "local", "localtime", "localtimestamp", "location", "locator", "lock", "locks", "log", "loged", "long", "loop", "lower", "map", "match", "materialized", "max", "maxlen", "member", "merge", "method", "metrics", "min", "minus", "minute", "missing", "mod", "mode", "modifies", "modify", "module", "month", "multi", "multiset", "name", "names", "national", "natural", "nchar", "nclob", "new", "next", "no", "none", "not", "null", "nullif", "number", "numeric", "object", "of", "offline", "offset", "old", "on", "online", "only", "opaque", "open", "operator", "option", "or", "order", "ordinality", "other", "others", "out", "outer", "output", "over", "overlaps", "override", "owner", "pad", "parallel", "parameter", "parameters", "partial", "partition", "partitioned", "partitions", "path", "percent", "percentile", "permission", "permissions", "pipe", "pipelined", "plan", "pool", "position", "precision", "prepare", "preserve", "primary", "prior", "private", "privileges", "procedure", "processed", "project", "projection", "property", "provisioning", "public", "put", "query", "quit", "quorum", "raise", "random", "range", "rank", "raw", "read", "reads", "real", "rebuild", "record", "recursive", "reduce", "ref", "reference", "references", "referencing", "regexp", "region", "reindex", "relative", "release", "remainder", "rename", "repeat", "replace", "request", "reset", "resignal", "resource", "response", "restore", "restrict", "result", "return", "returning", "returns", "reverse", "revoke", "right", "role", "roles", "rollback", "rollup", "routine", "row", "rows", "rule", "rules", "sample", "satisfies", "save", "savepoint", "scan", "schema", "scope", "scroll", "search", "second", "section", "segment", "segments", "select", "self", "semi", "sensitive", "separate", "sequence", "serializable", "session", "set", "sets", "shard", "share", "shared", "short", "show", "signal", "similar", "size", "skewed", "smallint", "snapshot", "some", "source", "space", "spaces", "sparse", "specific", "specifictype", "split", "sql", "sqlcode", "sqlerror", "sqlexception", "sqlstate", "sqlwarning", "start", "state", "static", "status", "storage", "store", "stored", "stream", "string", "struct", "style", "sub", "submultiset", "subpartition", "substring", "subtype", "sum", "super", "symmetric", "synonym", "system", "table", "tablesample", "temp", "temporary", "terminated", "text", "than", "then", "throughput", "time", "timestamp", "timezone", "tinyint", "to", "token", "total", "touch", "trailing", "transaction", "transform", "translate", "translation", "treat", "trigger", "trim", "true", "truncate", "ttl", "tuple", "type", "under", "undo", "union", "unique", "unit", "unknown", "unlogged", "unnest", "unprocessed", "unsigned", "until", "update", "upper", "url", "usage", "use", "user", "users", "using", "uuid", "vacuum", "value", "valued", "values", "varchar", "variable", "variance", "varint", "varying", "view", "views", "virtual", "void", "wait", "when", "whenever", "where", "while", "window", "with", "within", "without", "work", "wrapped", "write", "year", "zone "
312];
313
314    debug_assert!(!KEYS.contains(&key), "Reserved key: {key}");
315}
316
317#[allow(unused_variables)]
318pub(crate) fn assert_not_reserved_key(key: &str) {
319    #[cfg(debug_assertions)]
320    _assert_not_reserved_key(key);
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    #[test]
328    fn test_composite_key_tuple() {
329        let key: CompositeKey<String, String> =
330            ("user123".to_string(), Some("order456".to_string()));
331        assert_eq!(key.0, "user123");
332        assert_eq!(key.1, Some("order456".to_string()));
333    }
334
335    #[test]
336    fn test_composite_key_no_sort_key() {
337        let key: CompositeKey<String, String> = ("user123".to_string(), None);
338        assert_eq!(key.0, "user123");
339        assert_eq!(key.1, None);
340    }
341
342    #[cfg(debug_assertions)]
343    #[test]
344    #[should_panic(expected = "Reserved key: user")]
345    fn test_assert_reserved_key_panics() {
346        assert_not_reserved_key("user");
347    }
348
349    #[cfg(debug_assertions)]
350    #[test]
351    fn test_assert_not_reserved_key_ok() {
352        assert_not_reserved_key("user_id");
353        assert_not_reserved_key("custom_field");
354    }
355}