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