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 /// Creates a projection that contains **only** the table's key attributes.
107 ///
108 /// The result is a projection that includes exactly PK (for simple-key tables)
109 /// or PK + SK (for composite-key tables).
110 ///
111 /// This is useful when you want to list or scan matching items without
112 /// fetching any payload data — for example, collecting the keys of all
113 /// enrollments for a user before issuing a batch delete.
114 ///
115 /// # Examples
116 ///
117 /// ```
118 /// # use dynamodb_facade::test_fixtures::*;
119 /// use dynamodb_facade::Projection;
120 ///
121 /// // PlatformTable has composite key PK + SK — those are the only attributes returned.
122 /// let proj = Projection::<PlatformTable>::keys_only();
123 ///
124 /// let rendered = format!("{proj}");
125 /// assert_eq!(rendered, "PK,SK");
126 /// ```
127 pub fn keys_only() -> Self {
128 Self::new([] as [Cow<'a, str>; 0])
129 }
130}
131
132mod key_schema_projection {
133 //! Provides key attribute names to seed every [`Projection`], ensuring key attributes
134 //! are always included regardless of the caller-supplied attribute list.
135 use super::*;
136
137 /// Returns the key attribute names that must always be present in a projection.
138 pub trait KeySchemaProjection<'a, KS: KeySchema, KSK: KeySchemaKind> {
139 fn key_schema_names() -> impl Iterator<Item = Cow<'a, str>>;
140 }
141
142 impl<'a, TD: TableDefinition> KeySchemaProjection<'a, TD::KeySchema, SimpleKey>
143 for Projection<'a, TD>
144 where
145 TD::KeySchema: SimpleKeySchema,
146 {
147 fn key_schema_names() -> impl Iterator<Item = Cow<'a, str>> {
148 [<<TD::KeySchema as KeySchema>::PartitionKey as AttributeDefinition>::NAME.into()]
149 .into_iter()
150 }
151 }
152 impl<'a, TD: TableDefinition> KeySchemaProjection<'a, TD::KeySchema, CompositeKey>
153 for Projection<'a, TD>
154 where
155 TD::KeySchema: CompositeKeySchema,
156 {
157 fn key_schema_names() -> impl Iterator<Item = Cow<'a, str>> {
158 [
159 <<TD::KeySchema as KeySchema>::PartitionKey as AttributeDefinition>::NAME.into(),
160 <<TD::KeySchema as CompositeKeySchema>::SortKey as AttributeDefinition>::NAME
161 .into(),
162 ]
163 .into_iter()
164 }
165 }
166}
167
168// -- Internal build machinery -------------------------------------------------
169
170impl<TD> Projection<'_, TD> {
171 /// Resolves all attribute paths and assembles the projection expression string.
172 fn build(&self) -> BuiltProjection {
173 let mut counter = 0;
174 let mut all_names = Vec::new();
175 let mut resolved_parts = Vec::new();
176
177 for attr in &self.attrs {
178 let (expr, names) = resolve_attr_path(attr, "p", &mut counter);
179 all_names.extend(names);
180 resolved_parts.push(expr.into_owned());
181 }
182
183 BuiltProjection {
184 expression: resolved_parts.join(","),
185 names: all_names,
186 }
187 }
188}
189
190// -- Display ------------------------------------------------------------------
191
192impl<TD> fmt::Display for Projection<'_, TD> {
193 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
194 let built = self.build();
195 if built.expression.is_empty() {
196 return f.write_str("<none>");
197 }
198 let no_values = vec![];
199 if f.alternate() {
200 f.write_str(&built.expression)?;
201 fmt_attr_maps(f, &built.names, &no_values)
202 } else {
203 f.write_str(&resolve_expression(
204 &built.expression,
205 &built.names,
206 &no_values,
207 ))
208 }
209 }
210}
211
212// -- ApplyProjection impl -----------------------------------------------------
213
214impl<TD, B: ProjectionableBuilder> ApplyProjection<B> for Projection<'_, TD> {
215 fn apply_projection(self, builder: B) -> B {
216 let built = self.build();
217 if built.expression.is_empty() {
218 return builder;
219 }
220 builder
221 .projection_expression(built.expression)
222 .apply_names(built.names)
223 }
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229 use crate::test_fixtures::*;
230
231 // Local simple-key table for tests that need a non-composite key schema.
232 crate::attribute_definitions! {
233 MyPk { "MyPk": crate::StringAttribute }
234 }
235 crate::table_definitions! {
236 SimpleTable {
237 type PartitionKey = MyPk;
238 fn table_name() -> String { "simple".to_owned() }
239 }
240 }
241
242 // -- Auto-inclusion + dedup -----------------------------------------------
243
244 #[test]
245 fn test_projection_new_simple_key_auto_includes_pk() {
246 // BTreeSet ordering (ASCII): uppercase before lowercase.
247 // "MyPk" < "email" < "name" → sorted: MyPk, email, name
248 let proj = Projection::<SimpleTable>::new(["name", "email"]);
249 let output = format!("{proj}");
250 assert_eq!(output, "MyPk,email,name");
251 }
252
253 #[test]
254 fn test_projection_new_composite_key_auto_includes_pk_and_sk() {
255 // BTreeSet ordering: "PK" < "SK" < "email" < "role"
256 let proj = Projection::<PlatformTable>::new(["email", "role"]);
257 let output = format!("{proj}");
258 assert_eq!(output, "PK,SK,email,role");
259 }
260
261 #[test]
262 fn test_projection_new_dedup_when_user_supplies_pk() {
263 // User supplies "PK" and "SK" explicitly — BTreeSet deduplicates them.
264 // Result should be identical to supplying only "email".
265 let proj = Projection::<PlatformTable>::new(["PK", "SK", "email"]);
266 let output = format!("{proj}");
267 assert_eq!(output, "PK,SK,email");
268 }
269
270 // -- Display --------------------------------------------------------------
271
272 #[test]
273 fn test_projection_display_default_with_reserved_word() {
274 // "Status" is a DynamoDB reserved word.
275 // BTreeSet ordering: "PK" < "SK" < "Status" < "email"
276 // Default mode resolves placeholders inline → "Status" appears as-is.
277 let proj = Projection::<PlatformTable>::new(["email", "Status"]);
278 let output = format!("{proj}");
279 assert_eq!(output, "PK,SK,Status,email");
280 }
281
282 #[test]
283 fn test_projection_display_alternate_with_reserved_word() {
284 // Alternate mode: raw expression with #p0 placeholder + name map.
285 // BTreeSet ordering: "PK" < "SK" < "Status" < "email"
286 // "Status" is reserved → replaced with #p0 (counter starts at 0,
287 // iterates BTreeSet in order: PK (not reserved), SK (not reserved),
288 // Status (reserved → #p0), email (not reserved)).
289 let proj = Projection::<PlatformTable>::new(["email", "Status"]);
290 let output = format!("{proj:#}");
291 assert_eq!(output, "PK,SK,#p0,email\n names: { #p0 = Status }");
292 }
293
294 // -- Empty projection -----------------------------------------------------
295
296 // NOTE: `Projection::new` always auto-inserts the table's key attributes
297 // (PK for simple-key tables, PK + SK for composite-key tables), so it is
298 // impossible to construct an empty `Projection` via the public constructor.
299 // The `<none>` branch in `Display` and the no-op path in `apply_projection`
300 // are only reachable if `attrs` is empty after `build()`, which cannot
301 // happen through `Projection::new`. No test is written for this branch
302 // because there is no public API to reach it.
303}