Skip to main content

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}