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