Skip to main content

dynoxide/actions/
batch_get_item.rs

1use crate::actions::helpers;
2use crate::errors::{DynoxideError, Result};
3use crate::expressions;
4use crate::storage_backend::StorageBackend;
5use crate::types::{AttributeValue, Item};
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9#[derive(Debug, Default, Deserialize)]
10pub struct BatchGetItemRequest {
11    #[serde(rename = "RequestItems")]
12    pub request_items: HashMap<String, KeysAndAttributes>,
13    #[serde(rename = "ReturnConsumedCapacity", default)]
14    pub return_consumed_capacity: Option<String>,
15}
16
17#[derive(Debug, Default, Deserialize)]
18pub struct KeysAndAttributes {
19    #[serde(rename = "Keys")]
20    pub keys: Vec<HashMap<String, AttributeValue>>,
21    #[serde(rename = "ProjectionExpression", default)]
22    pub projection_expression: Option<String>,
23    #[serde(rename = "ExpressionAttributeNames", default)]
24    pub expression_attribute_names: Option<HashMap<String, String>>,
25    #[serde(rename = "ConsistentRead", default)]
26    pub consistent_read: Option<bool>,
27    #[serde(rename = "AttributesToGet", default)]
28    pub attributes_to_get: Option<Vec<String>>,
29}
30
31#[derive(Debug, Default, Serialize)]
32pub struct BatchGetItemResponse {
33    #[serde(rename = "Responses")]
34    pub responses: HashMap<String, Vec<Item>>,
35    #[serde(rename = "UnprocessedKeys")]
36    pub unprocessed_keys: HashMap<String, serde_json::Value>,
37    #[serde(rename = "ConsumedCapacity", skip_serializing_if = "Option::is_none")]
38    pub consumed_capacity: Option<Vec<crate::types::ConsumedCapacity>>,
39}
40
41pub async fn execute<S: StorageBackend>(
42    storage: &S,
43    request: BatchGetItemRequest,
44) -> Result<BatchGetItemResponse> {
45    // Validate RequestItems is not empty.
46    // AWS routes the empty-map case through a separate parameter-required path
47    // rather than the standard "N validation errors detected" envelope.
48    if request.request_items.is_empty() {
49        return Err(DynoxideError::ValidationException(
50            "The requestItems parameter is required for BatchGetItem".to_string(),
51        ));
52    }
53
54    // Validate each table entry has at least one key
55    for (table_name, ka) in &request.request_items {
56        if ka.keys.is_empty() {
57            return Err(DynoxideError::ValidationException(format!(
58                "1 validation error detected: Value at 'requestItems.{table_name}.member.keys' failed to satisfy constraint: Member must have length greater than or equal to 1"
59            )));
60        }
61    }
62
63    // Validate table name format for all tables before checking existence
64    for table_name in request.request_items.keys() {
65        crate::validation::validate_table_name(table_name)?;
66    }
67
68    // Validate total key count.
69    // AWS surfaces this as the standard "1 validation error detected" envelope
70    // with the field path pinned to one of the request's tables. The conformance
71    // suite exercises a single-table case; for multi-table requests we pick the
72    // table with the largest Keys array (and, on ties, fall through to whichever
73    // HashMap iteration yields first) so the field path lines up with the table
74    // that pushed the total over.
75    let total_keys: usize = request.request_items.values().map(|ka| ka.keys.len()).sum();
76    if total_keys > 100 {
77        let table_name = request
78            .request_items
79            .iter()
80            .max_by_key(|(_, ka)| ka.keys.len())
81            .map(|(name, _)| name.as_str())
82            .unwrap_or("");
83        return Err(DynoxideError::ValidationException(format!(
84            "1 validation error detected: Value at 'RequestItems.{table_name}.member.Keys' failed to satisfy constraint: Member must have length less than or equal to 100"
85        )));
86    }
87
88    // --- Pre-table validations ---
89    // DynamoDB validates expression attributes, key values, projections, and duplicates
90    // BEFORE checking table existence. Perform these checks first.
91
92    // Reject a request that pairs an expression projection with a non-expression
93    // AttributesToGet, whether in the same block or across blocks. AWS rejects the
94    // request as a whole even when each block is internally consistent.
95    // Order-independent over the request map.
96    let any_projection = request
97        .request_items
98        .values()
99        .any(|ka| ka.projection_expression.is_some());
100    let any_attributes_to_get = request
101        .request_items
102        .values()
103        .any(|ka| ka.attributes_to_get.is_some());
104    if any_projection && any_attributes_to_get {
105        return Err(DynoxideError::ValidationException(
106            "Can not use both expression and non-expression parameters in the same request: Non-expression parameters: {AttributesToGet} Expression parameters: {ProjectionExpression}".to_string(),
107        ));
108    }
109
110    for keys_and_attrs in request.request_items.values() {
111        let has_projection_expr = keys_and_attrs.projection_expression.is_some();
112        let has_expr_attr_names = keys_and_attrs.expression_attribute_names.is_some();
113
114        // ExpressionAttributeNames without expression
115        if has_expr_attr_names && !has_projection_expr {
116            return Err(DynoxideError::ValidationException(
117                "ExpressionAttributeNames can only be specified when using expressions".to_string(),
118            ));
119        }
120
121        // Empty ExpressionAttributeNames
122        if let Some(ref ean) = keys_and_attrs.expression_attribute_names {
123            if ean.is_empty() {
124                return Err(DynoxideError::ValidationException(
125                    "ExpressionAttributeNames must not be empty".to_string(),
126                ));
127            }
128            // Invalid EAN keys (must start with #)
129            for key in ean.keys() {
130                if !key.starts_with('#') {
131                    return Err(DynoxideError::ValidationException(format!(
132                        "ExpressionAttributeNames contains invalid key: Syntax error; key: \"{key}\""
133                    )));
134                }
135            }
136        }
137
138        // Empty ProjectionExpression
139        if let Some(ref pe) = keys_and_attrs.projection_expression {
140            if pe.is_empty() {
141                return Err(DynoxideError::ValidationException(
142                    "Invalid ProjectionExpression: The expression can not be empty;".to_string(),
143                ));
144            }
145        }
146
147        // Duplicate AttributesToGet check (must come before duplicate keys check)
148        if let Some(ref atg) = keys_and_attrs.attributes_to_get {
149            let mut seen = std::collections::HashSet::new();
150            for attr in atg {
151                if !seen.insert(attr.as_str()) {
152                    return Err(DynoxideError::ValidationException(format!(
153                        "One or more parameter values were invalid: Duplicate value in attribute name: {attr}"
154                    )));
155                }
156            }
157        }
158
159        // Validate key attribute values (empty attrs, invalid numbers, etc.)
160        for key in &keys_and_attrs.keys {
161            crate::validation::validate_item_attribute_values(key)?;
162        }
163
164        // Duplicate keys check
165        if keys_and_attrs.keys.len() > 1 {
166            let serialised: Vec<String> = keys_and_attrs
167                .keys
168                .iter()
169                .map(|k| {
170                    let mut pairs: Vec<_> = k.iter().map(|(k, v)| format!("{k}={v:?}")).collect();
171                    pairs.sort();
172                    pairs.join(",")
173                })
174                .collect();
175            let mut seen = std::collections::HashSet::new();
176            for s in &serialised {
177                if !seen.insert(s) {
178                    return Err(DynoxideError::ValidationException(
179                        "Provided list of item keys contains duplicates".to_string(),
180                    ));
181                }
182            }
183        }
184    }
185
186    const MAX_RESPONSE_SIZE: usize = 16 * 1024 * 1024; // 16MB
187
188    let mut responses: HashMap<String, Vec<Item>> = HashMap::new();
189    let mut unprocessed_keys: HashMap<String, serde_json::Value> = HashMap::new();
190    let mut cumulative_size: usize = 0;
191    let mut size_limit_reached = false;
192    // Track per-key RCU for ConsumedCapacity (uses full item size, not projected)
193    let mut table_rcu: HashMap<String, f64> = HashMap::new();
194
195    for (table_name, keys_and_attrs) in &request.request_items {
196        let meta = helpers::require_table_for_item_op(storage, table_name).await?;
197        let key_schema = helpers::parse_key_schema(&meta)?;
198
199        // Parse projection if present; also handle legacy AttributesToGet
200        let projection = if let Some(ref expr) = keys_and_attrs.projection_expression {
201            Some(expressions::projection::parse(expr).map_err(DynoxideError::ValidationException)?)
202        } else {
203            keys_and_attrs
204                .attributes_to_get
205                .as_ref()
206                .map(|attrs| crate::actions::helpers::attributes_to_get_to_projection(attrs))
207        };
208
209        let tracker = crate::expressions::TrackedExpressionAttributes::new(
210            &keys_and_attrs.expression_attribute_names,
211            &None, // BatchGetItem has no ExpressionAttributeValues
212        );
213
214        // Pre-register projection expression references
215        if let Some(ref proj) = projection {
216            tracker.track_projection_expr(proj);
217            // Reject undefined names and overlapping paths before reading.
218            expressions::projection::validate(proj, &tracker)
219                .map_err(DynoxideError::ValidationException)?;
220        }
221
222        // BatchGetItem does NOT automatically include key attributes in projections.
223        let key_attrs = Vec::new();
224
225        let consistent = keys_and_attrs.consistent_read.unwrap_or(false);
226        let mut table_items = Vec::new();
227        let mut remaining_keys: Vec<HashMap<String, AttributeValue>> = Vec::new();
228        let mut per_table_rcu: f64 = 0.0;
229
230        for key in &keys_and_attrs.keys {
231            if size_limit_reached {
232                remaining_keys.push(key.clone());
233                continue;
234            }
235
236            helpers::validate_key_only(key, &key_schema)?;
237            // TODO: validation must precede this call -- if reaching this line, caller has already validated keys.
238            let (pk, sk) = helpers::extract_key_strings(key, &key_schema)?;
239
240            if let Some(item_json) = storage.get_item(table_name, &pk, &sk).await? {
241                let item: Item = serde_json::from_str(&item_json).map_err(|e| {
242                    DynoxideError::InternalServerError(format!("Bad item JSON: {e}"))
243                })?;
244
245                // Use full item size for both capacity and response limit
246                let item_size = crate::types::item_size(&item);
247
248                if cumulative_size + item_size > MAX_RESPONSE_SIZE {
249                    size_limit_reached = true;
250                    remaining_keys.push(key.clone());
251                    continue;
252                }
253
254                cumulative_size += item_size;
255
256                // RCU is based on full item size, not projected size
257                per_table_rcu +=
258                    crate::types::read_capacity_units_with_consistency(item_size, consistent);
259
260                let result_item = if let Some(ref proj) = projection {
261                    expressions::projection::apply(&item, proj, &tracker, &key_attrs)
262                        .map_err(DynoxideError::ValidationException)?
263                } else {
264                    item
265                };
266
267                table_items.push(result_item);
268            } else {
269                // DynamoDB charges for the read attempt even if the item is not found
270                per_table_rcu += crate::types::read_capacity_units_with_consistency(0, consistent);
271            }
272        }
273
274        // Check for unused expression attribute names
275        tracker.check_unused()?;
276
277        table_rcu.insert(table_name.clone(), per_table_rcu);
278        responses.insert(table_name.clone(), table_items);
279
280        if !remaining_keys.is_empty() {
281            let mut unprocessed = serde_json::json!({
282                "Keys": remaining_keys,
283            });
284            // Preserve original request settings so the caller can retry
285            // without losing projection or consistency configuration.
286            if let Some(ref pe) = keys_and_attrs.projection_expression {
287                unprocessed["ProjectionExpression"] = serde_json::json!(pe);
288            }
289            if let Some(ref ean) = keys_and_attrs.expression_attribute_names {
290                unprocessed["ExpressionAttributeNames"] = serde_json::json!(ean);
291            }
292            if let Some(cr) = keys_and_attrs.consistent_read {
293                unprocessed["ConsistentRead"] = serde_json::json!(cr);
294            }
295            unprocessed_keys.insert(table_name.clone(), unprocessed);
296        }
297    }
298
299    // Build consumed capacity per table
300    let consumed_capacity = if matches!(
301        request.return_consumed_capacity.as_deref(),
302        Some("TOTAL") | Some("INDEXES")
303    ) {
304        let mut caps = Vec::new();
305        for table_name in request.request_items.keys() {
306            let total_rcu = table_rcu.get(table_name).copied().unwrap_or(0.0);
307            if let Some(cc) = crate::types::consumed_capacity(
308                table_name,
309                total_rcu,
310                &request.return_consumed_capacity,
311            ) {
312                caps.push(cc);
313            }
314        }
315        Some(caps)
316    } else {
317        None
318    };
319
320    Ok(BatchGetItemResponse {
321        responses,
322        unprocessed_keys,
323        consumed_capacity,
324    })
325}