Skip to main content

dynamodb_facade/item/
mod.rs

1mod key;
2mod key_id;
3
4use core::{fmt, marker::PhantomData, ops::Deref};
5use std::collections::HashMap;
6
7use serde::{Serialize, de::DeserializeOwned};
8
9use super::{
10    AttributeDefinition, AttributeList, AttributeValue, AttributeValueRef, CompositeKey,
11    CompositeKeySchema, HasTableKeyAttributes, KeySchema, KeySchemaKind, Result, SimpleKey,
12    SimpleKeySchema, TableDefinition,
13};
14
15pub use key::{Key, KeyBuilder};
16pub use key_id::*;
17
18/// Core trait for types stored in a DynamoDB table.
19///
20/// Implementing this trait connects a Rust type to a specific [`TableDefinition`],
21/// enabling typed CRUD operations, expression building, and (de)serialization. Most of
22/// the time you implement it via the [`dynamodb_item!`](crate::dynamodb_item) macro
23/// rather than by hand.
24///
25/// # Why `TD` is a generic parameter
26///
27/// `TD` is a **type parameter** rather than an associated type so that a single
28/// type can implement this trait for multiple tables. This supports scenarios
29/// where the same domain type must live in more than one table — for example,
30/// shared types across a primary and an archive table, or migration logic that
31/// reads from one table and writes to another with different key mappings.
32///
33/// # Methods
34///
35/// The three associated methods cover the full round-trip between a Rust value and
36/// a DynamoDB item:
37///
38/// - [`to_item`](DynamoDBItem::to_item) — serialize `self` into an [`Item<TD>`]
39/// - [`try_from_item`](DynamoDBItem::try_from_item) — fallibly deserialize an [`Item<TD>`]
40/// - [`from_item`](DynamoDBItem::from_item) — infallibly deserialize (panics on mismatch)
41///
42/// # Associated Types
43///
44/// `AdditionalAttributes` lists the non-key attributes that are written by
45/// [`Item::minimal_from`] (e.g. type discriminators such as `_TYPE`). Usualy,
46/// additional attributes are there to ensure the resulting DynamoDB item is included
47/// in some Local or Global Secondary Indexes.
48///
49/// Note that the table Key attributes are always included automatically regardless of
50/// the content of `AdditionalAttributes`.
51///
52/// # Blanket Implementations
53///
54/// Implementing [`DynamoDBItem<TD>`] is the **gateway to the entire typed operation
55/// API**. Three additional traits are automatically provided via blanket
56/// implementations:
57///
58/// - [`DynamoDBItemOp<TD>`](crate::DynamoDBItemOp) — single-item CRUD and collection operations:
59///   `get`, `put`, `delete`, `update`, `query`, `scan`
60/// - [`DynamoDBItemBatchOp<TD>`](crate::DynamoDBItemBatchOp) — batch write requests:
61///   `batch_put`, `batch_delete`
62/// - [`DynamoDBItemTransactOp<TD>`](crate::DynamoDBItemTransactOp) — transactional requests:
63///   `transact_put`, `transact_delete`, `transact_update`, `transact_condition`
64///
65/// # Examples
66///
67/// Serializing a user to a DynamoDB item and back:
68///
69/// ```
70/// # use dynamodb_facade::test_fixtures::*;
71/// use serde::{Deserialize, Serialize};
72/// use dynamodb_facade::{dynamodb_item, DynamoDBItem, DynamoDBItemOp, KeyId};
73///
74/// #[derive(Serialize, Deserialize)]
75/// pub struct User {
76///     pub id: String,
77///     pub name: String,
78///     pub email: String,
79///     pub role: String,
80/// }
81///
82/// dynamodb_item! {
83///     #[table = PlatformTable]
84///     User {
85///         #[partition_key]
86///         PK {
87///             fn attribute_id(&self) -> &'id str { &self.id }
88///             fn attribute_value(id) -> String { format!("USER#{id}") }
89///         }
90///         #[sort_key]
91///         SK { const VALUE: &'static str = "USER"; }
92///     }
93/// }
94///
95/// fn _assert_dynamodb_item<DBI: DynamoDBItem<PlatformTable> + DynamoDBItemOp<PlatformTable>>() {}
96/// _assert_dynamodb_item::<User>();
97///
98/// # async fn example(cclient: aws_sdk_dynamodb::Client) -> dynamodb_facade::Result<()> {
99/// let user = User {
100///     id: "user-1".to_owned(),
101///     name: "Alice".to_owned(),
102///     email: "alice@example.com".to_owned(),
103///     role: "student".to_owned(),
104/// };
105///
106/// # let client = cclient.clone();
107/// // Put the User in DynamoDB if it does not already exist
108/// user.put(client).not_exists().await?;
109///
110/// # let client = cclient.clone();
111/// // Retrieve an existing user
112/// let existing /* : Option<User> */ = User::get(client, KeyId::pk("user-2")).await?;
113///
114/// # let client = cclient.clone();
115/// // Delete the user, if it exist
116/// if let Some(u) = existing {
117///     u.delete(client).exists().await?;
118/// }
119/// # Ok(())
120/// # }
121/// ```
122pub trait DynamoDBItem<TD: TableDefinition>:
123    Sized + HasTableKeyAttributes<TD> + KeyBuilder<TD>
124{
125    type AdditionalAttributes: AttributeList<TD, Self>;
126
127    /// Serializes `self` into an [`Item<TD>`].
128    ///
129    /// Combines the key attributes and
130    /// [`AdditionalAttributes`](DynamoDBItem::AdditionalAttributes) produced by
131    /// [`Item::minimal_from`] with the full `serde_dynamo` representation of
132    /// `self`.
133    ///
134    /// # Panics
135    ///
136    /// Panics if [`serde_dynamo::to_item`] fails to serialize `self` — e.g.
137    /// non-string map keys, or a [`Serialize`] impl that returns an error.
138    /// Users are responsible for providing a [`Serialize`] implementation
139    /// compatible with DynamoDB's attribute-value model; plain
140    /// `#[derive(Serialize)]` on structs with string / number / bool / Vec /
141    /// Option / nested-struct fields is always fine.
142    fn to_item(&self) -> Item<TD>
143    where
144        Self: Serialize,
145    {
146        let minimal_item = Item::minimal_from(self);
147        let item: HashMap<_, _> = serde_dynamo::to_item(self).expect("valid serialization");
148        minimal_item.with_attributes(item)
149    }
150
151    /// Fallibly deserializes an [`Item<TD>`] into `Self`.
152    ///
153    /// # Errors
154    ///
155    /// Returns [`Err`] if [`serde_dynamo::from_item`] fails — e.g. a required
156    /// attribute is missing, has an unexpected type, or cannot be decoded into
157    /// the target field.
158    fn try_from_item(item: Item<TD>) -> Result<Self>
159    where
160        Self: DeserializeOwned,
161    {
162        Ok(serde_dynamo::from_item(item.into_inner())?)
163    }
164
165    /// Infallibly deserializes an [`Item<TD>`] into `Self`.
166    ///
167    /// # Panics
168    ///
169    /// Panics if [`try_from_item`](DynamoDBItem::try_from_item) returns an
170    /// error. Use [`try_from_item`](DynamoDBItem::try_from_item) when the
171    /// item may not match the schema.
172    fn from_item(item: Item<TD>) -> Self
173    where
174        Self: DeserializeOwned,
175    {
176        Self::try_from_item(item).expect("valid schema")
177    }
178}
179/// A type-safe wrapper around a DynamoDB item for a specific table.
180///
181/// `Item<TD>` is a [`HashMap<String, AttributeValue>`] branded with the
182/// [`TableDefinition`] `TD`. The branding enforces at compile time that items
183/// from different tables are never mixed up, and it unlocks typed attribute
184/// accessors such as [`pk`](Item::pk), [`sk`](Item::sk), and
185/// [`attribute`](Item::attribute).
186///
187/// An `Item<TD>` is guaranteed to contain the table's key attributes (PK, and SK
188/// for composite-key tables). This invariant is upheld by all constructors.
189///
190/// `Item<TD>` implements [`Deref`] to `HashMap<String, AttributeValue>`, so you
191/// can call `.get("field")` and other map methods directly.
192///
193/// # Examples
194///
195/// Building an item and inspecting its attributes:
196///
197/// ```
198/// # use dynamodb_facade::test_fixtures::*;
199/// use dynamodb_facade::DynamoDBItem;
200///
201/// let user /* : User */ = sample_user();
202/// let item /* : Item<PlatformTable> */ = user.to_item();
203///
204/// // Typed key accessors — always present.
205/// assert_eq!(item.pk(), "USER#user-1");
206/// assert_eq!(item.sk(), "USER");
207///
208/// // Optional typed attribute access (present for the User type).
209/// let item_type: Option<&str> = item.attribute::<ItemType>();
210/// assert_eq!(item_type, Some("USER"));
211/// ```
212///
213/// Consuming the item into its raw map:
214///
215/// ```
216/// # use dynamodb_facade::test_fixtures::*;
217/// use dynamodb_facade::DynamoDBItem;
218///
219/// let item /* : Item<PlatformTable> */ = sample_user().to_item();
220/// let raw /* : HashMap<String, AttributeValue> */ = item.into_inner();
221/// assert!(raw.contains_key("PK"));
222/// ```
223///
224/// # Equality and hashing
225///
226/// `Item<TD>` intentionally does not implement [`PartialEq`], [`Eq`], or
227/// [`Hash`](core::hash::Hash). The backing type is a
228/// [`HashMap<String, AttributeValue>`], and while attribute-value byte-equality
229/// could be derived, it does not match DynamoDB's semantic equality (number
230/// string normalization, set element ordering, etc.). Compare items by
231/// deserializing to `T` first.
232#[derive(Clone)]
233pub struct Item<TD: TableDefinition>(HashMap<String, AttributeValue>, PhantomData<TD>);
234impl<TD: TableDefinition> fmt::Debug for Item<TD> {
235    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
236        fmt::Debug::fmt(&self.0, f)
237    }
238}
239
240/// Shorthand for the partition key [`AttributeDefinition`] of table `TD`.
241pub(crate) type PartitionKeyDefinition<TD> =
242    <<TD as TableDefinition>::KeySchema as KeySchema>::PartitionKey;
243/// The [`AttributeType`](crate::AttributeType) of the partition key for table `TD`.
244type PartitionKeyType<TD> = <PartitionKeyDefinition<TD> as AttributeDefinition>::Type;
245/// Shorthand for the sort key [`AttributeDefinition`] of composite-key table `TD`.
246type SortKeyDefinition<TD> = <<TD as TableDefinition>::KeySchema as CompositeKeySchema>::SortKey;
247/// The [`AttributeType`](crate::AttributeType) of the sort key for table `TD`.
248type SortKeyType<TD> = <SortKeyDefinition<TD> as AttributeDefinition>::Type;
249
250impl<TD: TableDefinition> Item<TD> {
251    /// Returns the partition key value of this item.
252    ///
253    /// The return type is a typed reference determined by the table's partition
254    /// key attribute definition (e.g. `&str` for a `StringAttribute` PK).
255    ///
256    /// # Panics
257    ///
258    /// Panics if the partition key attribute is absent. This should never happen
259    /// for items produced by this crate's constructors, which always include the
260    /// key attributes.
261    ///
262    /// # Examples
263    ///
264    /// ```
265    /// # use dynamodb_facade::test_fixtures::*;
266    /// use dynamodb_facade::DynamoDBItem;
267    ///
268    /// let item /* : Item<PlatformTable> */ = sample_user().to_item();
269    /// assert_eq!(item.pk(), "USER#user-1");
270    /// ```
271    pub fn pk(&self) -> <PartitionKeyType<TD> as AttributeValueRef>::Ref<'_> {
272        self.attribute::<PartitionKeyDefinition<TD>>()
273            .expect("PK is always present")
274    }
275
276    /// Returns a typed reference to the named attribute, or `None` if absent.
277    ///
278    /// The attribute is identified by the type parameter `A`, which must
279    /// implement [`AttributeDefinition`]. The return type is a typed reference
280    /// whose concrete type is determined by `A::Type` (e.g. `&str` for
281    /// `StringAttribute`, `&str` for `NumberAttribute`).
282    ///
283    /// # Examples
284    ///
285    /// ```
286    /// # use dynamodb_facade::test_fixtures::*;
287    /// use dynamodb_facade::DynamoDBItem;
288    ///
289    /// let item /* : Item<PlatformTable> */ = sample_user().to_item();
290    ///
291    /// // Present attribute:
292    /// let item_type: Option<&str> = item.attribute::<ItemType>();
293    /// assert_eq!(item_type, Some("USER"));
294    ///
295    /// // Absent attribute returns None:
296    /// let expiration: Option<&str> = item.attribute::<Expiration>();
297    /// assert!(expiration.is_none());
298    /// ```
299    pub fn attribute<A: AttributeDefinition>(
300        &self,
301    ) -> Option<<A::Type as AttributeValueRef>::Ref<'_>> {
302        self.0
303            .get(A::NAME)
304            .map(<A::Type as AttributeValueRef>::attribute_value_ref)
305    }
306
307    /// Consumes the item and returns the underlying raw attribute map.
308    ///
309    /// Use this when you need to pass the item to code that works with the raw
310    /// `aws-sdk-dynamodb` types.
311    ///
312    /// Beware that you will not be able to re-construct the original [`Item<TD>`].
313    /// See [`Item::extract_key`] and [`Item::from_key_and_attributes`] if you want
314    /// to manipulate the underlying `HashMap` and then re-create it afterward.
315    ///
316    /// # Examples
317    ///
318    /// ```
319    /// # use dynamodb_facade::test_fixtures::*;
320    /// use dynamodb_facade::DynamoDBItem;
321    ///
322    /// let raw /* : HashMap<String, AttributeValue> */ = sample_user().to_item().into_inner();
323    /// assert!(raw.contains_key("PK"));
324    /// assert!(raw.contains_key("SK"));
325    /// ```
326    pub fn into_inner(self) -> HashMap<String, AttributeValue> {
327        self.0
328    }
329
330    /// Wraps a raw attribute map returned by the SDK into a typed `Item<TD>`.
331    pub(crate) fn from_dynamodb_response(item: HashMap<String, AttributeValue>) -> Self {
332        Self(item, PhantomData)
333    }
334
335    /// Creates a minimal [`Item<TD>`] from a [`DynamoDBItem`] value.
336    ///
337    /// The resulting item contains only the key attributes (PK and SK for
338    /// composite-key tables) and the type's
339    /// [`AdditionalAttributes`](DynamoDBItem::AdditionalAttributes) (e.g. type
340    /// discriminators). It does **not** include the serialized underlying type.
341    ///
342    /// This is mainly used when you need to implement [`DynamoDBItem::to_item`]
343    /// manually.
344    ///
345    /// # Examples
346    ///
347    /// ```
348    /// # use dynamodb_facade::test_fixtures::*;
349    /// use dynamodb_facade::{DynamoDBItem, Item};
350    ///
351    /// let user = sample_user();
352    /// let minimal = Item::minimal_from(&user);
353    ///
354    /// // Key attributes are present.
355    /// assert_eq!(minimal.pk(), "USER#user-1");
356    /// assert_eq!(minimal.sk(), "USER");
357    ///
358    /// // The type discriminator (AdditionalAttributes) is also present.
359    /// assert_eq!(minimal.attribute::<ItemType>(), Some("USER"));
360    ///
361    /// // But non-key payload fields are absent.
362    /// assert!(!minimal.contains_key("name"));
363    /// ```
364    pub fn minimal_from<DBI: DynamoDBItem<TD>>(dynamodb_item: &DBI) -> Self {
365        let key = dynamodb_item.get_key();
366        let additional_attributes = DBI::AdditionalAttributes::get_attributes(dynamodb_item);
367        Item::from_key_and_attributes(key, additional_attributes)
368    }
369
370    /// Merges additional attributes into this item.
371    ///
372    /// In case of an attribute name conflict, attributes already present on
373    /// the item take precedence. In other words, this method cannot overwrite
374    /// existing item attributes.
375    ///
376    /// This is the mechanism used by [`DynamoDBItem::to_item`]'s default
377    /// implementation to combine the full serde payload with the typed key
378    /// attributes.
379    ///
380    /// # Examples
381    ///
382    /// ```
383    /// # use dynamodb_facade::test_fixtures::*;
384    /// use dynamodb_facade::{DynamoDBItem, Item, IntoAttributeValue};
385    ///
386    /// let user = sample_user();
387    /// let minimal = Item::minimal_from(&user);
388    ///
389    /// // Merge in extra attributes.
390    /// let enriched = minimal.with_attributes([
391    ///     ("extra".to_owned(), "hello".into_attribute_value()),
392    /// ]);
393    ///
394    /// assert_eq!(enriched.pk(), "USER#user-1");
395    /// assert!(enriched.contains_key("extra"));
396    /// ```
397    pub fn with_attributes(self, attributes: impl Into<HashMap<String, AttributeValue>>) -> Self {
398        let mut item = attributes.into();
399        item.extend(self.0);
400        Self(item, PhantomData)
401    }
402
403    /// Constructs an item from a [`Key<TD>`] and an additional attribute map.
404    ///
405    /// The key attributes always take precedence: if `attributes` contains an
406    /// entry with the same name as a key attribute, the key attribute wins.
407    ///
408    /// # Examples
409    ///
410    /// ```
411    /// # use dynamodb_facade::test_fixtures::*;
412    /// use dynamodb_facade::{DynamoDBItem, KeyBuilder, Item, IntoAttributeValue};
413    ///
414    /// let key = sample_user().get_key();
415    /// let item: Item<PlatformTable> = Item::from_key_and_attributes(key, [
416    ///     ("role".to_owned(), "instructor".into_attribute_value()),
417    /// ]);
418    ///
419    /// assert_eq!(item.pk(), "USER#user-1");
420    /// assert!(item.contains_key("role"));
421    /// ```
422    pub fn from_key_and_attributes(
423        key: Key<TD>,
424        attributes: impl Into<HashMap<String, AttributeValue>>,
425    ) -> Self {
426        let mut item = attributes.into();
427        item.extend(key.into_inner());
428        Self(item, PhantomData)
429    }
430}
431impl<TD: TableDefinition> Item<TD>
432where
433    TD::KeySchema: CompositeKeySchema,
434{
435    /// Returns the sort key value of this item.
436    ///
437    /// Only available when the table uses a [`CompositeKeySchema`] (PK + SK).
438    /// The return type is a typed reference determined by the table's sort key
439    /// attribute definition (e.g. `&str` for a `StringAttribute` SK).
440    ///
441    /// # Panics
442    ///
443    /// Panics if the sort key attribute is absent. This should never happen for
444    /// items produced by this crate's constructors, which always include the key
445    /// attributes.
446    ///
447    /// # Examples
448    ///
449    /// ```
450    /// # use dynamodb_facade::test_fixtures::*;
451    /// use dynamodb_facade::DynamoDBItem;
452    ///
453    /// let item = sample_user().to_item();
454    /// assert_eq!(item.sk(), "USER");
455    ///
456    /// let enrollment_item = sample_enrollment().to_item();
457    /// assert_eq!(enrollment_item.sk(), "ENROLL#course-42");
458    /// ```
459    pub fn sk(&self) -> <SortKeyType<TD> as AttributeValueRef>::Ref<'_> {
460        self.attribute::<SortKeyDefinition<TD>>()
461            .expect("SK is always present")
462    }
463}
464
465impl<TD: TableDefinition> Deref for Item<TD> {
466    type Target = HashMap<String, AttributeValue>;
467
468    fn deref(&self) -> &Self::Target {
469        &self.0
470    }
471}
472
473impl<TD: TableDefinition> IntoIterator for Item<TD> {
474    type Item = <HashMap<String, AttributeValue> as IntoIterator>::Item;
475    type IntoIter = <HashMap<String, AttributeValue> as IntoIterator>::IntoIter;
476
477    fn into_iter(self) -> Self::IntoIter {
478        self.0.into_iter()
479    }
480}
481
482#[cfg(test)]
483mod tests {
484    use std::collections::HashMap;
485
486    use aws_sdk_dynamodb::types::AttributeValue;
487
488    use super::super::test_fixtures::*;
489    use super::*;
490
491    // ---------------------------------------------------------------------------
492    // test_item_minimal_from_user
493    // ---------------------------------------------------------------------------
494
495    #[test]
496    fn test_item_minimal_from_user() {
497        let user = sample_user();
498        let item = Item::<PlatformTable>::minimal_from(&user);
499
500        // Key attributes are present with correct values.
501        assert_eq!(
502            item.get("PK"),
503            Some(&AttributeValue::S("USER#user-1".to_owned()))
504        );
505        assert_eq!(item.get("SK"), Some(&AttributeValue::S("USER".to_owned())));
506        // ItemType is in AdditionalAttributes (not marker_only).
507        assert_eq!(
508            item.get("_TYPE"),
509            Some(&AttributeValue::S("USER".to_owned()))
510        );
511        // Email is #[marker_only] — NOT in AdditionalAttributes, so absent from minimal_from.
512        assert!(!item.contains_key("email"));
513        // Exactly PK + SK + _TYPE = 3 attributes.
514        assert_eq!(item.len(), 3);
515    }
516
517    // ---------------------------------------------------------------------------
518    // test_item_with_attributes_key_takes_precedence
519    // ---------------------------------------------------------------------------
520
521    #[test]
522    fn test_item_with_attributes_key_takes_precedence() {
523        let item = sample_user().to_item();
524        let extra = HashMap::from([
525            (
526                "PK".to_owned(),
527                AttributeValue::S("SHOULD_NOT_WIN".to_owned()),
528            ),
529            ("custom".to_owned(), AttributeValue::S("added".to_owned())),
530        ]);
531
532        let enriched = item.with_attributes(extra);
533
534        // Key attribute wins over the conflicting extra value.
535        assert_eq!(
536            enriched.get("PK"),
537            Some(&AttributeValue::S("USER#user-1".to_owned()))
538        );
539        // New attribute is present.
540        assert_eq!(
541            enriched.get("custom"),
542            Some(&AttributeValue::S("added".to_owned()))
543        );
544        // Existing non-conflicting attributes are unchanged.
545        assert_eq!(
546            enriched.get("SK"),
547            Some(&AttributeValue::S("USER".to_owned()))
548        );
549        assert_eq!(
550            enriched.get("_TYPE"),
551            Some(&AttributeValue::S("USER".to_owned()))
552        );
553    }
554
555    // ---------------------------------------------------------------------------
556    // test_item_from_key_and_attributes_key_takes_precedence
557    // ---------------------------------------------------------------------------
558
559    #[test]
560    fn test_item_from_key_and_attributes_key_takes_precedence() {
561        let key: Key<PlatformTable> = sample_user().get_key();
562        let extra = HashMap::from([
563            ("PK".to_owned(), AttributeValue::S("WRONG".to_owned())),
564            ("other".to_owned(), AttributeValue::S("kept".to_owned())),
565        ]);
566
567        let item = Item::from_key_and_attributes(key, extra);
568
569        // Key attribute wins over the conflicting extra value.
570        assert_eq!(
571            item.get("PK"),
572            Some(&AttributeValue::S("USER#user-1".to_owned()))
573        );
574        // SK from key is present.
575        assert_eq!(item.get("SK"), Some(&AttributeValue::S("USER".to_owned())));
576        // Non-conflicting extra attribute is preserved.
577        assert_eq!(
578            item.get("other"),
579            Some(&AttributeValue::S("kept".to_owned()))
580        );
581    }
582
583    // ---------------------------------------------------------------------------
584    // test_item_extract_key_composite
585    // ---------------------------------------------------------------------------
586
587    #[test]
588    fn test_item_extract_key_composite() {
589        let item = sample_enrollment().to_item();
590        let (key, rest) = item.extract_key();
591
592        let raw_key = key.into_inner();
593
594        // Key contains PK and SK with correct values.
595        assert_eq!(
596            raw_key.get("PK"),
597            Some(&AttributeValue::S("USER#user-1".to_owned()))
598        );
599        assert_eq!(
600            raw_key.get("SK"),
601            Some(&AttributeValue::S("ENROLL#course-42".to_owned()))
602        );
603        // Key contains exactly PK + SK.
604        assert_eq!(raw_key.len(), 2);
605
606        // Remaining map does NOT contain key attributes.
607        assert!(!rest.contains_key("PK"));
608        assert!(!rest.contains_key("SK"));
609
610        // Remaining map contains the payload fields.
611        assert!(rest.contains_key("user_id"));
612        assert!(rest.contains_key("course_id"));
613        assert!(rest.contains_key("enrolled_at"));
614        assert!(rest.contains_key("progress"));
615        // _TYPE is in AdditionalAttributes for Enrollment.
616        assert!(rest.contains_key("_TYPE"));
617    }
618
619    // ---------------------------------------------------------------------------
620    // test_item_extract_key_simple — local simple-key table
621    // ---------------------------------------------------------------------------
622
623    crate::attribute_definitions! {
624        SimplePK { "SPK": crate::StringAttribute }
625    }
626    crate::table_definitions! {
627        SimpleTable {
628            type PartitionKey = SimplePK;
629            fn table_name() -> String { "simple".to_owned() }
630        }
631    }
632    #[derive(serde::Deserialize, serde::Serialize)]
633    struct SimpleItem {
634        id: String,
635        value: String,
636    }
637    crate::dynamodb_item! {
638        #[table = SimpleTable]
639        SimpleItem {
640            #[partition_key]
641            SimplePK {
642                fn attribute_id(&self) -> &'id str { &self.id }
643                fn attribute_value(id) -> String { format!("ID#{id}") }
644            }
645        }
646    }
647
648    #[test]
649    fn test_item_extract_key_simple() {
650        let si = SimpleItem {
651            id: "x".to_owned(),
652            value: "v".to_owned(),
653        };
654        let item = si.to_item();
655        let (key, rest) = item.extract_key();
656
657        let raw_key = key.into_inner();
658
659        // Key contains only SPK.
660        assert_eq!(raw_key.len(), 1);
661        assert_eq!(
662            raw_key.get("SPK"),
663            Some(&AttributeValue::S("ID#x".to_owned()))
664        );
665
666        // Remaining map does NOT contain the key attribute.
667        assert!(!rest.contains_key("SPK"));
668        // Payload field is present.
669        assert!(rest.contains_key("value"));
670    }
671
672    // ---------------------------------------------------------------------------
673    // test_dynamodb_item_to_item_and_try_from_item_roundtrip
674    // ---------------------------------------------------------------------------
675
676    #[test]
677    fn test_dynamodb_item_to_item_and_try_from_item_roundtrip() {
678        let original = sample_user();
679        let item = original.to_item();
680        let restored = User::try_from_item(item).unwrap();
681
682        assert_eq!(restored.id, original.id);
683        assert_eq!(restored.name, original.name);
684        assert_eq!(restored.email, original.email);
685        assert_eq!(restored.role, original.role);
686    }
687}