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
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}