Skip to main content

dynoxide/actions/
scan.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/// 1MB response size limit for Query/Scan.
10const MAX_RESPONSE_SIZE: usize = 1_048_576;
11
12/// Internal deserialization struct for detecting missing fields.
13#[derive(Debug, Default, Deserialize)]
14struct ScanRequestRaw {
15    #[serde(rename = "TableName", default)]
16    table_name: Option<String>,
17    #[serde(rename = "FilterExpression", default)]
18    filter_expression: Option<String>,
19    #[serde(rename = "ProjectionExpression", default)]
20    projection_expression: Option<String>,
21    #[serde(rename = "ExpressionAttributeNames", default)]
22    expression_attribute_names: Option<HashMap<String, String>>,
23    #[serde(
24        rename = "ExpressionAttributeValues",
25        default,
26        deserialize_with = "crate::types::deserialize_older_expression_values"
27    )]
28    expression_attribute_values: Option<HashMap<String, AttributeValue>>,
29    #[serde(rename = "Limit", default)]
30    limit: Option<usize>,
31    #[serde(rename = "ExclusiveStartKey", default)]
32    exclusive_start_key: Option<serde_json::Value>,
33    #[serde(rename = "Select", default)]
34    select: Option<String>,
35    #[serde(
36        rename = "ConsistentRead",
37        default,
38        deserialize_with = "crate::types::deserialize_lenient_flag"
39    )]
40    consistent_read: Option<bool>,
41    #[serde(rename = "IndexName", default)]
42    index_name: Option<String>,
43    // Signed so a negative Segment deserialises and can be rejected with the
44    // DynamoDB ValidationException rather than a serde error. Cast to u32 after
45    // the >= 0 check below.
46    #[serde(rename = "Segment", default)]
47    segment: Option<i64>,
48    #[serde(rename = "TotalSegments", default)]
49    total_segments: Option<u32>,
50    #[serde(rename = "ReturnConsumedCapacity", default)]
51    return_consumed_capacity: Option<String>,
52    #[serde(rename = "AttributesToGet", default)]
53    attributes_to_get: Option<Vec<String>>,
54    #[serde(rename = "ScanFilter", default)]
55    scan_filter: Option<serde_json::Value>,
56    #[serde(rename = "ConditionalOperator", default)]
57    conditional_operator: Option<String>,
58}
59
60#[derive(Debug, Default)]
61pub struct ScanRequest {
62    pub table_name: String,
63    pub filter_expression: Option<String>,
64    pub projection_expression: Option<String>,
65    pub expression_attribute_names: Option<HashMap<String, String>>,
66    pub expression_attribute_values: Option<HashMap<String, AttributeValue>>,
67    pub limit: Option<usize>,
68    pub exclusive_start_key: Option<HashMap<String, AttributeValue>>,
69    pub select: Option<String>,
70    pub consistent_read: Option<bool>,
71    pub index_name: Option<String>,
72    pub segment: Option<u32>,
73    pub total_segments: Option<u32>,
74    pub return_consumed_capacity: Option<String>,
75    pub attributes_to_get: Option<Vec<String>>,
76    pub scan_filter: Option<serde_json::Value>,
77    pub conditional_operator: Option<String>,
78    /// Raw JSON for ExclusiveStartKey when deserialized from HTTP request.
79    /// Parsed lazily in `execute()` after other validations run.
80    pub exclusive_start_key_raw: Option<serde_json::Value>,
81}
82
83impl<'de> serde::Deserialize<'de> for ScanRequest {
84    fn deserialize<D: serde::Deserializer<'de>>(
85        deserializer: D,
86    ) -> std::result::Result<Self, D::Error> {
87        let raw = ScanRequestRaw::deserialize(deserializer)?;
88        use crate::validation::format_validation_errors;
89
90        // A BOOL or NULL in a legacy condition, or its `Exists`, that doesn't
91        // read as a boolean makes the whole request malformed, ahead of
92        // everything else.
93        if let Some(message) = crate::types::malformed_condition_boolean(raw.scan_filter.as_ref()) {
94            return Err(serde::de::Error::custom(message));
95        }
96
97        // Scan still runs on the older validation layer in most regions (six
98        // of seven, 2026-10-09). Its member errors come back together in the
99        // order Select, ReturnConsumedCapacity, ConditionalOperator, Limit,
100        // Segment, with a missing or empty table name last. Any other table
101        // name error is reported only when none of those fired.
102        let mut errors = Vec::new();
103
104        // Select enum
105        if let Some(ref sel) = raw.select
106            && ![
107                "ALL_ATTRIBUTES",
108                "ALL_PROJECTED_ATTRIBUTES",
109                "COUNT",
110                "SPECIFIC_ATTRIBUTES",
111            ]
112            .contains(&sel.as_str())
113        {
114            errors.push(format!(
115                "Value '{}' at 'select' failed to satisfy constraint: \
116                 Member must satisfy enum value set: [SPECIFIC_ATTRIBUTES, COUNT, ALL_ATTRIBUTES, ALL_PROJECTED_ATTRIBUTES]",
117                sel
118            ));
119        }
120
121        // ReturnConsumedCapacity enum
122        if let Some(msg) = crate::validation::return_consumed_capacity_rejection(
123            raw.return_consumed_capacity.as_deref(),
124        ) {
125            errors.push(msg);
126        }
127
128        // ConditionalOperator enum, which only this layer checks.
129        if let Some(msg) =
130            crate::validation::conditional_operator_rejection(raw.conditional_operator.as_deref())
131        {
132            errors.push(msg);
133        }
134
135        // Limit must be >= 1.
136        // AWS DynamoDB's Scan message diverges from Query: Scan keeps the rejected
137        // value and lowercases 'limit', whereas Query omits the value and uses
138        // capital 'Limit'. Do not collapse these into a shared helper.
139        if let Some(limit) = raw.limit
140            && limit == 0
141        {
142            errors.push(
143                "Value '0' at 'limit' failed to satisfy constraint: \
144                     Member must have value greater than or equal to 1"
145                    .to_string(),
146            );
147        }
148
149        // Segment must be >= 0. (Segment vs TotalSegments and the TotalSegments
150        // range are checked in execute().)
151        if let Some(segment) = raw.segment
152            && segment < 0
153        {
154            errors.push(format!(
155                "Value '{}' at 'segment' failed to satisfy constraint: \
156                     Member must have value greater than or equal to 0",
157                segment
158            ));
159        }
160
161        // A ScanFilter condition with no ComparisonOperator. Captured alone
162        // in four regions (2026-10-10); its place among the other member
163        // errors is inferred.
164        errors.extend(helpers::missing_comparison_operators(
165            raw.scan_filter.as_ref(),
166            "scanFilter",
167        ));
168
169        match raw.table_name.as_deref() {
170            None | Some("") => errors.extend(crate::validation::table_name_constraint_errors(
171                raw.table_name.as_deref(),
172                crate::validation::TableNameContext::ReadWrite,
173            )),
174            Some(_) => {}
175        }
176
177        if let Some(msg) = format_validation_errors(&errors) {
178            return Err(serde::de::Error::custom(format!("VALIDATION:{}", msg)));
179        }
180
181        if let Some(name) = raw.table_name.as_deref()
182            && let Some(msg) =
183                format_validation_errors(&crate::validation::scan_table_name_errors(name))
184        {
185            return Err(serde::de::Error::custom(format!("VALIDATION:{}", msg)));
186        }
187        let table_name = raw.table_name.unwrap_or_default();
188
189        Ok(ScanRequest {
190            table_name,
191            filter_expression: raw.filter_expression,
192            projection_expression: raw.projection_expression,
193            expression_attribute_names: raw.expression_attribute_names,
194            expression_attribute_values: raw.expression_attribute_values,
195            limit: raw.limit,
196            exclusive_start_key: None,
197            select: raw.select,
198            consistent_read: raw.consistent_read,
199            index_name: raw.index_name,
200            segment: raw.segment.map(|s| s as u32),
201            total_segments: raw.total_segments,
202            return_consumed_capacity: raw.return_consumed_capacity,
203            attributes_to_get: raw.attributes_to_get,
204            scan_filter: raw.scan_filter,
205            conditional_operator: raw.conditional_operator,
206            exclusive_start_key_raw: raw.exclusive_start_key,
207        })
208    }
209}
210
211#[derive(Debug, Default, Serialize)]
212pub struct ScanResponse {
213    #[serde(
214        rename = "Items",
215        skip_serializing_if = "Option::is_none",
216        serialize_with = "crate::set_order::items_in_hash_order"
217    )]
218    // Number sets in Java hash-set order here, as DynamoDB returns them;
219    // pinned by set_order's every_response_item_field test.
220    pub items: Option<Vec<Item>>,
221    #[serde(rename = "Count")]
222    pub count: usize,
223    #[serde(rename = "ScannedCount")]
224    pub scanned_count: usize,
225    #[serde(rename = "LastEvaluatedKey", skip_serializing_if = "Option::is_none")]
226    pub last_evaluated_key: Option<HashMap<String, AttributeValue>>,
227    #[serde(rename = "ConsumedCapacity", skip_serializing_if = "Option::is_none")]
228    pub consumed_capacity: Option<crate::types::ConsumedCapacity>,
229}
230
231pub async fn execute<S: StorageBackend>(
232    storage: &S,
233    mut request: ScanRequest,
234) -> Result<ScanResponse> {
235    // Validate table name format before checking existence (DynamoDB validates input first)
236    crate::validation::validate_scan_table_name(&request.table_name)?;
237
238    // ConditionalOperator outside its enum is a member check, reported ahead of
239    // the value checks. The wire path refuses it as the request is read; this
240    // covers a request an embedded caller builds (captured 2026-10-09, six of
241    // seven regions).
242    if let Some(msg) =
243        crate::validation::conditional_operator_rejection(request.conditional_operator.as_deref())
244    {
245        return Err(DynoxideError::ValidationException(
246            crate::validation::envelope_message(&msg),
247        ));
248    }
249
250    // An empty attribute name is refused ahead of the table lookup and the
251    // expressions (captured in ten regions). AttributesToGet and a ScanFilter
252    // value answer bare; the ExclusiveStartKey and the expression values name
253    // what they are, as seven of the ten regions do (eu-west-2, eu-central-1 and
254    // ap-southeast-2 answer bare). An empty name in ScanFilter itself is accepted.
255    if crate::empty_names::names_include_empty(request.attributes_to_get.as_ref())
256        || crate::empty_names::filter_values_have_empty_name(request.scan_filter.as_ref())
257    {
258        return Err(crate::empty_names::bare());
259    }
260    if request
261        .exclusive_start_key
262        .as_ref()
263        .is_some_and(crate::empty_names::map_has_empty_name)
264        || request
265            .exclusive_start_key_raw
266            .as_ref()
267            .is_some_and(crate::empty_names::json_map_has_empty_name)
268    {
269        return Err(DynoxideError::ValidationException(format!(
270            "The provided starting key is invalid: {}",
271            crate::empty_names::MESSAGE
272        )));
273    }
274    crate::empty_names::check_names(request.expression_attribute_names.as_ref())?;
275    if let Some(key) =
276        crate::empty_names::value_placeholder(request.expression_attribute_values.as_ref(), None)
277    {
278        return Err(DynoxideError::ValidationException(
279            crate::empty_names::value_placeholder_message(key),
280        ));
281    }
282
283    // ---- Expression vs non-expression mixing validation ----
284    {
285        let mut non_expr = Vec::new();
286        let mut expr = Vec::new();
287        if request.attributes_to_get.is_some() {
288            non_expr.push("AttributesToGet");
289        }
290        if request.scan_filter.is_some()
291            && request.scan_filter.as_ref().is_some_and(|v| !v.is_null())
292        {
293            non_expr.push("ScanFilter");
294        }
295        if request.conditional_operator.is_some() {
296            non_expr.push("ConditionalOperator");
297        }
298        if request.projection_expression.is_some() {
299            expr.push("ProjectionExpression");
300        }
301        if request.filter_expression.is_some() {
302            expr.push("FilterExpression");
303        }
304        let no_raw_eav: Option<serde_json::Value> = None;
305        let ctx = helpers::ExpressionParamContext {
306            non_expression_params: non_expr,
307            expression_params: expr,
308            all_expression_param_names: vec!["FilterExpression"],
309            expression_attribute_names: &request.expression_attribute_names,
310            expression_attribute_values: &request.expression_attribute_values,
311            expression_attribute_values_raw: &no_raw_eav,
312            layer: crate::validation::ValueLayer::Older,
313        };
314        helpers::validate_expression_params(&ctx)?;
315    }
316
317    // ---- ConditionalOperator only joins legacy ScanFilter conditions, so a
318    // valid one is refused without two of them; Scan reports it without the
319    // envelope (captured 2026-10-09, all seven regions). ----
320    if let Some(msg) = crate::validation::conditional_operator_misuse(
321        request.conditional_operator.as_deref(),
322        crate::validation::legacy_condition_count(request.scan_filter.as_ref()),
323    ) {
324        return Err(DynoxideError::ValidationException(msg.to_string()));
325    }
326    // ---- A ScanFilter value nested too deep is refused first ----
327    crate::value_nesting::check_scan_filter(request.scan_filter.as_ref())?;
328
329    // ---- Validate ScanFilter attribute values (before argument counts, matching DynamoDB) ----
330    helpers::validate_filter_conditions_raw(
331        request.scan_filter.as_ref(),
332        "ScanFilter",
333        crate::validation::ValueLayer::Older,
334    )?;
335
336    // ---- Validate filter argument counts and type compatibility ----
337    helpers::validate_filter_condition_args(request.scan_filter.as_ref(), "scanFilter")?;
338
339    // ---- Validate duplicate AttributesToGet ----
340    if let Some(ref attrs) = request.attributes_to_get {
341        helpers::validate_attributes_to_get_no_duplicates(attrs)?;
342    }
343
344    // ---- Parse ExclusiveStartKey from JSON value ----
345    let exclusive_start_key = if let Some(ref esk_val) = request.exclusive_start_key_raw {
346        Some(helpers::parse_exclusive_start_key(
347            esk_val,
348            crate::validation::ValueLayer::Older,
349        )?)
350    } else {
351        request.exclusive_start_key.clone()
352    };
353    crate::value_nesting::check_scan_start_key(exclusive_start_key.as_ref())?;
354
355    // Convert legacy ScanFilter to FilterExpression if no expression is set
356    let scan_filter: Option<HashMap<String, helpers::FilterCondition>> =
357        match request.scan_filter.as_ref() {
358            Some(sf_val) if request.filter_expression.is_none() && !sf_val.is_null() => Some(
359                helpers::read_legacy_conditions(sf_val, crate::validation::ValueLayer::Older)?,
360            ),
361            _ => None,
362        };
363    if let Some(sf) = scan_filter
364        && !sf.is_empty()
365    {
366        let converted =
367            helpers::convert_filter_conditions(&sf, request.conditional_operator.as_deref())?;
368        if !converted.expression.is_empty() {
369            request.filter_expression = Some(converted.expression);
370            let expr_values = request
371                .expression_attribute_values
372                .get_or_insert_with(HashMap::new);
373            expr_values.extend(converted.attribute_values);
374            let expr_names = request
375                .expression_attribute_names
376                .get_or_insert_with(HashMap::new);
377            expr_names.extend(converted.attribute_names);
378        }
379    }
380
381    // Validate parallel scan parameters (before table existence check)
382    match (request.segment, request.total_segments) {
383        (Some(segment), Some(total)) => {
384            if !(1..=1_000_000).contains(&total) {
385                return Err(DynoxideError::ValidationException(
386                    "1 validation error detected: Value at 'totalSegments' failed to satisfy constraint: \
387                     Member must have value between 1 and 1000000".to_string(),
388                ));
389            }
390            if segment >= total {
391                return Err(DynoxideError::ValidationException(format!(
392                    "The Segment parameter is zero-based and must be less than parameter TotalSegments: Segment: {} is not less than TotalSegments: {}",
393                    segment, total
394                )));
395            }
396        }
397        (Some(_), None) => {
398            return Err(DynoxideError::ValidationException(
399                "The TotalSegments parameter is required but was not present in the request when Segment parameter is present".to_string(),
400            ));
401        }
402        (None, Some(_)) => {
403            return Err(DynoxideError::ValidationException(
404                "The Segment parameter is required but was not present in the request when parameter TotalSegments is present".to_string(),
405            ));
406        }
407        (None, None) => {}
408    }
409
410    // ---- Validate FilterExpression and ProjectionExpression BEFORE table existence ----
411    // DynamoDB validates expression syntax before checking if the table exists.
412    if let Some(ref filter_expr_str) = request.filter_expression {
413        if filter_expr_str.is_empty() {
414            // Only report empty if the user explicitly set FilterExpression
415            // (not if it was converted from ScanFilter, which never produces empty)
416            if request.scan_filter.is_none() || request.filter_expression.as_deref() == Some("") {
417                return Err(DynoxideError::ValidationException(
418                    "Invalid FilterExpression: The expression can not be empty;".to_string(),
419                ));
420            }
421        } else {
422            // A Scan is the one FilterExpression whose size message still
423            // carries the figure in us-east-1, so it is checked here first.
424            expressions::check_expression_size_with_figure(filter_expr_str).map_err(|e| {
425                DynoxideError::ValidationException(format!("Invalid FilterExpression: {e}"))
426            })?;
427            // Try parsing the expression to catch syntax errors early
428            let parsed_fe = expressions::condition::parse(filter_expr_str).map_err(|e| {
429                DynoxideError::ValidationException(format!("Invalid FilterExpression: {e}"))
430            })?;
431            // Validate that all #name references are defined in ExpressionAttributeNames
432            if let Err(e) = expressions::condition::validate_name_refs(
433                &parsed_fe,
434                &request.expression_attribute_names,
435            ) {
436                return Err(DynoxideError::ValidationException(format!(
437                    "Invalid FilterExpression: {e}"
438                )));
439            }
440            if let Err(e) = expressions::condition::validate_operand_semantics(
441                &parsed_fe,
442                &request.expression_attribute_names,
443                &request.expression_attribute_values,
444            ) {
445                return Err(DynoxideError::ValidationException(format!(
446                    "Invalid FilterExpression: {e}"
447                )));
448            }
449        }
450    }
451    if let Some(ref proj_expr_str) = request.projection_expression
452        && proj_expr_str.is_empty()
453    {
454        return Err(DynoxideError::ValidationException(
455            "Invalid ProjectionExpression: The expression can not be empty;".to_string(),
456        ));
457    }
458
459    // SPECIFIC_ATTRIBUTES requires ProjectionExpression or AttributesToGet
460    if request.select.as_deref() == Some("SPECIFIC_ATTRIBUTES")
461        && request.projection_expression.is_none()
462        && request.attributes_to_get.is_none()
463    {
464        return Err(DynoxideError::ValidationException(
465            "Must specify the AttributesToGet or ProjectionExpression when choosing to get SPECIFIC_ATTRIBUTES"
466                .to_string(),
467        ));
468    }
469
470    // A ProjectionExpression is only valid with Select = SPECIFIC_ATTRIBUTES.
471    if request.projection_expression.is_some()
472        && let Some(select) = request.select.as_deref()
473        && select != "SPECIFIC_ATTRIBUTES"
474    {
475        // AWS phrases COUNT as "only the Count"; other values use the literal.
476        let target = if select == "COUNT" {
477            "only the Count"
478        } else {
479            select
480        };
481        return Err(DynoxideError::ValidationException(format!(
482            "Cannot specify the ProjectionExpression when choosing to get {target}"
483        )));
484    }
485
486    // ALL_PROJECTED_ATTRIBUTES projects an index, so it requires an IndexName.
487    // AWS keeps the word "Querying" in this message even for Scan; preserve it verbatim.
488    if request.select.as_deref() == Some("ALL_PROJECTED_ATTRIBUTES") && request.index_name.is_none()
489    {
490        return Err(DynoxideError::ValidationException(
491            "ALL_PROJECTED_ATTRIBUTES can be used only when Querying using an IndexName"
492                .to_string(),
493        ));
494    }
495
496    let meta = helpers::require_table_for_item_op(storage, &request.table_name).await?;
497    let table_key_schema = helpers::parse_key_schema(&meta)?;
498
499    // Convert legacy AttributesToGet to ProjectionExpression if no expression-based
500    // projection is provided.
501    let legacy_projection = if request.projection_expression.is_none() {
502        request
503            .attributes_to_get
504            .as_ref()
505            .map(|attrs| helpers::attributes_to_get_to_projection(attrs))
506    } else {
507        None
508    };
509
510    // Determine effective key schema (GSI, LSI, or base table)
511    let lsi_keys = request
512        .index_name
513        .as_ref()
514        .and_then(|idx| super::lsi::parse_lsi_key_schema(&meta, idx).ok());
515    let is_lsi = lsi_keys.is_some();
516
517    // ConsistentRead is not supported on GSIs (LSIs are fine)
518    if request.consistent_read.unwrap_or(false) && request.index_name.is_some() && !is_lsi {
519        return Err(DynoxideError::ValidationException(
520            "Consistent reads are not supported on global secondary indexes".to_string(),
521        ));
522    }
523
524    let (effective_pk, effective_sk) = if let Some(ref index_name) = request.index_name {
525        if let Some(keys) = lsi_keys {
526            keys
527        } else {
528            // A vector index is listed by DescribeTable but no Scan can read
529            // it, and DynamoDB refuses it by type rather than reporting the
530            // index missing (captured eu-west-2, 2026-08-19). Checked before
531            // the GSI lookup, which would otherwise answer "does not have the
532            // specified index" for an index the table does have.
533            reject_vector_index(&meta, index_name)?;
534            super::gsi::parse_gsi_key_schema(&meta, index_name)?
535        }
536    } else {
537        (
538            table_key_schema.partition_key.clone(),
539            table_key_schema.sort_key.clone(),
540        )
541    };
542
543    // Every partition key and sort key attribute, in key-schema order. Only a
544    // GSI can have more than one of each.
545    let (key_hash, key_range): (Vec<String>, Vec<String>) = match request.index_name {
546        Some(ref index_name) if !is_lsi => super::gsi::parse_gsi_key_attrs(&meta, index_name)?,
547        _ => (
548            vec![effective_pk.clone()],
549            effective_sk.iter().cloned().collect(),
550        ),
551    };
552
553    // ---- Validate ExclusiveStartKey structure against key schema ----
554    // Stage 1+2: count check + index key type check
555    if let Some(ref esk) = exclusive_start_key {
556        let count_msg = if request.index_name.is_some() {
557            "The provided starting key is invalid"
558        } else {
559            "The provided starting key is invalid: The provided key element does not match the schema"
560        };
561        helpers::validate_esk_count_and_index_keys(
562            esk,
563            &meta,
564            request.index_name.as_deref(),
565            count_msg,
566        )?;
567    }
568
569    // Check ALL_ATTRIBUTES on global index (between index key check and table key check)
570    if let Some(ref index_name) = request.index_name
571        && !is_lsi
572        && let Some(ref select) = request.select
573        && select == "ALL_ATTRIBUTES"
574    {
575        // Check if index projection is ALL
576        let gsi_defs = super::gsi::parse_gsi_defs(&meta)?;
577        if let Some(gsi) = gsi_defs.iter().find(|g| g.index_name == *index_name)
578            && gsi.projection_type != crate::types::ProjectionType::ALL
579        {
580            return Err(DynoxideError::ValidationException(format!(
581                "One or more parameter values were invalid: \
582                                 Select type ALL_ATTRIBUTES is not supported for global secondary index {} \
583                                 because its projection type is not ALL",
584                index_name
585            )));
586        }
587    }
588
589    // Stage 3: table key type check
590    if let Some(ref esk) = exclusive_start_key {
591        helpers::validate_esk_table_keys(esk, &meta)?;
592    }
593
594    // Extract ExclusiveStartKey pk/sk using effective key names
595    let (start_pk, start_sk) = if let Some(ref esk) = exclusive_start_key {
596        let key_string = |attrs: &[String]| {
597            let values: Option<Vec<&AttributeValue>> = attrs.iter().map(|a| esk.get(a)).collect();
598            values.and_then(|v| super::gsi::composite_key_string(&v))
599        };
600        let pk = key_string(&key_hash);
601        let sk = if key_range.is_empty() {
602            Some(String::new())
603        } else {
604            key_string(&key_range)
605        };
606        (pk, sk)
607    } else {
608        (None, None)
609    };
610
611    // For index scans (LSI and GSI), extract the base table key from
612    // ExclusiveStartKey for composite cursor pagination. The GSI/LSI
613    // tables have a composite primary key that includes the base table
614    // keys, so the cursor must include them to avoid skipping rows that
615    // share the same index key.
616    let (start_base_pk, start_base_sk) = if is_lsi || request.index_name.is_some() {
617        if let Some(ref esk) = exclusive_start_key {
618            let base_pk = esk
619                .get(&table_key_schema.partition_key)
620                .and_then(|v| v.to_key_string());
621            // When the base table is hash-only there is no base sort key, but
622            // the GSI/LSI row stores the empty-string default in its table_sk
623            // column. Default base_sk to "" (mirroring start_sk above) so the
624            // composite cursor keeps its full width and disambiguates tied
625            // index keys by table_pk. Leaving it None collapses scan_gsi_items
626            // to the 2-column (gsi_pk, gsi_sk) cursor, which cannot advance past
627            // rows that share the same index key and silently drops them.
628            let base_sk = if let Some(sk_name) = table_key_schema.sort_key.as_ref() {
629                esk.get(sk_name).and_then(|v| v.to_key_string())
630            } else {
631                Some(String::new())
632            };
633            (base_pk, base_sk)
634        } else {
635            (None, None)
636        }
637    } else {
638        (None, None)
639    };
640
641    // Scan either GSI table or base table
642    let scan_params = crate::storage::ScanParams {
643        limit: request.limit,
644        exclusive_start_pk: start_pk.as_deref(),
645        exclusive_start_sk: start_sk.as_deref(),
646        segment: request.segment,
647        total_segments: request.total_segments,
648        exclusive_start_base_pk: start_base_pk.as_deref(),
649        exclusive_start_base_sk: start_base_sk.as_deref(),
650    };
651    let rows = if let Some(ref index_name) = request.index_name {
652        if is_lsi {
653            storage
654                .scan_lsi_items(&request.table_name, index_name, &scan_params)
655                .await?
656        } else {
657            storage
658                .scan_gsi_items(&request.table_name, index_name, &scan_params)
659                .await?
660        }
661    } else {
662        storage
663            .scan_items(&request.table_name, &scan_params)
664            .await?
665    };
666
667    // Create tracker for unused expression attribute names/values
668    let tracker = crate::expressions::TrackedExpressionAttributes::new(
669        &request.expression_attribute_names,
670        &request.expression_attribute_values,
671    );
672
673    // Parse filter expression if present
674    let filter_expr = request
675        .filter_expression
676        .as_ref()
677        .map(|expr| expressions::condition::parse(expr))
678        .transpose()
679        .map_err(DynoxideError::ValidationException)?;
680
681    // Check for non-scalar key access in FilterExpression
682    if let Some(ref filter) = filter_expr {
683        // Build key attribute lists for non-scalar check
684        let mut base_key_attrs = vec![table_key_schema.partition_key.clone()];
685        if let Some(ref sk) = table_key_schema.sort_key {
686            base_key_attrs.push(sk.clone());
687        }
688        let mut index_key_attrs = Vec::new();
689        if request.index_name.is_some() {
690            for attr in key_hash.iter().chain(&key_range) {
691                if !base_key_attrs.contains(attr) {
692                    index_key_attrs.push(attr.clone());
693                }
694            }
695        }
696        if let Some((attr, is_index)) = expressions::condition::check_non_scalar_key_access(
697            filter,
698            &request.expression_attribute_names,
699            &base_key_attrs,
700            &index_key_attrs,
701        ) {
702            let prefix = if is_index { "IndexKey" } else { "Key" };
703            return Err(DynoxideError::ValidationException(format!(
704                "Key attributes must be scalars; \
705                 list random access '[]' and map lookup '.' are not allowed: {prefix}: {attr}"
706            )));
707        }
708    }
709
710    // Parse projection expression if present; fall back to legacy AttributesToGet
711    let projection = if let Some(ref proj_expr) = request.projection_expression {
712        Some(
713            expressions::projection::parse(proj_expr)
714                .map_err(DynoxideError::ValidationException)?,
715        )
716    } else {
717        legacy_projection.clone()
718    };
719
720    // Pre-register expression references so unused check works even with zero items
721    if let Some(ref filter) = filter_expr {
722        tracker.track_condition_expr(filter);
723    }
724    if let Some(ref proj) = projection {
725        tracker.track_projection_expr(proj);
726        // Reject undefined names and overlapping paths before the row loop, so a
727        // zero-match scan still rejects.
728        expressions::projection::validate(proj, &tracker)
729            .map_err(DynoxideError::ValidationException)?;
730    }
731
732    // Untracked variant for the per-item hot loop: tracking already done above
733    let loop_tracker = crate::expressions::TrackedExpressionAttributes::without_tracking(
734        &request.expression_attribute_names,
735        &request.expression_attribute_values,
736    );
737
738    // Determine if SELECT COUNT
739    let is_count = request
740        .select
741        .as_deref()
742        .map(|s| s.eq_ignore_ascii_case("COUNT"))
743        .unwrap_or(false);
744
745    // Key attribute names for projection (use effective keys for GSI)
746    let mut key_attrs = vec![effective_pk.clone()];
747    if let Some(ref sk) = effective_sk {
748        key_attrs.push(sk.clone());
749    }
750    if request.index_name.is_some() {
751        if !key_attrs.contains(&table_key_schema.partition_key) {
752            key_attrs.push(table_key_schema.partition_key.clone());
753        }
754        if let Some(ref sk) = table_key_schema.sort_key
755            && !key_attrs.contains(sk)
756        {
757            key_attrs.push(sk.clone());
758        }
759    }
760
761    let mut items = Vec::new();
762    let mut scanned_count = 0;
763    let mut filtered_count = 0;
764    let mut cumulative_size = 0;
765    let mut last_evaluated_item: Option<Item> = None;
766    let mut truncated_by_size = false;
767
768    for (_pk, _sk, item_json) in &rows {
769        let item: Item = serde_json::from_str(item_json).map_err(|e| {
770            DynoxideError::InternalServerError(format!("Bad item JSON in storage: {e}"))
771        })?;
772
773        scanned_count += 1;
774
775        // Check 1MB limit BEFORE filtering - DynamoDB counts all evaluated data
776        // towards the 1MB response size limit, not just items that pass the filter.
777        let item_size = crate::types::item_size(&item);
778        if cumulative_size + item_size > MAX_RESPONSE_SIZE && scanned_count > 1 {
779            truncated_by_size = true;
780            break;
781        }
782        cumulative_size += item_size;
783
784        // Apply filter
785        if let Some(ref filter) = filter_expr {
786            let passes = expressions::condition::evaluate(filter, &item, &loop_tracker)
787                .map_err(DynoxideError::ValidationException)?;
788            if !passes {
789                last_evaluated_item = Some(item);
790                continue;
791            }
792        }
793
794        filtered_count += 1;
795
796        // Apply projection -- do NOT auto-include key attributes when the
797        // user explicitly specified ProjectionExpression or AttributesToGet.
798        let result_item = if let Some(ref proj) = projection {
799            let no_keys: &[String] = &[];
800            expressions::projection::apply(&item, proj, &loop_tracker, no_keys)
801                .map_err(DynoxideError::ValidationException)?
802        } else {
803            item.clone()
804        };
805
806        last_evaluated_item = Some(item);
807        if !is_count {
808            items.push(result_item);
809        }
810    }
811
812    // Check for unused expression attribute names/values
813    tracker.check_unused()?;
814
815    let count = if is_count {
816        filtered_count
817    } else {
818        items.len()
819    };
820
821    // Determine LastEvaluatedKey
822    let has_more = truncated_by_size
823        || (request.limit.is_some() && scanned_count >= request.limit.unwrap_or(usize::MAX));
824
825    // For index scans, include the base table primary key in LastEvaluatedKey
826    // alongside the effective (index) keys so the cursor can uniquely identify
827    // the position. For LSIs, include the table sort key. For GSIs, include
828    // both the table partition key and sort key.
829    let is_gsi_scan = request.index_name.is_some() && !is_lsi;
830    let last_evaluated_key = if has_more {
831        last_evaluated_item.map(|item| {
832            let mut key: HashMap<String, AttributeValue> = key_hash
833                .iter()
834                .chain(&key_range)
835                .filter_map(|name| item.get(name).map(|v| (name.clone(), v.clone())))
836                .collect();
837            // For LSI scans, add the table sort key if different from the index sort key
838            if is_lsi
839                && let Some(tsk) = table_key_schema.sort_key.as_deref()
840                && !key.contains_key(tsk)
841                && let Some(v) = item.get(tsk)
842            {
843                key.insert(tsk.to_string(), v.clone());
844            }
845            // For GSI scans, add the base table primary key (pk and sk)
846            if is_gsi_scan {
847                if !key.contains_key(&table_key_schema.partition_key)
848                    && let Some(v) = item.get(&table_key_schema.partition_key)
849                {
850                    key.insert(table_key_schema.partition_key.clone(), v.clone());
851                }
852                if let Some(ref tsk) = table_key_schema.sort_key
853                    && !key.contains_key(tsk)
854                    && let Some(v) = item.get(tsk)
855                {
856                    key.insert(tsk.clone(), v.clone());
857                }
858            }
859            key
860        })
861    } else {
862        None
863    };
864
865    // Attribute read capacity to the GSI if scanning one
866    let is_gsi = is_gsi_scan;
867    let consistent = request.consistent_read.unwrap_or(false);
868    let consumed_capacity = if is_gsi {
869        let mut gsi_units = std::collections::HashMap::new();
870        gsi_units.insert(
871            request.index_name.as_ref().unwrap().clone(),
872            crate::types::read_capacity_units_with_consistency(cumulative_size, consistent),
873        );
874        crate::types::consumed_capacity_with_indexes(
875            &request.table_name,
876            0.0,
877            &gsi_units,
878            &request.return_consumed_capacity,
879        )
880    } else {
881        crate::types::consumed_capacity(
882            &request.table_name,
883            crate::types::read_capacity_units_with_consistency(cumulative_size, consistent),
884            &request.return_consumed_capacity,
885        )
886    };
887
888    Ok(ScanResponse {
889        items: if is_count { None } else { Some(items) },
890        count,
891        scanned_count,
892        last_evaluated_key,
893        consumed_capacity,
894    })
895}
896
897/// Refuse a read against a vector index by type.
898///
899/// The wording differs between the two operations, full stop included: AWS
900/// ends Scan's with one where the other has none. Captured eu-west-2,
901/// 2026-08-19.
902fn reject_vector_index(
903    meta: &crate::storage::TableMetadata,
904    index_name: &str,
905) -> crate::errors::Result<()> {
906    if super::vector_index::parse_vector_defs(meta)?
907        .iter()
908        .any(|v| v.index_name == index_name)
909    {
910        return Err(DynoxideError::ValidationException(
911            "Scan operation not supported on this index type".to_string(),
912        ));
913    }
914    Ok(())
915}