dynamodb_facade/expressions/projections.rs
1use core::fmt;
2use std::{borrow::Cow, collections::BTreeSet, marker::PhantomData};
3
4use super::{
5 ApplyExpressionNames, ApplyProjection, AttrNames, Expression, ProjectionableBuilder,
6 fmt_attr_maps, resolve_expression, utils::resolve_attr_path,
7};
8use crate::{
9 AttributeDefinition, CompositeKey, CompositeKeySchema, KeySchema, KeySchemaKind, SimpleKey,
10 SimpleKeySchema, TableDefinition,
11};
12
13// ---------------------------------------------------------------------------
14// Composable Projection type
15// ---------------------------------------------------------------------------
16
17#[derive(Debug, Clone)]
18struct BuiltProjection {
19 expression: Expression,
20 names: AttrNames,
21}
22
23/// Projection expression builder that automatically includes the table's key attributes.
24///
25/// `Projection<'a, TD>` builds a DynamoDB `ProjectionExpression` that limits
26/// which attributes are returned by a Get or Query/Scan operation. It always
27/// includes the table's partition key (and sort key for composite-key tables)
28/// so that the resulting [`Item<TD>`](crate::Item) upholds its invariant of
29/// always containing the key attributes.
30///
31/// Attribute names that are DynamoDB reserved words are automatically escaped
32/// with `#` expression attribute name placeholders.
33///
34/// # Examples
35///
36/// Projecting a subset of user attributes:
37///
38/// ```
39/// # use dynamodb_facade::test_fixtures::*;
40/// use dynamodb_facade::Projection;
41///
42/// // Request only "name" and "email" — PK and SK are added automatically.
43/// let proj = Projection::<PlatformTable>::new(["name", "email"]);
44///
45/// // The rendered expression includes PK, SK, and the requested fields.
46/// let rendered = format!("{proj}");
47/// assert_eq!(format!("{proj}"), "PK,SK,email,name");
48/// let rendered_with_placeholders = format!("{proj:#}");
49/// assert!(rendered_with_placeholders.contains("#p0 = name"));
50/// ```
51#[derive(Debug, Clone)]
52#[must_use = "expression does nothing until applied to a request"]
53pub struct Projection<'a, TD> {
54 attrs: BTreeSet<Cow<'a, str>>,
55 _marker: PhantomData<TD>,
56}
57
58// -- Constructor --------------------------------------------------------------
59
60impl<'a, TD: TableDefinition> Projection<'a, TD>
61where
62 Self: key_schema_projection::KeySchemaProjection<
63 'a,
64 TD::KeySchema,
65 <TD::KeySchema as KeySchema>::Kind,
66 >,
67{
68 /// Creates a projection from an iterator of attribute names.
69 ///
70 /// The table's key attributes (PK, and SK for composite-key tables) are
71 /// **always** prepended to the provided list, ensuring the resulting
72 /// [`Item<TD>`](crate::Item) is always valid for the table schema.
73 ///
74 /// Duplicate attribute names are deduplicated automatically.
75 ///
76 /// # Examples
77 ///
78 /// ```
79 /// # use dynamodb_facade::test_fixtures::*;
80 /// use dynamodb_facade::Projection;
81 ///
82 /// // Project "name" and "email"; PK + SK are added automatically.
83 /// let proj = Projection::<PlatformTable>::new(["name", "email"]);
84 ///
85 /// let rendered = format!("{proj}");
86 /// assert!(rendered.contains("PK"));
87 /// assert!(rendered.contains("SK"));
88 /// assert!(rendered.contains("name"));
89 /// assert!(rendered.contains("email"));
90 /// let rendered_with_placeholders = format!("{proj:#}");
91 /// assert!(rendered_with_placeholders.contains("#p0 = name"));
92 /// ```
93 pub fn new(attrs: impl IntoIterator<Item = impl Into<Cow<'a, str>>>) -> Self {
94 Self {
95 attrs: <Self as key_schema_projection::KeySchemaProjection<
96 'a,
97 TD::KeySchema,
98 <TD::KeySchema as KeySchema>::Kind,
99 >>::key_schema_names()
100 .chain(attrs.into_iter().map(Into::into))
101 .collect(),
102 _marker: PhantomData,
103 }
104 }
105}
106
107mod key_schema_projection {
108 //! Provides key attribute names to seed every [`Projection`], ensuring key attributes
109 //! are always included regardless of the caller-supplied attribute list.
110 use super::*;
111
112 /// Returns the key attribute names that must always be present in a projection.
113 pub trait KeySchemaProjection<'a, KS: KeySchema, KSK: KeySchemaKind> {
114 fn key_schema_names() -> impl Iterator<Item = Cow<'a, str>>;
115 }
116
117 impl<'a, TD: TableDefinition> KeySchemaProjection<'a, TD::KeySchema, SimpleKey>
118 for Projection<'a, TD>
119 where
120 TD::KeySchema: SimpleKeySchema,
121 {
122 fn key_schema_names() -> impl Iterator<Item = Cow<'a, str>> {
123 [<<TD::KeySchema as KeySchema>::PartitionKey as AttributeDefinition>::NAME.into()]
124 .into_iter()
125 }
126 }
127 impl<'a, TD: TableDefinition> KeySchemaProjection<'a, TD::KeySchema, CompositeKey>
128 for Projection<'a, TD>
129 where
130 TD::KeySchema: CompositeKeySchema,
131 {
132 fn key_schema_names() -> impl Iterator<Item = Cow<'a, str>> {
133 [
134 <<TD::KeySchema as KeySchema>::PartitionKey as AttributeDefinition>::NAME.into(),
135 <<TD::KeySchema as CompositeKeySchema>::SortKey as AttributeDefinition>::NAME
136 .into(),
137 ]
138 .into_iter()
139 }
140 }
141}
142
143// -- Internal build machinery -------------------------------------------------
144
145impl<TD> Projection<'_, TD> {
146 /// Resolves all attribute paths and assembles the projection expression string.
147 fn build(&self) -> BuiltProjection {
148 let mut counter = 0;
149 let mut all_names = Vec::new();
150 let mut resolved_parts = Vec::new();
151
152 for attr in &self.attrs {
153 let (expr, names) = resolve_attr_path(attr, "p", &mut counter);
154 all_names.extend(names);
155 resolved_parts.push(expr.into_owned());
156 }
157
158 BuiltProjection {
159 expression: resolved_parts.join(","),
160 names: all_names,
161 }
162 }
163}
164
165// -- Display ------------------------------------------------------------------
166
167impl<TD> fmt::Display for Projection<'_, TD> {
168 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
169 let built = self.build();
170 if built.expression.is_empty() {
171 return f.write_str("<none>");
172 }
173 let no_values = vec![];
174 if f.alternate() {
175 f.write_str(&built.expression)?;
176 fmt_attr_maps(f, &built.names, &no_values)
177 } else {
178 f.write_str(&resolve_expression(
179 &built.expression,
180 &built.names,
181 &no_values,
182 ))
183 }
184 }
185}
186
187// -- ApplyProjection impl -----------------------------------------------------
188
189impl<TD, B: ProjectionableBuilder> ApplyProjection<B> for Projection<'_, TD> {
190 fn apply_projection(self, builder: B) -> B {
191 let built = self.build();
192 if built.expression.is_empty() {
193 return builder;
194 }
195 builder
196 .projection_expression(built.expression)
197 .apply_names(built.names)
198 }
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204 use crate::test_fixtures::*;
205
206 // Local simple-key table for tests that need a non-composite key schema.
207 crate::attribute_definitions! {
208 MyPk { "MyPk": crate::StringAttribute }
209 }
210 crate::table_definitions! {
211 SimpleTable {
212 type PartitionKey = MyPk;
213 fn table_name() -> String { "simple".to_owned() }
214 }
215 }
216
217 // -- Auto-inclusion + dedup -----------------------------------------------
218
219 #[test]
220 fn test_projection_new_simple_key_auto_includes_pk() {
221 // BTreeSet ordering (ASCII): uppercase before lowercase.
222 // "MyPk" < "email" < "name" → sorted: MyPk, email, name
223 let proj = Projection::<SimpleTable>::new(["name", "email"]);
224 let output = format!("{proj}");
225 assert_eq!(output, "MyPk,email,name");
226 }
227
228 #[test]
229 fn test_projection_new_composite_key_auto_includes_pk_and_sk() {
230 // BTreeSet ordering: "PK" < "SK" < "email" < "role"
231 let proj = Projection::<PlatformTable>::new(["email", "role"]);
232 let output = format!("{proj}");
233 assert_eq!(output, "PK,SK,email,role");
234 }
235
236 #[test]
237 fn test_projection_new_dedup_when_user_supplies_pk() {
238 // User supplies "PK" and "SK" explicitly — BTreeSet deduplicates them.
239 // Result should be identical to supplying only "email".
240 let proj = Projection::<PlatformTable>::new(["PK", "SK", "email"]);
241 let output = format!("{proj}");
242 assert_eq!(output, "PK,SK,email");
243 }
244
245 // -- Display --------------------------------------------------------------
246
247 #[test]
248 fn test_projection_display_default_with_reserved_word() {
249 // "Status" is a DynamoDB reserved word.
250 // BTreeSet ordering: "PK" < "SK" < "Status" < "email"
251 // Default mode resolves placeholders inline → "Status" appears as-is.
252 let proj = Projection::<PlatformTable>::new(["email", "Status"]);
253 let output = format!("{proj}");
254 assert_eq!(output, "PK,SK,Status,email");
255 }
256
257 #[test]
258 fn test_projection_display_alternate_with_reserved_word() {
259 // Alternate mode: raw expression with #p0 placeholder + name map.
260 // BTreeSet ordering: "PK" < "SK" < "Status" < "email"
261 // "Status" is reserved → replaced with #p0 (counter starts at 0,
262 // iterates BTreeSet in order: PK (not reserved), SK (not reserved),
263 // Status (reserved → #p0), email (not reserved)).
264 let proj = Projection::<PlatformTable>::new(["email", "Status"]);
265 let output = format!("{proj:#}");
266 assert_eq!(output, "PK,SK,#p0,email\n names: { #p0 = Status }");
267 }
268
269 // -- Empty projection -----------------------------------------------------
270
271 // NOTE: `Projection::new` always auto-inserts the table's key attributes
272 // (PK for simple-key tables, PK + SK for composite-key tables), so it is
273 // impossible to construct an empty `Projection` via the public constructor.
274 // The `<none>` branch in `Display` and the no-op path in `apply_projection`
275 // are only reachable if `attrs` is empty after `build()`, which cannot
276 // happen through `Projection::new`. No test is written for this branch
277 // because there is no public API to reach it.
278}