#![doc = include_str!("../docs/modyne.md")]
#![warn(missing_docs)]
#![deny(missing_debug_implementations)]
#![deny(rustdoc::broken_intra_doc_links)]
mod error;
pub mod expr;
pub mod keys;
pub mod model;
pub mod types;
use std::collections::HashMap;
#[doc(inline)]
pub use aws_sdk_dynamodb::types::AttributeValue;
use keys::{IndexKeys, PrimaryKey};
use model::{ConditionCheck, ConditionalPut, Delete, Get, Put, Query, Scan, Update};
#[cfg(feature = "derive")]
pub use modyne_derive::EntityDef;
#[cfg(feature = "derive")]
pub use modyne_derive::Projection;
use serde_dynamo::aws_sdk_dynamodb_1 as codec;
pub use crate::error::{Error, MalformedEntityTypeError};
pub type Item = HashMap<String, AttributeValue>;
#[aliri_braid::braid(serde)]
pub struct EntityTypeName;
pub trait Table {
const ENTITY_TYPE_ATTRIBUTE: &'static str = "entity_type";
type PrimaryKey: keys::PrimaryKey;
type IndexKeys: IndexKeys;
fn table_name(&self) -> &str;
fn client(&self) -> &aws_sdk_dynamodb::Client;
#[inline]
fn deserialize_entity_type(
attr: &AttributeValue,
) -> Result<&EntityTypeNameRef, MalformedEntityTypeError> {
let Ok(value) = attr.as_s() else {
return Err(MalformedEntityTypeError::ExpectedStringValue);
};
Ok(EntityTypeNameRef::from_str(value.as_str()))
}
#[inline]
fn serialize_entity_type(entity_type: &EntityTypeNameRef) -> AttributeValue {
AttributeValue::S(entity_type.to_string())
}
}
pub trait EntityDef {
const ENTITY_TYPE: &'static EntityTypeNameRef;
const PROJECTED_ATTRIBUTES: &'static [&'static str] = &[];
}
pub trait Entity: EntityDef + Sized {
type KeyInput<'a>;
type Table: Table;
type IndexKeys: keys::IndexKeys;
fn primary_key(input: Self::KeyInput<'_>) -> <Self::Table as Table>::PrimaryKey;
fn full_key(&self) -> keys::FullKey<<Self::Table as Table>::PrimaryKey, Self::IndexKeys>;
}
pub trait EntityExt: Entity {
const KEY_DEFINITION: keys::PrimaryKeyDefinition =
<<Self::Table as Table>::PrimaryKey as keys::PrimaryKey>::PRIMARY_KEY_DEFINITION;
fn into_item(self) -> Item
where
Self: serde::Serialize,
{
let full_entity = FullEntity {
keys: self.full_key(),
entity: self,
};
let mut item = crate::codec::to_item(full_entity).unwrap();
if item
.insert(
<Self::Table as Table>::ENTITY_TYPE_ATTRIBUTE.to_string(),
<Self::Table as Table>::serialize_entity_type(Self::ENTITY_TYPE),
)
.is_some()
{
tracing::warn!(
"serialized entity had attribute collision with entity type attribute `{}`",
<Self::Table as Table>::ENTITY_TYPE_ATTRIBUTE,
);
}
item
}
#[inline]
fn get(input: Self::KeyInput<'_>) -> Get {
Get::new(Self::primary_key(input).into_key())
}
#[inline]
fn put(self) -> Put
where
Self: serde::Serialize,
{
Put::new(self.into_item())
}
#[inline]
fn create(self) -> ConditionalPut
where
Self: serde::Serialize,
{
let condition = expr::Condition::new("attribute_not_exists(#PK)").name(
"#PK",
<<Self::Table as Table>::PrimaryKey as keys::PrimaryKey>::PRIMARY_KEY_DEFINITION
.hash_key,
);
self.put().condition(condition)
}
#[inline]
fn replace(self) -> ConditionalPut
where
Self: serde::Serialize,
{
let condition = expr::Condition::new("attribute_exists(#PK)").name(
"#PK",
<<Self::Table as Table>::PrimaryKey as keys::PrimaryKey>::PRIMARY_KEY_DEFINITION
.hash_key,
);
self.put().condition(condition)
}
#[inline]
fn update(key: Self::KeyInput<'_>) -> Update {
Update::new(Self::primary_key(key).into_key())
}
#[inline]
fn delete(key: Self::KeyInput<'_>) -> Delete {
Delete::new(Self::primary_key(key).into_key())
}
#[inline]
fn condition_check(key: Self::KeyInput<'_>, condition: expr::Condition) -> ConditionCheck {
ConditionCheck::new(Self::primary_key(key).into_key(), condition)
}
}
impl<T: Entity> EntityExt for T {}
pub trait Projection: Sized {
const PROJECTED_ATTRIBUTES: &'static [&'static str] =
<Self::Entity as EntityDef>::PROJECTED_ATTRIBUTES;
type Entity: Entity;
}
impl<T> Projection for T
where
T: Entity,
{
type Entity = Self;
}
pub trait ProjectionExt: Projection {
fn from_item(item: Item) -> Result<Self, Error>;
}
impl<'a, P> ProjectionExt for P
where
P: Projection + serde::Deserialize<'a>,
{
fn from_item(item: Item) -> Result<Self, Error> {
let parsed = crate::codec::from_item(item).map_err(|error| {
crate::error::ItemDeserializationError::new(Self::Entity::ENTITY_TYPE, error)
})?;
Ok(parsed)
}
}
pub trait ProjectionSet: Sized {
fn try_from_item(item: Item) -> Result<Option<Self>, Error>;
fn projection_expression() -> Option<expr::StaticProjection>;
}
#[macro_export]
macro_rules! projections {
($(#[$meta:meta])* $v:vis enum $name:ident { $ty:ident }) => {
$crate::projections!{
($(#[$meta])* $v:vis enum $name { $ty, })
}
};
($(#[$meta:meta])* $v:vis enum $name:ident { $ty:ident, $($tys:ident),* $(,)? }) => {
$(#[$meta])*
$v enum $name {
$ty($ty),
$($tys($tys),)*
}
impl $crate::ProjectionSet for $name {
fn try_from_item(item: $crate::Item) -> ::std::result::Result<::std::option::Option<Self>, $crate::Error> {
let entity_type = $crate::__private::get_entity_type::<$ty>(&item)?;
let parsed =
if entity_type == <<$ty as $crate::Projection>::Entity as $crate::EntityDef>::ENTITY_TYPE {
let parsed = <$ty as $crate::ProjectionExt>::from_item(item)
.map(Self::$ty)?;
::std::option::Option::Some(parsed)
} else
$(
if entity_type == <<$tys as $crate::Projection>::Entity as $crate::EntityDef>::ENTITY_TYPE {
let parsed = <$tys as $crate::ProjectionExt>::from_item(item)
.map(Self::$tys)?;
::std::option::Option::Some(parsed)
} else
)*
{
tracing::warn!(entity_type = entity_type.as_str(), "unknown entity type");
::std::option::Option::None
};
::std::result::Result::Ok(parsed)
}
fn projection_expression() -> ::std::option::Option<$crate::expr::StaticProjection> {
$crate::once_projection_expression!($ty,$($tys),*)
}
}
};
}
#[macro_export]
macro_rules! once_projection_expression {
($ty:path) => { $crate::once_projection_expression!($ty,) };
($ty:path, $($tys:path),* $(,)?) => {{
$crate::ensure_table_types_are_same!($ty, $($tys),*);
const PROJECTIONS: &'static [&'static [&'static str]] = &[
<$ty as $crate::Projection>::PROJECTED_ATTRIBUTES,
$(
<$tys as $crate::Projection>::PROJECTED_ATTRIBUTES,
)*
];
static PROJECTION_ONCE: $crate::__private::OnceLock<
::std::option::Option<$crate::expr::StaticProjection>,
> = $crate::__private::OnceLock::new();
*PROJECTION_ONCE.get_or_init(|| {
$crate::__private::generate_projection_expression::<<<$ty as $crate::Projection>::Entity as $crate::Entity>::Table>(
PROJECTIONS,
)
})
}};
}
#[macro_export]
macro_rules! read_projection {
($item:expr) => {{
match <Self::Projections as $crate::ProjectionSet>::try_from_item($item) {
Ok(Some(entity)) => Ok(entity),
Ok(None) => return Ok(()),
Err(error) => Err(error),
}
}};
}
#[macro_export]
#[doc(hidden)]
macro_rules! ensure_table_types_are_same {
($ty:path) => {};
($ty:path, $($tys:path),* $(,)?) => {
const _: fn() = || { $({
trait TypeEq {
type This: ?Sized;
}
impl<T: ?Sized> TypeEq for T {
type This = Self;
}
fn assert_table_types_match_for_all_projection_variants<T, U>()
where
T: ?Sized + TypeEq<This = U>,
U: ?Sized,
{}
assert_table_types_match_for_all_projection_variants::<
<<$ty as $crate::Projection>::Entity as $crate::Entity>::Table,
<<$tys as $crate::Projection>::Entity as $crate::Entity>::Table,
>();
})* };
};
}
pub trait Aggregate: Default {
type Projections: ProjectionSet;
fn reduce<I>(&mut self, items: I) -> Result<(), Error>
where
I: IntoIterator<Item = Item>,
{
for item in items {
self.merge(item)?;
}
Ok(())
}
fn merge(&mut self, item: Item) -> Result<(), Error>;
}
impl<'a, P> ProjectionSet for P
where
P: Projection + serde::Deserialize<'a> + 'static,
{
fn try_from_item(item: Item) -> Result<Option<Self>, Error> {
let entity_type = crate::__private::get_entity_type::<Self>(&item)?;
if entity_type == <P::Entity as EntityDef>::ENTITY_TYPE {
let parsed = P::from_item(item)?;
Ok(Some(parsed))
} else {
tracing::warn!(entity_type = entity_type.as_str(), "unknown entity type");
Ok(None)
}
}
fn projection_expression() -> Option<expr::StaticProjection> {
use std::{any::TypeId, collections::BTreeMap, sync::RwLock};
static ENTITY_PROJECTION_EXPRESSION: RwLock<
BTreeMap<TypeId, Option<expr::StaticProjection>>,
> = RwLock::new(BTreeMap::new());
{
let projections = ENTITY_PROJECTION_EXPRESSION.read().unwrap();
if let Some(&projection) = projections.get(&TypeId::of::<P>()) {
return projection;
}
}
let mut projections = ENTITY_PROJECTION_EXPRESSION.write().unwrap();
*projections.entry(TypeId::of::<P>()).or_insert_with(|| {
if !P::PROJECTED_ATTRIBUTES.iter().all(|a| !a.is_empty()) {
return None;
}
let projection =
expr::Projection::new(P::PROJECTED_ATTRIBUTES.iter().copied().chain([
<<P::Entity as crate::Entity>::Table as crate::Table>::ENTITY_TYPE_ATTRIBUTE,
]));
Some(projection.leak())
})
}
}
impl<'a, P> Aggregate for Vec<P>
where
P: Projection + serde::Deserialize<'a> + 'static,
{
type Projections = P;
fn reduce<I>(&mut self, items: I) -> Result<(), Error>
where
I: IntoIterator<Item = Item>,
{
let items = items.into_iter();
self.reserve(items.size_hint().0);
for item in items {
self.merge(item)?;
}
Ok(())
}
fn merge(&mut self, item: Item) -> Result<(), Error> {
let entity = read_projection!(item)?;
self.push(entity);
Ok(())
}
}
pub trait QueryInput {
const CONSISTENT_READ: bool = false;
const SCAN_INDEX_FORWARD: bool = true;
type Index: keys::Key;
type Aggregate: Aggregate;
fn key_condition(&self) -> expr::KeyCondition<Self::Index>;
#[inline]
fn filter_expression(&self) -> Option<expr::Filter> {
None
}
}
pub trait QueryInputExt: QueryInput {
fn query(&self) -> Query<Self::Index>;
}
impl<Q> QueryInputExt for Q
where
Q: QueryInput + ?Sized,
{
fn query(&self) -> Query<Self::Index> {
let mut query = Query::new(self.key_condition());
if let Some(projection) =
<<Self as QueryInput>::Aggregate as Aggregate>::Projections::projection_expression()
{
query = query.projection(projection);
}
if let Some(filter) = self.filter_expression() {
query = query.filter(filter);
}
if Self::CONSISTENT_READ {
query = query.consistent_read();
}
if !Self::SCAN_INDEX_FORWARD {
query = query.scan_index_backward();
}
query
}
}
pub trait ScanInput {
const CONSISTENT_READ: bool = false;
type Index: keys::Key;
#[inline]
fn filter_expression(&self) -> Option<expr::Filter> {
None
}
#[inline]
fn projection_expression() -> Option<expr::StaticProjection> {
None
}
}
pub trait ScanInputExt: ScanInput {
fn scan(&self) -> Scan<Self::Index>;
}
impl<S> ScanInputExt for S
where
S: ScanInput + ?Sized,
{
fn scan(&self) -> Scan<Self::Index> {
let mut scan = Scan::new();
if let Some(filter) = self.filter_expression() {
scan = scan.filter(filter);
}
if let Some(projection) = Self::projection_expression() {
scan = scan.projection(projection)
}
if Self::CONSISTENT_READ {
scan = scan.consistent_read();
}
scan
}
}
#[derive(serde::Serialize)]
struct FullEntity<T: Entity> {
#[serde(flatten)]
keys: keys::FullKey<<T::Table as Table>::PrimaryKey, T::IndexKeys>,
#[serde(flatten)]
entity: T,
}
#[doc(hidden)]
pub mod __private {
#[cfg(not(feature = "once_cell"))]
pub type OnceLock<T> = std::sync::OnceLock<T>;
#[cfg(feature = "once_cell")]
pub type OnceLock<T> = once_cell::sync::OnceCell<T>;
pub fn get_entity_type<P: crate::Projection>(
item: &crate::Item,
) -> Result<&crate::EntityTypeNameRef, crate::Error> {
let entity_type_attr = item
.get(<<P::Entity as crate::Entity>::Table as crate::Table>::ENTITY_TYPE_ATTRIBUTE)
.ok_or(crate::error::MissingEntityTypeError {})?;
let entity_type =
<<P::Entity as crate::Entity>::Table as crate::Table>::deserialize_entity_type(
entity_type_attr,
)?;
Ok(entity_type)
}
pub fn generate_projection_expression<T: crate::Table>(
attributes: &[&[&str]],
) -> Option<crate::expr::StaticProjection> {
if !attributes.iter().all(|attrs| !attrs.is_empty()) {
return None;
}
let expr = crate::expr::Projection::new(
attributes
.iter()
.copied()
.flatten()
.copied()
.chain([T::ENTITY_TYPE_ATTRIBUTE]),
);
Some(expr.leak())
}
}
pub trait TestTableExt {
fn create_table(
&self,
) -> aws_sdk_dynamodb::operation::create_table::builders::CreateTableFluentBuilder;
fn delete_table(
&self,
) -> aws_sdk_dynamodb::operation::delete_table::builders::DeleteTableFluentBuilder;
}
impl<T> TestTableExt for T
where
T: Table,
{
fn create_table(
&self,
) -> aws_sdk_dynamodb::operation::create_table::builders::CreateTableFluentBuilder {
let definitions: std::collections::BTreeSet<_> =
<<Self as Table>::IndexKeys as keys::IndexKeys>::KEY_DEFINITIONS
.iter()
.copied()
.collect();
let mut builder = self
.client()
.create_table()
.set_table_name(Some(self.table_name().into()));
for definition in definitions {
let hash = aws_sdk_dynamodb::types::AttributeDefinition::builder()
.set_attribute_name(Some(definition.hash_key().into()))
.set_attribute_type(Some(aws_sdk_dynamodb::types::ScalarAttributeType::S))
.build()
.expect("attribute name and attribute type are always provided");
let mut key_schema = vec![aws_sdk_dynamodb::types::KeySchemaElement::builder()
.set_attribute_name(Some(definition.hash_key().into()))
.set_key_type(Some(aws_sdk_dynamodb::types::KeyType::Hash))
.build()
.expect("attribute name and key type are always provided")];
builder = builder.attribute_definitions(hash);
if let Some(range_key) = definition.range_key() {
let range = aws_sdk_dynamodb::types::AttributeDefinition::builder()
.set_attribute_name(Some(range_key.into()))
.set_attribute_type(Some(aws_sdk_dynamodb::types::ScalarAttributeType::S))
.build()
.expect("attribute name and attribute type are always provided");
key_schema.push(
aws_sdk_dynamodb::types::KeySchemaElement::builder()
.set_attribute_name(Some(range_key.into()))
.set_key_type(Some(aws_sdk_dynamodb::types::KeyType::Range))
.build()
.expect("attribute name and key type are always provided"),
);
builder = builder.attribute_definitions(range)
}
let gsi = aws_sdk_dynamodb::types::GlobalSecondaryIndex::builder()
.set_index_name(Some(definition.index_name().into()))
.set_projection(Some(
aws_sdk_dynamodb::types::Projection::builder()
.set_projection_type(Some(aws_sdk_dynamodb::types::ProjectionType::All))
.build(),
))
.set_key_schema(Some(key_schema))
.build()
.expect("index name and key schema are always provided");
builder = builder.global_secondary_indexes(gsi);
}
let primary_key_definition =
<<Self as Table>::PrimaryKey as keys::PrimaryKey>::PRIMARY_KEY_DEFINITION;
let hash = aws_sdk_dynamodb::types::AttributeDefinition::builder()
.set_attribute_name(Some(primary_key_definition.hash_key.into()))
.set_attribute_type(Some(aws_sdk_dynamodb::types::ScalarAttributeType::S))
.build()
.expect("attribute name and attribute type are always provided");
let mut key_schema = vec![aws_sdk_dynamodb::types::KeySchemaElement::builder()
.set_attribute_name(Some(primary_key_definition.hash_key.into()))
.set_key_type(Some(aws_sdk_dynamodb::types::KeyType::Hash))
.build()
.expect("attribute name and key type are always provided")];
builder = builder.attribute_definitions(hash);
if let Some(range_key) = primary_key_definition.range_key {
let range = aws_sdk_dynamodb::types::AttributeDefinition::builder()
.set_attribute_name(Some(range_key.into()))
.set_attribute_type(Some(aws_sdk_dynamodb::types::ScalarAttributeType::S))
.build()
.expect("attribute name and attribute type are always provided");
key_schema.push(
aws_sdk_dynamodb::types::KeySchemaElement::builder()
.set_attribute_name(Some(range_key.into()))
.set_key_type(Some(aws_sdk_dynamodb::types::KeyType::Range))
.build()
.expect("attribute name and key type are always provided"),
);
builder = builder.attribute_definitions(range)
}
builder
.set_key_schema(Some(key_schema))
.billing_mode(aws_sdk_dynamodb::types::BillingMode::PayPerRequest)
}
fn delete_table(
&self,
) -> aws_sdk_dynamodb::operation::delete_table::builders::DeleteTableFluentBuilder {
self.client()
.delete_table()
.set_table_name(Some(self.table_name().into()))
}
}
#[cfg(test)]
mod tests {
use super::*;
mod standard {
use super::*;
struct TestTable;
impl Table for TestTable {
type PrimaryKey = keys::Primary;
type IndexKeys = keys::Gsi13;
fn client(&self) -> &aws_sdk_dynamodb::Client {
unimplemented!()
}
fn table_name(&self) -> &str {
unimplemented!()
}
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
struct TestEntity {
id: String,
name: String,
email: String,
}
impl EntityDef for TestEntity {
const ENTITY_TYPE: &'static EntityTypeNameRef =
EntityTypeNameRef::from_static("test_ent");
}
impl Entity for TestEntity {
type KeyInput<'a> = (&'a str, &'a str);
type Table = TestTable;
type IndexKeys = keys::Gsi13;
fn primary_key((id, email): Self::KeyInput<'_>) -> keys::Primary {
keys::Primary {
hash: format!("PK#{id}"),
range: format!("NAME#{email}"),
}
}
fn full_key(&self) -> keys::FullKey<keys::Primary, Self::IndexKeys> {
keys::FullKey {
primary: Self::primary_key((&self.id, &self.email)),
indexes: keys::Gsi13 {
hash: format!("GSI13#{}", self.id),
range: format!("GSI13#NAME#{}", self.name),
},
}
}
}
#[test]
fn test_entity_serializes_as_expected() {
let entity = TestEntity {
id: "test1".to_string(),
name: "Test".to_string(),
email: "my_email@not_real.com".to_string(),
};
let item = entity.into_item();
assert_eq!(item.len(), 8);
assert_eq!(item["entity_type"].as_s().unwrap(), "test_ent");
assert_eq!(item["PK"].as_s().unwrap(), "PK#test1");
assert_eq!(item["SK"].as_s().unwrap(), "NAME#my_email@not_real.com");
assert_eq!(item["GSI13PK"].as_s().unwrap(), "GSI13#test1");
assert_eq!(item["GSI13SK"].as_s().unwrap(), "GSI13#NAME#Test");
assert_eq!(item["id"].as_s().unwrap(), "test1");
assert_eq!(item["name"].as_s().unwrap(), "Test");
assert_eq!(item["email"].as_s().unwrap(), "my_email@not_real.com");
}
#[test]
fn test_entity_deserializes_as_expected() {
let entity = TestEntity {
id: "test1".to_string(),
name: "Test".to_string(),
email: "my_email@not_real.com".to_string(),
};
let item = entity.clone().into_item();
let entity_type = TestTable::deserialize_entity_type(&item["entity_type"])
.unwrap()
.to_owned();
let clone = TestEntity::from_item(item).unwrap();
assert_eq!(entity, clone);
assert_eq!(entity_type, TestEntity::ENTITY_TYPE);
}
}
mod as_string_set {
use super::*;
struct TestTable;
impl Table for TestTable {
type PrimaryKey = keys::Primary;
type IndexKeys = keys::Gsi13;
fn client(&self) -> &aws_sdk_dynamodb::Client {
unimplemented!()
}
fn table_name(&self) -> &str {
unimplemented!()
}
fn deserialize_entity_type(
attr: &AttributeValue,
) -> Result<&EntityTypeNameRef, MalformedEntityTypeError> {
let values = attr.as_ss().map_err(|_| {
MalformedEntityTypeError::Custom("expected a string set".into())
})?;
let value = values
.first()
.expect("a DynamoDB string set always has at least one element");
Ok(EntityTypeNameRef::from_str(value.as_str()))
}
fn serialize_entity_type(entity_type: &EntityTypeNameRef) -> AttributeValue {
AttributeValue::Ss(vec![entity_type.to_string()])
}
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
struct TestEntity {
id: String,
name: String,
email: String,
}
impl EntityDef for TestEntity {
const ENTITY_TYPE: &'static EntityTypeNameRef =
EntityTypeNameRef::from_static("test_ent");
}
impl Entity for TestEntity {
type KeyInput<'a> = (&'a str, &'a str);
type Table = TestTable;
type IndexKeys = keys::Gsi13;
fn primary_key((id, email): Self::KeyInput<'_>) -> keys::Primary {
keys::Primary {
hash: format!("PK#{id}"),
range: format!("NAME#{email}"),
}
}
fn full_key(&self) -> keys::FullKey<keys::Primary, Self::IndexKeys> {
keys::FullKey {
primary: Self::primary_key((&self.id, &self.email)),
indexes: keys::Gsi13 {
hash: format!("GSI13#{}", self.id),
range: format!("GSI13#NAME#{}", self.name),
},
}
}
}
#[test]
fn test_entity_serializes_as_expected() {
let entity = TestEntity {
id: "test1".to_string(),
name: "Test".to_string(),
email: "my_email@not_real.com".to_string(),
};
let item = entity.into_item();
assert_eq!(item.len(), 8);
assert_eq!(
item["entity_type"].as_ss().unwrap(),
&["test_ent".to_owned()]
);
assert_eq!(item["PK"].as_s().unwrap(), "PK#test1");
assert_eq!(item["SK"].as_s().unwrap(), "NAME#my_email@not_real.com");
assert_eq!(item["GSI13PK"].as_s().unwrap(), "GSI13#test1");
assert_eq!(item["GSI13SK"].as_s().unwrap(), "GSI13#NAME#Test");
assert_eq!(item["id"].as_s().unwrap(), "test1");
assert_eq!(item["name"].as_s().unwrap(), "Test");
assert_eq!(item["email"].as_s().unwrap(), "my_email@not_real.com");
}
#[test]
fn test_entity_deserializes_as_expected() {
let entity = TestEntity {
id: "test1".to_string(),
name: "Test".to_string(),
email: "my_email@not_real.com".to_string(),
};
let item = entity.clone().into_item();
let entity_type = TestTable::deserialize_entity_type(&item["entity_type"])
.unwrap()
.to_owned();
let clone = TestEntity::from_item(item).unwrap();
assert_eq!(entity, clone);
assert_eq!(entity_type, TestEntity::ENTITY_TYPE);
}
}
mod alternate_attribute {
use super::*;
struct TestTable;
impl Table for TestTable {
const ENTITY_TYPE_ATTRIBUTE: &'static str = "et";
type PrimaryKey = keys::Primary;
type IndexKeys = keys::Gsi13;
fn client(&self) -> &aws_sdk_dynamodb::Client {
unimplemented!()
}
fn table_name(&self) -> &str {
unimplemented!()
}
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
struct TestEntity {
id: String,
name: String,
email: String,
}
impl EntityDef for TestEntity {
const ENTITY_TYPE: &'static EntityTypeNameRef =
EntityTypeNameRef::from_static("test_ent");
}
impl Entity for TestEntity {
type KeyInput<'a> = (&'a str, &'a str);
type Table = TestTable;
type IndexKeys = keys::Gsi13;
fn primary_key((id, email): Self::KeyInput<'_>) -> keys::Primary {
keys::Primary {
hash: format!("PK#{id}"),
range: format!("NAME#{email}"),
}
}
fn full_key(&self) -> keys::FullKey<keys::Primary, Self::IndexKeys> {
keys::FullKey {
primary: Self::primary_key((&self.id, &self.email)),
indexes: keys::Gsi13 {
hash: format!("GSI13#{}", self.id),
range: format!("GSI13#NAME#{}", self.name),
},
}
}
}
#[test]
fn test_entity_serializes_as_expected() {
let entity = TestEntity {
id: "test1".to_string(),
name: "Test".to_string(),
email: "my_email@not_real.com".to_string(),
};
let item = entity.into_item();
assert_eq!(item.len(), 8);
assert_eq!(item["et"].as_s().unwrap(), "test_ent");
assert_eq!(item["PK"].as_s().unwrap(), "PK#test1");
assert_eq!(item["SK"].as_s().unwrap(), "NAME#my_email@not_real.com");
assert_eq!(item["GSI13PK"].as_s().unwrap(), "GSI13#test1");
assert_eq!(item["GSI13SK"].as_s().unwrap(), "GSI13#NAME#Test");
assert_eq!(item["id"].as_s().unwrap(), "test1");
assert_eq!(item["name"].as_s().unwrap(), "Test");
assert_eq!(item["email"].as_s().unwrap(), "my_email@not_real.com");
}
#[test]
fn test_entity_deserializes_as_expected() {
let entity = TestEntity {
id: "test1".to_string(),
name: "Test".to_string(),
email: "my_email@not_real.com".to_string(),
};
let item = entity.clone().into_item();
let entity_type = TestTable::deserialize_entity_type(&item["et"])
.unwrap()
.to_owned();
let clone = TestEntity::from_item(item).unwrap();
assert_eq!(entity, clone);
assert_eq!(entity_type, TestEntity::ENTITY_TYPE);
}
}
}