Skip to main content

dynamodb_facade/
lib.rs

1//! A typed facade over [`aws-sdk-dynamodb`][aws_sdk_dynamodb] with composable
2//! expression builders and typestate operation builders.
3//!
4//! `dynamodb-facade` eliminates the boilerplate of raw DynamoDB calls —
5//! manual key maps, expression strings, placeholder tracking, pagination loops,
6//! and batch-write chunking — while enforcing correct usage at compile time
7//! through Rust's type system.
8//!
9//! # Key Concepts
10//!
11//! ## Tables, items, and the `TD` parameter
12//!
13//! The [`DynamoDBItem<TD>`] trait wires a Rust struct to a
14//! [`TableDefinition`], declaring how its fields map to DynamoDB key
15//! attributes. The blanket traits [`DynamoDBItemOp<TD>`],
16//! [`DynamoDBItemBatchOp<TD>`], and [`DynamoDBItemTransactOp<TD>`] are
17//! automatically implemented for every type that implements [`DynamoDBItem`],
18//! providing `get`, `put`, `delete`, `update`, `query`, `scan`, `batch_put`,
19//! `batch_delete`, `transact_put`, and friends as associated functions.
20//!
21//! `TD` is deliberately a **generic type parameter**, not an associated type.
22//! A single Rust struct can implement `DynamoDBItem` for multiple tables,
23//! which is useful when:
24//!
25//! - **Multiple tables share domain types** — for example, a `User` struct
26//!   that exists in both a primary table and an archive table, possibly with
27//!   different key mappings.
28//! - **Migration logic** — reading items from one table and writing them to
29//!   another, for one-shot migrations, compaction, or aggregation across
30//!   tables.
31//!
32//! ## Mono-table (single-table) design
33//!
34//! The crate has first-class support for the single-table pattern, where all
35//! entity types share one DynamoDB table with a composite `PK + SK` key and
36//! a type discriminator attribute (e.g. `_TYPE`). This is a natural fit
37//! because the trait system already enforces per-entity key mappings, type
38//! discriminators, and serialization — but it is not the only layout the
39//! crate supports.
40//!
41//! ## Schema definitions
42//!
43//! Attributes, tables, and indexes are declared as zero-sized types using the
44//! [`attribute_definitions!`], [`table_definitions!`], and
45//! [`index_definitions!`] macros. These types serve as compile-time tokens
46//! that the library uses to build correct key maps and expression attribute
47//! name/value maps without any runtime string manipulation by the caller.
48//! They also encode key schema shape into the type system — for instance,
49//! attempting to supply a sort key for a table declared with a partition key
50//! only is a compile-time error.
51//!
52//! ## Expression builders
53//!
54//! [`Condition<'a>`] and [`Update<'a>`] are composable value types that build
55//! DynamoDB condition and update expressions. They support the full DynamoDB
56//! expression language — comparisons, `begins_with`, `contains`, `between`,
57//! `IN`, `size`, `if_not_exists`, `list_append`, set `ADD`/`DELETE` — and
58//! compose with `&`, `|`, `!` operators and `.and()` / `.combine()` methods.
59//! All placeholder names and values are managed internally; callers never
60//! touch `#name` or `:value` strings.
61//!
62//! ## Typestate operation builders
63//!
64//! Every operation builder ([`GetItemRequest`], [`PutItemRequest`],
65//! [`DeleteItemRequest`], [`UpdateItemRequest`], [`QueryRequest`],
66//! [`ScanRequest`]) uses compile-time typestate parameters to enforce correct
67//! usage:
68//!
69//! - **`OutputFormat`** (`Typed` / `Raw`) — whether the terminal method
70//!   deserializes into `T` or returns [`Item<TD>`].
71//! - **`ReturnValue`** (`ReturnNothing` / `Return<Old>` / `Return<New>`) —
72//!   whether put/delete/update return item attributes.
73//! - **Expression-set state** (`NoCondition` / `AlreadyHasCondition`, etc.) —
74//!   calling `.condition()` or `.filter()` twice is a **compile-time error**.
75//!
76//! # Quick Start
77//!
78//! Define the schema, wire a struct, then perform CRUD operations:
79//!
80//! ```no_run
81//! use dynamodb_facade::{
82//!     attribute_definitions, table_definitions, index_definitions, dynamodb_item,
83//!     Condition, Update, KeyId, DynamoDBItemOp, DynamoDBError, Error,
84//!     StringAttribute, NumberAttribute, HasAttribute
85//! };
86//! use serde::{Deserialize, Serialize};
87//!
88//! // 1. Declare attribute zero-sized types.
89//! attribute_definitions! {
90//!     PK { "PK": StringAttribute }
91//!     SK { "SK": StringAttribute }
92//!     ItemType { "_TYPE": StringAttribute }
93//!     Email { "email": StringAttribute }
94//! }
95//!
96//! // 2. Declare the table.
97//! table_definitions! {
98//!     PlatformTable {
99//!         type PartitionKey = PK;
100//!         type SortKey = SK;
101//!         fn table_name() -> String {
102//!             std::env::var("TABLE_NAME").expect("TABLE_NAME must be set")
103//!         }
104//!     }
105//! }
106//!
107//! // 3. Define an item type and wire it to the table.
108//! #[derive(Debug, Clone, Serialize, Deserialize)]
109//! pub struct User {
110//!     pub id: String,
111//!     pub name: String,
112//!     pub email: String,
113//! }
114//!
115//! dynamodb_item! {
116//!     #[table = PlatformTable]
117//!     User {
118//!         #[partition_key]
119//!         PK {
120//!             fn attribute_id(&self) -> &'id str { &self.id }
121//!             fn attribute_value(id) -> String { format!("USER#{id}") }
122//!         }
123//!         #[sort_key]
124//!         SK { const VALUE: &'static str = "PROFILE"; }
125//!         ItemType { const VALUE: &'static str = "USER"; }
126//!     }
127//! }
128//!
129//! #[derive(Debug, Clone, Serialize, Deserialize)]
130//! pub struct Enrollment {
131//!     pub user_id: String,
132//!     pub course_id: String,
133//!     pub enrolled_at: u64,
134//!     pub progress: f64,
135//! }
136//!
137//! dynamodb_item! {
138//!     #[table = PlatformTable]
139//!     Enrollment {
140//!         #[partition_key]
141//!         PK {
142//!             fn attribute_id(&self) -> <User as HasAttribute<PK>>::Id<'id> {
143//!                 &self.user_id
144//!             }
145//!             fn attribute_value(id) -> <User as HasAttribute<PK>>::Value {
146//!                 <User as HasAttribute<PK>>::attribute_value(id)
147//!             }
148//!         }
149//!         #[sort_key]
150//!         SK {
151//!             fn attribute_id(&self) -> &'id str { &self.course_id }
152//!             fn attribute_value(id) -> String { format!("ENROLL#{id}") }
153//!         }
154//!         ItemType { const VALUE: &'static str = "ENROLLMENT"; }
155//!     }
156//! }
157//!
158//! # async fn example(cclient: dynamodb_facade::Client) -> dynamodb_facade::Result<()> {
159//! // 4. CRUD — no boilerplate.
160//! let user = User {
161//!     id: "u-1".to_owned(),
162//!     name: "Alice".to_owned(),
163//!     email: "alice@example.com".to_owned(),
164//! };
165//!
166//! # let client = cclient.clone();
167//! // Put (create or overwrite):
168//! user.put(client).await?;
169//!
170//! # let client = cclient.clone();
171//! // Put with create-only guard:
172//! user.put(client).not_exists().await?;
173//!
174//! # let client = cclient.clone();
175//! // Get by ID:
176//! let loaded /* : Option<User> */ = User::get(client, KeyId::pk("u-1")).await?;
177//!
178//! # let client = cclient.clone();
179//! // Update with condition:
180//! User::update_by_id(
181//!     client,
182//!     KeyId::pk("u-1"),
183//!     Update::set("name", "Alicia"),
184//! )
185//! .exists()
186//! .await?;
187//!
188//! # let client = cclient.clone();
189//! // Delete:
190//! User::delete_by_id(client, KeyId::pk("u-1")).await?;
191//! # Ok(())
192//! # }
193//! ```
194//!
195//! # Feature Highlights
196//!
197//! ## Composable conditions
198//!
199//! ```
200//! # use dynamodb_facade::{Condition, DynamoDBItemOp};
201//! # use dynamodb_facade::test_fixtures::*;
202//! // Attribute-level existence checks:
203//! let c = Condition::exists("email") & Condition::not_exists("deleted_at");
204//!
205//! // Item-level existence (uses the table's PK attribute):
206//! let c = User::exists() & Condition::eq("role", "student");
207//!
208//! // OR / NOT:
209//! let c = User::not_exists() | Condition::lt("expiration_timestamp", 9999999999u64);
210//! let c = !Condition::eq("status", "archived");
211//!
212//! // Variadic AND over a collection:
213//! let c = Condition::and([
214//!     Condition::eq("role", "instructor"),
215//!     Condition::size_gt("bio", 0),
216//!     Condition::exists("verified_at"),
217//! ]);
218//! ```
219//!
220//! ## Composable updates
221//!
222//! ```
223//! # use dynamodb_facade::Update;
224//! // Simple set / remove:
225//! let u = Update::set("name", "Alice").and(Update::remove("legacy_field"));
226//!
227//! // Atomic counters:
228//! let u = Update::increment("login_count", 1);
229//! let u = Update::init_increment("enrollment_count", 0, 1); // if_not_exists + increment
230//!
231//! // Merge optional updates from an iterator:
232//! let new_name: Option<&str> = Some("Alice");
233//! let new_role: Option<&str> = None;
234//! let u = Update::combine(
235//!     [
236//!         new_name.map(|n| Update::set("name", n)),
237//!         new_role.map(|r| Update::set("role", r)),
238//!     ]
239//!     .into_iter()
240//!     .flatten(),
241//! );
242//! ```
243//!
244//! ## Query and scan with automatic pagination
245//!
246//! ```no_run
247//! # use dynamodb_facade::{Condition, KeyCondition, DynamoDBItemOp, DynamoDBItemBatchOp};
248//! # use dynamodb_facade::test_fixtures::*;
249//! # async fn example(cclient: dynamodb_facade::Client) -> dynamodb_facade::Result<()> {
250//! # let client = cclient.clone();
251//! // Query all enrollments for a user (auto-paginates):
252//! let enrollments: Vec<Enrollment> =
253//!     Enrollment::query(client, Enrollment::key_condition("user-1"))
254//!         .all()
255//!         .await?;
256//!
257//! # let client = cclient.clone();
258//! // Query a GSI:
259//! let users: Vec<User> =
260//!     User::query_index::<EmailIndex>(client, KeyCondition::pk("alice@example.com"))
261//!         .all()
262//!         .await?;
263//!
264//! # let client = cclient.clone();
265//! // Scan with a filter (note: from a pure DynamoDB stand point you should never do that):
266//! let instructors: Vec<User> = User::scan(client)
267//!     .filter(Condition::eq("role", "instructor"))
268//!     .all()
269//!     .await?;
270//! # Ok(())
271//! # }
272//! ```
273//!
274//! ## Batch writes
275//!
276//! ```no_run
277//! # use dynamodb_facade::{dynamodb_batch_write, DynamoDBItemBatchOp};
278//! # use dynamodb_facade::test_fixtures::*;
279//! # async fn example(client: dynamodb_facade::Client, enrollments: Vec<Enrollment>) -> dynamodb_facade::Result<()> {
280//! // Automatically chunks into 25-item batches, runs in parallel,
281//! // and retries unprocessed items:
282//! let requests: Vec<_> = enrollments.iter().map(|e| e.batch_put()).collect();
283//! dynamodb_batch_write::<PlatformTable>(client, requests).await?;
284//! # Ok(())
285//! # }
286//! ```
287//!
288//! ## Transactions
289//!
290//! ```no_run
291//! # use dynamodb_facade::{Condition, Update, KeyId, DynamoDBItemOp, DynamoDBItemTransactOp};
292//! # use dynamodb_facade::test_fixtures::*;
293//! # async fn example(
294//! #     client: dynamodb_facade::Client,
295//! #     enrollment: Enrollment,
296//! # ) -> dynamodb_facade::Result<()> {
297//! // Atomically create an enrollment and increment the user's enrollment count:
298//! client
299//!     .transact_write_items()
300//!     .transact_items(enrollment.transact_put().not_exists().build())
301//!     .transact_items(
302//!         User::transact_update_by_id(
303//!             KeyId::pk("user-1"),
304//!             Update::init_increment("enrollment_count", 0, 1),
305//!         )
306//!         .condition(
307//!             User::exists()
308//!                 & Condition::lt("enrollment_count", 10u32),
309//!         )
310//!         .build(),
311//!     )
312//!     .send()
313//!     .await?;
314//! # Ok(())
315//! # }
316//! ```
317//!
318//! # Logical Module Organization
319//!
320//! All items are re-exported from the crate root. The internal modules are:
321//!
322//! - **`schema`** — [`TableDefinition`], [`IndexDefinition`], [`KeySchema`],
323//!   [`AttributeDefinition`], [`HasAttribute`], [`HasConstAttribute`], and the
324//!   attribute type markers ([`StringAttribute`], [`NumberAttribute`],
325//!   [`BinaryAttribute`]).
326//! - **`item`** — [`DynamoDBItem<TD>`], [`Item<TD>`], [`Key<TD>`],
327//!   [`KeyId`], [`NoId`], [`KeyBuilder`].
328//! - **`expressions`** — [`Condition<'a>`], [`Update<'a>`],
329//!   [`UpdateSetRhs<'a>`], [`KeyCondition`], [`Projection`], [`Comparison`].
330//! - **`operations`** — [`GetItemRequest`], [`PutItemRequest`],
331//!   [`DeleteItemRequest`], [`UpdateItemRequest`], [`QueryRequest`],
332//!   [`ScanRequest`], [`DynamoDBItemOp`], [`DynamoDBItemBatchOp`],
333//!   [`DynamoDBItemTransactOp`], batch helpers ([`dynamodb_batch_write`],
334//!   [`batch_put`], [`batch_delete`]), and pagination helpers
335//!   ([`dynamodb_execute_query`], [`dynamodb_stream_query`],
336//!   [`dynamodb_execute_scan`], [`dynamodb_stream_scan`]).
337//! - **`values`** — [`IntoAttributeValue`], [`to_attribute_value`],
338//!   [`try_to_attribute_value`], [`AsSet<T>`], [`AsNumber<T>`].
339//! - **`error`** — [`Error`], [`Result<T>`].
340//! - **`macros`** — [`attribute_definitions!`], [`table_definitions!`],
341//!   [`index_definitions!`], [`dynamodb_item!`], [`has_attributes!`],
342//!   [`attr_list!`], [`key_schema!`].
343//!
344//! # Error Handling
345//!
346//! All fallible operations return [`Result<T>`] (an alias for
347//! `core::result::Result<T, `[`Error`]`>`). The [`Error`] enum has five
348//! variants:
349//!
350//! - [`Error::DynamoDB`] — wraps a boxed [`DynamoDBError`] from the AWS SDK.
351//!   Use [`Error::as_dynamodb_error`] to downcast and match on specific SDK
352//!   error types such as `ConditionalCheckFailedException`.
353//! - [`Error::Serde`] — a `serde_dynamo` (de)serialization failure.
354//! - [`Error::FailedBatchWrite`] — a batch write that could not complete
355//!   after all retry attempts. Contains the unprocessed
356//!   [`WriteRequest`](aws_sdk_dynamodb::types::WriteRequest)s.
357//! - [`Error::Custom`] — a caller-supplied string message, created via
358//!   [`Error::custom`].
359//! - [`Error::Other`] — any `Box<dyn Error + Send + Sync>`, created via
360//!   [`Error::other`].
361//!
362//! ```no_run
363//! # use dynamodb_facade::{Error, DynamoDBError, DynamoDBItemOp};
364//! # use dynamodb_facade::test_fixtures::*;
365//! # async fn example(client: dynamodb_facade::Client) -> dynamodb_facade::Result<()> {
366//! let user = sample_user();
367//!
368//! // Override an existing item and retrieve the previous version.
369//! // `.exists()` adds a condition that fails if the item is not already present.
370//! match user.put(client).exists().return_old().await {
371//!     Ok(Some(old)) => { /* found old value */ }
372//!     Ok(None) => { unreachable!("condition fail if nothing to return") }
373//!     Err(err)
374//!         if matches!(
375//!             err.as_dynamodb_error(),
376//!             Some(DynamoDBError::ConditionalCheckFailedException(_))
377//!         ) =>
378//!     {
379//!         println!("item did not exist yet — nothing was overwritten");
380//!     }
381//!     Err(err) => return Err(err),
382//! }
383//! # Ok(())
384//! # }
385//! ```
386//!
387//! # Feature Flags
388//!
389//! - **`test-fixtures`** — exposes the [`test_fixtures`] module outside of
390//!   `cfg(test)` and `cfg(doc)`. Useful for integration test crates that want
391//!   to reuse the domain types defined there.
392//! - **`integration`** — gates integration tests that require a running
393//!   DynamoDB Local instance (via `testcontainers`). Not needed for normal
394//!   library use.
395
396// TODO: `#[derive(DynamoDBItem)]` proc macro to eliminate the boilerplate of
397//       implementing PkId/SkId/get_pk_from_id/get_sk_from_id/get_key/TYPE.
398
399// TODO: Enrich Error::DynamoDB with operation context (operation name, table
400//       name, key) for better diagnostics at each .execute() call site.
401
402mod error;
403mod expressions;
404mod item;
405mod macros;
406mod operations;
407mod schema;
408mod utils;
409mod values;
410
411pub use error::*;
412pub use expressions::*;
413pub use item::*;
414pub use operations::*;
415pub use schema::*;
416pub use values::*;
417
418pub use aws_sdk_dynamodb;
419pub use aws_sdk_dynamodb::{Client, Error as DynamoDBError, types::AttributeValue};
420
421#[cfg(any(test, feature = "test-fixtures", doc))]
422pub mod test_fixtures;