Skip to main content

dynoxide/actions/
query.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 QueryRequestRaw {
15    #[serde(rename = "TableName", default)]
16    table_name: Option<String>,
17    #[serde(rename = "KeyConditionExpression", default)]
18    key_condition_expression: Option<String>,
19    #[serde(rename = "FilterExpression", default)]
20    filter_expression: Option<String>,
21    #[serde(rename = "ProjectionExpression", default)]
22    projection_expression: Option<String>,
23    #[serde(rename = "ExpressionAttributeNames", default)]
24    expression_attribute_names: Option<HashMap<String, String>>,
25    #[serde(rename = "ExpressionAttributeValues", default)]
26    expression_attribute_values: Option<HashMap<String, AttributeValue>>,
27    #[serde(rename = "ScanIndexForward", default = "default_true")]
28    scan_index_forward: bool,
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(rename = "ConsistentRead", default)]
36    consistent_read: Option<bool>,
37    #[serde(rename = "IndexName", default)]
38    index_name: Option<String>,
39    #[serde(rename = "ReturnConsumedCapacity", default)]
40    return_consumed_capacity: Option<String>,
41    #[serde(rename = "KeyConditions", default)]
42    key_conditions: Option<serde_json::Value>,
43    #[serde(rename = "AttributesToGet", default)]
44    attributes_to_get: Option<Vec<String>>,
45    #[serde(rename = "QueryFilter", default)]
46    query_filter: Option<serde_json::Value>,
47    #[serde(rename = "ConditionalOperator", default)]
48    conditional_operator: Option<String>,
49}
50
51fn default_true() -> bool {
52    true
53}
54
55#[derive(Debug, Default)]
56pub struct QueryRequest {
57    pub table_name: String,
58    pub key_condition_expression: Option<String>,
59    pub filter_expression: Option<String>,
60    pub projection_expression: Option<String>,
61    pub expression_attribute_names: Option<HashMap<String, String>>,
62    pub expression_attribute_values: Option<HashMap<String, AttributeValue>>,
63    pub scan_index_forward: bool,
64    pub limit: Option<usize>,
65    pub exclusive_start_key: Option<HashMap<String, AttributeValue>>,
66    pub select: Option<String>,
67    pub consistent_read: Option<bool>,
68    pub index_name: Option<String>,
69    pub return_consumed_capacity: Option<String>,
70    pub key_conditions: Option<serde_json::Value>,
71    pub attributes_to_get: Option<Vec<String>>,
72    pub query_filter: Option<serde_json::Value>,
73    pub conditional_operator: Option<String>,
74    /// Raw JSON for ExclusiveStartKey when deserialized from HTTP request.
75    /// Parsed lazily in `execute()` after other validations run.
76    /// When constructed directly (e.g. from MCP), this is `None` and
77    /// `exclusive_start_key` is used instead.
78    pub exclusive_start_key_raw: Option<serde_json::Value>,
79}
80
81impl<'de> serde::Deserialize<'de> for QueryRequest {
82    fn deserialize<D: serde::Deserializer<'de>>(
83        deserializer: D,
84    ) -> std::result::Result<Self, D::Error> {
85        let raw = QueryRequestRaw::deserialize(deserializer)?;
86        use crate::validation::{
87            TableNameContext, format_validation_errors, table_name_constraint_errors,
88        };
89
90        let mut errors = Vec::new();
91        errors.extend(table_name_constraint_errors(
92            raw.table_name.as_deref(),
93            TableNameContext::ReadWrite,
94        ));
95        let table_name = raw.table_name.unwrap_or_default();
96
97        // ReturnConsumedCapacity enum
98        if let Some(msg) = crate::validation::return_consumed_capacity_rejection(
99            raw.return_consumed_capacity.as_deref(),
100        ) {
101            errors.push(msg);
102        }
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        // Limit must be >= 1.
122        // AWS DynamoDB's Query message diverges from Scan: Query omits the rejected
123        // value entirely and capitalises 'Limit', whereas Scan keeps the value and
124        // lowercases 'limit'. Do not collapse these into a shared helper.
125        if let Some(limit) = raw.limit
126            && limit == 0
127        {
128            errors.push(
129                "Value at 'Limit' failed to satisfy constraint: \
130                     Member must have value greater than or equal to 1"
131                    .to_string(),
132            );
133        }
134
135        if let Some(msg) = format_validation_errors(&errors) {
136            return Err(serde::de::Error::custom(format!("VALIDATION:{}", msg)));
137        }
138
139        Ok(QueryRequest {
140            table_name,
141            key_condition_expression: raw.key_condition_expression,
142            filter_expression: raw.filter_expression,
143            projection_expression: raw.projection_expression,
144            expression_attribute_names: raw.expression_attribute_names,
145            expression_attribute_values: raw.expression_attribute_values,
146            scan_index_forward: raw.scan_index_forward,
147            limit: raw.limit,
148            exclusive_start_key: None,
149            select: raw.select,
150            consistent_read: raw.consistent_read,
151            index_name: raw.index_name,
152            return_consumed_capacity: raw.return_consumed_capacity,
153            key_conditions: raw.key_conditions,
154            attributes_to_get: raw.attributes_to_get,
155            query_filter: raw.query_filter,
156            conditional_operator: raw.conditional_operator,
157            exclusive_start_key_raw: raw.exclusive_start_key,
158        })
159    }
160}
161
162#[derive(Debug, Default, Serialize)]
163pub struct QueryResponse {
164    #[serde(rename = "Items", skip_serializing_if = "Option::is_none")]
165    pub items: Option<Vec<Item>>,
166    #[serde(rename = "Count")]
167    pub count: usize,
168    #[serde(rename = "ScannedCount")]
169    pub scanned_count: usize,
170    #[serde(rename = "LastEvaluatedKey", skip_serializing_if = "Option::is_none")]
171    pub last_evaluated_key: Option<HashMap<String, AttributeValue>>,
172    #[serde(rename = "ConsumedCapacity", skip_serializing_if = "Option::is_none")]
173    pub consumed_capacity: Option<crate::types::ConsumedCapacity>,
174}
175
176pub async fn execute<S: StorageBackend>(
177    storage: &S,
178    mut request: QueryRequest,
179) -> Result<QueryResponse> {
180    // Validate table name format before checking existence (DynamoDB validates input first)
181    crate::validation::validate_table_name(&request.table_name)?;
182
183    // ---- Expression vs non-expression mixing validation ----
184    // DynamoDB checks this before anything else (except table name format and ESK values).
185    {
186        let mut non_expr = Vec::new();
187        let mut expr = Vec::new();
188        if request.attributes_to_get.is_some() {
189            non_expr.push("AttributesToGet");
190        }
191        if request.query_filter.is_some()
192            && request.query_filter.as_ref().is_some_and(|v| !v.is_null())
193        {
194            non_expr.push("QueryFilter");
195        }
196        if request.conditional_operator.is_some() {
197            non_expr.push("ConditionalOperator");
198        }
199        if request.key_conditions.is_some()
200            && request
201                .key_conditions
202                .as_ref()
203                .is_some_and(|v| !v.is_null())
204        {
205            non_expr.push("KeyConditions");
206        }
207        if request.projection_expression.is_some() {
208            expr.push("ProjectionExpression");
209        }
210        if request.filter_expression.is_some() {
211            expr.push("FilterExpression");
212        }
213        if request.key_condition_expression.is_some() {
214            expr.push("KeyConditionExpression");
215        }
216        let no_raw_eav: Option<serde_json::Value> = None;
217        let ctx = helpers::ExpressionParamContext {
218            non_expression_params: non_expr,
219            expression_params: expr,
220            all_expression_param_names: vec!["FilterExpression", "KeyConditionExpression"],
221            expression_attribute_names: &request.expression_attribute_names,
222            expression_attribute_values: &request.expression_attribute_values,
223            expression_attribute_values_raw: &no_raw_eav,
224        };
225        helpers::validate_expression_params(&ctx)?;
226    }
227
228    // ---- Validate filter attribute values (before argument counts, matching DynamoDB) ----
229    helpers::validate_filter_conditions_raw(request.query_filter.as_ref(), "QueryFilter")?;
230    helpers::validate_filter_conditions_raw(request.key_conditions.as_ref(), "KeyConditions")?;
231
232    // ---- Validate filter argument counts and type compatibility ----
233    helpers::validate_filter_condition_args(request.query_filter.as_ref())?;
234    helpers::validate_filter_condition_args(request.key_conditions.as_ref())?;
235
236    // ---- Validate duplicate AttributesToGet ----
237    if let Some(ref attrs) = request.attributes_to_get {
238        helpers::validate_attributes_to_get_no_duplicates(attrs)?;
239    }
240
241    // ---- Parse ExclusiveStartKey from JSON value ----
242    let exclusive_start_key = if let Some(ref esk_val) = request.exclusive_start_key_raw {
243        Some(helpers::parse_exclusive_start_key(esk_val)?)
244    } else {
245        request.exclusive_start_key.clone()
246    };
247
248    // ---- Validate expression syntax BEFORE table existence ----
249    // DynamoDB validates KeyConditionExpression, FilterExpression, and
250    // ProjectionExpression syntax before checking if the table exists.
251    if let Some(ref kce) = request.key_condition_expression
252        && kce.is_empty()
253    {
254        return Err(DynoxideError::ValidationException(
255            "Invalid KeyConditionExpression: The expression can not be empty;".to_string(),
256        ));
257    }
258    if let Some(ref fe) = request.filter_expression {
259        if fe.is_empty() {
260            if request.query_filter.is_none() || request.filter_expression.as_deref() == Some("") {
261                return Err(DynoxideError::ValidationException(
262                    "Invalid FilterExpression: The expression can not be empty;".to_string(),
263                ));
264            }
265        } else {
266            let parsed_fe = expressions::condition::parse(fe).map_err(|e| {
267                DynoxideError::ValidationException(format!("Invalid FilterExpression: {e}"))
268            })?;
269            // Validate that all #name references are defined in ExpressionAttributeNames
270            // (before table existence check, matching DynamoDB's validation ordering)
271            if let Err(e) = expressions::condition::validate_name_refs(
272                &parsed_fe,
273                &request.expression_attribute_names,
274            ) {
275                return Err(DynoxideError::ValidationException(format!(
276                    "Invalid FilterExpression: {e}"
277                )));
278            }
279            if let Err(e) = expressions::condition::validate_operand_semantics(
280                &parsed_fe,
281                &request.expression_attribute_names,
282                &request.expression_attribute_values,
283            ) {
284                return Err(DynoxideError::ValidationException(format!(
285                    "Invalid FilterExpression: {e}"
286                )));
287            }
288        }
289    }
290    if let Some(ref pe) = request.projection_expression
291        && pe.is_empty()
292    {
293        return Err(DynoxideError::ValidationException(
294            "Invalid ProjectionExpression: The expression can not be empty;".to_string(),
295        ));
296    }
297
298    // SPECIFIC_ATTRIBUTES requires ProjectionExpression or AttributesToGet.
299    // Query wraps the phrase in the "1 validation error detected:" envelope;
300    // Scan returns it bare. This asymmetry matches real DynamoDB.
301    if request.select.as_deref() == Some("SPECIFIC_ATTRIBUTES")
302        && request.projection_expression.is_none()
303        && request.attributes_to_get.is_none()
304    {
305        return Err(DynoxideError::ValidationException(
306            "1 validation error detected: Must specify the AttributesToGet or ProjectionExpression when choosing to get SPECIFIC_ATTRIBUTES"
307                .to_string(),
308        ));
309    }
310
311    // A ProjectionExpression is only valid with Select = SPECIFIC_ATTRIBUTES.
312    if request.projection_expression.is_some()
313        && let Some(select) = request.select.as_deref()
314        && select != "SPECIFIC_ATTRIBUTES"
315    {
316        // AWS phrases COUNT as "only the Count"; other values use the literal.
317        let target = if select == "COUNT" {
318            "only the Count"
319        } else {
320            select
321        };
322        return Err(DynoxideError::ValidationException(format!(
323            "Cannot specify the ProjectionExpression when choosing to get {target}"
324        )));
325    }
326
327    // ALL_PROJECTED_ATTRIBUTES projects an index, so it requires an IndexName.
328    if request.select.as_deref() == Some("ALL_PROJECTED_ATTRIBUTES") && request.index_name.is_none()
329    {
330        return Err(DynoxideError::ValidationException(
331            "ALL_PROJECTED_ATTRIBUTES can be used only when Querying using an IndexName"
332                .to_string(),
333        ));
334    }
335
336    // For KeyConditionExpression, validate syntax early too
337    if let Some(ref kce) = request.key_condition_expression
338        && !kce.is_empty()
339    {
340        // Create a temporary tracker for early syntax validation
341        let temp_tracker = crate::expressions::TrackedExpressionAttributes::new(
342            &request.expression_attribute_names,
343            &request.expression_attribute_values,
344        );
345        if let Err(e) = expressions::key_condition::parse(kce, &temp_tracker) {
346            return Err(DynoxideError::ValidationException(e));
347        }
348    }
349
350    let meta = helpers::require_table_for_item_op(storage, &request.table_name).await?;
351    let table_key_schema = helpers::parse_key_schema(&meta)?;
352
353    // Determine effective partition key name early so we can pass it to
354    // the legacy KeyConditions converter (ensures correct ordering when
355    // both hash and range keys use EQ).
356    let effective_pk_for_kc = if let Some(ref index_name) = request.index_name {
357        if let Some((pk, _)) = request
358            .index_name
359            .as_ref()
360            .and_then(|idx| super::lsi::parse_lsi_key_schema(&meta, idx).ok())
361        {
362            pk
363        } else if let Ok((pk, _)) = super::gsi::parse_gsi_key_schema(&meta, index_name) {
364            pk
365        } else {
366            table_key_schema.partition_key.clone()
367        }
368    } else {
369        table_key_schema.partition_key.clone()
370    };
371
372    // Convert legacy KeyConditions to KeyConditionExpression if no expression is set
373    if request.key_condition_expression.is_none()
374        && let Some(ref kc_val) = request.key_conditions
375        && let Ok(kc) =
376            serde_json::from_value::<HashMap<String, helpers::KeyCondition>>(kc_val.clone())
377        && !kc.is_empty()
378    {
379        let converted = helpers::convert_key_conditions(&kc, Some(&effective_pk_for_kc))?;
380        request.key_condition_expression = Some(converted.expression);
381        let expr_values = request
382            .expression_attribute_values
383            .get_or_insert_with(HashMap::new);
384        expr_values.extend(converted.attribute_values);
385        let expr_names = request
386            .expression_attribute_names
387            .get_or_insert_with(HashMap::new);
388        expr_names.extend(converted.attribute_names);
389    }
390
391    // Convert legacy QueryFilter to FilterExpression if no expression is set
392    if request.filter_expression.is_none()
393        && let Some(ref qf_val) = request.query_filter
394        && let Ok(qf) =
395            serde_json::from_value::<HashMap<String, helpers::FilterCondition>>(qf_val.clone())
396        && !qf.is_empty()
397    {
398        let converted =
399            helpers::convert_filter_conditions(&qf, request.conditional_operator.as_deref())?;
400        if !converted.expression.is_empty() {
401            request.filter_expression = Some(converted.expression);
402            let expr_values = request
403                .expression_attribute_values
404                .get_or_insert_with(HashMap::new);
405            expr_values.extend(converted.attribute_values);
406            let expr_names = request
407                .expression_attribute_names
408                .get_or_insert_with(HashMap::new);
409            expr_names.extend(converted.attribute_names);
410        }
411    }
412
413    // Convert legacy AttributesToGet to projection
414    let legacy_projection = if request.projection_expression.is_none() {
415        request
416            .attributes_to_get
417            .as_ref()
418            .map(|attrs| helpers::attributes_to_get_to_projection(attrs))
419    } else {
420        None
421    };
422
423    // Ensure KeyConditionExpression is present (required)
424    let key_condition_expression = request.key_condition_expression.as_deref().ok_or_else(|| {
425        DynoxideError::ValidationException(
426            "Either the KeyConditions or KeyConditionExpression parameter must be specified in the request."
427                .to_string(),
428        )
429    })?;
430    let key_condition_expression = key_condition_expression.to_string();
431
432    // Determine effective key schema (GSI, LSI, or base table)
433    let lsi_keys = request
434        .index_name
435        .as_ref()
436        .and_then(|idx| super::lsi::parse_lsi_key_schema(&meta, idx).ok());
437    let is_lsi = lsi_keys.is_some();
438
439    // ConsistentRead is not supported on GSIs (LSIs are fine)
440    if request.consistent_read.unwrap_or(false) && request.index_name.is_some() && !is_lsi {
441        return Err(DynoxideError::ValidationException(
442            "Consistent reads are not supported on global secondary indexes".to_string(),
443        ));
444    }
445
446    // Parse full index definition to get projection type
447    let index_projection_type = if let Some(ref index_name) = request.index_name {
448        if is_lsi {
449            super::lsi::parse_lsi_defs(&meta)?
450                .into_iter()
451                .find(|l| l.index_name == *index_name)
452                .map(|l| l.projection_type)
453        } else {
454            super::gsi::parse_gsi_defs(&meta)?
455                .into_iter()
456                .find(|g| g.index_name == *index_name)
457                .map(|g| g.projection_type)
458        }
459    } else {
460        None
461    };
462
463    let (effective_pk, effective_sk) = if let Some(ref index_name) = request.index_name {
464        if let Some(keys) = lsi_keys {
465            keys
466        } else {
467            // A vector index is listed by DescribeTable but no Query can read
468            // it, and DynamoDB refuses it by type rather than reporting the
469            // index missing (captured eu-west-2, 2026-08-19). Checked before
470            // the GSI lookup, which would otherwise answer "does not have the
471            // specified index" for an index the table does have.
472            reject_vector_index(&meta, index_name)?;
473            super::gsi::parse_gsi_key_schema(&meta, index_name)?
474        }
475    } else {
476        (
477            table_key_schema.partition_key.clone(),
478            table_key_schema.sort_key.clone(),
479        )
480    };
481
482    // ---- Validate ExclusiveStartKey structure against key schema ----
483    if let Some(ref esk) = exclusive_start_key {
484        // Stage 1+2: count check + index key type check
485        helpers::validate_esk_count_and_index_keys(
486            esk,
487            &meta,
488            request.index_name.as_deref(),
489            "The provided starting key is invalid",
490        )?;
491        // Stage 3: table key type check
492        helpers::validate_esk_table_keys(esk, &meta)?;
493    }
494
495    // Create tracker for unused expression attribute names/values
496    let tracker = crate::expressions::TrackedExpressionAttributes::new(
497        &request.expression_attribute_names,
498        &request.expression_attribute_values,
499    );
500
501    // Parse KeyConditionExpression
502    let key_cond = expressions::key_condition::parse(&key_condition_expression, &tracker)
503        .map_err(DynoxideError::ValidationException)?;
504
505    // Validate pk_name matches the effective partition key
506    if key_cond.pk_name != effective_pk {
507        return Err(DynoxideError::ValidationException(format!(
508            "Query condition missed key schema element: {}",
509            effective_pk
510        )));
511    }
512
513    // Resolve values
514    let resolved = expressions::key_condition::resolve_values(&key_cond, &tracker)
515        .map_err(DynoxideError::ValidationException)?;
516
517    // Get pk string
518    let pk_str = resolved.pk_value.to_key_string().ok_or_else(|| {
519        DynoxideError::ValidationException(
520            "Cannot convert partition key value to string".to_string(),
521        )
522    })?;
523
524    // Build sk SQL conditions
525    let (sk_condition_sql, sk_param_values) = match resolved.sk_condition {
526        Some(ref sk_cond) => {
527            // Validate sk name matches effective sort key
528            if let Some(ref eff_sk) = effective_sk {
529                if sk_cond.sk_name() != eff_sk {
530                    return Err(DynoxideError::ValidationException(format!(
531                        "Query condition missed key schema element: {eff_sk}"
532                    )));
533                }
534            } else {
535                return Err(DynoxideError::ValidationException(
536                    "Query filter contains a sort key condition but the table has no sort key"
537                        .to_string(),
538                ));
539            }
540
541            expressions::key_condition::sk_conditions_to_sql(std::slice::from_ref(sk_cond))
542        }
543        None => (None, Vec::new()),
544    };
545
546    // ---- Validate QueryFilter/FilterExpression don't reference primary key attrs ----
547    // Collect effective key attribute names
548    let mut effective_key_attrs = vec![effective_pk.clone()];
549    if let Some(ref sk) = effective_sk {
550        effective_key_attrs.push(sk.clone());
551    }
552
553    // Check legacy QueryFilter
554    if let Some(ref qf_val) = request.query_filter
555        && let Some(obj) = qf_val.as_object()
556    {
557        for attr_name in obj.keys() {
558            if effective_key_attrs.contains(attr_name) {
559                return Err(DynoxideError::ValidationException(format!(
560                    "QueryFilter can only contain non-primary key attributes: \
561                         Primary key attribute: {attr_name}"
562                )));
563            }
564        }
565    }
566
567    // Check FilterExpression for key attribute references (only for user-supplied expressions,
568    // not those converted from QueryFilter - QueryFilter is checked separately above)
569    if request.query_filter.is_none()
570        && let Some(ref fe) = request.filter_expression
571        && let Ok(parsed_fe) = expressions::condition::parse(fe)
572    {
573        let top_attrs = expressions::condition::extract_top_level_attributes(
574            &parsed_fe,
575            &request.expression_attribute_names,
576        );
577        for attr in &top_attrs {
578            if effective_key_attrs.contains(attr) {
579                return Err(DynoxideError::ValidationException(format!(
580                    "Filter Expression can only contain non-primary key attributes: \
581                             Primary key attribute: {attr}"
582                )));
583            }
584        }
585        // Check for non-scalar key access in FilterExpression
586        // Build index key attribute lists
587        let mut index_key_attrs = Vec::new();
588        if request.index_name.is_some() {
589            // Index keys that are not also table keys
590            for k in &effective_key_attrs {
591                if ![table_key_schema.partition_key.clone()]
592                    .iter()
593                    .chain(table_key_schema.sort_key.iter())
594                    .any(|tk| tk == k)
595                {
596                    index_key_attrs.push(k.clone());
597                }
598            }
599        }
600        let base_key_attrs: Vec<String> = {
601            let mut v = vec![table_key_schema.partition_key.clone()];
602            if let Some(ref sk) = table_key_schema.sort_key {
603                v.push(sk.clone());
604            }
605            v
606        };
607        if let Some((attr, is_index)) = expressions::condition::check_non_scalar_key_access(
608            &parsed_fe,
609            &request.expression_attribute_names,
610            &base_key_attrs,
611            &index_key_attrs,
612        ) {
613            let prefix = if is_index { "IndexKey" } else { "Key" };
614            return Err(DynoxideError::ValidationException(format!(
615                "Key attributes must be scalars; \
616                         list random access '[]' and map lookup '.' are not allowed: {prefix}: {attr}"
617            )));
618        }
619    }
620
621    let is_index_query = request.index_name.is_some();
622
623    // Build ExclusiveStartKey sk value.
624    // For hash-only GSIs (no sort key), use empty string so the composite
625    // cursor (gsi_sk, table_pk, table_sk) can drive pagination.
626    let start_sk = if let Some(ref esk) = exclusive_start_key {
627        if let Some(ref sk_name) = effective_sk {
628            esk.get(sk_name).and_then(|v| v.to_key_string())
629        } else if is_index_query {
630            // Hash-only index: gsi_sk / lsi_sk is always ''
631            Some(String::new())
632        } else {
633            None
634        }
635    } else {
636        None
637    };
638
639    // For LSI and GSI queries, extract the base table keys from ExclusiveStartKey
640    // to enable composite cursor pagination.
641    let (start_base_pk, start_base_sk) = if is_index_query {
642        if let Some(ref esk) = exclusive_start_key {
643            let base_pk = esk
644                .get(&table_key_schema.partition_key)
645                .and_then(|v| v.to_key_string());
646            // When the base table is hash-only there is no base sort key, but the
647            // GSI/LSI row stores the empty-string default in its table_sk column.
648            // Default base_sk to "" (mirroring start_sk) so the composite cursor
649            // keeps its full width and disambiguates tied index keys by table_pk.
650            // Leaving it None collapses query_gsi_items to the 1-column gsi_sk
651            // cursor, which cannot advance past rows sharing the same index key
652            // and silently drops them. See scan.rs for the Scan-path counterpart.
653            let base_sk = if let Some(sk_name) = table_key_schema.sort_key.as_ref() {
654                esk.get(sk_name).and_then(|v| v.to_key_string())
655            } else {
656                Some(String::new())
657            };
658            (base_pk, base_sk)
659        } else {
660            (None, None)
661        }
662    } else {
663        (None, None)
664    };
665
666    // Validate Select=ALL_ATTRIBUTES against index projection type.
667    // For GSI with non-ALL projection, DynamoDB rejects ALL_ATTRIBUTES.
668    let is_select_all_attributes = request
669        .select
670        .as_deref()
671        .map(|s| s.eq_ignore_ascii_case("ALL_ATTRIBUTES"))
672        .unwrap_or(false);
673    let fetch_from_base_table = if is_select_all_attributes {
674        if let Some(ref proj_type) = index_projection_type {
675            if *proj_type != crate::types::ProjectionType::ALL {
676                if !is_lsi {
677                    return Err(DynoxideError::ValidationException(format!(
678                        "One or more parameter values were invalid: \
679                         Select type ALL_ATTRIBUTES is not supported for global secondary index {} \
680                         because its projection type is not ALL",
681                        request.index_name.as_deref().unwrap_or("")
682                    )));
683                }
684                // LSI with non-ALL projection: fetch full items from base table
685                true
686            } else {
687                false
688            }
689        } else {
690            false
691        }
692    } else {
693        false
694    };
695
696    let fetch_limit = request.limit;
697    let sk_params_refs: Vec<&str> = sk_param_values.iter().map(|s| s.as_str()).collect();
698
699    // Query either GSI table or base table
700    let query_params = crate::storage::QueryParams {
701        sk_condition: sk_condition_sql.as_deref(),
702        sk_params: &sk_params_refs,
703        forward: request.scan_index_forward,
704        limit: fetch_limit,
705        exclusive_start_sk: start_sk.as_deref(),
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                .query_lsi_items(&request.table_name, index_name, &pk_str, &query_params)
713                .await?
714        } else {
715            storage
716                .query_gsi_items(&request.table_name, index_name, &pk_str, &query_params)
717                .await?
718        }
719    } else {
720        storage
721            .query_items(&request.table_name, &pk_str, &query_params)
722            .await?
723    };
724
725    // Parse filter expression if present
726    let filter_expr = request
727        .filter_expression
728        .as_ref()
729        .map(|expr| expressions::condition::parse(expr))
730        .transpose()
731        .map_err(DynoxideError::ValidationException)?;
732
733    // Parse projection expression if present; fall back to legacy AttributesToGet
734    let projection = if let Some(ref proj_expr) = request.projection_expression {
735        Some(
736            expressions::projection::parse(proj_expr)
737                .map_err(DynoxideError::ValidationException)?,
738        )
739    } else {
740        legacy_projection.clone()
741    };
742
743    // Pre-register expression references so unused check works even with zero items
744    if let Some(ref filter) = filter_expr {
745        tracker.track_condition_expr(filter);
746    }
747    if let Some(ref proj) = projection {
748        tracker.track_projection_expr(proj);
749        // Reject undefined names and overlapping paths before the row loop, so a
750        // zero-match query still rejects.
751        expressions::projection::validate(proj, &tracker)
752            .map_err(DynoxideError::ValidationException)?;
753    }
754
755    // Untracked variant for the per-item hot loop: tracking already done above
756    let loop_tracker = crate::expressions::TrackedExpressionAttributes::without_tracking(
757        &request.expression_attribute_names,
758        &request.expression_attribute_values,
759    );
760
761    // Determine if SELECT COUNT
762    let is_count = request
763        .select
764        .as_deref()
765        .map(|s| s.eq_ignore_ascii_case("COUNT"))
766        .unwrap_or(false);
767
768    // Key attribute names for projection (use effective keys for GSI)
769    let mut key_attrs = vec![effective_pk.clone()];
770    if let Some(ref sk) = effective_sk {
771        key_attrs.push(sk.clone());
772    }
773    // Also include base table keys when querying a GSI
774    if request.index_name.is_some() {
775        if !key_attrs.contains(&table_key_schema.partition_key) {
776            key_attrs.push(table_key_schema.partition_key.clone());
777        }
778        if let Some(ref sk) = table_key_schema.sort_key
779            && !key_attrs.contains(sk)
780        {
781            key_attrs.push(sk.clone());
782        }
783    }
784
785    let mut items = Vec::new();
786    let mut scanned_count = 0;
787    let mut filtered_count = 0;
788    let mut cumulative_size = 0;
789    let mut last_evaluated_item: Option<Item> = None;
790    let mut truncated_by_size = false;
791
792    // Track sizes separately for ALL_ATTRIBUTES LSI queries where both
793    // index reads and base table reads contribute to ConsumedCapacity.
794    let mut base_table_cumulative_size = 0usize;
795    let mut index_cumulative_size = 0usize;
796
797    for (_pk, _sk, item_json) in &rows {
798        let index_item: Item = serde_json::from_str(item_json).map_err(|e| {
799            DynoxideError::InternalServerError(format!("Bad item JSON in storage: {e}"))
800        })?;
801
802        // If Select=ALL_ATTRIBUTES on LSI with non-ALL projection, fetch full
803        // item from the base table for the response while using the index item
804        // for cursor tracking.
805        index_cumulative_size += crate::types::item_size(&index_item);
806        let item = if fetch_from_base_table {
807            let base_pk = index_item
808                .get(&table_key_schema.partition_key)
809                .and_then(|v| v.to_key_string())
810                .unwrap_or_default();
811            let base_sk = table_key_schema
812                .sort_key
813                .as_ref()
814                .and_then(|sk_name| index_item.get(sk_name))
815                .and_then(|v| v.to_key_string())
816                .unwrap_or_default();
817            if let Some(full_json) = storage
818                .get_item(&request.table_name, &base_pk, &base_sk)
819                .await?
820            {
821                let full_item: Item = serde_json::from_str(&full_json).map_err(|e| {
822                    DynoxideError::InternalServerError(format!("Bad item JSON: {e}"))
823                })?;
824                base_table_cumulative_size += crate::types::item_size(&full_item);
825                full_item
826            } else {
827                index_item.clone()
828            }
829        } else {
830            index_item.clone()
831        };
832
833        scanned_count += 1;
834
835        // Check 1MB limit BEFORE filtering - DynamoDB counts all evaluated data
836        // towards the 1MB response size limit, not just items that pass the filter.
837        let item_size = crate::types::item_size(&item);
838        if cumulative_size + item_size > MAX_RESPONSE_SIZE && scanned_count > 1 {
839            truncated_by_size = true;
840            break;
841        }
842        cumulative_size += item_size;
843
844        // Apply filter
845        if let Some(ref filter) = filter_expr {
846            let passes = expressions::condition::evaluate(filter, &item, &loop_tracker)
847                .map_err(DynoxideError::ValidationException)?;
848            if !passes {
849                last_evaluated_item = Some(index_item);
850                continue;
851            }
852        }
853
854        filtered_count += 1;
855
856        // Apply projection -- do NOT auto-include key attributes when the
857        // user explicitly specified ProjectionExpression or AttributesToGet.
858        let result_item = if let Some(ref proj) = projection {
859            let no_keys: &[String] = &[];
860            expressions::projection::apply(&item, proj, &loop_tracker, no_keys)
861                .map_err(DynoxideError::ValidationException)?
862        } else {
863            item
864        };
865
866        last_evaluated_item = Some(index_item);
867        if !is_count {
868            items.push(result_item);
869        }
870    }
871
872    // Check for unused expression attribute names/values
873    tracker.check_unused()?;
874
875    let count = if is_count {
876        filtered_count
877    } else {
878        items.len()
879    };
880
881    // Determine LastEvaluatedKey
882    // We return LEK if: we hit the Limit, or we hit the 1MB limit
883    let has_more = truncated_by_size
884        || (fetch_limit.is_some() && scanned_count >= fetch_limit.unwrap_or(usize::MAX));
885
886    // For index queries, include the base table primary key in LastEvaluatedKey
887    // alongside the effective (index) keys so the cursor can uniquely identify
888    // the position. For LSIs, include the table sort key. For GSIs, include
889    // both the table partition key and sort key.
890    let is_gsi_query = request.index_name.is_some() && !is_lsi;
891    let last_evaluated_key = if has_more {
892        last_evaluated_item.map(|item| {
893            let mut key = build_last_evaluated_key(&item, &effective_pk, effective_sk.as_deref());
894            // For LSI queries, add the table sort key if different from the index sort key
895            if is_lsi
896                && let Some(tsk) = table_key_schema.sort_key.as_deref()
897                && !key.contains_key(tsk)
898                && let Some(v) = item.get(tsk)
899            {
900                key.insert(tsk.to_string(), v.clone());
901            }
902            // For GSI queries, add the base table primary key (pk and sk)
903            if is_gsi_query {
904                if !key.contains_key(&table_key_schema.partition_key)
905                    && let Some(v) = item.get(&table_key_schema.partition_key)
906                {
907                    key.insert(table_key_schema.partition_key.clone(), v.clone());
908                }
909                if let Some(ref tsk) = table_key_schema.sort_key
910                    && !key.contains_key(tsk)
911                    && let Some(v) = item.get(tsk)
912                {
913                    key.insert(tsk.clone(), v.clone());
914                }
915            }
916            key
917        })
918    } else {
919        None
920    };
921
922    // Attribute read capacity to the index if querying one
923    let is_gsi = is_gsi_query;
924    let consistent = request.consistent_read.unwrap_or(false);
925    let consumed_capacity = if is_gsi {
926        let mut gsi_units = std::collections::HashMap::new();
927        gsi_units.insert(
928            request.index_name.as_ref().unwrap().clone(),
929            crate::types::read_capacity_units_with_consistency(cumulative_size, consistent),
930        );
931        crate::types::consumed_capacity_with_indexes(
932            &request.table_name,
933            0.0,
934            &gsi_units,
935            &request.return_consumed_capacity,
936        )
937    } else if is_lsi {
938        // When fetching from the base table (ALL_ATTRIBUTES on non-ALL LSI),
939        // split capacity between the index read and the table read.
940        let (table_cap, lsi_cap) = if fetch_from_base_table {
941            let table_rcu = crate::types::read_capacity_units_with_consistency(
942                base_table_cumulative_size,
943                consistent,
944            );
945            let lsi_rcu = crate::types::read_capacity_units_with_consistency(
946                index_cumulative_size,
947                consistent,
948            );
949            (table_rcu, lsi_rcu)
950        } else {
951            (
952                0.0,
953                crate::types::read_capacity_units_with_consistency(cumulative_size, consistent),
954            )
955        };
956        let mut lsi_units = std::collections::HashMap::new();
957        lsi_units.insert(request.index_name.as_ref().unwrap().clone(), lsi_cap);
958        crate::types::consumed_capacity_with_secondary_indexes(
959            &request.table_name,
960            table_cap,
961            &std::collections::HashMap::new(),
962            &lsi_units,
963            &request.return_consumed_capacity,
964        )
965    } else {
966        crate::types::consumed_capacity(
967            &request.table_name,
968            crate::types::read_capacity_units_with_consistency(cumulative_size, consistent),
969            &request.return_consumed_capacity,
970        )
971    };
972
973    Ok(QueryResponse {
974        items: if is_count { None } else { Some(items) },
975        count,
976        scanned_count,
977        last_evaluated_key,
978        consumed_capacity,
979    })
980}
981
982fn build_last_evaluated_key(
983    item: &Item,
984    pk_name: &str,
985    sk_name: Option<&str>,
986) -> HashMap<String, AttributeValue> {
987    let mut key = HashMap::new();
988    if let Some(pk_val) = item.get(pk_name) {
989        key.insert(pk_name.to_string(), pk_val.clone());
990    }
991    if let Some(sk) = sk_name
992        && let Some(sk_val) = item.get(sk)
993    {
994        key.insert(sk.to_string(), sk_val.clone());
995    }
996    key
997}
998
999/// Refuse a read against a vector index by type.
1000///
1001/// The wording differs between the two operations, full stop included: AWS
1002/// ends Query's with one where the other has none. Captured eu-west-2,
1003/// 2026-08-19.
1004fn reject_vector_index(
1005    meta: &crate::storage::TableMetadata,
1006    index_name: &str,
1007) -> crate::errors::Result<()> {
1008    if super::vector_index::parse_vector_defs(meta)?
1009        .iter()
1010        .any(|v| v.index_name == index_name)
1011    {
1012        return Err(DynoxideError::ValidationException(
1013            "Query operation not supported on this index type.".to_string(),
1014        ));
1015    }
1016    Ok(())
1017}